fix(cloud): keep initial task dispatch owned by the Hub

This commit is contained in:
John Choi
2026-10-23 15:44:12 -07:00
parent 56a028ed51
commit e0ca19614c
18 changed files with 371 additions and 148 deletions
@@ -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?.();
@@ -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,
@@ -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,
@@ -133,7 +133,7 @@ export function CloudHandoffStatus({
? state.message
? "Check the cloud task"
: "Cloud task ready"
: "Cloud preparation stopped";
: "Cloud handoff needs attention";
return (
<output
aria-live="polite"
@@ -35,6 +35,33 @@ function harness() {
}
describe("summary handoff lifecycle", () => {
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,
@@ -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,
@@ -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;
@@ -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)
+1 -1
View File
@@ -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
+4
View File
@@ -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.
+1
View File
@@ -104,6 +104,7 @@ export type CloudErrorCode =
| "session_not_found"
| "session_expired"
| "session_failed"
| "request_unconfirmed"
| "request_failed";
export class CloudSessionError extends Error {
+46 -10
View File
@@ -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],
+34
View File
@@ -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<CreateCloudSessionInput, "handoff"> & { initialPrompt: string },
images?: string[],
): Promise<{
target: CloudSessionAttachment;
send:
| Awaited<ReturnType<CloudSessionController["send"]>>
| 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,
+28 -9
View File
@@ -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<CloudHubResult<"create">> {
return this.call("create", [input], scope);
}
createAndSend(
input: CloudHubArgs<"createAndSend">[0],
images?: string[],
scope?: CloudHubScope,
): Promise<CloudHubResult<"createAndSend">> {
return this.call("createAndSend", [input, images], scope);
}
attach(...args: CloudHubArgs<"attach">): Promise<CloudHubResult<"attach">> {
return this.call("attach", args);
}
@@ -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<typeof cloudSendFailureSchema>;
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,
@@ -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();
@@ -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();
@@ -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),
)
)