mirror of
https://github.com/cline/cline.git
synced 2026-10-24 23:34:55 +08:00
fix(cloud): send initial handoff prompt before follow-ups
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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="] },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user