diff --git a/src/lib/cloudAgent/agents/jules.ts b/src/lib/cloudAgent/agents/jules.ts index 932eb4dfaa..d57bae630f 100644 --- a/src/lib/cloudAgent/agents/jules.ts +++ b/src/lib/cloudAgent/agents/jules.ts @@ -1,43 +1,221 @@ +import { randomUUID } from "node:crypto"; import { CloudAgentBase, type AgentCredentials, type CreateTaskParams, type GetStatusResult, } from "../baseAgent.ts"; -import type { CloudAgentTask, CloudAgentActivity } from "../types.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 { + const headers: Record = { + "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 { + // fall through to string split for non-URL inputs + } + 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): CloudAgentActivity { + const progress = act.progressUpdated as Record | undefined; + const planGenerated = act.planGenerated as Record | undefined; + let type: CloudAgentActivity["type"] = "command"; + let content = ""; + + if (act.planGenerated) { + type = "plan"; + const plan = planGenerated?.plan as Record | undefined; + const steps = Array.isArray(plan?.steps) ? plan.steps : []; + content = steps + .map((step) => { + const row = step as Record; + 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; + const pullRequest = output.pullRequest as Record | 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 { + 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; + const message = record.message; + if (typeof message === "string" && message.trim()) { + return message.trim(); + } + } + return ""; +} + +function inferJulesStatus( + data: Record, + activities: Record[] +): 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 | undefined { + const githubRepo = source.githubRepo as Record | undefined; + const githubRepoContext = source.githubRepoContext as Record | 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 = "https://jules.googleapis.com/v1alpha"; + readonly baseUrl = JULES_API_BASE_URL; async createTask( params: CreateTaskParams, credentials: AgentCredentials ): Promise { const taskId = this.generateTaskId(); + const { owner, repo } = parseGithubOwnerRepo(params.source.repoUrl, params.source.repoName); + const sourceResource = buildJulesSourceResourceName(owner, repo); const body: Record = { prompt: params.prompt, - source: { - repository: { - owner: params.source.repoUrl.split("/").filter(Boolean).slice(-2, -1)[0] || "", - name: params.source.repoName, + title: params.source.repoName || repo, + sourceContext: { + source: sourceResource, + githubRepoContext: { + startingBranch: params.source.branch || "main", }, - branch: params.source.branch || "main", }, }; if (params.options.autoCreatePr) { body.automationMode = "AUTO_CREATE_PR"; } + if (params.options.planApprovalRequired) { + body.requirePlanApproval = true; + } - const response = await fetch(`${this.baseUrl}/sessions`, { + const response = await fetch(buildJulesApiUrl("/sessions"), { method: "POST", - headers: { - "Content-Type": "application/json", - "X-Goog-Api-Key": credentials.apiKey, - }, + headers: julesHeaders(credentials.apiKey, true), body: JSON.stringify(body), }); @@ -46,13 +224,17 @@ export class JulesAgent extends CloudAgentBase { throw new Error(`Jules create task failed: ${response.status} ${error}`); } - const data = await response.json(); + const data = (await response.json()) as Record; + const sessionId = + (typeof data.id === "string" && data.id) || + (typeof data.name === "string" ? normalizeJulesSessionId(data.name) : "") || + taskId; return { id: taskId, providerId: this.providerId, - externalId: data.name?.split("/").pop() || taskId, - status: this.mapStatus(data.state || "pending"), + externalId: sessionId, + status: CLOUD_AGENT_STATUS.QUEUED, prompt: params.prompt, source: params.source, options: params.options, @@ -62,55 +244,52 @@ export class JulesAgent extends CloudAgentBase { }; } - async getStatus(externalId: string, _credentials: AgentCredentials): Promise { - const response = await fetch(`${this.baseUrl}/sessions/${externalId}`, { - headers: { - "X-Goog-Api-Key": _credentials.apiKey, - }, - }); + async getStatus(externalId: string, credentials: AgentCredentials): Promise { + const sessionId = normalizeJulesSessionId(externalId); - if (!response.ok) { - const error = await response.text(); - throw new Error(`Jules get status failed: ${response.status} ${error}`); + 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 response.json(); - const status = this.mapStatus(data.state || "pending"); - - const activities: CloudAgentActivity[] = (data.activities || []).map( - (act: Record) => ({ - id: this.generateActivityId(), - type: act.type as CloudAgentActivity["type"], - content: (act.description as string) || "", - timestamp: (act.timestamp as string) || new Date().toISOString(), - }) - ); - - let result; - if (status === CLOUD_AGENT_STATUS.COMPLETED && data.outputs) { - result = { - prUrl: data.outputs.prUrl, - commitMessage: data.outputs.commitMessage, - summary: data.outputs.summary, - }; + const data = (await sessionRes.json()) as Record; + let rawActivities: Record[] = []; + if (activitiesRes.ok) { + const activitiesPayload = (await activitiesRes.json()) as Record; + rawActivities = Array.isArray(activitiesPayload.activities) + ? (activitiesPayload.activities as Record[]) + : []; } + const activities = rawActivities.map(mapJulesActivity); + const status = inferJulesStatus(data, rawActivities); + const result = extractJulesResult(data.outputs); + const errorMessage = readJulesErrorMessage(data); + return { status, - externalId, + externalId: sessionId, result, activities, - error: data.error, + error: errorMessage || undefined, }; } async approvePlan(externalId: string, credentials: AgentCredentials): Promise { - const response = await fetch(`${this.baseUrl}/sessions/${externalId}:approvePlan`, { + const sessionId = normalizeJulesSessionId(externalId); + const response = await fetch(buildJulesApiUrl(`/sessions/${sessionId}:approvePlan`), { method: "POST", - headers: { - "Content-Type": "application/json", - "X-Goog-Api-Key": credentials.apiKey, - }, + headers: julesHeaders(credentials.apiKey, true), + body: "{}", }); if (!response.ok) { @@ -124,13 +303,11 @@ export class JulesAgent extends CloudAgentBase { message: string, credentials: AgentCredentials ): Promise { - const response = await fetch(`${this.baseUrl}/sessions/${externalId}:sendMessage`, { + const sessionId = normalizeJulesSessionId(externalId); + const response = await fetch(buildJulesApiUrl(`/sessions/${sessionId}:sendMessage`), { method: "POST", - headers: { - "Content-Type": "application/json", - "X-Goog-Api-Key": credentials.apiKey, - }, - body: JSON.stringify({ message }), + headers: julesHeaders(credentials.apiKey, true), + body: JSON.stringify({ prompt: message }), }); if (!response.ok) { @@ -149,10 +326,8 @@ export class JulesAgent extends CloudAgentBase { async listSources( credentials: AgentCredentials ): Promise<{ name: string; url: string; branch?: string }[]> { - const response = await fetch(`${this.baseUrl}/sources`, { - headers: { - "X-Goog-Api-Key": credentials.apiKey, - }, + const response = await fetch(buildJulesApiUrl("/sources"), { + headers: julesHeaders(credentials.apiKey), }); if (!response.ok) { @@ -160,11 +335,20 @@ export class JulesAgent extends CloudAgentBase { throw new Error(`Jules list sources failed: ${response.status} ${error}`); } - const data = await response.json(); - return (data.sources || []).map((source: Record) => ({ - name: source.name as string, - url: `https://github.com/${source.repoOwner}/${source.repoName}`, - branch: source.defaultBranch as string | undefined, - })); + const data = (await response.json()) as Record; + return (Array.isArray(data.sources) ? data.sources : []).map( + (source: Record) => { + const githubRepo = source.githubRepo as Record | 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 } : {}), + }; + } + ); } } diff --git a/src/lib/cloudAgent/julesApi.ts b/src/lib/cloudAgent/julesApi.ts new file mode 100644 index 0000000000..b087b30ac5 --- /dev/null +++ b/src/lib/cloudAgent/julesApi.ts @@ -0,0 +1,7 @@ +/** Jules REST API base — https://developers.google.com/jules/api */ +export const JULES_API_BASE_URL = "https://jules.googleapis.com/v1alpha"; + +export function buildJulesApiUrl(path: string): string { + const normalized = path.startsWith("/") ? path : `/${path}`; + return `${JULES_API_BASE_URL}${normalized}`; +} diff --git a/src/lib/providers/catalog.ts b/src/lib/providers/catalog.ts index 16bd18723c..f8ab13118e 100644 --- a/src/lib/providers/catalog.ts +++ b/src/lib/providers/catalog.ts @@ -161,6 +161,7 @@ const MANAGED_PROVIDER_CONNECTION_CATEGORIES = new Set ""); + return { + valid: false, + error: errorText.trim() || `Jules API returned ${response.status}`, + }; + } catch (error: unknown) { + return toValidationErrorResult(error); + } +} + export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) { const requiresApiKey = !providerAllowsOptionalApiKey(provider); const isLocal = isLocalProvider(provider); @@ -3162,6 +3191,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi // ── Specialty provider validation ── const SPECIALTY_VALIDATORS = { + jules: validateJulesProvider, qoder: ({ apiKey, providerSpecificData }: any) => validateQoderCliPat({ apiKey, providerSpecificData }), "command-code": validateCommandCodeProvider, diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index 1b913fb1fa..a9760f340a 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -387,6 +387,9 @@ test("managed provider connection ids include supported static categories and ex assert.equal(providerCatalog.isManagedProviderConnectionId("youcom-search"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("cliproxyapi"), false); assert.equal(providerCatalog.isManagedProviderConnectionId("claude"), false); + assert.equal(providerCatalog.isManagedProviderConnectionId("jules"), true); + assert.equal(providerCatalog.isManagedProviderConnectionId("devin"), true); + assert.equal(providerCatalog.isManagedProviderConnectionId("codex-cloud"), true); }); test("grok-web taxonomy stays web-cookie only and does not leak into api-key entries", () => { diff --git a/tests/unit/providers-route-managed-catalog.test.ts b/tests/unit/providers-route-managed-catalog.test.ts index c59f3e5fbc..d2db1dbfcd 100644 --- a/tests/unit/providers-route-managed-catalog.test.ts +++ b/tests/unit/providers-route-managed-catalog.test.ts @@ -411,6 +411,14 @@ test("providers route accepts managed local, audio, web-cookie and search provid }, }, }, + { + provider: "jules", + body: { + provider: "jules", + apiKey: "jules-test-key", + name: "Jules API", + }, + }, ]; for (const entry of cases) {