Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit fd95a4a

Browse files
authored
fix: Handle stale session resume with self-healing retry and New Session option (#993)
The last bit of work to do here (in a separate PR) is figuring out how to load the previous history into a new session with the S3 logs we have.
1 parent 98320d2 commit fd95a4a

7 files changed

Lines changed: 261 additions & 125 deletions

File tree

‎apps/twig/src/main/services/agent/service.ts‎

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,32 @@ export type { InterruptReason };
4444

4545
const log = logger.scope("agent-service");
4646

47+
function getErrorMessage(error: unknown): string {
48+
if (error instanceof Error) return error.message;
49+
if (typeof error === "object" && error !== null && "message" in error) {
50+
return String((error as { message: unknown }).message);
51+
}
52+
return "";
53+
}
54+
4755
function isAuthError(error: unknown): boolean {
48-
return (
49-
error instanceof Error &&
50-
error.message.startsWith("Authentication required")
51-
);
56+
return getErrorMessage(error).startsWith("Authentication required");
57+
}
58+
59+
/** Mark all content blocks as hidden so the renderer doesn't show a duplicate user message on retry. */
60+
function hidePromptBlocks(prompt: ContentBlock[]): ContentBlock[] {
61+
return prompt.map((block) => {
62+
const existing = (
63+
block as ContentBlock & { _meta?: { ui?: Record<string, unknown> } }
64+
)._meta;
65+
return {
66+
...block,
67+
_meta: {
68+
...existing,
69+
ui: { ...existing?.ui, hidden: true },
70+
},
71+
};
72+
});
5273
}
5374

5475
type MessageCallback = (message: unknown) => void;
@@ -529,10 +550,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
529550
});
530551
configOptions = loadResponse.configOptions ?? undefined;
531552
agentSessionId = config.sessionId;
532-
} else if (isReconnect && adapter !== "codex") {
533-
if (!config.sessionId) {
534-
throw new Error("Cannot resume session without sessionId");
535-
}
553+
} else if (isReconnect && adapter !== "codex" && config.sessionId) {
536554
const systemPrompt = this.buildSystemPrompt(
537555
credentials,
538556
customInstructions,
@@ -567,6 +585,12 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
567585
configOptions = resumeMeta?.configOptions;
568586
agentSessionId = config.sessionId;
569587
} else {
588+
if (isReconnect) {
589+
log.info("No sessionId for reconnect, creating new session", {
590+
taskId,
591+
taskRunId,
592+
});
593+
}
570594
const systemPrompt = this.buildSystemPrompt(
571595
credentials,
572596
customInstructions,
@@ -723,7 +747,7 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
723747
session = await this.recreateSession(sessionId);
724748
const result = await session.clientSideConnection.prompt({
725749
sessionId: session.config.sessionId!,
726-
prompt: finalPrompt,
750+
prompt: hidePromptBlocks(finalPrompt),
727751
});
728752
return {
729753
stopReason: result.stopReason,

‎apps/twig/src/renderer/features/sessions/components/SessionView.tsx‎

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,13 @@ interface SessionViewProps {
4747
errorTitle?: string;
4848
errorMessage?: string;
4949
onRetry?: () => void;
50-
onDelete?: () => void;
50+
onNewSession?: () => void;
5151
isInitializing?: boolean;
5252
readOnlyMessage?: string;
5353
}
5454

5555
const DEFAULT_ERROR_MESSAGE =
56-
"Failed to resume this session. The working directory may have been deleted. Please start a new task.";
56+
"Failed to resume this session. The working directory may have been deleted. Please start a new session.";
5757

5858
export function SessionView({
5959
events,
@@ -69,7 +69,7 @@ export function SessionView({
6969
errorTitle,
7070
errorMessage = DEFAULT_ERROR_MESSAGE,
7171
onRetry,
72-
onDelete,
72+
onNewSession,
7373
isInitializing = false,
7474
readOnlyMessage,
7575
}: SessionViewProps) {
@@ -199,7 +199,6 @@ export function SessionView({
199199
[onSendPrompt],
200200
);
201201

202-
const [isBashMode, setIsBashMode] = useState(false);
203202
const [isDraggingFile, setIsDraggingFile] = useState(false);
204203
const editorRef = useRef<MessageEditorHandle>(null);
205204
const dragCounterRef = useRef(0);
@@ -408,14 +407,14 @@ export function SessionView({
408407
Retry
409408
</Button>
410409
)}
411-
{onDelete && (
410+
{onNewSession && (
412411
<Button
413412
variant="soft"
414413
size="2"
415-
color="red"
416-
onClick={onDelete}
414+
color="green"
415+
onClick={onNewSession}
417416
>
418-
Delete Task
417+
New Session
419418
</Button>
420419
)}
421420
</Flex>
@@ -448,26 +447,40 @@ export function SessionView({
448447
</Box>
449448
</Box>
450449
) : (
451-
<Box
452-
className={
453-
isBashMode
454-
? "border border-accent-9"
455-
: "border-gray-4 border-t"
456-
}
457-
>
458-
<Box className="mx-auto max-w-[750px] p-2">
459-
<MessageEditor
460-
ref={editorRef}
461-
sessionId={sessionId}
462-
placeholder="Type a message... @ to mention files, ! for bash mode"
463-
onSubmit={handleSubmit}
464-
onBashCommand={onBashCommand}
465-
onBashModeChange={setIsBashMode}
466-
onCancel={onCancelPrompt}
467-
modeOption={modeOption}
468-
onModeChange={modeOption ? handleModeChange : undefined}
469-
adapter={adapter}
470-
/>
450+
<Box className="relative border-gray-4 border-t">
451+
<Box
452+
className={`absolute inset-0 flex items-center justify-center gap-2 transition-opacity duration-200 ${
453+
isRunning
454+
? "pointer-events-none opacity-0"
455+
: "opacity-100"
456+
}`}
457+
style={{ minHeight: 66 }}
458+
>
459+
<Spinner size={28} className="animate-spin text-gray-9" />
460+
<Text size="3" color="gray">
461+
Connecting to agent...
462+
</Text>
463+
</Box>
464+
<Box
465+
className={`transition-all duration-300 ease-out ${
466+
isRunning
467+
? "translate-y-0 opacity-100"
468+
: "pointer-events-none translate-y-4 opacity-0"
469+
}`}
470+
>
471+
<Box className="mx-auto max-w-[750px] p-2">
472+
<MessageEditor
473+
ref={editorRef}
474+
sessionId={sessionId}
475+
placeholder="Type a message... @ to mention files, ! for bash mode"
476+
onSubmit={handleSubmit}
477+
onBashCommand={onBashCommand}
478+
onCancel={onCancelPrompt}
479+
modeOption={modeOption}
480+
onModeChange={modeOption ? handleModeChange : undefined}
481+
adapter={adapter}
482+
/>
483+
</Box>
471484
</Box>
472485
</Box>
473486
)}

‎apps/twig/src/renderer/features/sessions/service/service.test.ts‎

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ const mockTrpcWorkspace = vi.hoisted(() => ({
2424

2525
const mockTrpcLogs = vi.hoisted(() => ({
2626
fetchS3Logs: { query: vi.fn() },
27+
readLocalLogs: { query: vi.fn() },
28+
writeLocalLogs: { mutate: vi.fn() },
2729
}));
2830

2931
vi.mock("@renderer/trpc/client", () => ({
@@ -51,6 +53,8 @@ const mockSessionStoreSetters = vi.hoisted(() => ({
5153

5254
vi.mock("@features/sessions/stores/sessionStore", () => ({
5355
sessionStoreSetters: mockSessionStoreSetters,
56+
getConfigOptionByCategory: vi.fn(() => undefined),
57+
mergeConfigOptions: vi.fn((live: unknown[], _persisted: unknown[]) => live),
5458
}));
5559

5660
const mockAuthStore = vi.hoisted(() => ({
@@ -116,6 +120,21 @@ vi.mock("@renderer/stores/connectivityStore", () => ({
116120
getIsOnline: () => mockGetIsOnline(),
117121
}));
118122

123+
vi.mock("@features/settings/stores/settingsStore", () => ({
124+
useSettingsStore: {
125+
getState: () => ({ customInstructions: "" }),
126+
},
127+
}));
128+
129+
vi.mock("@features/sidebar/stores/taskViewedStore", () => ({
130+
useTaskViewedStore: {
131+
getState: () => ({
132+
markActivity: vi.fn(),
133+
markAsViewed: vi.fn(),
134+
}),
135+
},
136+
}));
137+
119138
vi.mock("@renderer/lib/analytics", () => ({ track: vi.fn() }));
120139
vi.mock("@renderer/lib/logger", () => ({
121140
logger: {
@@ -753,31 +772,45 @@ describe("SessionService", () => {
753772
});
754773

755774
describe("clearSessionError", () => {
756-
it("cancels agent and tears down session fully", async () => {
775+
it("cancels agent and reconnects in place (no teardown)", async () => {
757776
const service = getSessionService();
758-
const mockSession = createMockSession({ status: "error" });
777+
const mockSession = createMockSession({
778+
status: "error",
779+
logUrl: "https://logs.example.com/run-123",
780+
});
759781
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(mockSession);
782+
mockTrpcAgent.reconnect.mutate.mockResolvedValue({
783+
sessionId: "run-123",
784+
channel: "agent-event:run-123",
785+
configOptions: [],
786+
});
787+
mockTrpcAgent.onSessionEvent.subscribe.mockReturnValue({
788+
unsubscribe: vi.fn(),
789+
});
790+
mockTrpcAgent.onPermissionRequest.subscribe.mockReturnValue({
791+
unsubscribe: vi.fn(),
792+
});
793+
mockTrpcWorkspace.verify.query.mockResolvedValue({ exists: true });
794+
mockTrpcLogs.readLocalLogs.query.mockResolvedValue("");
760795

761-
await service.clearSessionError("task-123");
796+
await service.clearSessionError("task-123", "/repo");
762797

798+
// Should cancel the backend agent
763799
expect(mockTrpcAgent.cancel.mutate).toHaveBeenCalledWith({
764800
sessionId: "run-123",
765801
});
766-
expect(mockSessionStoreSetters.removeSession).toHaveBeenCalledWith(
767-
"run-123",
768-
);
769-
expect(mockAdapterFns.removeAdapter).toHaveBeenCalledWith("run-123");
770-
expect(
771-
mockSessionConfigStore.removePersistedConfigOptions,
772-
).toHaveBeenCalledWith("run-123");
802+
// Should NOT remove session from store (avoids connect effect loop)
803+
expect(mockSessionStoreSetters.removeSession).not.toHaveBeenCalled();
804+
// Should attempt reconnect in place
805+
expect(mockTrpcAgent.reconnect.mutate).toHaveBeenCalled();
773806
});
774807

775808
it("handles missing session gracefully", async () => {
776809
const service = getSessionService();
777810
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(undefined);
778811

779812
await expect(
780-
service.clearSessionError("task-123"),
813+
service.clearSessionError("task-123", "/repo"),
781814
).resolves.not.toThrow();
782815
});
783816
});

0 commit comments

Comments
 (0)