fix(jules): Jules API parity and cloud-agent provider registration (#2438)

Integrated into release/v3.8.1
This commit is contained in:
Gi99lin
2026-05-20 22:35:20 +03:00
committed by GitHub
parent 8670950da3
commit a0d2dee0c5
6 changed files with 299 additions and 66 deletions

View File

@@ -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<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 {
// 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<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 = "https://jules.googleapis.com/v1alpha";
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,
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<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: 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<GetStatusResult> {
const response = await fetch(`${this.baseUrl}/sessions/${externalId}`, {
headers: {
"X-Goog-Api-Key": _credentials.apiKey,
},
});
async getStatus(externalId: string, credentials: AgentCredentials): Promise<GetStatusResult> {
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<string, unknown>) => ({
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<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,
externalId: sessionId,
result,
activities,
error: data.error,
error: errorMessage || undefined,
};
}
async approvePlan(externalId: string, credentials: AgentCredentials): Promise<void> {
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<CloudAgentActivity> {
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<string, unknown>) => ({
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<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 } : {}),
};
}
);
}
}

View File

@@ -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}`;
}

View File

@@ -161,6 +161,7 @@ const MANAGED_PROVIDER_CONNECTION_CATEGORIES = new Set<StaticProviderCatalogCate
"local",
"search",
"audio",
"cloud-agent",
]);
export function getStaticProviderCatalogGroup(

View File

@@ -29,6 +29,7 @@ import {
} from "@/shared/network/safeOutboundFetch";
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
import { extractCookieValue, normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth";
import { buildJulesApiUrl } from "@/lib/cloudAgent/julesApi.ts";
import { getGigachatAccessToken } from "@omniroute/open-sse/services/gigachatAuth.ts";
import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts";
import {
@@ -3129,6 +3130,34 @@ async function validateMuseSparkWebProvider({ apiKey, providerSpecificData = {}
}
}
/** Jules API — GET /v1alpha/sources with X-Goog-Api-Key (see developers.google.com/jules/api). */
async function validateJulesProvider({ apiKey }: { apiKey: string }) {
try {
const response = await validationWrite(buildJulesApiUrl("/sources"), {
method: "GET",
headers: {
"X-Goog-Api-Key": apiKey,
},
});
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid API key" };
}
if (response.ok) {
return { valid: true, error: null };
}
const errorText = await response.text().catch(() => "");
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,

View File

@@ -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", () => {

View File

@@ -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) {