Skip to content

Commit 57082f8

Browse files
committed
fix(supervisor): surface resume failures with provider-specific copy
- rewrite ACP `session/load` errors into user-facing "can't be resumed" messages, with a Cursor-specific override for its unsupported resume - thread an optional `loadSessionErrorRewriter` through the structured session input so adapters can supply provider copy - strip Electron's `Error invoking remote method '...'` framing from launch errors in ThreadView before surfacing them
1 parent 85c5936 commit 57082f8

7 files changed

Lines changed: 204 additions & 6 deletions

File tree

src/renderer/components/thread/ThreadView.test.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,65 @@ describe("ThreadView", () => {
211211
});
212212
});
213213

214+
it("strips Electron IPC framing from launch errors before surfacing them", async () => {
215+
bridge.startThread.mockRejectedValueOnce(
216+
new Error(
217+
"Error invoking remote method 'lightcode:start-thread': Error: This conversation can't be resumed.",
218+
),
219+
);
220+
const onLaunchFailed = vi.fn<(message: string) => void>();
221+
222+
renderThreadView({
223+
thread: {
224+
id: "thread-launch-ipc-error",
225+
projectId: "project-1",
226+
title: "Cursor resume",
227+
agentKind: "cursor",
228+
config: { model: "auto" },
229+
status: "launching",
230+
attention: "none",
231+
canResumeWithConfig: false,
232+
archived: false,
233+
done: false,
234+
starred: false,
235+
createdAt: new Date().toISOString(),
236+
updatedAt: new Date().toISOString(),
237+
},
238+
agentStatus: {
239+
kind: "cursor",
240+
label: "Cursor",
241+
installed: true,
242+
authState: "authenticated",
243+
capabilities: {
244+
models: [{ id: "auto", label: "Auto" }],
245+
efforts: [],
246+
modelEfforts: {},
247+
modes: ["agent"],
248+
approvalPolicies: [{ id: "default", label: "Default" }],
249+
sandboxModes: [],
250+
supportsResume: true,
251+
supportsDirectInput: true,
252+
liveInputMode: "terminal",
253+
presentationMode: "terminal",
254+
settingDefs: [],
255+
},
256+
},
257+
projectLocation: { kind: "posix", path: "/tmp" },
258+
pendingLaunchPrompt: "hi",
259+
onConfigChange: () => undefined,
260+
onLaunchConsumed: () => undefined,
261+
onLaunchFailed,
262+
onResolveServerRequest: async () => undefined,
263+
onSubmitInput: async () => undefined,
264+
});
265+
266+
fireEvent.click(screen.getByText("report terminal size"));
267+
268+
await waitFor(() => {
269+
expect(onLaunchFailed).toHaveBeenCalledWith("This conversation can't be resumed.");
270+
});
271+
});
272+
214273
it("renders a server-mode composer for Codex live threads", () => {
215274
renderThreadView({
216275
thread: {

src/renderer/components/thread/ThreadView.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,20 @@ import { ThreadHeaderStatusButton } from "./ThreadHeaderStatus";
2828

2929
const DEFAULT_HIDDEN_TERMINAL_SIZE: TerminalSize = { cols: 120, rows: 30 };
3030

31+
/**
32+
* Strip Electron's `Error invoking remote method '<channel>': Error: ` prefix
33+
* from IPC rejections so users see the supervisor's actual message verbatim.
34+
*/
35+
function stripIpcInvokeFraming(message: string): string {
36+
return message.replace(/^Error invoking remote method '[^']+':\s*(?:Error:\s*)?/, "");
37+
}
38+
3139
function formatLaunchError(error: unknown): string {
3240
if (error instanceof Error && error.message.trim().length > 0) {
33-
return error.message;
41+
return stripIpcInvokeFraming(error.message);
3442
}
3543
if (typeof error === "string" && error.trim().length > 0) {
36-
return error;
44+
return stripIpcInvokeFraming(error);
3745
}
3846
if (
3947
error &&
@@ -42,7 +50,7 @@ function formatLaunchError(error: unknown): string {
4250
typeof error.message === "string" &&
4351
error.message.trim().length > 0
4452
) {
45-
return error.message;
53+
return stripIpcInvokeFraming(error.message);
4654
}
4755
return "Thread failed to start.";
4856
}

src/supervisor/agents/acp/session.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { describe, expect, it, vi } from "vitest";
2+
import { RequestError } from "@agentclientprotocol/sdk";
23
import type { CreateStructuredSessionInput } from "../base";
34
import type { ThreadConfig } from "@/shared/contracts";
45
import {
56
AcpStructuredSession,
67
resolveAcpResourcePath,
8+
rewriteLoadSessionError,
79
shouldSpawnAcpSession,
810
toAcpResourceUri,
911
} from "./session";
@@ -143,6 +145,36 @@ describe("shouldSpawnAcpSession — shared resume/presentation gate for all ACP
143145
});
144146
});
145147

148+
describe("rewriteLoadSessionError — user-facing copy for session/load failures", () => {
149+
it("rewrites a 'Session not found' invalidParams into resume-specific guidance", () => {
150+
const raw = RequestError.invalidParams({ message: 'Session "abc-123" not found' });
151+
const out = rewriteLoadSessionError(raw, "abc-123");
152+
expect(out.message).toBe(
153+
"This conversation can't be resumed — the agent no longer recognizes this session. Start a new thread to continue.",
154+
);
155+
expect((out as { cause?: unknown }).cause).toBe(raw);
156+
});
157+
158+
it("includes the agent's error message verbatim for non-not-found failures", () => {
159+
const raw = RequestError.invalidParams({ message: "cwd does not match" });
160+
const out = rewriteLoadSessionError(raw, "ses-9");
161+
expect(out.message).toContain("cwd does not match");
162+
expect(out.message).toContain("Start a new thread");
163+
});
164+
165+
it("falls back to the Error message when the error isn't a RequestError", () => {
166+
const out = rewriteLoadSessionError(new Error("stream closed"), "ses-9");
167+
expect(out.message).toContain("stream closed");
168+
expect(out.message).toContain("Start a new thread");
169+
});
170+
171+
it("detects 'session ... not found' phrasing inside plain Error messages", () => {
172+
const out = rewriteLoadSessionError(new Error('session "ses-9" not found'), "ses-9");
173+
expect(out.message).toContain("can't be resumed");
174+
expect(out.message).not.toContain("ses-9");
175+
});
176+
});
177+
146178
describe("ACP resource path helpers", () => {
147179
it("resolves repo-relative paths against the project root", () => {
148180
expect(

src/supervisor/agents/acp/session.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
ClientSideConnection,
2121
ndJsonStream,
2222
PROTOCOL_VERSION,
23+
RequestError,
2324
type Client,
2425
type ContentBlock,
2526
type RequestPermissionRequest,
@@ -217,6 +218,39 @@ function createAcpPromptUsageEvent(threadId: string, usage: unknown): RuntimeEve
217218
);
218219
}
219220

221+
/**
222+
* Replace the raw JSON-RPC error from `session/load` with a message the
223+
* renderer can show verbatim. Provider-agnostic on purpose: the same code
224+
* path triggers whenever any ACP agent rejects a `session/load` call (lost,
225+
* rotated, or never-persisted sessionId).
226+
*/
227+
export function rewriteLoadSessionError(error: unknown, _sessionId: string): Error {
228+
const detail = extractLoadSessionDetail(error);
229+
const message = detail.notFound
230+
? "This conversation can't be resumed — the agent no longer recognizes this session. Start a new thread to continue."
231+
: `This conversation can't be resumed: ${detail.message ?? (error instanceof Error ? error.message : String(error))}. Start a new thread to continue.`;
232+
return Object.assign(new Error(message), { cause: error });
233+
}
234+
235+
function extractLoadSessionDetail(error: unknown): { message?: string; notFound: boolean } {
236+
let message: string | undefined;
237+
let notFound = false;
238+
if (error instanceof RequestError) {
239+
message = error.message;
240+
const data = error.data as { message?: unknown } | undefined;
241+
if (data && typeof data.message === "string") {
242+
message = data.message;
243+
if (/not\s+found/i.test(data.message)) notFound = true;
244+
}
245+
} else if (error instanceof Error) {
246+
message = error.message;
247+
if (/session.*not\s+found/i.test(error.message)) notFound = true;
248+
}
249+
return notFound
250+
? { ...(message ? { message } : {}), notFound: true }
251+
: { ...(message ? { message } : {}), notFound: false };
252+
}
253+
220254
const INTERRUPT_ACK_TEXT_TAIL_LIMIT = 512;
221255
const USER_INTERRUPT_ACK_RE = /\boperation cancelled by user\b/i;
222256

@@ -582,9 +616,21 @@ function applyAcpModeUpdateToConfig(currentConfig: ThreadConfig, modeId: string)
582616

583617
// ── Session ──────────────────────────────────────────────────────
584618

619+
export interface AcpStructuredSessionOptions {
620+
/**
621+
* Hook the adapter passes in when it wants to control the message a failed
622+
* `session/load` produces. Receives the raw transport error and the
623+
* sessionId that was being loaded; must return the Error to throw.
624+
*/
625+
loadSessionErrorRewriter?: (error: unknown, sessionId: string) => Error;
626+
}
627+
585628
export class AcpStructuredSession implements StructuredSessionHandle {
586629
launchOptions: AgentLaunchOptions;
587630

631+
private loadSessionErrorRewriter: (error: unknown, sessionId: string) => Error =
632+
rewriteLoadSessionError;
633+
588634
private readonly child: ChildProcess;
589635
private readonly connection: ClientSideConnection;
590636
private readonly cwd: string;
@@ -644,13 +690,17 @@ export class AcpStructuredSession implements StructuredSessionHandle {
644690
projectLocation: ProjectLocation,
645691
cwd: string,
646692
threadId: string,
693+
options?: AcpStructuredSessionOptions,
647694
) {
648695
this.child = child;
649696
this.connection = connection;
650697
this.projectLocation = projectLocation;
651698
this.cwd = cwd;
652699
this.threadId = threadId;
653700
this.launchOptions = { suppressResumeConfigOverrides: true };
701+
if (options?.loadSessionErrorRewriter) {
702+
this.loadSessionErrorRewriter = options.loadSessionErrorRewriter;
703+
}
654704
}
655705

656706
/** Initialize the canonical mapper once we have a stable thread id. */
@@ -831,6 +881,7 @@ export class AcpStructuredSession implements StructuredSessionHandle {
831881
command: CommandSpec,
832882
projectLocation: ProjectLocation,
833883
threadId: string,
884+
options?: AcpStructuredSessionOptions,
834885
): AcpStructuredSession {
835886
const sessionCwd = resolveSessionCwd(projectLocation);
836887
const spawnCwd = command.cwd ?? resolveSpawnCwd(projectLocation);
@@ -897,7 +948,14 @@ export class AcpStructuredSession implements StructuredSessionHandle {
897948
stream,
898949
);
899950

900-
session = new AcpStructuredSession(child, connection, projectLocation, sessionCwd, threadId);
951+
session = new AcpStructuredSession(
952+
child,
953+
connection,
954+
projectLocation,
955+
sessionCwd,
956+
threadId,
957+
options,
958+
);
901959
session.spawnReady = spawnReady;
902960
session.stderrChunks.push(...stderrChunks);
903961

@@ -1003,6 +1061,8 @@ export class AcpStructuredSession implements StructuredSessionHandle {
10031061
this.adoptSessionRef(sessionRef);
10041062
availableModeIds = result.modes?.availableModes?.map((m) => m.id) ?? [];
10051063
configOptions = result.configOptions ?? [];
1064+
} catch (error) {
1065+
throw this.loadSessionErrorRewriter(error, sessionRef.providerSessionId);
10061066
} finally {
10071067
this.isReplayingHistory = false;
10081068
this.replayHistoryUntil = Date.now() + 500;
@@ -1483,5 +1543,9 @@ export function createAcpStructuredSession(
14831543
if (!shouldSpawnAcpSession(input)) {
14841544
return undefined;
14851545
}
1486-
return AcpStructuredSession.create(acpCommand, input.projectLocation, input.threadId);
1546+
return AcpStructuredSession.create(acpCommand, input.projectLocation, input.threadId, {
1547+
...(input.loadSessionErrorRewriter
1548+
? { loadSessionErrorRewriter: input.loadSessionErrorRewriter }
1549+
: {}),
1550+
});
14871551
}

src/supervisor/agents/base.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ export interface CreateStructuredSessionInput {
122122
* return `undefined` for terminal-mode threads to skip the spawn.
123123
*/
124124
presentationMode?: ThreadPresentationMode;
125+
/**
126+
* Optional adapter-supplied rewriter for `session/load` failures. The ACP
127+
* session calls this when the agent rejects a resume so the adapter can
128+
* surface provider-specific copy (e.g. Cursor's ACP doesn't yet support
129+
* resuming sessions). Falls back to a generic "can't resume" message when
130+
* omitted.
131+
*/
132+
loadSessionErrorRewriter?: (error: unknown, sessionId: string) => Error;
125133
}
126134

127135
/**

src/supervisor/agents/cursor/cursor.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
buildCursorProbeSpec,
1212
createCursorAdapter,
1313
detectCursorTerminalStatus,
14+
rewriteCursorLoadSessionError,
1415
sortCursorModels,
1516
} from "./index";
1617
import { buildCursorArgs } from "./argv";
@@ -42,6 +43,17 @@ describe("createCursorAdapter capabilities", () => {
4243
});
4344
});
4445

46+
describe("rewriteCursorLoadSessionError", () => {
47+
it("returns the Cursor-specific 'resume not supported' copy", () => {
48+
const raw = new Error("Invalid params");
49+
const out = rewriteCursorLoadSessionError(raw, "ses-1");
50+
expect(out.message).toBe(
51+
"Cursor's ACP integration doesn't currently support resuming chat sessions. Start a new thread to continue.",
52+
);
53+
expect((out as { cause?: unknown }).cause).toBe(raw);
54+
});
55+
});
56+
4557
describe("detectCursorTerminalStatus", () => {
4658
it("detects working from ctrl+c to stop", () => {
4759
expect(

src/supervisor/agents/cursor/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ function cursorHookActiveTerminalFallback(hint: TerminalStatusHint): boolean {
4848
return hint.status === "needs_approval";
4949
}
5050

51+
/**
52+
* Cursor's ACP server advertises `loadSession: true` but immediately rejects
53+
* every sessionId it issues via `session/new` with `-32602 Session not found`.
54+
* Acknowledged Cursor bug (forum thread #155516) with no client workaround, so
55+
* we replace the raw transport error with copy that names the limitation.
56+
*/
57+
export function rewriteCursorLoadSessionError(error: unknown, _sessionId: string): Error {
58+
const message =
59+
"Cursor's ACP integration doesn't currently support resuming chat sessions. Start a new thread to continue.";
60+
return Object.assign(new Error(message), { cause: error });
61+
}
62+
5163
export function createCursorAdapter(): AgentAdapter {
5264
let capabilities: AgentCapability = cursorDefaultCapabilities;
5365

@@ -105,7 +117,10 @@ export function createCursorAdapter(): AgentAdapter {
105117
["acp"],
106118
resolveAgentBinaryPath(input.projectLocation, "cursor-agent"),
107119
);
108-
return createAcpStructuredSession(command, input);
120+
return createAcpStructuredSession(command, {
121+
...input,
122+
loadSessionErrorRewriter: rewriteCursorLoadSessionError,
123+
});
109124
},
110125
buildDirectInput(prompt, _segments, _config, projectLocation) {
111126
// Cursor's TUI debounces fast incoming bytes as a paste burst. With

0 commit comments

Comments
 (0)