fix(cloud): send initial handoff prompt before follow-ups

This commit is contained in:
John Choi
2026-10-23 17:08:00 -07:00
parent e0ca19614c
commit 882c456753
4 changed files with 157 additions and 24 deletions
@@ -111,13 +111,16 @@ async function fixture() {
status: "idle",
startedAt: 1,
});
const create = vi.fn(
async (_input: Parameters<CloudSessionApi["create"]>[0]) => ({
sessionId: "ses-target",
status: "ready" as const,
sandboxUrl: "",
}),
);
const cloud = await createCloudSessionTestRuntime(ctx, {
api: {
create: vi.fn(async () => ({
sessionId: "ses-target",
status: "ready",
sandboxUrl: "",
})),
create,
} as unknown as CloudSessionApi,
apiBaseUrl: "https://api.example",
getAuthToken: async () => "synthetic",
@@ -128,7 +131,6 @@ async function fixture() {
{ id: modelId, catalogId: "cline-cloud", name: "Test" },
]);
vi.spyOn(cloud, "prepareHandoffRepository").mockResolvedValue({});
const create = vi.spyOn(cloud, "create");
const send = vi.spyOn(cloud, "send").mockImplementation(async (...args) => {
args[5]?.beforeDispatch?.();
return { sessionId: "ses-target", ok: true };
@@ -480,7 +482,7 @@ describe("summary cloud handoff", () => {
expect(f.send).toHaveBeenCalledWith(
"ses-target",
prompt,
undefined,
"queue",
"test-model",
[image],
expect.any(Object),
+66 -1
View File
@@ -1029,6 +1029,71 @@ describe("CloudSessionController neutral host contract", () => {
expect(accepted()).toHaveLength(1);
await f.controller.dispose();
});
it.each([
false,
true,
])("holds follow-ups behind the initial prompt (rejected: %s)", async (rejected) => {
const f = fixture();
let release!: () => void;
let entered!: () => void;
const blocked = new Promise<void>((resolve) => {
entered = resolve;
});
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const original = f.command.getMockImplementation()!;
let attaches = 0;
f.command.mockImplementation(async (...args) => {
if (args[0] === "session.attach" && ++attaches === 2) {
entered();
await gate;
if (rejected) throw new Error("Initial attachment failed");
}
return original(...args);
});
try {
const creating = f.controller.createAndSend({
repoUrl: "https://github.com/example/project",
modelId: "model",
initialPrompt: "INITIAL SUMMARY",
});
await blocked;
const following = f.controller.send(record.id, "FOLLOW UP", "queue");
const outcome = following.then(
() => "sent",
() => "rejected",
);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(
f.commands.filter((entry) => entry.command === "session.send_input"),
).toEqual([]);
release();
await creating;
expect(await outcome).toBe(rejected ? "rejected" : "sent");
expect(
f.commands
.filter((entry) => entry.command === "session.send_input")
.map((entry) => entry.payload),
).toEqual(
rejected
? []
: [
{ prompt: "INITIAL SUMMARY", delivery: "queue" },
{ prompt: "FOLLOW UP", delivery: "queue" },
],
);
if (rejected) {
await f.controller.send(record.id, "Explicit retry", "queue");
expect(
f.commands.filter((entry) => entry.command === "session.send_input"),
).toHaveLength(1);
}
} finally {
release();
await f.controller.dispose();
}
});
it("creates and sends one initial prompt with images without a second caller action", async () => {
const f = fixture();
try {
@@ -1053,7 +1118,7 @@ describe("CloudSessionController neutral host contract", () => {
sessionId: "inner",
payload: {
prompt: "Summary and next instruction",
delivery: undefined,
delivery: "queue",
attachments: { userImages: ["data:image/png;base64,aW1hZ2U="] },
},
},
+57 -11
View File
@@ -455,6 +455,13 @@ export class CloudSessionController {
string,
Promise<CloudSessionAttachment>
>();
private readonly initialSends = new Map<
string,
{
lifecycle: CloudSendLifecycle;
settled: Promise<CloudSendFailure | undefined>;
}
>();
private readonly provisioningControllers = new Map<
string,
Set<AbortController>
@@ -1035,12 +1042,23 @@ export class CloudSessionController {
async create(
input: CreateCloudSessionInput,
): Promise<CloudSessionAttachment> {
return this.createSession(input);
}
private async createSession(
input: CreateCloudSessionInput,
beforePublish?: (sessionId: string) => void,
): Promise<CloudSessionAttachment> {
const key = input.requestId?.trim();
if (!key) return await this.createOnce(input);
if (!key) return await this.createOnce(input, beforePublish);
const existing = this.createRequests.get(key);
if (existing) return await existing;
const creating = this.createOnce(input).finally(() => {
if (existing) {
const target = await existing;
beforePublish?.(target.sessionId);
return target;
}
const creating = this.createOnce(input, beforePublish).finally(() => {
if (this.createRequests.get(key) === creating) {
this.createRequests.delete(key);
}
@@ -1058,24 +1076,40 @@ export class CloudSessionController {
| Awaited<ReturnType<CloudSessionController["send"]>>
| CloudSendFailure;
}> {
const target = await this.create(input);
let dispatched = false;
let settle!: (failure?: CloudSendFailure) => void;
const initial = {
lifecycle: {
beforeDispatch: () => {
dispatched = true;
},
},
settled: new Promise<CloudSendFailure | undefined>((resolve) => {
settle = resolve;
}),
};
const target = await this.createSession(input, (sessionId) => {
if (!this.initialSends.has(sessionId))
this.initialSends.set(sessionId, initial);
});
try {
const send = await this.send(
target.sessionId,
input.initialPrompt,
undefined,
"queue",
input.modelId,
images,
{
beforeDispatch: () => {
dispatched = true;
},
},
initial.lifecycle,
);
settle();
return { target, send };
} catch (error) {
return { target, send: serializeCloudSendFailure(error, dispatched) };
const send = serializeCloudSendFailure(error, dispatched);
settle(send);
return { target, send };
} finally {
if (this.initialSends.get(target.sessionId) === initial)
this.initialSends.delete(target.sessionId);
}
}
@@ -1195,6 +1229,7 @@ export class CloudSessionController {
private async createOnce(
input: CreateCloudSessionInput,
beforePublish?: (sessionId: string) => void,
): Promise<CloudSessionAttachment> {
if (this.disposed) throw new Error("Cloud session manager was disposed");
const cwd = cloudWorkspaceCwd(input.workspaceRelativePath);
@@ -1306,6 +1341,7 @@ export class CloudSessionController {
}
this.assertSessionActive(record.id);
beforePublish?.(record.id);
this.notify("chat_session_status", {
sessionId: record.id,
status: live.status,
@@ -1411,6 +1447,16 @@ export class CloudSessionController {
result?: unknown;
}> {
const abortToken = this.sendAbortTokens.get(outerSessionId);
const initial = this.initialSends.get(outerSessionId);
// Only the initial send may bypass its own reservation.
if (initial && lifecycle !== initial.lifecycle) {
if (await initial.settled) {
throw new CloudSessionError(
"request_failed",
"The initial cloud prompt was not confirmed. Check the cloud task before sending another message.",
);
}
}
const knownForSend = this.knownSessions.get(outerSessionId);
if (knownForSend && isExpiredRecord(knownForSend)) {
throw new CloudSessionError(
@@ -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 { CloudSessionController } from "../../cloud/controller";
import type { CloudSessionController } from "../../cloud/controller";
import {
type CloudHubConnection,
HubCloudSessionClient,
@@ -78,8 +78,29 @@ function fixture() {
emit = listener;
return () => {};
},
create: vi.fn(async () => ({ sessionId: "ses-one", status: "ready" })),
createAndSend: CloudSessionController.prototype.createAndSend,
create: vi.fn(
async (_input: Parameters<CloudSessionController["create"]>[0]) => ({
sessionId: "ses-one",
status: "ready",
}),
),
createAndSend: vi.fn(
async (
...[input, images]: Parameters<
CloudSessionController["createAndSend"]
>
) => {
const target = await controller.create(input);
const send = await controller.send(
target.sessionId,
input.initialPrompt,
"queue",
input.modelId,
images,
);
return { target, send };
},
),
attach: vi.fn(async (sessionId: string) => {
emit({
type: "snapshot",
@@ -692,10 +713,9 @@ describe("cloud RPC over a shared hub socket", () => {
expect(controller.send).toHaveBeenCalledExactlyOnceWith(
"ses-one",
"Summary and next instruction",
undefined,
"queue",
"model",
["image"],
{ beforeDispatch: expect.any(Function) },
),
);
expect((await observer.command("session.list")).payload).toMatchObject({