| import { randomUUID } from "node:crypto";
|
| import {
|
| CloudAgentBase,
|
| type AgentCredentials,
|
| type CreateTaskParams,
|
| type GetStatusResult,
|
| } from "../baseAgent.ts";
|
| import { buildJulesApiUrl, JULES_API_BASE_URL } from "../julesApi.ts";
|
| import type {
|
| CloudAgentTask,
|
| CloudAgentActivity,
|
| CloudAgentStatus,
|
| CloudAgentResult,
|
| } from "../types.ts";
|
| import { CLOUD_AGENT_STATUS } from "../types.ts";
|
|
|
| function julesHeaders(apiKey: string, json = false): Record<string, string> {
|
| const headers: Record<string, string> = {
|
| "X-Goog-Api-Key": apiKey,
|
| };
|
| if (json) {
|
| headers["Content-Type"] = "application/json";
|
| }
|
| return headers;
|
| }
|
|
|
| function parseGithubOwnerRepo(repoUrl: string, repoName: string): { owner: string; repo: string } {
|
| const normalized = repoUrl.includes("://") ? repoUrl : `https://${repoUrl}`;
|
| try {
|
| const url = new URL(normalized);
|
| const parts = url.pathname.split("/").filter(Boolean);
|
| if (parts.length >= 2) {
|
| return {
|
| owner: parts[0],
|
| repo: parts[1].replace(/\.git$/i, ""),
|
| };
|
| }
|
| } catch {
|
|
|
| }
|
| const parts = repoUrl.split("/").filter(Boolean);
|
| const owner = parts.length >= 2 ? parts[parts.length - 2] : "";
|
| const repo = parts.length >= 2 ? parts[parts.length - 1].replace(/\.git$/i, "") : repoName.trim();
|
| return { owner, repo: repo || repoName.trim() };
|
| }
|
|
|
| function buildJulesSourceResourceName(owner: string, repo: string): string {
|
| return `sources/github/${owner}/${repo}`;
|
| }
|
|
|
| function normalizeJulesSessionId(externalId: string): string {
|
| const trimmed = externalId.trim();
|
| return trimmed.startsWith("sessions/") ? trimmed.slice("sessions/".length) : trimmed;
|
| }
|
|
|
| function mapJulesActivity(act: Record<string, unknown>): CloudAgentActivity {
|
| const progress = act.progressUpdated as Record<string, unknown> | undefined;
|
| const planGenerated = act.planGenerated as Record<string, unknown> | undefined;
|
| let type: CloudAgentActivity["type"] = "command";
|
| let content = "";
|
|
|
| if (act.planGenerated) {
|
| type = "plan";
|
| const plan = planGenerated?.plan as Record<string, unknown> | undefined;
|
| const steps = Array.isArray(plan?.steps) ? plan.steps : [];
|
| content = steps
|
| .map((step) => {
|
| const row = step as Record<string, unknown>;
|
| return typeof row.title === "string" ? row.title : "";
|
| })
|
| .filter(Boolean)
|
| .join("\n");
|
| } else if (act.sessionCompleted) {
|
| type = "completion";
|
| content = "Session completed";
|
| } else if (act.planApproved) {
|
| type = "message";
|
| content = "Plan approved";
|
| } else if (progress) {
|
| content = [progress.title, progress.description].filter(Boolean).join(": ");
|
| }
|
|
|
| return {
|
| id: (act.id as string) || randomUUID(),
|
| type,
|
| content,
|
| timestamp: (act.createTime as string) || new Date().toISOString(),
|
| };
|
| }
|
|
|
| function extractJulesResult(outputs: unknown): CloudAgentResult | undefined {
|
| if (!Array.isArray(outputs)) return undefined;
|
|
|
| for (const item of outputs) {
|
| const output = item as Record<string, unknown>;
|
| const pullRequest = output.pullRequest as Record<string, unknown> | undefined;
|
| if (pullRequest?.url) {
|
| return {
|
| prUrl: String(pullRequest.url),
|
| commitMessage:
|
| typeof pullRequest.description === "string" ? pullRequest.description : undefined,
|
| summary: typeof pullRequest.title === "string" ? pullRequest.title : undefined,
|
| };
|
| }
|
| }
|
|
|
| return undefined;
|
| }
|
|
|
| function readJulesErrorMessage(data: Record<string, unknown>): string {
|
| if (typeof data.error === "string" && data.error.trim()) {
|
| return data.error.trim();
|
| }
|
| if (data.error && typeof data.error === "object") {
|
| const record = data.error as Record<string, unknown>;
|
| const message = record.message;
|
| if (typeof message === "string" && message.trim()) {
|
| return message.trim();
|
| }
|
| }
|
| return "";
|
| }
|
|
|
| function inferJulesStatus(
|
| data: Record<string, unknown>,
|
| activities: Record<string, unknown>[]
|
| ): CloudAgentStatus {
|
| if (extractJulesResult(data.outputs)) {
|
| return CLOUD_AGENT_STATUS.COMPLETED;
|
| }
|
| if (activities.some((act) => act.sessionCompleted)) {
|
| return CLOUD_AGENT_STATUS.COMPLETED;
|
| }
|
| if (activities.some((act) => act.planGenerated) && !activities.some((act) => act.planApproved)) {
|
| return CLOUD_AGENT_STATUS.AWAITING_APPROVAL;
|
| }
|
|
|
| if (readJulesErrorMessage(data)) {
|
| return CLOUD_AGENT_STATUS.FAILED;
|
| }
|
|
|
| const state = typeof data.state === "string" ? data.state.toLowerCase() : "";
|
| if (state.includes("failed") || state.includes("error")) {
|
| return CLOUD_AGENT_STATUS.FAILED;
|
| }
|
| if (state.includes("cancelled") || state.includes("canceled")) {
|
| return CLOUD_AGENT_STATUS.CANCELLED;
|
| }
|
| if (state.includes("completed") || state.includes("done")) {
|
| return CLOUD_AGENT_STATUS.COMPLETED;
|
| }
|
| if (state.includes("pending") || state.includes("queued")) {
|
| return CLOUD_AGENT_STATUS.QUEUED;
|
| }
|
| if (state.includes("running") || state.includes("active")) {
|
| return CLOUD_AGENT_STATUS.RUNNING;
|
| }
|
|
|
| if (activities.some((act) => act.progressUpdated)) {
|
| return CLOUD_AGENT_STATUS.RUNNING;
|
| }
|
|
|
| return CLOUD_AGENT_STATUS.QUEUED;
|
| }
|
|
|
| function readJulesSourceBranch(source: Record<string, unknown>): string | undefined {
|
| const githubRepo = source.githubRepo as Record<string, unknown> | undefined;
|
| const githubRepoContext = source.githubRepoContext as Record<string, unknown> | undefined;
|
|
|
| const candidates = [
|
| githubRepoContext?.startingBranch,
|
| githubRepoContext?.defaultBranch,
|
| githubRepo?.defaultBranch,
|
| source.defaultBranch,
|
| ];
|
|
|
| for (const candidate of candidates) {
|
| if (typeof candidate === "string" && candidate.trim()) {
|
| return candidate.trim();
|
| }
|
| }
|
|
|
| return undefined;
|
| }
|
|
|
| export class JulesAgent extends CloudAgentBase {
|
| readonly providerId = "jules";
|
| readonly baseUrl = JULES_API_BASE_URL;
|
|
|
| async createTask(
|
| params: CreateTaskParams,
|
| credentials: AgentCredentials
|
| ): Promise<CloudAgentTask> {
|
| const taskId = this.generateTaskId();
|
| const { owner, repo } = parseGithubOwnerRepo(params.source.repoUrl, params.source.repoName);
|
| const sourceResource = buildJulesSourceResourceName(owner, repo);
|
|
|
| const body: Record<string, unknown> = {
|
| prompt: params.prompt,
|
| title: params.source.repoName || repo,
|
| sourceContext: {
|
| source: sourceResource,
|
| githubRepoContext: {
|
| startingBranch: params.source.branch || "main",
|
| },
|
| },
|
| };
|
|
|
| if (params.options.autoCreatePr) {
|
| body.automationMode = "AUTO_CREATE_PR";
|
| }
|
| if (params.options.planApprovalRequired) {
|
| body.requirePlanApproval = true;
|
| }
|
|
|
| const response = await fetch(buildJulesApiUrl("/sessions"), {
|
| method: "POST",
|
| headers: julesHeaders(credentials.apiKey, true),
|
| body: JSON.stringify(body),
|
| });
|
|
|
| if (!response.ok) {
|
| const error = await response.text();
|
| throw new Error(`Jules create task failed: ${response.status} ${error}`);
|
| }
|
|
|
| const data = (await response.json()) as Record<string, unknown>;
|
| const sessionId =
|
| (typeof data.id === "string" && data.id) ||
|
| (typeof data.name === "string" ? normalizeJulesSessionId(data.name) : "") ||
|
| taskId;
|
|
|
| return {
|
| id: taskId,
|
| providerId: this.providerId,
|
| externalId: sessionId,
|
| status: CLOUD_AGENT_STATUS.QUEUED,
|
| prompt: params.prompt,
|
| source: params.source,
|
| options: params.options,
|
| activities: [],
|
| createdAt: new Date().toISOString(),
|
| updatedAt: new Date().toISOString(),
|
| };
|
| }
|
|
|
| async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
|
| const sessionId = normalizeJulesSessionId(externalId);
|
|
|
| const [sessionRes, activitiesRes] = await Promise.all([
|
| fetch(buildJulesApiUrl(`/sessions/${sessionId}`), {
|
| headers: julesHeaders(credentials.apiKey),
|
| }),
|
| fetch(buildJulesApiUrl(`/sessions/${sessionId}/activities?pageSize=30`), {
|
| headers: julesHeaders(credentials.apiKey),
|
| }),
|
| ]);
|
|
|
| if (!sessionRes.ok) {
|
| const error = await sessionRes.text();
|
| throw new Error(`Jules get status failed: ${sessionRes.status} ${error}`);
|
| }
|
|
|
| const data = (await sessionRes.json()) as Record<string, unknown>;
|
| let rawActivities: Record<string, unknown>[] = [];
|
| if (activitiesRes.ok) {
|
| const activitiesPayload = (await activitiesRes.json()) as Record<string, unknown>;
|
| rawActivities = Array.isArray(activitiesPayload.activities)
|
| ? (activitiesPayload.activities as Record<string, unknown>[])
|
| : [];
|
| }
|
|
|
| const activities = rawActivities.map(mapJulesActivity);
|
| const status = inferJulesStatus(data, rawActivities);
|
| const result = extractJulesResult(data.outputs);
|
| const errorMessage = readJulesErrorMessage(data);
|
|
|
| return {
|
| status,
|
| externalId: sessionId,
|
| result,
|
| activities,
|
| error: errorMessage || undefined,
|
| };
|
| }
|
|
|
| async approvePlan(externalId: string, credentials: AgentCredentials): Promise<void> {
|
| const sessionId = normalizeJulesSessionId(externalId);
|
| const response = await fetch(buildJulesApiUrl(`/sessions/${sessionId}:approvePlan`), {
|
| method: "POST",
|
| headers: julesHeaders(credentials.apiKey, true),
|
| body: "{}",
|
| });
|
|
|
| if (!response.ok) {
|
| const error = await response.text();
|
| throw new Error(`Jules approve plan failed: ${response.status} ${error}`);
|
| }
|
| }
|
|
|
| async sendMessage(
|
| externalId: string,
|
| message: string,
|
| credentials: AgentCredentials
|
| ): Promise<CloudAgentActivity> {
|
| const sessionId = normalizeJulesSessionId(externalId);
|
| const response = await fetch(buildJulesApiUrl(`/sessions/${sessionId}:sendMessage`), {
|
| method: "POST",
|
| headers: julesHeaders(credentials.apiKey, true),
|
| body: JSON.stringify({ prompt: message }),
|
| });
|
|
|
| if (!response.ok) {
|
| const error = await response.text();
|
| throw new Error(`Jules send message failed: ${response.status} ${error}`);
|
| }
|
|
|
| return {
|
| id: this.generateActivityId(),
|
| type: "message",
|
| content: message,
|
| timestamp: new Date().toISOString(),
|
| };
|
| }
|
|
|
| async listSources(
|
| credentials: AgentCredentials
|
| ): Promise<{ name: string; url: string; branch?: string }[]> {
|
| const response = await fetch(buildJulesApiUrl("/sources"), {
|
| headers: julesHeaders(credentials.apiKey),
|
| });
|
|
|
| if (!response.ok) {
|
| const error = await response.text();
|
| throw new Error(`Jules list sources failed: ${response.status} ${error}`);
|
| }
|
|
|
| const data = (await response.json()) as Record<string, unknown>;
|
| return (Array.isArray(data.sources) ? data.sources : []).map(
|
| (source: Record<string, unknown>) => {
|
| const githubRepo = source.githubRepo as Record<string, unknown> | undefined;
|
| const owner = typeof githubRepo?.owner === "string" ? githubRepo.owner : "";
|
| const repo = typeof githubRepo?.repo === "string" ? githubRepo.repo : "";
|
| const branch = readJulesSourceBranch(source);
|
|
|
| return {
|
| name: typeof source.name === "string" ? source.name : `${owner}/${repo}`,
|
| url: owner && repo ? `https://github.com/${owner}/${repo}` : "",
|
| ...(branch ? { branch } : {}),
|
| };
|
| }
|
| );
|
| }
|
| }
|
|
|