From e0ca19614ce301a8a63b052eddceb4123465a9f5 Mon Sep 17 00:00:00 2001 From: John Choi Date: Fri, 23 Oct 2026 15:44:12 -0700 Subject: [PATCH] fix(cloud): keep initial task dispatch owned by the Hub --- .../desktop-app/sidecar/cloud-handoff.test.ts | 78 +++++------- .../desktop-app/sidecar/cloud-handoff.ts | 64 ++++++---- .../sidecar/cloud-session-test-runtime.ts | 20 ++- .../components/views/chat/cloud-handoff.tsx | 2 +- .../lib/cloud-handoff-lifecycle.test.ts | 27 ++++ .../webview/lib/cloud-handoff-lifecycle.ts | 5 +- .../webview/lib/cloud-session-error.ts | 2 + .../services/cloud/CloudSessionsService.ts | 1 + sdk/ARCHITECTURE.md | 2 +- sdk/DOC.md | 4 + sdk/packages/core/src/cloud/api.ts | 1 + .../core/src/cloud/controller.test.ts | 56 ++++++-- sdk/packages/core/src/cloud/controller.ts | 34 +++++ sdk/packages/core/src/cloud/hub-client.ts | 37 ++++-- sdk/packages/core/src/cloud/hub-protocol.ts | 16 +++ .../hub/server/cloud-session-service.test.ts | 120 ++++++++++++++---- .../src/hub/server/cloud-session-service.ts | 48 ++++--- .../src/hub/server/hub-server-transport.ts | 2 +- 18 files changed, 371 insertions(+), 148 deletions(-) diff --git a/apps/examples/desktop-app/sidecar/cloud-handoff.test.ts b/apps/examples/desktop-app/sidecar/cloud-handoff.test.ts index e1f8306a86..fb77e5f7f9 100644 --- a/apps/examples/desktop-app/sidecar/cloud-handoff.test.ts +++ b/apps/examples/desktop-app/sidecar/cloud-handoff.test.ts @@ -112,7 +112,13 @@ async function fixture() { startedAt: 1, }); const cloud = await createCloudSessionTestRuntime(ctx, { - api: {} as CloudSessionApi, + api: { + create: vi.fn(async () => ({ + sessionId: "ses-target", + status: "ready", + sandboxUrl: "", + })), + } as unknown as CloudSessionApi, apiBaseUrl: "https://api.example", getAuthToken: async () => "synthetic", }); @@ -122,18 +128,7 @@ async function fixture() { { id: modelId, catalogId: "cline-cloud", name: "Test" }, ]); vi.spyOn(cloud, "prepareHandoffRepository").mockResolvedValue({}); - const create = vi.spyOn(cloud, "create").mockResolvedValue({ - sessionId: "ses-target", - status: "idle", - origin: "cloud", - executionTarget: "cloud", - provider: "cline", - model: modelId, - repoUrl: git.repoUrl, - branch: git.branch, - cwd: "/workspace/packages/app", - workspaceRoot: "/workspace", - }); + const create = vi.spyOn(cloud, "create"); const send = vi.spyOn(cloud, "send").mockImplementation(async (...args) => { args[5]?.beforeDispatch?.(); return { sessionId: "ses-target", ok: true }; @@ -181,32 +176,6 @@ async function fixture() { } describe("summary cloud handoff", () => { - it("preserves the prepared prompt when cloud preferences cannot be restored", async () => { - const f = await fixture(); - await f.prepare(); - f.ctx.liveSessions.set("ses-target", { - config: { autoApproveTools: false }, - messages: [], - promptsInQueue: [], - busy: false, - status: "idle", - startedAt: 1, - }); - vi.spyOn(f.viewer, "restoreCreationOptions").mockRejectedValueOnce( - new Error("Preferences unavailable"), - ); - const result = await f.handoff({ - ...f.request, - nextCommand: "Add tests", - attachments: { userImages: [image] }, - }); - expect(f.send).not.toHaveBeenCalled(); - expect(result).toMatchObject({ - warningKind: "unqueued", - unsentPrompt: - "Search form: implemented. Next: add tests.\n\nNext instruction from the user:\nAdd tests", - }); - }); it("returns a missed completion before preparing another cloud task", async () => { const f = await fixture(); await f.prepare(); @@ -453,11 +422,23 @@ describe("summary cloud handoff", () => { const event = JSON.parse(raw).event; if ( event?.name === "cloud_handoff_progress" && - ["summarizing", "checking"].includes(event.payload.phase) + ["summarizing", "checking", "provisioning"].includes( + event.payload.phase, + ) ) preparationProgress.push(event.payload); }, } as unknown as SidecarWebSocketClient); + f.send.mockImplementation(async (...args) => { + expect(preparationProgress).toContainEqual( + expect.objectContaining({ + phase: "provisioning", + sessionId: "ses-target", + }), + ); + args[5]?.beforeDispatch?.(); + return { sessionId: "ses-target", ok: true }; + }); await f.prepare(); const result = await f.handoff({ ...f.request, @@ -480,6 +461,11 @@ describe("summary cloud handoff", () => { handoffAttemptId: "attempt-1", phase: "checking", }), + expect.objectContaining({ + handoffAttemptId: "attempt-1", + phase: "provisioning", + sessionId: "ses-target", + }), ]); expect(f.create).toHaveBeenCalledWith( expect.objectContaining({ @@ -657,11 +643,13 @@ describe("summary cloud handoff", () => { } as unknown as SidecarWebSocketClient); await f.prepare(); if (dispatched === undefined) { - const send = f.viewer.send.bind(f.viewer); - vi.spyOn(f.viewer, "send").mockImplementation(async (...args) => { - await send(...args); - throw new Error("Reply lost after send"); - }); + const createAndSend = f.viewer.createAndSend.bind(f.viewer); + vi.spyOn(f.viewer, "createAndSend").mockImplementation( + async (...args) => { + await createAndSend(...args); + throw new Error("Reply lost after send"); + }, + ); } else { f.send.mockImplementation(async (...args) => { if (dispatched) args[5]?.beforeDispatch?.(); diff --git a/apps/examples/desktop-app/sidecar/cloud-handoff.ts b/apps/examples/desktop-app/sidecar/cloud-handoff.ts index b5f54a141f..3c9b9d8417 100644 --- a/apps/examples/desktop-app/sidecar/cloud-handoff.ts +++ b/apps/examples/desktop-app/sidecar/cloud-handoff.ts @@ -336,41 +336,51 @@ async function handleHandoffOnce( : {}), reasoningEffort: readReasoningEffort(config.reasoningEffort), }; - const target = await cloud.create( - { - requestId: request.handoffAttemptId?.trim() || randomUUID(), - repoUrl: plan.repoUrl, - branch: plan.branch, - modelId, - organizationId: organizationId ?? null, - ...initialTask, - initialPrompt: command, - }, - scope, - ); - const sessionId = target.sessionId; - const dashboardUrl = buildCloudHandoffDashboardUrl( - getClineEnvironmentConfig().appBaseUrl, - sessionId, - ); + let sessionId: string | undefined; + const unsubscribe = cloud.subscribe((event) => { + if ( + sessionId || + event.type !== "snapshot" || + cloud.scope?.serviceId !== scope.serviceId || + cloud.scope?.scopeGeneration !== scope.scopeGeneration || + event.snapshot.record?.repoContext.repoUrl !== plan.repoUrl || + event.snapshot.record.repoContext.branch !== plan.branch + ) + return; + // Each approved plan has its own generated branch. + sessionId = event.sessionId; + emitProgress("provisioning", "Preparing the cloud workspace.", sessionId); + }); let warning: string | undefined; let warningKind: "unqueued" | "unconfirmed" | undefined; try { - emitProgress("provisioning", "Preparing the cloud workspace.", sessionId); - const result = await cloud.send( - sessionId, - command, - undefined, - modelId, + const result = await cloud.createAndSend( + { + requestId: request.handoffAttemptId?.trim() || randomUUID(), + repoUrl: plan.repoUrl, + branch: plan.branch, + modelId, + organizationId: organizationId ?? null, + ...initialTask, + initialPrompt: command, + }, images, scope, ); - if (result.recoveredAfterDisconnect) { + sessionId = result.target.sessionId; + if ("dispatched" in result.send) + throw new CloudSendError( + result.send.message, + result.send.dispatched, + result.send.detail, + ); + if (result.send.recoveredAfterDisconnect) { warningKind = "unconfirmed"; warning = "The cloud connection was interrupted. Check the conversation before resending."; } } catch (error) { + if (!sessionId) throw error; warningKind = error instanceof CloudSendError && !error.dispatched ? "unqueued" @@ -381,8 +391,14 @@ async function handleHandoffOnce( : "The cloud workspace was created, but the prompt was not sent. Keep Cline open to retry; closing it loses the unsent draft and may require a new cloud task."; if (error instanceof CloudSendError && error.detail) warning = `${error.detail} ${warning}`; + } finally { + unsubscribe(); } assertHandoffAccount(ctx, cloud, scope); + const dashboardUrl = buildCloudHandoffDashboardUrl( + getClineEnvironmentConfig().appBaseUrl, + sessionId, + ); const result = { sourceSessionId, sessionId, diff --git a/apps/examples/desktop-app/sidecar/cloud-session-test-runtime.ts b/apps/examples/desktop-app/sidecar/cloud-session-test-runtime.ts index 1c44f26199..8ee5a2eb9e 100644 --- a/apps/examples/desktop-app/sidecar/cloud-session-test-runtime.ts +++ b/apps/examples/desktop-app/sidecar/cloud-session-test-runtime.ts @@ -2,7 +2,7 @@ import { CloudSessionController, type CloudSessionControllerOptions, } from "@cline/core/cloud"; -import { HubCloudSessionService } from "@cline/core/hub"; +import { HubCloudSessionService, HubCommandError } from "@cline/core/hub"; import type { HubEventEnvelope } from "@cline/shared"; import { afterEach } from "vitest"; import { CloudSessionManager } from "./cloud-sessions"; @@ -79,11 +79,19 @@ export async function createCloudSessionTestRuntime( const result = command === "cloud.watch" ? service.watch() - : await service.command( - String(serialized?.operation), - serialized?.args, - serialized?.scope, - ); + : await service + .command( + String(serialized?.operation), + serialized?.args, + serialized?.scope, + ) + .catch((error: unknown) => { + throw new HubCommandError( + command, + undefined, + error instanceof Error ? error.message : String(error), + ); + }); return { version: "v1", ok: true, diff --git a/apps/examples/desktop-app/webview/components/views/chat/cloud-handoff.tsx b/apps/examples/desktop-app/webview/components/views/chat/cloud-handoff.tsx index b8deffc42b..f62b7f1915 100644 --- a/apps/examples/desktop-app/webview/components/views/chat/cloud-handoff.tsx +++ b/apps/examples/desktop-app/webview/components/views/chat/cloud-handoff.tsx @@ -133,7 +133,7 @@ export function CloudHandoffStatus({ ? state.message ? "Check the cloud task" : "Cloud task ready" - : "Cloud preparation stopped"; + : "Cloud handoff needs attention"; return ( { + it("keeps the draft and warns to check cloud tasks when submission has no known target", async () => { + const h = harness(); + const attachments = [new File(["image"], "image.png")]; + const handoffAttemptId = h.start(attachments); + const message = + "The cloud task may have started. Check your cloud tasks before submitting this draft again."; + await h.lifecycle.onRpcRejected("local", { + handoffAttemptId, + error: new Error( + `CLOUD_SESSION_ERROR:${JSON.stringify({ code: "request_unconfirmed", message })}`, + ), + nextCommand: "Continue", + sourceAttachments: attachments, + }); + expect(h.state().local).toMatchObject({ + status: "failed", + message, + retryDraft: "Continue", + retryAttachments: attachments, + }); + expect(h.toast).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Check cloud tasks", + description: message, + }), + ); + }); it.each([ false, true, diff --git a/apps/examples/desktop-app/webview/lib/cloud-handoff-lifecycle.ts b/apps/examples/desktop-app/webview/lib/cloud-handoff-lifecycle.ts index 95519f68e4..3fbac74e58 100644 --- a/apps/examples/desktop-app/webview/lib/cloud-handoff-lifecycle.ts +++ b/apps/examples/desktop-app/webview/lib/cloud-handoff-lifecycle.ts @@ -46,6 +46,7 @@ function handoffError(error: unknown) { return { message: humanizeCloudHandoffError(parsed?.message ?? raw), connectUrl: parsed?.connectUrl, + unconfirmed: parsed?.code === "request_unconfirmed", }; } @@ -206,7 +207,7 @@ export function createHandoffLifecycle(effects: HandoffLifecycleEffects) { }); return; } - const { message, connectUrl } = handoffError(ctx.error); + const { message, connectUrl, unconfirmed } = handoffError(ctx.error); effects.dispatch({ type: "failed", sourceSessionId, @@ -215,7 +216,7 @@ export function createHandoffLifecycle(effects: HandoffLifecycleEffects) { retryAttachments: ctx.sourceAttachments, }); effects.toast({ - title: "Handoff failed", + title: unconfirmed ? "Check cloud tasks" : "Handoff failed", description: message, variant: "destructive", connectUrl, diff --git a/apps/examples/desktop-app/webview/lib/cloud-session-error.ts b/apps/examples/desktop-app/webview/lib/cloud-session-error.ts index a8e9bbcbf8..c4b15b1633 100644 --- a/apps/examples/desktop-app/webview/lib/cloud-session-error.ts +++ b/apps/examples/desktop-app/webview/lib/cloud-session-error.ts @@ -9,6 +9,7 @@ export type CloudSessionError = { | "session_not_found" | "session_expired" | "session_failed" + | "request_unconfirmed" | "request_failed"; message: string; connectUrl?: string; @@ -49,6 +50,7 @@ export function parseCloudSessionError( parsed.code !== "session_not_found" && parsed.code !== "session_expired" && parsed.code !== "session_failed" && + parsed.code !== "request_unconfirmed" && parsed.code !== "request_failed" ) { return null; diff --git a/apps/vscode/src/services/cloud/CloudSessionsService.ts b/apps/vscode/src/services/cloud/CloudSessionsService.ts index 600e4064cf..0384f2f470 100644 --- a/apps/vscode/src/services/cloud/CloudSessionsService.ts +++ b/apps/vscode/src/services/cloud/CloudSessionsService.ts @@ -137,6 +137,7 @@ export class CloudSessionsService { session_expired: 410, github_not_connected: 412, session_failed: undefined, + request_unconfirmed: undefined, request_failed: undefined, }[error.code] throw new CloudSessionError(error.code, error.detail, error.connectUrl, status) diff --git a/sdk/ARCHITECTURE.md b/sdk/ARCHITECTURE.md index e85b51da86..17dc3d15ae 100644 --- a/sdk/ARCHITECTURE.md +++ b/sdk/ARCHITECTURE.md @@ -140,7 +140,7 @@ Design rules: - `cloud/models` owns cloud model eligibility; `services/cloud-handoff` owns Git preflight, fingerprints, and transcript verification. Hosts own transfer orchestration, source locks, persistence, feature gating, and draft recovery. - Cloud RPC validates an explicit operation allowlist and binds requests to the viewer’s observed hub instance and account-scope generation before dispatch. Handoff and prompt dispatch lifecycle callbacks stay with direct controller hosts and are excluded from the serializable RPC arguments. Viewer disconnects do not stop cloud work or close daemon-owned sandbox connections. Account changes retire the old controller and invalidate viewer caches; daemon shutdown disposes it. Cloud events carry a hub-instance id, account-scope generation, and revision; reconnecting subscriptions receive a complete snapshot set, including an empty set, and remove sessions and approvals absent from it. Scope checks run at command/auth boundaries and periodically, avoiding provider-settings reads per streamed event. Token refresh updates credential-based scope identity without retiring the same account’s controller. Discovery caches, pending listing requests, and newly created fallback rows are scoped to their organization; switching the sidebar preserves other organizations’ task state without displaying their rows. The discovery deadline covers organization resolution and listing together; an unresolved organization yields no cloud rows at the deadline. After an organization switch, desktop attempts a cloud-enabled organization refresh without making switch success or viewer cleanup depend on it. Fresh requests invalidate the old cache before fetching; a returning viewer also invalidates it to cover switches made while the Hub was unreachable. Cloud events are excluded from the durable local event log so old-account transcripts cannot be replayed after a scope change. Dispatched cloud mutations are never automatically resent after a lost reply. - The cloud controller resumes suspended sessions and restores saved tasks. -- Multi-step handoff callers can pin the existing Hub account scope across repository preparation, model lookup, creation, and sending. Send lifecycle callbacks stay inside the daemon; a cloud-specific failure payload preserves whether inner dispatch began without changing ordinary send success results. Missing replies remain uncertain. +- Multi-step handoff callers can pin the existing Hub account scope across repository preparation, model lookup, creation, and sending. Desktop keeps local Git preparation and summarization, then submits `createAndSend` as one Hub command so viewer disconnects cannot interrupt the gap between creation and sending. Send lifecycle callbacks stay inside the daemon; a cloud-specific failure payload preserves whether inner dispatch began without changing ordinary send success results. Missing replies remain uncertain. - The hub retains pending first-task creation options with its controller across desktop viewer disconnects and reconnects. The controller consumes that intent when an inner task exists or is created. Account changes dispose the old scope and its intent. - hub-related modules live under `packages/core/src/hub/`, grouped by service: - `client/` contains host-facing hub clients and browser connection helpers diff --git a/sdk/DOC.md b/sdk/DOC.md index 9f07f09825..f3740f6f25 100644 --- a/sdk/DOC.md +++ b/sdk/DOC.md @@ -121,6 +121,10 @@ The optional sixth argument to `send` accepts a `beforeDispatch` callback. The callback runs before submission and can throw to prevent it. A successful return without `recoveredAfterDisconnect` confirms acknowledgement. The callback does not confirm acceptance. +`createAndSend(input, images?)` combines creation and the initial prompt, returning +`{ target, send }`. A failed `send` includes `dispatched`; only `false` means the +prompt can be retried on that target. Through `HubCloudSessionClient`, the operation survives viewer +disconnects. A lost reply raises `request_unconfirmed`; check cloud tasks before retrying. Viewers hydrating active runs with `readMessages` reconcile canonical history at completion even when they missed the run-start event and earlier content deltas. diff --git a/sdk/packages/core/src/cloud/api.ts b/sdk/packages/core/src/cloud/api.ts index ecce4e8610..230d80775d 100644 --- a/sdk/packages/core/src/cloud/api.ts +++ b/sdk/packages/core/src/cloud/api.ts @@ -104,6 +104,7 @@ export type CloudErrorCode = | "session_not_found" | "session_expired" | "session_failed" + | "request_unconfirmed" | "request_failed"; export class CloudSessionError extends Error { diff --git a/sdk/packages/core/src/cloud/controller.test.ts b/sdk/packages/core/src/cloud/controller.test.ts index 21dc3921b7..e93919bb1c 100644 --- a/sdk/packages/core/src/cloud/controller.test.ts +++ b/sdk/packages/core/src/cloud/controller.test.ts @@ -1029,6 +1029,39 @@ describe("CloudSessionController neutral host contract", () => { expect(accepted()).toHaveLength(1); await f.controller.dispose(); }); + it("creates and sends one initial prompt with images without a second caller action", async () => { + const f = fixture(); + try { + const result = await f.controller.createAndSend( + { + repoUrl: "https://github.com/example/project", + branch: "qa/summary", + modelId: "model", + initialPrompt: "Summary and next instruction", + }, + ["data:image/png;base64,aW1hZ2U="], + ); + expect(result).toMatchObject({ + target: { sessionId: record.id, branch: "qa/summary" }, + send: { ok: true, sessionId: record.id }, + }); + expect( + f.commands.filter((entry) => entry.command === "session.send_input"), + ).toEqual([ + { + command: "session.send_input", + sessionId: "inner", + payload: { + prompt: "Summary and next instruction", + delivery: undefined, + attachments: { userImages: ["data:image/png;base64,aW1hZ2U="] }, + }, + }, + ]); + } finally { + await f.controller.dispose(); + } + }); it("prevents dispatch if beforeDispatch throws", async () => { const f = await attached(); const previousTitle = f.controller.getSnapshot(record.id)?.title; @@ -1374,9 +1407,10 @@ describe("CloudSessionController neutral host contract", () => { await f.controller.dispose(); }); it.each([ - "preserve", - "delete", - ] as const)("uses explicit %s policy for a POST completing after disposal", async (policy) => { + ["preserve", "create"], + ["delete", "create"], + ["preserve", "createAndSend"], + ] as const)("uses explicit %s policy for %s completing after disposal", async (policy, operation) => { let resolve!: (value: { sessionId: string; status: string; @@ -1393,13 +1427,12 @@ describe("CloudSessionController neutral host contract", () => { }); const f = fixture({ lateCreateDisposition: policy }); f.api.create.mockImplementationOnce(() => created); - const creating = f.controller - .create({ - requestId: "request", - modelId: "model", - repoUrl: record.repoContext.repoUrl!, - }) - .catch((error) => error); + const creating = f.controller[operation]({ + requestId: "request", + modelId: "model", + repoUrl: record.repoContext.repoUrl!, + initialPrompt: "Continue", + }).catch((error) => error); await vi.waitFor(() => expect(f.api.create).toHaveBeenCalledOnce()); await f.controller.dispose(); resolve({ @@ -1411,6 +1444,9 @@ describe("CloudSessionController neutral host contract", () => { expect(await creating).toBeInstanceOf(Error); expect(f.api.delete).toHaveBeenCalledTimes(policy === "delete" ? 1 : 0); expect(f.controller.getSnapshot(record.id)).toBeUndefined(); + expect( + f.commands.some((entry) => entry.command === "session.send_input"), + ).toBe(false); }); it.each([ ["persist", true], diff --git a/sdk/packages/core/src/cloud/controller.ts b/sdk/packages/core/src/cloud/controller.ts index 14245a69b7..a2c4e9655a 100644 --- a/sdk/packages/core/src/cloud/controller.ts +++ b/sdk/packages/core/src/cloud/controller.ts @@ -20,6 +20,10 @@ import { deriveCloudSessionTitle, parseCloudProvisioningPhase, } from "./api"; +import { + type CloudSendFailure, + serializeCloudSendFailure, +} from "./hub-protocol"; import { type CloudModel, loadCloudModels } from "./models"; import type { CloudBranchListOptions, @@ -1045,6 +1049,36 @@ export class CloudSessionController { return await creating; } + async createAndSend( + input: Omit & { initialPrompt: string }, + images?: string[], + ): Promise<{ + target: CloudSessionAttachment; + send: + | Awaited> + | CloudSendFailure; + }> { + const target = await this.create(input); + let dispatched = false; + try { + const send = await this.send( + target.sessionId, + input.initialPrompt, + undefined, + input.modelId, + images, + { + beforeDispatch: () => { + dispatched = true; + }, + }, + ); + return { target, send }; + } catch (error) { + return { target, send: serializeCloudSendFailure(error, dispatched) }; + } + } + private trackProvisioningController( outerSessionId: string, controller: AbortController, diff --git a/sdk/packages/core/src/cloud/hub-client.ts b/sdk/packages/core/src/cloud/hub-client.ts index 74afc55cc1..65a40b528b 100644 --- a/sdk/packages/core/src/cloud/hub-client.ts +++ b/sdk/packages/core/src/cloud/hub-client.ts @@ -1,6 +1,6 @@ import type { HubEventEnvelope, HubReplyEnvelope } from "@cline/shared"; import type { NodeHubClient } from "../hub/client"; -import type { CloudSessionRecord } from "./api"; +import { CloudSessionError, type CloudSessionRecord } from "./api"; import { type CloudHubArgs, type CloudHubOperation, @@ -155,12 +155,11 @@ export class HubCloudSessionClient { scope.serviceId === this.serviceId && scope.scopeGeneration === this.scopeGeneration; let dispatched = false; - const reply = await client.command( - "cloud.command", - { operation, args, scope }, - undefined, - { - timeoutMs: ["create", "send", "attach"].includes(operation) + const reply = await client + .command("cloud.command", { operation, args, scope }, undefined, { + timeoutMs: ["create", "createAndSend", "send", "attach"].includes( + operation, + ) ? null : undefined, beforeDispatch: () => { @@ -175,8 +174,21 @@ export class HubCloudSessionClient { onDispatch: () => { dispatched = true; }, - }, - ); + }) + .catch((error: unknown) => { + // Cloud and Hub entrypoints bundle separate error constructors. + const rejected = + error instanceof Error && + error.name === "HubCommandError" && + "code" in error && + error.code !== "hub_command_timeout"; + if (operation === "createAndSend" && dispatched && !rejected) + throw new CloudSessionError( + "request_unconfirmed", + "The cloud task may have started. Check your cloud tasks before submitting this draft again.", + ); + throw error; + }); this.checkReply(reply); if ( !isCurrentScope() || @@ -202,6 +214,13 @@ export class HubCloudSessionClient { ): Promise> { return this.call("create", [input], scope); } + createAndSend( + input: CloudHubArgs<"createAndSend">[0], + images?: string[], + scope?: CloudHubScope, + ): Promise> { + return this.call("createAndSend", [input, images], scope); + } attach(...args: CloudHubArgs<"attach">): Promise> { return this.call("attach", args); } diff --git a/sdk/packages/core/src/cloud/hub-protocol.ts b/sdk/packages/core/src/cloud/hub-protocol.ts index 6621b2291f..9f1ac802c6 100644 --- a/sdk/packages/core/src/cloud/hub-protocol.ts +++ b/sdk/packages/core/src/cloud/hub-protocol.ts @@ -5,6 +5,7 @@ import type { CloudSessionController } from "./controller"; /** Operations available to cloud UI clients. Lifecycle callbacks remain host-local. */ export const CLOUD_HUB_OPERATIONS = [ "create", + "createAndSend", "attach", "send", "abort", @@ -34,6 +35,21 @@ export const cloudSendFailureSchema = z.object({ }); export type CloudSendFailure = z.infer; +export function serializeCloudSendFailure( + error: unknown, + dispatched: boolean, +): CloudSendFailure { + return { + dispatched, + message: error instanceof Error ? error.message : String(error), + ...(error instanceof Error && + "detail" in error && + typeof error.detail === "string" + ? { detail: error.detail } + : {}), + }; +} + export class CloudSendError extends Error { constructor( message: string, diff --git a/sdk/packages/core/src/hub/server/cloud-session-service.test.ts b/sdk/packages/core/src/hub/server/cloud-session-service.test.ts index 4b5f26b588..86e559dc68 100644 --- a/sdk/packages/core/src/hub/server/cloud-session-service.test.ts +++ b/sdk/packages/core/src/hub/server/cloud-session-service.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { ClineAccountService } from "../../account"; import { getProviderAuthHandler } from "../../auth/provider-auth-registry"; import { CloudSessionError } from "../../cloud/api"; -import type { CloudSessionController } from "../../cloud/controller"; +import { CloudSessionController } from "../../cloud/controller"; import { type CloudHubConnection, HubCloudSessionClient, @@ -25,7 +25,7 @@ import { emptyStoredProviderSettings, ProviderSettingsSchemaTyped, } from "../../types/provider-settings"; -import { NodeHubClient } from "../client"; +import { HubCommandError, NodeHubClient } from "../client"; import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers"; import { createInMemoryHubOwnerContext } from "../discovery"; import { HubCloudSessionService } from "./cloud-session-service"; @@ -79,6 +79,7 @@ function fixture() { return () => {}; }, create: vi.fn(async () => ({ sessionId: "ses-one", status: "ready" })), + createAndSend: CloudSessionController.prototype.createAndSend, attach: vi.fn(async (sessionId: string) => { emit({ type: "snapshot", @@ -137,7 +138,15 @@ function fixture() { const result = name === "cloud.watch" ? service.watch() - : await service.command(body.operation, body.args, body.scope); + : await service + .command(body.operation, body.args, body.scope) + .catch((error: unknown) => { + throw new HubCommandError( + name, + undefined, + error instanceof Error ? error.message : String(error), + ); + }); return JSON.parse( JSON.stringify({ version: "v1", ok: true, payload: result }), ); @@ -189,6 +198,12 @@ describe("hub-owned cloud sessions", () => { ), () => viewer.listModels(scope), () => viewer.create({ modelId: "model", repoUrl: "repo" }, scope), + () => + viewer.createAndSend( + { modelId: "model", repoUrl: "repo", initialPrompt: "Continue" }, + [], + scope, + ), () => viewer.send( "ses-one", @@ -214,7 +229,10 @@ describe("hub-owned cloud sessions", () => { await expect(viewer.listModels()).resolves.toEqual([]); }); - it("keeps a lost send reply unknown without replaying the accepted input", async () => { + it.each([ + "send", + "createAndSend", + ] as const)("keeps a lost %s reply unknown without replaying the accepted input", async (operation) => { const f = fixture(); const viewer = f.viewer(); await viewer.connect(); @@ -223,11 +241,18 @@ describe("hub-owned cloud sessions", () => { await original(...args); throw new Error("Reply lost"); }; - const failure = await viewer - .send("ses-one", "Once") - .catch((error: unknown) => error); + const failure = await (operation === "send" + ? viewer.send("ses-one", "Once") + : viewer.createAndSend({ + modelId: "model", + repoUrl: "repo", + initialPrompt: "Once", + }) + ).catch((error: unknown) => error); expect(failure).toBeInstanceOf(Error); expect(failure).not.toBeInstanceOf(CloudSendError); + if (operation === "createAndSend") + expect(failure).toMatchObject({ code: "request_unconfirmed" }); expect(f.controllers[0]?.controller.send).toHaveBeenCalledOnce(); }); @@ -612,7 +637,7 @@ describe("cloud RPC over a shared hub socket", () => { } }); - it("reports in-flight cloud commands as hub activity", async () => { + it("keeps creation and sending active after the requesting viewer disconnects", async () => { const f = fixture(); const { root, server } = await startCloudHub(f); const client = new NodeHubClient({ @@ -625,33 +650,75 @@ describe("cloud RPC over a shared hub socket", () => { await client.connect(); return client; }); + const observer = new NodeHubClient({ + url: server.url, + authToken: server.authToken, + clientId: "cloud-observer", + workspaceRoot: root, + }); try { await viewer.connect(); + await observer.connect(); + const controller = f.controllers[0]?.controller; + if (!controller) throw new Error("Missing connected controller"); const provisioning = deferred<{ sessionId: string; status: string }>(); - f.controllers[0]!.controller.create.mockReturnValueOnce( - provisioning.promise, - ); - const created = viewer.create({ - modelId: "model", - repoUrl: "https://github.com/cline/cline", - }); - await vi.waitFor(() => - expect(f.controllers[0]!.controller.create).toHaveBeenCalled(), - ); - expect((await client.command("session.list")).payload).toMatchObject({ + const sending = deferred<{ sessionId: string; ok: true }>(); + controller.create.mockReturnValueOnce(provisioning.promise); + controller.send.mockReturnValueOnce(sending.promise); + const created = viewer + .createAndSend( + { + modelId: "model", + repoUrl: "https://github.com/example/project", + initialPrompt: "Summary and next instruction", + mode: "plan", + workspaceRelativePath: "packages/app", + }, + ["image"], + ) + .catch((error: unknown) => error); + await vi.waitFor(() => expect(controller.create).toHaveBeenCalled()); + expect((await observer.command("session.list")).payload).toMatchObject({ activeCloudCommands: 1, }); - expect((await client.command("hub.status")).payload).toMatchObject({ + expect((await observer.command("hub.status")).payload).toMatchObject({ idle: false, }); + await viewer.dispose(); + await client.dispose(); + expect(await created).toMatchObject({ code: "request_unconfirmed" }); provisioning.resolve({ sessionId: "ses-one", status: "ready" }); - await created; - expect((await client.command("session.list")).payload).toMatchObject({ - activeCloudCommands: 0, + await vi.waitFor(() => + expect(controller.send).toHaveBeenCalledExactlyOnceWith( + "ses-one", + "Summary and next instruction", + undefined, + "model", + ["image"], + { beforeDispatch: expect.any(Function) }, + ), + ); + expect((await observer.command("session.list")).payload).toMatchObject({ + activeCloudCommands: 1, }); + expect(controller.create).toHaveBeenCalledExactlyOnceWith({ + modelId: "model", + repoUrl: "https://github.com/example/project", + initialPrompt: "Summary and next instruction", + mode: "plan", + workspaceRelativePath: "packages/app", + sandboxType: "resumable", + }); + sending.resolve({ sessionId: "ses-one", ok: true }); + await vi.waitFor(async () => + expect((await observer.command("session.list")).payload).toMatchObject({ + activeCloudCommands: 0, + }), + ); } finally { await viewer.dispose(); await client.dispose(); + await observer.dispose(); await server.close(); rmSync(root, { recursive: true, force: true }); } @@ -710,6 +777,13 @@ describe("cloud RPC over a shared hub socket", () => { await expect(next.send("ses-one", "New work")).rejects.toThrow( /draining/, ); + await expect( + next.createAndSend({ + modelId: "model", + repoUrl: "repo", + initialPrompt: "Continue", + }), + ).rejects.toThrow(/draining/); await next.dispose(); } finally { await first.dispose(); diff --git a/sdk/packages/core/src/hub/server/cloud-session-service.ts b/sdk/packages/core/src/hub/server/cloud-session-service.ts index b5935be8a8..82199a7923 100644 --- a/sdk/packages/core/src/hub/server/cloud-session-service.ts +++ b/sdk/packages/core/src/hub/server/cloud-session-service.ts @@ -6,10 +6,11 @@ import { ClineAccountService } from "../../account"; import { getProviderAuthHandler } from "../../auth/provider-auth-registry"; import { CloudSessionApi } from "../../cloud/api"; import { CloudSessionController } from "../../cloud/controller"; -import type { - CloudHubOperation, - CloudHubScope, - CloudSendFailure, +import { + type CloudHubOperation, + type CloudHubScope, + type CloudSendFailure, + serializeCloudSendFailure, } from "../../cloud/hub-protocol"; import type { CloudSessionEvent, @@ -31,19 +32,22 @@ const creationOptions = z.object({ thinking: z.boolean().optional(), reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(), }); +const createInput = creationOptions + .extend({ + sandboxType: z.enum(["standard", "resumable"]).optional(), + requestId: id.optional(), + modelId: id, + repoUrl: id, + initialPrompt: z.string().optional(), + branch: id.optional(), + organizationId: id.nullable().optional(), + }) + .strict(); const schemas = { - create: z.tuple([ - creationOptions - .extend({ - sandboxType: z.enum(["standard", "resumable"]).optional(), - requestId: id.optional(), - modelId: id, - repoUrl: id, - initialPrompt: z.string().optional(), - branch: id.optional(), - organizationId: id.nullable().optional(), - }) - .strict(), + create: z.tuple([createInput]), + createAndSend: z.tuple([ + createInput.extend({ initialPrompt: id }), + z.array(z.string()).nullish(), ]), attach: z.tuple([id, creationOptions.nullish()]), send: z.tuple([ @@ -340,7 +344,7 @@ export class HubCloudSessionService { const normalized = args.map((value) => value === null ? undefined : value, ); - if (name === "create") { + if (name === "create" || name === "createAndSend") { const input = normalized[0] as { sandboxType?: string }; input.sandboxType ??= "resumable"; } @@ -370,15 +374,7 @@ export class HubCloudSessionService { } catch (error) { if (name !== "send") throw error; outcome = { - sendFailure: { - dispatched, - message: error instanceof Error ? error.message : String(error), - ...(error instanceof Error && - "detail" in error && - typeof error.detail === "string" - ? { detail: error.detail } - : {}), - }, + sendFailure: serializeCloudSendFailure(error, dispatched), }; } this.checkScope(); diff --git a/sdk/packages/core/src/hub/server/hub-server-transport.ts b/sdk/packages/core/src/hub/server/hub-server-transport.ts index 4bb84da25e..b99f4525fc 100644 --- a/sdk/packages/core/src/hub/server/hub-server-transport.ts +++ b/sdk/packages/core/src/hub/server/hub-server-transport.ts @@ -869,7 +869,7 @@ export class HubServerTransport implements NativeHubTransport { if ( this.draining && envelope.command === "cloud.command" && - ["create", "attach", "send"].includes( + ["create", "createAndSend", "attach", "send"].includes( String(envelope.payload?.operation), ) )