mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
fix(desktop): reconcile missed cloud handoff completion
Reject previews overlapped by a local turn without blocking that turn. Release canceled previews and display structured cloud failures with delivery guidance.
This commit is contained in:
@@ -49,6 +49,7 @@ import {
|
||||
import { createDesktopExtensionContext } from "./client-context";
|
||||
import {
|
||||
beginActiveSessionSend,
|
||||
handleDiscardHandoff,
|
||||
handleHandoff,
|
||||
handlePrepareHandoff,
|
||||
} from "./cloud-handoff";
|
||||
@@ -2500,6 +2501,7 @@ const ACTION_HANDLERS: Record<
|
||||
attach: handleAttach,
|
||||
send: handleSend,
|
||||
prepare_handoff: handlePrepareHandoff,
|
||||
discard_handoff: handleDiscardHandoff,
|
||||
handoff: (ctx, request) =>
|
||||
handleHandoff(ctx, request, async (messages, config) => {
|
||||
const connection = buildSessionConnectionUpdate(
|
||||
@@ -2674,7 +2676,9 @@ export async function handleChatSessionCommand(
|
||||
"local",
|
||||
);
|
||||
if (
|
||||
(request.action === "prepare_handoff" || request.action === "handoff") &&
|
||||
(request.action === "prepare_handoff" ||
|
||||
request.action === "discard_handoff" ||
|
||||
request.action === "handoff") &&
|
||||
getSessionRuntimeBinding(ctx).kind !== "local"
|
||||
) {
|
||||
throw new Error("Cloud handoff is only supported for local sessions.");
|
||||
|
||||
@@ -314,10 +314,12 @@ describe("cloud handoff Git preparation", () => {
|
||||
|
||||
it("captures file contents before remote inspection while local edits continue", async () => {
|
||||
writeFileSync(join(repo, "tracked.txt"), "captured with conversation\n");
|
||||
const captured = vi.fn();
|
||||
transport.beforeFetch = () => {
|
||||
expect(captured).toHaveBeenCalledOnce();
|
||||
writeFileSync(join(repo, "tracked.txt"), "later local turn\n");
|
||||
};
|
||||
const plan = await inspectHandoffGit(repo);
|
||||
const plan = await inspectHandoffGit(repo, captured);
|
||||
await applyHandoffGit(plan);
|
||||
expect(
|
||||
git("--git-dir", transport.remote, "show", `${plan.branch}:tracked.txt`),
|
||||
|
||||
@@ -88,7 +88,10 @@ function isSensitivePath(path: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export async function inspectHandoffGit(cwd: string): Promise<HandoffGitPlan> {
|
||||
export async function inspectHandoffGit(
|
||||
cwd: string,
|
||||
onCaptured?: () => void,
|
||||
): Promise<HandoffGitPlan> {
|
||||
const root = await git(cwd, ["rev-parse", "--show-toplevel"]);
|
||||
const workspaceRelativePath = (
|
||||
await git(cwd, ["rev-parse", "--show-prefix"])
|
||||
@@ -174,6 +177,7 @@ export async function inspectHandoffGit(cwd: string): Promise<HandoffGitPlan> {
|
||||
"The repository changed while preparing the preview. Run /cloud again.",
|
||||
);
|
||||
}
|
||||
onCaptured?.();
|
||||
const remote =
|
||||
(await optionalConfig(root, `branch.${sourceBranch}.remote`)) || "origin";
|
||||
if (remote === "." || remote.startsWith("-"))
|
||||
|
||||
@@ -2,11 +2,17 @@ import { summarizeConversation } from "@cline/core";
|
||||
import { loadCloudModels } from "@cline/core/cloud";
|
||||
import { DEFAULT_MAX_IMAGE_DECODED_BYTES } from "@cline/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleHandoff, handlePrepareHandoff } from "./cloud-handoff";
|
||||
import {
|
||||
beginActiveSessionSend,
|
||||
handleDiscardHandoff,
|
||||
handleHandoff,
|
||||
handlePrepareHandoff,
|
||||
} from "./cloud-handoff";
|
||||
import * as gitPreparation from "./cloud-handoff-git";
|
||||
import {
|
||||
CloudCreationUnconfirmedError,
|
||||
type CloudSessionApi,
|
||||
CloudSessionError,
|
||||
CloudSessionManager,
|
||||
resetCloudSessionManager,
|
||||
} from "./cloud-sessions";
|
||||
@@ -145,12 +151,13 @@ function fixture() {
|
||||
handoffAttemptId: "attempt-1",
|
||||
gitPreparationId: plan.id,
|
||||
};
|
||||
const prepare = async () => {
|
||||
const prepare = async (completedHandoffAttemptId?: string) => {
|
||||
const preview = await handlePrepareHandoff(ctx, {
|
||||
...request,
|
||||
action: "prepare_handoff",
|
||||
completedHandoffAttemptId,
|
||||
});
|
||||
expect(preview.gitPreparation.id).toBe(plan.id);
|
||||
expect(preview).toMatchObject({ gitPreparation: { id: plan.id } });
|
||||
};
|
||||
return {
|
||||
ctx,
|
||||
@@ -174,6 +181,105 @@ function fixture() {
|
||||
}
|
||||
|
||||
describe("summary cloud handoff", () => {
|
||||
it("returns a missed completion before preparing another cloud task", async () => {
|
||||
const f = fixture();
|
||||
await f.prepare();
|
||||
const result = await f.handoff();
|
||||
const retry = await handlePrepareHandoff(f.ctx, f.request);
|
||||
expect(retry).toEqual({
|
||||
previousHandoff: { handoffAttemptId: "attempt-1", result },
|
||||
});
|
||||
expect(f.create).toHaveBeenCalledTimes(1);
|
||||
expect(f.send).toHaveBeenCalledTimes(1);
|
||||
const acknowledged = {
|
||||
...f.request,
|
||||
completedHandoffAttemptId: "attempt-1",
|
||||
};
|
||||
await expect(
|
||||
handlePrepareHandoff(f.ctx, acknowledged),
|
||||
).resolves.toMatchObject({ gitPreparation: { id: "plan" } });
|
||||
await handleDiscardHandoff(f.ctx, acknowledged);
|
||||
await expect(handlePrepareHandoff(f.ctx, f.request)).resolves.toEqual(
|
||||
retry,
|
||||
);
|
||||
await f.prepare("attempt-1");
|
||||
f.summarize.mockRejectedValueOnce(new Error("Summary unavailable"));
|
||||
await expect(
|
||||
f.handoff({ ...f.request, handoffAttemptId: "attempt-2" }),
|
||||
).rejects.toThrow("Summary unavailable");
|
||||
await f.prepare();
|
||||
});
|
||||
it("does not return another account's completed handoff", async () => {
|
||||
const f = fixture();
|
||||
await f.prepare();
|
||||
await f.handoff();
|
||||
f.ctx.cloudSessionManager = fixture().ctx.cloudSessionManager;
|
||||
await f.prepare();
|
||||
});
|
||||
it("discards only the canceled preview, not a newer preview", async () => {
|
||||
const f = fixture();
|
||||
await f.prepare();
|
||||
await handleDiscardHandoff(f.ctx, {
|
||||
...f.request,
|
||||
gitPreparationId: "older-plan",
|
||||
});
|
||||
await expect(f.handoff()).resolves.toMatchObject({
|
||||
sessionId: "ses-target",
|
||||
});
|
||||
await expect(
|
||||
handlePrepareHandoff(f.ctx, {
|
||||
...f.request,
|
||||
completedHandoffAttemptId: "attempt-1",
|
||||
}),
|
||||
).resolves.toMatchObject({ gitPreparation: { id: "plan" } });
|
||||
await handleDiscardHandoff(f.ctx, f.request);
|
||||
await expect(f.handoff()).rejects.toThrow("review the preparation plan");
|
||||
});
|
||||
it.each([
|
||||
false,
|
||||
true,
|
||||
])("invalidates only turns overlapping capture (captured=%s)", async (captured) => {
|
||||
const f = fixture();
|
||||
const ctx = getEnvironmentContext(f.ctx, "local");
|
||||
vi.mocked(gitPreparation.inspectHandoffGit).mockImplementation(
|
||||
async (_cwd, onCaptured?: () => void) => {
|
||||
if (captured) onCaptured?.();
|
||||
const release = beginActiveSessionSend(ctx, f.sourceSessionId);
|
||||
f.messages.push({ role: "user", content: "Keep this turn local." });
|
||||
release();
|
||||
if (!captured) onCaptured?.();
|
||||
return f.plan;
|
||||
},
|
||||
);
|
||||
if (!captured) {
|
||||
await expect(handlePrepareHandoff(ctx, f.request)).rejects.toThrow(
|
||||
"conversation changed",
|
||||
);
|
||||
await expect(handleHandoff(ctx, f.request, f.summarize)).rejects.toThrow(
|
||||
"review the preparation plan",
|
||||
);
|
||||
} else {
|
||||
await handlePrepareHandoff(ctx, f.request);
|
||||
await expect(
|
||||
handleHandoff(ctx, f.request, f.summarize),
|
||||
).resolves.toMatchObject({
|
||||
sessionId: "ses-target",
|
||||
});
|
||||
expect(f.summarize.mock.calls[0]?.[0]).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
it("shows a provisioning failure reason without losing the unsent draft", async () => {
|
||||
const f = fixture();
|
||||
await f.prepare();
|
||||
f.send.mockRejectedValue(
|
||||
new CloudSessionError("session_failed", "Repository clone failed."),
|
||||
);
|
||||
await expect(f.handoff()).resolves.toMatchObject({
|
||||
warning: expect.stringContaining("Repository clone failed."),
|
||||
warningKind: "unqueued",
|
||||
unsentPrompt: expect.stringContaining("Search form: implemented."),
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
"preview",
|
||||
"summary",
|
||||
@@ -356,7 +462,7 @@ describe("summary cloud handoff", () => {
|
||||
"If no next action is clear, ask",
|
||||
);
|
||||
expect(f.send.mock.calls[0]?.[4]).toEqual([image]);
|
||||
await f.prepare();
|
||||
await f.prepare("attempt-1");
|
||||
await f.handoff({ ...f.request, handoffAttemptId: "attempt-2" });
|
||||
expect(f.create.mock.calls.map(([input]) => input.requestId)).toEqual([
|
||||
"attempt-1",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { buildCloudHandoffDashboardUrl, type ClineCore } from "@cline/core";
|
||||
import { type CloudCreationOptions, loadCloudModels } from "@cline/core/cloud";
|
||||
import {
|
||||
type CloudCreationOptions,
|
||||
CloudSessionError,
|
||||
loadCloudModels,
|
||||
} from "@cline/core/cloud";
|
||||
import type { MessageWithMetadata } from "@cline/llms";
|
||||
import {
|
||||
type AgentMode,
|
||||
@@ -47,10 +51,25 @@ type HandoffSnapshot = {
|
||||
};
|
||||
|
||||
const gitPlans = new WeakMap<SidecarContext, Map<string, HandoffSnapshot>>();
|
||||
const gitPreparations = new WeakMap<SidecarContext, Set<string>>();
|
||||
const handoffRequests = new WeakMap<SidecarContext, Set<string>>();
|
||||
const gitPreparations = new WeakMap<
|
||||
SidecarContext,
|
||||
Map<string, "capturing" | "changed" | "captured">
|
||||
>();
|
||||
type HandoffRequest = {
|
||||
id: string;
|
||||
cloud: CloudSessionManager;
|
||||
} & ({ status: "running" } | { status: "complete"; result: HandoffResult });
|
||||
const handoffRequests = new WeakMap<
|
||||
SidecarContext,
|
||||
Map<string, HandoffRequest>
|
||||
>();
|
||||
const activeSendRequests = new WeakMap<SidecarContext, Map<string, number>>();
|
||||
|
||||
export type HandoffResult = Awaited<ReturnType<typeof handleHandoffOnce>>;
|
||||
export type PrepareHandoffResult =
|
||||
| { gitPreparation: HandoffGitPreview }
|
||||
| { previousHandoff: { handoffAttemptId: string; result: HandoffResult } };
|
||||
|
||||
function assertCloudHandoffAvailable(): void {
|
||||
if (!isCloudAgentsEnabled())
|
||||
throw new Error("Cloud sessions are not available for this account.");
|
||||
@@ -148,17 +167,30 @@ async function resolveCloudModel(
|
||||
export async function handlePrepareHandoff(
|
||||
ctx: SidecarContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
): Promise<{ gitPreparation: HandoffGitPreview }> {
|
||||
): Promise<PrepareHandoffResult> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
if (isCloudHandoffInProgress(ctx, sessionId))
|
||||
throw new Error("Cloud preparation is already in progress.");
|
||||
const previous = handoffRequests.get(ctx)?.get(sessionId);
|
||||
if (
|
||||
previous?.status === "complete" &&
|
||||
previous.cloud === getSidecarContextOwner(ctx).cloudSessionManager &&
|
||||
previous.id !== request.completedHandoffAttemptId
|
||||
) {
|
||||
return {
|
||||
previousHandoff: {
|
||||
handoffAttemptId: previous.id,
|
||||
result: previous.result,
|
||||
},
|
||||
};
|
||||
}
|
||||
let active = gitPreparations.get(ctx);
|
||||
if (!active) {
|
||||
active = new Set();
|
||||
active = new Map();
|
||||
gitPreparations.set(ctx, active);
|
||||
}
|
||||
active.add(sessionId);
|
||||
active.set(sessionId, "capturing");
|
||||
try {
|
||||
const cloud = getCloudSessionManager(ctx);
|
||||
const source = await prepareCloudHandoff(ctx, request);
|
||||
@@ -168,7 +200,13 @@ export async function handlePrepareHandoff(
|
||||
);
|
||||
if (!messages.length)
|
||||
throw new Error("Start a conversation before using /cloud.");
|
||||
const plan = await inspectHandoffGit(source.cwd);
|
||||
const plan = await inspectHandoffGit(source.cwd, () => {
|
||||
if (active.get(sessionId) === "changed")
|
||||
throw new Error(
|
||||
"The local conversation changed during capture. Run /cloud again when the turn finishes.",
|
||||
);
|
||||
active.set(sessionId, "captured");
|
||||
});
|
||||
await resolveCloudModel(ctx, cloud, config, plan.repoUrl);
|
||||
assertHandoffAccount(ctx, cloud);
|
||||
let plans = gitPlans.get(ctx);
|
||||
@@ -183,6 +221,16 @@ export async function handlePrepareHandoff(
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDiscardHandoff(
|
||||
ctx: SidecarContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
): Promise<void> {
|
||||
const plans = gitPlans.get(ctx);
|
||||
const id = request.sessionId?.trim();
|
||||
if (id && plans && plans.get(id)?.plan.id === request.gitPreparationId)
|
||||
plans.delete(id);
|
||||
}
|
||||
|
||||
type Summarize = (
|
||||
messages: MessageWithMetadata[],
|
||||
config: JsonRecord,
|
||||
@@ -192,7 +240,8 @@ async function handleHandoffOnce(
|
||||
ctx: SidecarContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
summarize: Summarize,
|
||||
): Promise<unknown> {
|
||||
approved: HandoffSnapshot,
|
||||
) {
|
||||
if (request.attachments?.userFiles?.length)
|
||||
throw new Error("Only image attachments can be sent with /cloud.");
|
||||
const images = request.attachments?.userImages ?? [];
|
||||
@@ -210,9 +259,6 @@ async function handleHandoffOnce(
|
||||
assertCloudHandoffAvailable();
|
||||
const sourceSessionId = request.sessionId?.trim();
|
||||
if (!sourceSessionId) throw new Error("sessionId is required");
|
||||
const approved = gitPlans.get(ctx)?.get(sourceSessionId);
|
||||
if (!approved || approved.plan.id !== request.gitPreparationId)
|
||||
throw new Error("Run /cloud again to review the preparation plan.");
|
||||
gitPlans.get(ctx)?.delete(sourceSessionId);
|
||||
const { cloud, config, messages, plan } = approved;
|
||||
assertHandoffAccount(ctx, cloud);
|
||||
@@ -316,12 +362,14 @@ async function handleHandoffOnce(
|
||||
warning =
|
||||
"The cloud connection was interrupted. Check the conversation before resending.";
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
warningKind = dispatched ? "unconfirmed" : "unqueued";
|
||||
warning =
|
||||
warningKind === "unconfirmed"
|
||||
? "The cloud session was created, but delivery is unconfirmed. Check the conversation before resending."
|
||||
: "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 CloudSessionError)
|
||||
warning = `${error.detail} ${warning}`;
|
||||
}
|
||||
const result = {
|
||||
sourceSessionId,
|
||||
@@ -343,23 +391,36 @@ export async function handleHandoff(
|
||||
ctx: SidecarContext,
|
||||
request: ChatSessionCommandRequest,
|
||||
summarize: Summarize,
|
||||
): Promise<unknown> {
|
||||
): Promise<HandoffResult> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (!sessionId) throw new Error("sessionId is required");
|
||||
if (gitPreparations.get(ctx)?.has(sessionId))
|
||||
throw new Error("Wait for Git preparation to finish before continuing.");
|
||||
let requests = handoffRequests.get(ctx);
|
||||
if (!requests) {
|
||||
requests = new Set();
|
||||
requests = new Map();
|
||||
handoffRequests.set(ctx, requests);
|
||||
}
|
||||
if (requests.has(sessionId))
|
||||
if (requests.get(sessionId)?.status === "running")
|
||||
throw new Error("Cloud preparation is already in progress.");
|
||||
requests.add(sessionId);
|
||||
const approved = gitPlans.get(ctx)?.get(sessionId);
|
||||
if (!approved || approved.plan.id !== request.gitPreparationId)
|
||||
throw new Error("Run /cloud again to review the preparation plan.");
|
||||
const id = request.handoffAttemptId?.trim() || randomUUID();
|
||||
const { cloud } = approved;
|
||||
requests.set(sessionId, { id, cloud, status: "running" });
|
||||
try {
|
||||
return await handleHandoffOnce(ctx, request, summarize);
|
||||
} finally {
|
||||
const result = await handleHandoffOnce(
|
||||
ctx,
|
||||
{ ...request, handoffAttemptId: id },
|
||||
summarize,
|
||||
approved,
|
||||
);
|
||||
requests.set(sessionId, { id, cloud, status: "complete", result });
|
||||
return result;
|
||||
} catch (error) {
|
||||
requests.delete(sessionId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +429,9 @@ export function beginActiveSessionSend(
|
||||
sessionId: string,
|
||||
): () => void {
|
||||
ctx = getEnvironmentContext(ctx, ctx.activeEnvironmentId ?? "local");
|
||||
const preparation = gitPreparations.get(ctx);
|
||||
if (preparation?.get(sessionId) === "capturing")
|
||||
preparation.set(sessionId, "changed");
|
||||
let sends = activeSendRequests.get(ctx);
|
||||
if (!sends) {
|
||||
sends = new Map();
|
||||
@@ -394,7 +458,7 @@ function isCloudHandoffInProgress(
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
handoffRequests.get(ctx)?.has(sessionId) ||
|
||||
handoffRequests.get(ctx)?.get(sessionId)?.status === "running" ||
|
||||
gitPreparations.get(ctx)?.has(sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export type ChatSessionCommandRequest = {
|
||||
| "attach"
|
||||
| "send"
|
||||
| "prepare_handoff"
|
||||
| "discard_handoff"
|
||||
| "handoff"
|
||||
| "stop"
|
||||
| "abort"
|
||||
@@ -47,6 +48,7 @@ export type ChatSessionCommandRequest = {
|
||||
attachments?: ChatTurnAttachments;
|
||||
/** Opaque webview correlation token echoed on handoff progress events. */
|
||||
handoffAttemptId?: string;
|
||||
completedHandoffAttemptId?: string;
|
||||
gitPreparationId?: string;
|
||||
/** Optional instruction appended to the locally generated summary. */
|
||||
nextCommand?: string;
|
||||
|
||||
@@ -179,6 +179,7 @@ import {
|
||||
workspacePathsFromSessions,
|
||||
writeWorkspaceSelectionToWindow,
|
||||
} from "@/lib/workspace-paths";
|
||||
import type { PrepareHandoffResult } from "../../sidecar/cloud-handoff";
|
||||
import type { HandoffGitPreview } from "../../sidecar/cloud-handoff-git";
|
||||
|
||||
// Lazily loaded views: none of these are needed for the first paint of the
|
||||
@@ -2234,20 +2235,43 @@ function ChatThreadPane({
|
||||
sourceSessionId,
|
||||
});
|
||||
const attachments = await serializeAttachments(sourceAttachments);
|
||||
const { gitPreparation } = await desktopClient.invoke<{
|
||||
gitPreparation: HandoffGitPreview;
|
||||
}>(
|
||||
const preparation = await desktopClient.invoke<PrepareHandoffResult>(
|
||||
"chat_session_command",
|
||||
{
|
||||
request: {
|
||||
action: "prepare_handoff",
|
||||
sessionId: sourceSessionId,
|
||||
completedHandoffAttemptId:
|
||||
handoffLifecycle.getCompletedAttemptId(sourceSessionId),
|
||||
config,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: HANDOFF_STAGE_TIMEOUT_MS },
|
||||
);
|
||||
if ("previousHandoff" in preparation) {
|
||||
handoffLifecycle.onPreparationRejected(sourceSessionId, {
|
||||
error: new Error(
|
||||
"Your earlier cloud task completed. Check it before starting another task.",
|
||||
),
|
||||
nextCommand,
|
||||
sourceAttachments,
|
||||
silent: true,
|
||||
});
|
||||
handoffLifecycle.onRecovered(
|
||||
sourceSessionId,
|
||||
preparation.previousHandoff,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { gitPreparation } = preparation;
|
||||
if (!(await confirmHandoffGit(gitPreparation))) {
|
||||
await desktopClient.invoke("chat_session_command", {
|
||||
request: {
|
||||
action: "discard_handoff",
|
||||
sessionId: sourceSessionId,
|
||||
gitPreparationId: gitPreparation.id,
|
||||
},
|
||||
});
|
||||
handoffLifecycle.onPreparationRejected(sourceSessionId, {
|
||||
error: new Error("Cloud preparation cancelled."),
|
||||
nextCommand,
|
||||
|
||||
@@ -35,6 +35,44 @@ function harness() {
|
||||
}
|
||||
|
||||
describe("summary handoff lifecycle", () => {
|
||||
it.each([
|
||||
false,
|
||||
true,
|
||||
])("recovers A without substituting B's files (reload=%s)", async (reload) => {
|
||||
const h = harness();
|
||||
const aFiles = [new File(["a"], "a.png")];
|
||||
const bFiles = [new File(["b"], "b.png")];
|
||||
const id = reload ? "previous-window" : h.start(aFiles);
|
||||
if (!reload)
|
||||
await h.lifecycle.onRpcRejected("local", {
|
||||
handoffAttemptId: id,
|
||||
error: new Error("Connection closed"),
|
||||
nextCommand: "A",
|
||||
sourceAttachments: aFiles,
|
||||
});
|
||||
expect(h.lifecycle.getCompletedAttemptId("local")).toBeUndefined();
|
||||
expect(h.lifecycle.onPreparationStarted("local")).toBe(true);
|
||||
h.lifecycle.onPreparationRejected("local", {
|
||||
error: new Error("Check the existing task"),
|
||||
nextCommand: "B",
|
||||
sourceAttachments: bFiles,
|
||||
silent: true,
|
||||
});
|
||||
h.lifecycle.onRecovered("local", {
|
||||
handoffAttemptId: id,
|
||||
result: { ...result, warningKind: "unqueued", unsentPrompt: "Summary A" },
|
||||
});
|
||||
expect(h.state().local).toMatchObject({
|
||||
status: "complete",
|
||||
sessionId: "cloud",
|
||||
unsentDraft: { text: "Summary A", attachments: reload ? [] : aFiles },
|
||||
preparationFailure: { draft: { text: "B", attachments: bFiles } },
|
||||
});
|
||||
if (reload)
|
||||
expect(h.state().local.message).toContain("Reattach any images");
|
||||
expect(h.lifecycle.getCompletedAttemptId("local")).toBe(id);
|
||||
expect(h.lifecycle.onPreparationStarted("local")).toBe(true);
|
||||
});
|
||||
it.each([
|
||||
"before",
|
||||
"after",
|
||||
|
||||
@@ -84,6 +84,32 @@ export function createHandoffLifecycle(effects: HandoffLifecycleEffects) {
|
||||
if (warning) effects.toast(warning);
|
||||
};
|
||||
return {
|
||||
getCompletedAttemptId(sourceSessionId: string): string | undefined {
|
||||
const attempt = sources.get(sourceSessionId)?.attempt;
|
||||
return attempt?.completed ? attempt.id : undefined;
|
||||
},
|
||||
onRecovered(
|
||||
sourceSessionId: string,
|
||||
recovered: { handoffAttemptId: string; result: HandoffResult },
|
||||
): void {
|
||||
let attempt = current(sourceSessionId, recovered.handoffAttemptId);
|
||||
let result = recovered.result;
|
||||
if (!attempt) {
|
||||
attempt = {
|
||||
id: recovered.handoffAttemptId,
|
||||
completed: false,
|
||||
attachments: [],
|
||||
};
|
||||
sources.set(sourceSessionId, { attempt, preparing: false });
|
||||
if (result.unsentPrompt)
|
||||
result = {
|
||||
...result,
|
||||
warning:
|
||||
`${result.warning ?? ""} Reattach any images from the previous window before sending.`.trim(),
|
||||
};
|
||||
}
|
||||
complete(sourceSessionId, attempt, result);
|
||||
},
|
||||
onPreparationStarted(sourceSessionId: string): boolean {
|
||||
const source = sources.get(sourceSessionId);
|
||||
if (source?.preparing) return false;
|
||||
|
||||
@@ -7,14 +7,7 @@ type ParsedHandoffCommand = {
|
||||
nextCommand: string;
|
||||
};
|
||||
|
||||
export type HandoffResult = {
|
||||
sourceSessionId: string;
|
||||
sessionId: string;
|
||||
dashboardUrl: string;
|
||||
warning?: string;
|
||||
warningKind?: "unqueued" | "unconfirmed";
|
||||
unsentPrompt?: string;
|
||||
};
|
||||
export type { HandoffResult } from "../../sidecar/cloud-handoff";
|
||||
|
||||
export type HandoffProgressPhase =
|
||||
| "checking"
|
||||
|
||||
Reference in New Issue
Block a user