authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-09 01:41:01-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-09 01:42:58-08:00
log57f5b8362a3ef5d8499307ab04da2ae38fb84e20
tree631234ead27e313f669e299f9d129cc12fa7cdbb
parentccb0e0cf56b6c9651c58b89878e8692cd9aa1203
signature Commit is signed but in an unrecognized format.

Add durable Codex thread continuity and history bootstrap fallback


13 files changed, 559 insertions(+), 30 deletions(-)

apps/desktop/src/codexAppServerManager.test.ts+31-1
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import { classifyCodexStderrLine, normalizeCodexModelSlug } from "./codexAppServerManager";3import {
4 classifyCodexStderrLine,
5 isRecoverableThreadResumeError,
6 normalizeCodexModelSlug,
7} from "./codexAppServerManager";
48
5describe("classifyCodexStderrLine", () => {9describe("classifyCodexStderrLine", () => {
6 it("ignores empty lines", () => {10 it("ignores empty lines", () => {
...@@ -49,3 +53,29 @@ describe("normalizeCodexModelSlug", () => {...@@ -49,3 +53,29 @@ describe("normalizeCodexModelSlug", () => {
49 expect(normalizeCodexModelSlug("gpt-5.2")).toBe("gpt-5.2");53 expect(normalizeCodexModelSlug("gpt-5.2")).toBe("gpt-5.2");
50 });54 });
51});55});
56
57describe("isRecoverableThreadResumeError", () => {
58 it("matches not-found resume errors", () => {
59 expect(
60 isRecoverableThreadResumeError(
61 new Error("thread/resume failed: thread not found"),
62 ),
63 ).toBe(true);
64 });
65
66 it("ignores non-resume errors", () => {
67 expect(
68 isRecoverableThreadResumeError(
69 new Error("thread/start failed: permission denied"),
70 ),
71 ).toBe(false);
72 });
73
74 it("ignores non-recoverable resume errors", () => {
75 expect(
76 isRecoverableThreadResumeError(
77 new Error("thread/resume failed: timed out waiting for server"),
78 ),
79 ).toBe(false);
80 });
81});
apps/desktop/src/codexAppServerManager.ts+75-5
...@@ -75,6 +75,14 @@ const BENIGN_ERROR_LOG_SNIPPETS = [...@@ -75,6 +75,14 @@ const BENIGN_ERROR_LOG_SNIPPETS = [
75 "state db missing rollout path for thread",75 "state db missing rollout path for thread",
76 "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back",76 "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back",
77];77];
78const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
79 "not found",
80 "missing thread",
81 "no such thread",
82 "unknown thread",
83 "does not exist",
84];
85
78export function normalizeCodexModelSlug(86export function normalizeCodexModelSlug(
79 model: string | undefined | null,87 model: string | undefined | null,
80 preferredId?: string,88 preferredId?: string,
...@@ -113,6 +121,19 @@ export function classifyCodexStderrLine(rawLine: string): { message: string } |...@@ -113,6 +121,19 @@ export function classifyCodexStderrLine(rawLine: string): { message: string } |
113 return { message: line };121 return { message: line };
114}122}
115123
124export function isRecoverableThreadResumeError(error: unknown): boolean {
125 const message = (
126 error instanceof Error ? error.message : String(error)
127 ).toLowerCase();
128 if (!message.includes("thread/resume")) {
129 return false;
130 }
131
132 return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) =>
133 message.includes(snippet),
134 );
135}
136
116export interface CodexAppServerManagerEvents {137export interface CodexAppServerManagerEvents {
117 event: [event: ProviderEvent];138 event: [event: ProviderEvent];
118}139}
...@@ -170,17 +191,66 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -170,17 +191,66 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
170191
171 this.writeMessage(context, { method: "initialized" });192 this.writeMessage(context, { method: "initialized" });
172193
173 const threadStart = await this.sendRequest(context, "thread/start", {194 const normalizedModel = normalizeCodexModelSlug(input.model);
174 model: null,195 const sessionOverrides = {
196 model: normalizedModel ?? null,
175 cwd: input.cwd ?? null,197 cwd: input.cwd ?? null,
176 approvalPolicy: input.approvalPolicy,198 approvalPolicy: input.approvalPolicy,
177 sandbox: input.sandboxMode,199 sandbox: input.sandboxMode,
200 };
201
202 const threadStartParams = {
203 ...sessionOverrides,
178 experimentalRawEvents: false,204 experimentalRawEvents: false,
179 });205 };
206
207 let threadOpenMethod: "thread/start" | "thread/resume" = "thread/start";
208 let threadOpenResponse: unknown;
209 if (input.resumeThreadId) {
210 try {
211 threadOpenMethod = "thread/resume";
212 threadOpenResponse = await this.sendRequest(
213 context,
214 "thread/resume",
215 {
216 ...sessionOverrides,
217 threadId: input.resumeThreadId,
218 },
219 );
220 } catch (error) {
221 if (!isRecoverableThreadResumeError(error)) {
222 throw error;
223 }
224
225 threadOpenMethod = "thread/start";
226 this.emitLifecycleEvent(
227 context,
228 "session/threadResumeFallback",
229 `Could not resume thread ${input.resumeThreadId}; started a new thread instead.`,
230 );
231 threadOpenResponse = await this.sendRequest(
232 context,
233 "thread/start",
234 threadStartParams,
235 );
236 }
237 } else {
238 threadOpenMethod = "thread/start";
239 threadOpenResponse = await this.sendRequest(
240 context,
241 "thread/start",
242 threadStartParams,
243 );
244 }
180245
181 const threadId = this.readString(this.readObject(threadStart)?.thread, "id");246 const threadOpenRecord = this.readObject(threadOpenResponse);
247 const threadId =
248 this.readString(this.readObject(threadOpenRecord, "thread"), "id") ??
249 this.readString(threadOpenRecord, "threadId");
182 if (!threadId) {250 if (!threadId) {
183 throw new Error("thread/start response did not include a thread id.");251 throw new Error(
252 `${threadOpenMethod} response did not include a thread id.`,
253 );
184 }254 }
185255
186 this.updateSession(context, {256 this.updateSession(context, {
apps/renderer/src/components/ChatView.tsx+58-10
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import type { ProviderApprovalDecision, ProviderEvent } from "@acme/contracts";1import {
2 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3 type ProviderApprovalDecision,
4 type ProviderEvent,
5} from "@acme/contracts";
2import {6import {
3 type FormEvent,7 type FormEvent,
4 Fragment,8 Fragment,
...@@ -10,6 +14,7 @@ import {...@@ -10,6 +14,7 @@ import {
10} from "react";14} from "react";
1115
12import { EDITORS, type EditorId } from "@acme/contracts";16import { EDITORS, type EditorId } from "@acme/contracts";
17import { buildBootstrapInput } from "../historyBootstrap";
13import {18import {
14 DEFAULT_MODEL,19 DEFAULT_MODEL,
15 DEFAULT_REASONING,20 DEFAULT_REASONING,
...@@ -60,6 +65,14 @@ interface PendingApprovalCard {...@@ -60,6 +65,14 @@ interface PendingApprovalCard {
60 detail?: string;65 detail?: string;
61}66}
6267
68type SessionContinuityState = "resumed" | "new" | "fallback_new";
69
70interface EnsuredSessionInfo {
71 sessionId: string;
72 resolvedThreadId: string | null;
73 continuityState: SessionContinuityState;
74}
75
63function asRecord(value: unknown): Record<string, unknown> | undefined {76function asRecord(value: unknown): Record<string, unknown> | undefined {
64 if (!value || typeof value !== "object") return undefined;77 if (!value || typeof value !== "object") return undefined;
65 return value as Record<string, unknown>;78 return value as Record<string, unknown>;
...@@ -240,8 +253,7 @@ export default function ChatView() {...@@ -240,8 +253,7 @@ export default function ChatView() {
240 const sessionIds = state.threads253 const sessionIds = state.threads
241 .map((t) => t.session)254 .map((t) => t.session)
242 .filter(255 .filter(
243 (s): s is NonNullable<typeof s> =>256 (s): s is NonNullable<typeof s> => s !== null && s.status !== "closed",
244 s !== null && s.status !== "closed",
245 )257 )
246 .map((s) => s.sessionId);258 .map((s) => s.sessionId);
247259
...@@ -345,18 +357,31 @@ export default function ChatView() {...@@ -345,18 +357,31 @@ export default function ChatView() {
345 setIsEditorMenuOpen(false);357 setIsEditorMenuOpen(false);
346 };358 };
347359
348 const ensureSession = async (): Promise<string | null> => {360 const ensureSession = async (): Promise<EnsuredSessionInfo | null> => {
349 if (!api || !activeThread || !activeProject) return null;361 if (!api || !activeThread || !activeProject) return null;
350 if (activeThread.session && activeThread.session.status !== "closed") {362 if (activeThread.session && activeThread.session.status !== "closed") {
351 return activeThread.session.sessionId;363 const sessionThreadId = activeThread.session.threadId ?? null;
364 const continuityState: SessionContinuityState =
365 activeThread.codexThreadId === null
366 ? "new"
367 : sessionThreadId === activeThread.codexThreadId
368 ? "resumed"
369 : "fallback_new";
370 return {
371 sessionId: activeThread.session.sessionId,
372 resolvedThreadId: sessionThreadId,
373 continuityState,
374 } satisfies EnsuredSessionInfo;
352 }375 }
353376
377 const priorCodexThreadId = activeThread.codexThreadId;
354 setIsConnecting(true);378 setIsConnecting(true);
355 try {379 try {
356 const session = await api.providers.startSession({380 const session = await api.providers.startSession({
357 provider: "codex",381 provider: "codex",
358 cwd: activeProject.cwd || undefined,382 cwd: activeProject.cwd || undefined,
359 model: selectedModel || undefined,383 model: selectedModel || undefined,
384 resumeThreadId: priorCodexThreadId ?? undefined,
360 approvalPolicy: runtimeSessionConfig.approvalPolicy,385 approvalPolicy: runtimeSessionConfig.approvalPolicy,
361 sandboxMode: runtimeSessionConfig.sandboxMode,386 sandboxMode: runtimeSessionConfig.sandboxMode,
362 });387 });
...@@ -365,7 +390,18 @@ export default function ChatView() {...@@ -365,7 +390,18 @@ export default function ChatView() {
365 threadId: activeThread.id,390 threadId: activeThread.id,
366 session,391 session,
367 });392 });
368 return session.sessionId;393 const resolvedThreadId = session.threadId ?? null;
394 const continuityState: SessionContinuityState =
395 priorCodexThreadId === null
396 ? "new"
397 : resolvedThreadId === priorCodexThreadId
398 ? "resumed"
399 : "fallback_new";
400 return {
401 sessionId: session.sessionId,
402 resolvedThreadId,
403 continuityState,
404 };
369 } catch (err) {405 } catch (err) {
370 dispatch({406 dispatch({
371 type: "SET_ERROR",407 type: "SET_ERROR",
...@@ -405,16 +441,28 @@ export default function ChatView() {...@@ -405,16 +441,28 @@ export default function ChatView() {
405 id: crypto.randomUUID(),441 id: crypto.randomUUID(),
406 text: trimmed,442 text: trimmed,
407 });443 });
444 const previousMessages = activeThread.messages;
408 setPrompt("");445 setPrompt("");
409446
410 const sessionId = await ensureSession();447 const sessionInfo = await ensureSession();
411 if (!sessionId) return;448 if (!sessionInfo) return;
412449
413 setIsSending(true);450 setIsSending(true);
414 try {451 try {
452 const shouldBootstrap =
453 previousMessages.length > 0 &&
454 (sessionInfo.continuityState === "new" ||
455 sessionInfo.continuityState === "fallback_new");
456 const input = shouldBootstrap
457 ? buildBootstrapInput(
458 previousMessages,
459 trimmed,
460 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
461 ).text
462 : trimmed;
415 await api.providers.sendTurn({463 await api.providers.sendTurn({
416 sessionId,464 sessionId: sessionInfo.sessionId,
417 input: trimmed,465 input,
418 model: selectedModel || undefined,466 model: selectedModel || undefined,
419 effort: selectedEffort || undefined,467 effort: selectedEffort || undefined,
420 });468 });
apps/renderer/src/components/Sidebar.tsx+1-2
...@@ -7,8 +7,6 @@ import { useStore } from "../store";...@@ -7,8 +7,6 @@ import { useStore } from "../store";
7import type { Project } from "../types";7import type { Project } from "../types";
88
9const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;9const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
10
11
12function formatRelativeTime(iso: string): string {10function formatRelativeTime(iso: string): string {
13 const diff = Date.now() - new Date(iso).getTime();11 const diff = Date.now() - new Date(iso).getTime();
14 const minutes = Math.floor(diff / 60_000);12 const minutes = Math.floor(diff / 60_000);
...@@ -57,6 +55,7 @@ export default function Sidebar() {...@@ -57,6 +55,7 @@ export default function Sidebar() {
57 type: "ADD_THREAD",55 type: "ADD_THREAD",
58 thread: {56 thread: {
59 id: crypto.randomUUID(),57 id: crypto.randomUUID(),
58 codexThreadId: null,
60 projectId,59 projectId,
61 title: "New thread",60 title: "New thread",
62 model:61 model:
apps/renderer/src/historyBootstrap.test.ts created+94
...@@ -0,0 +1,94 @@
1import { describe, expect, it } from "vitest";
2
3import { buildBootstrapInput } from "./historyBootstrap";
4
5describe("buildBootstrapInput", () => {
6 it("includes full transcript when under budget", () => {
7 const result = buildBootstrapInput(
8 [
9 {
10 id: "u-1",
11 role: "user",
12 text: "hello",
13 createdAt: "2026-02-09T00:00:00.000Z",
14 streaming: false,
15 },
16 {
17 id: "a-1",
18 role: "assistant",
19 text: "world",
20 createdAt: "2026-02-09T00:00:01.000Z",
21 streaming: false,
22 },
23 ],
24 "what's next?",
25 1_500,
26 );
27
28 expect(result.includedCount).toBe(2);
29 expect(result.omittedCount).toBe(0);
30 expect(result.truncated).toBe(false);
31 expect(result.text).toContain("USER:\nhello");
32 expect(result.text).toContain("ASSISTANT:\nworld");
33 expect(result.text).toContain("Latest user request (answer this now):");
34 expect(result.text).toContain("what's next?");
35 });
36
37 it("truncates older transcript messages when over budget", () => {
38 const result = buildBootstrapInput(
39 [
40 {
41 id: "u-1",
42 role: "user",
43 text: "first question with details",
44 createdAt: "2026-02-09T00:00:00.000Z",
45 streaming: false,
46 },
47 {
48 id: "a-1",
49 role: "assistant",
50 text: "first answer with details",
51 createdAt: "2026-02-09T00:00:01.000Z",
52 streaming: false,
53 },
54 {
55 id: "u-2",
56 role: "user",
57 text: "second question with details",
58 createdAt: "2026-02-09T00:00:02.000Z",
59 streaming: false,
60 },
61 ],
62 "final request",
63 320,
64 );
65
66 expect(result.truncated).toBe(true);
67 expect(result.omittedCount).toBeGreaterThan(0);
68 expect(result.includedCount).toBeLessThan(3);
69 expect(result.text).toContain("omitted to stay within input limits");
70 expect(result.text.length).toBeLessThanOrEqual(320);
71 });
72
73 it("preserves the latest prompt when prompt-only fallback is required", () => {
74 const latestPrompt = "Please keep this exact latest prompt.";
75 const result = buildBootstrapInput(
76 [
77 {
78 id: "u-1",
79 role: "user",
80 text: "old context",
81 createdAt: "2026-02-09T00:00:00.000Z",
82 streaming: false,
83 },
84 ],
85 latestPrompt,
86 latestPrompt.length + 3,
87 );
88
89 expect(result.text).toBe(latestPrompt);
90 expect(result.includedCount).toBe(0);
91 expect(result.omittedCount).toBe(1);
92 expect(result.truncated).toBe(true);
93 });
94});
apps/renderer/src/historyBootstrap.ts created+120
...@@ -0,0 +1,120 @@
1import type { ChatMessage } from "./types";
2
3export interface BootstrapInputResult {
4 text: string;
5 includedCount: number;
6 omittedCount: number;
7 truncated: boolean;
8}
9
10const BOOTSTRAP_PREAMBLE =
11 "Continue this conversation using the transcript context below. The final section is the latest user request to answer now.";
12const TRANSCRIPT_HEADER = "Transcript context:";
13const LATEST_PROMPT_HEADER = "Latest user request (answer this now):";
14const OMITTED_SUMMARY = (count: number) =>
15 `[${count} earlier message(s) omitted to stay within input limits.]`;
16
17function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" {
18 return message.role === "assistant" ? "ASSISTANT" : "USER";
19}
20
21function buildMessageBlock(message: ChatMessage): string {
22 return `${messageRoleLabel(message)}:\n${message.text}`;
23}
24
25function finalizeWithPrompt(
26 transcriptBody: string,
27 latestPrompt: string,
28 maxChars: number,
29): string | null {
30 const text = `${BOOTSTRAP_PREAMBLE}\n\n${TRANSCRIPT_HEADER}\n${transcriptBody}\n\n${LATEST_PROMPT_HEADER}\n${latestPrompt}`;
31 return text.length <= maxChars ? text : null;
32}
33
34export function buildBootstrapInput(
35 previousMessages: ChatMessage[],
36 latestPrompt: string,
37 maxChars: number,
38): BootstrapInputResult {
39 const budget = Number.isFinite(maxChars)
40 ? Math.max(1, Math.floor(maxChars))
41 : 1;
42 const promptOnly =
43 latestPrompt.length <= budget
44 ? latestPrompt
45 : latestPrompt.slice(0, budget);
46
47 if (previousMessages.length === 0) {
48 return {
49 text: promptOnly,
50 includedCount: 0,
51 omittedCount: 0,
52 truncated: promptOnly.length !== latestPrompt.length,
53 };
54 }
55
56 const newestFirstBlocks: string[] = [];
57 for (let index = previousMessages.length - 1; index >= 0; index -= 1) {
58 const message = previousMessages[index];
59 if (!message) continue;
60 newestFirstBlocks.push(buildMessageBlock(message));
61 }
62
63 if (newestFirstBlocks.length === 0) {
64 return {
65 text: promptOnly,
66 includedCount: 0,
67 omittedCount: previousMessages.length,
68 truncated: true,
69 };
70 }
71
72 // Include a contiguous suffix from newest to oldest, then reverse to chronological.
73 let includedNewestFirst: string[] = [];
74 for (const block of newestFirstBlocks) {
75 const nextNewestFirst = [...includedNewestFirst, block];
76 const nextChronological = [...nextNewestFirst].reverse();
77 const omittedCount = newestFirstBlocks.length - nextChronological.length;
78 const transcriptBody =
79 omittedCount > 0
80 ? `${OMITTED_SUMMARY(omittedCount)}\n\n${nextChronological.join("\n\n")}`
81 : nextChronological.join("\n\n");
82 if (!finalizeWithPrompt(transcriptBody, latestPrompt, budget)) {
83 break;
84 }
85 includedNewestFirst = nextNewestFirst;
86 }
87
88 let includedChronological = [...includedNewestFirst].reverse();
89 while (true) {
90 const omittedCount =
91 newestFirstBlocks.length - includedChronological.length;
92 const transcriptBody =
93 omittedCount > 0
94 ? includedChronological.length > 0
95 ? `${OMITTED_SUMMARY(omittedCount)}\n\n${includedChronological.join("\n\n")}`
96 : OMITTED_SUMMARY(omittedCount)
97 : includedChronological.join("\n\n");
98 const finalized = finalizeWithPrompt(transcriptBody, latestPrompt, budget);
99 if (finalized) {
100 return {
101 text: finalized,
102 includedCount: includedChronological.length,
103 omittedCount,
104 truncated:
105 omittedCount > 0 || latestPrompt.length !== promptOnly.length,
106 };
107 }
108
109 if (includedChronological.length === 0) {
110 return {
111 text: promptOnly,
112 includedCount: 0,
113 omittedCount: previousMessages.length,
114 truncated: true,
115 };
116 }
117
118 includedChronological = includedChronological.slice(1);
119 }
120}
apps/renderer/src/persistenceSchema.test.ts+6-2
...@@ -46,6 +46,7 @@ describe("hydratePersistedState", () => {...@@ -46,6 +46,7 @@ describe("hydratePersistedState", () => {
46 expect(hydrated).not.toBeNull();46 expect(hydrated).not.toBeNull();
47 expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL);47 expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL);
48 expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL);48 expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL);
49 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
49 expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);50 expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);
50 expect(hydrated?.runtimeMode).toBe("full-access");51 expect(hydrated?.runtimeMode).toBe("full-access");
51 });52 });
...@@ -85,6 +86,7 @@ describe("hydratePersistedState", () => {...@@ -85,6 +86,7 @@ describe("hydratePersistedState", () => {
85 const hydrated = hydratePersistedState(payload, false);86 const hydrated = hydratePersistedState(payload, false);
86 expect(hydrated).not.toBeNull();87 expect(hydrated).not.toBeNull();
87 expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]);88 expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]);
89 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
88 expect(hydrated?.activeThreadId).toBe("t-1");90 expect(hydrated?.activeThreadId).toBe("t-1");
89 expect(hydrated?.runtimeMode).toBe("full-access");91 expect(hydrated?.runtimeMode).toBe("full-access");
90 });92 });
...@@ -112,9 +114,10 @@ describe("hydratePersistedState", () => {...@@ -112,9 +114,10 @@ describe("hydratePersistedState", () => {
112});114});
113115
114describe("toPersistedState", () => {116describe("toPersistedState", () => {
115 it("writes v3 payload and strips non-persisted thread fields", () => {117 it("writes v4 payload and strips non-persisted thread fields", () => {
116 const thread: Thread = {118 const thread: Thread = {
117 id: "t-1",119 id: "t-1",
120 codexThreadId: "thr_1",
118 projectId: "p-1",121 projectId: "p-1",
119 title: "Thread",122 title: "Thread",
120 model: "gpt-5.3-codex",123 model: "gpt-5.3-codex",
...@@ -148,10 +151,11 @@ describe("toPersistedState", () => {...@@ -148,10 +151,11 @@ describe("toPersistedState", () => {
148 runtimeMode: "full-access",151 runtimeMode: "full-access",
149 });152 });
150153
151 expect(persisted.version).toBe(3);154 expect(persisted.version).toBe(4);
152 expect(persisted.runtimeMode).toBe("full-access");155 expect(persisted.runtimeMode).toBe("full-access");
153 expect(persisted.threads[0]).toEqual({156 expect(persisted.threads[0]).toEqual({
154 id: "t-1",157 id: "t-1",
158 codexThreadId: "thr_1",
155 projectId: "p-1",159 projectId: "p-1",
156 title: "Thread",160 title: "Thread",
157 model: "gpt-5.3-codex",161 model: "gpt-5.3-codex",
apps/renderer/src/persistenceSchema.ts+11-2
...@@ -28,6 +28,7 @@ const persistedMessageSchema = z.object({...@@ -28,6 +28,7 @@ const persistedMessageSchema = z.object({
2828
29const persistedThreadSchema = z.object({29const persistedThreadSchema = z.object({
30 id: z.string().min(1),30 id: z.string().min(1),
31 codexThreadId: z.string().min(1).nullable().default(null),
31 projectId: z.string().min(1),32 projectId: z.string().min(1),
32 title: z.string().min(1),33 title: z.string().min(1),
33 model: z.string().min(1),34 model: z.string().min(1),
...@@ -56,7 +57,13 @@ export const persistedStateV3Schema = persistedStateBodySchema.extend({...@@ -56,7 +57,13 @@ export const persistedStateV3Schema = persistedStateBodySchema.extend({
56 version: z.literal(3).optional(),57 version: z.literal(3).optional(),
57});58});
5859
60export const persistedStateV4Schema = persistedStateBodySchema.extend({
61 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
62 version: z.literal(4).optional(),
63});
64
59const persistedStateSchema = z.union([65const persistedStateSchema = z.union([
66 persistedStateV4Schema,
60 persistedStateV3Schema,67 persistedStateV3Schema,
61 persistedStateV2Schema,68 persistedStateV2Schema,
62 persistedStateV1Schema,69 persistedStateV1Schema,
...@@ -93,6 +100,7 @@ function hydrateThread(...@@ -93,6 +100,7 @@ function hydrateThread(
93): Thread {100): Thread {
94 return {101 return {
95 id: thread.id,102 id: thread.id,
103 codexThreadId: thread.codexThreadId,
96 projectId: thread.projectId,104 projectId: thread.projectId,
97 title: thread.title,105 title: thread.title,
98 model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)),106 model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)),
...@@ -150,12 +158,13 @@ export function hydratePersistedState(...@@ -150,12 +158,13 @@ export function hydratePersistedState(
150158
151export function toPersistedState(159export function toPersistedState(
152 state: PersistedStoreSnapshot,160 state: PersistedStoreSnapshot,
153): z.infer<typeof persistedStateV3Schema> {161): z.infer<typeof persistedStateV4Schema> {
154 return {162 return {
155 version: 3,163 version: 4,
156 projects: state.projects,164 projects: state.projects,
157 threads: state.threads.map((thread) => ({165 threads: state.threads.map((thread) => ({
158 id: thread.id,166 id: thread.id,
167 codexThreadId: thread.codexThreadId,
159 projectId: thread.projectId,168 projectId: thread.projectId,
160 title: thread.title,169 title: thread.title,
161 model: thread.model,170 model: thread.model,
apps/renderer/src/store.test.ts created+111
...@@ -0,0 +1,111 @@
1import type { ProviderEvent, ProviderSession } from "@acme/contracts";
2import { describe, expect, it } from "vitest";
3
4import { type AppState, reducer } from "./store";
5import type { Thread } from "./types";
6
7function makeSession(
8 overrides: Partial<ProviderSession> = {},
9): ProviderSession {
10 return {
11 sessionId: "sess-1",
12 provider: "codex",
13 status: "ready",
14 createdAt: "2026-02-09T00:00:00.000Z",
15 updatedAt: "2026-02-09T00:00:00.000Z",
16 ...overrides,
17 };
18}
19
20function makeEvent(overrides: Partial<ProviderEvent> = {}): ProviderEvent {
21 return {
22 id: "evt-1",
23 kind: "notification",
24 provider: "codex",
25 sessionId: "sess-1",
26 createdAt: "2026-02-09T00:00:01.000Z",
27 method: "thread/started",
28 ...overrides,
29 };
30}
31
32function makeThread(overrides: Partial<Thread> = {}): Thread {
33 return {
34 id: "thread-local-1",
35 codexThreadId: null,
36 projectId: "project-1",
37 title: "Thread",
38 model: "gpt-5.3-codex",
39 session: makeSession(),
40 messages: [],
41 events: [],
42 error: null,
43 createdAt: "2026-02-09T00:00:00.000Z",
44 ...overrides,
45 };
46}
47
48function makeState(thread: Thread): AppState {
49 return {
50 projects: [
51 {
52 id: "project-1",
53 name: "Project",
54 cwd: "/tmp/project",
55 model: "gpt-5.3-codex",
56 expanded: true,
57 },
58 ],
59 threads: [thread],
60 activeThreadId: thread.id,
61 runtimeMode: "full-access",
62 diffOpen: false,
63 };
64}
65
66describe("store reducer thread continuity", () => {
67 it("stores codexThreadId from UPDATE_SESSION", () => {
68 const state = makeState(
69 makeThread({
70 session: null,
71 }),
72 );
73 const next = reducer(state, {
74 type: "UPDATE_SESSION",
75 threadId: "thread-local-1",
76 session: makeSession({ threadId: "thr_123" }),
77 });
78
79 expect(next.threads[0]?.codexThreadId).toBe("thr_123");
80 });
81
82 it("backfills codexThreadId from routed provider events", () => {
83 const state = makeState(makeThread({ codexThreadId: null }));
84 const next = reducer(state, {
85 type: "APPLY_EVENT",
86 event: makeEvent({
87 method: "thread/started",
88 payload: { thread: { id: "thr_backfilled" } },
89 }),
90 activeAssistantItemRef: { current: null },
91 });
92
93 expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled");
94 });
95
96 it("surfaces thread id mismatches without overwriting stored identity", () => {
97 const state = makeState(makeThread({ codexThreadId: "thr_expected" }));
98 const next = reducer(state, {
99 type: "APPLY_EVENT",
100 event: makeEvent({
101 method: "turn/started",
102 threadId: "thr_unexpected",
103 payload: { turn: { id: "turn-1" } },
104 }),
105 activeAssistantItemRef: { current: null },
106 });
107
108 expect(next.threads[0]?.codexThreadId).toBe("thr_expected");
109 expect(next.threads[0]?.error).toContain("Thread identity mismatch");
110 });
111});
apps/renderer/src/store.ts+32-7
...@@ -54,8 +54,9 @@ export interface AppState {...@@ -54,8 +54,9 @@ export interface AppState {
54 diffOpen: boolean;54 diffOpen: boolean;
55}55}
5656
57const PERSISTED_STATE_KEY = "codething:renderer-state:v3";57const PERSISTED_STATE_KEY = "codething:renderer-state:v4";
58const LEGACY_PERSISTED_STATE_KEYS = [58const LEGACY_PERSISTED_STATE_KEYS = [
59 "codething:renderer-state:v3",
59 "codething:renderer-state:v2",60 "codething:renderer-state:v2",
60 "codething:renderer-state:v1",61 "codething:renderer-state:v1",
61] as const;62] as const;
...@@ -75,14 +76,15 @@ function readPersistedState(): AppState {...@@ -75,14 +76,15 @@ function readPersistedState(): AppState {
7576
76 try {77 try {
77 const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY);78 const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY);
78 const [legacyV2Key, legacyV1Key] = LEGACY_PERSISTED_STATE_KEYS;79 const [legacyV3Key, legacyV2Key, legacyV1Key] = LEGACY_PERSISTED_STATE_KEYS;
80 const rawLegacyV3 = window.localStorage.getItem(legacyV3Key);
79 const rawLegacyV2 = window.localStorage.getItem(legacyV2Key);81 const rawLegacyV2 = window.localStorage.getItem(legacyV2Key);
80 const rawLegacyV1 = window.localStorage.getItem(legacyV1Key);82 const rawLegacyV1 = window.localStorage.getItem(legacyV1Key);
81 const raw = rawCurrent ?? rawLegacyV2 ?? rawLegacyV1;83 const raw = rawCurrent ?? rawLegacyV3 ?? rawLegacyV2 ?? rawLegacyV1;
82 if (!raw) return initialState;84 if (!raw) return initialState;
83 const hydrated = hydratePersistedState(85 const hydrated = hydratePersistedState(
84 raw,86 raw,
85 !rawCurrent && !rawLegacyV2 && Boolean(rawLegacyV1),87 !rawCurrent && !rawLegacyV3 && !rawLegacyV2 && Boolean(rawLegacyV1),
86 );88 );
87 if (!hydrated) return initialState;89 if (!hydrated) return initialState;
8890
...@@ -127,6 +129,12 @@ function getEventTurnId(event: ProviderEvent): string | undefined {...@@ -127,6 +129,12 @@ function getEventTurnId(event: ProviderEvent): string | undefined {
127 return asString(turn?.id);129 return asString(turn?.id);
128}130}
129131
132function getEventThreadId(event: ProviderEvent): string | undefined {
133 if (event.threadId) return event.threadId;
134 const payload = asObject(event.payload);
135 return asString(payload?.threadId) ?? asString(asObject(payload?.thread)?.id);
136}
137
130function durationMs(startIso: string, endIso: string): number | undefined {138function durationMs(startIso: string, endIso: string): number | undefined {
131 const start = Date.parse(startIso);139 const start = Date.parse(startIso);
132 const end = Date.parse(endIso);140 const end = Date.parse(endIso);
...@@ -173,7 +181,7 @@ function updateTurnFields(...@@ -173,7 +181,7 @@ function updateTurnFields(
173181
174// ── Reducer ──────────────────────────────────────────────────────────182// ── Reducer ──────────────────────────────────────────────────────────
175183
176function reducer(state: AppState, action: Action): AppState {184export function reducer(state: AppState, action: Action): AppState {
177 switch (action.type) {185 switch (action.type) {
178 case "ADD_PROJECT":186 case "ADD_PROJECT":
179 return {187 return {
...@@ -223,6 +231,24 @@ function reducer(state: AppState, action: Action): AppState {...@@ -223,6 +231,24 @@ function reducer(state: AppState, action: Action): AppState {
223 ...state,231 ...state,
224 threads: updateThread(state.threads, target.id, (t) => ({232 threads: updateThread(state.threads, target.id, (t) => ({
225 ...t,233 ...t,
234 ...(() => {
235 const eventThreadId = getEventThreadId(event);
236 const hasThreadMismatch =
237 t.codexThreadId !== null &&
238 eventThreadId !== undefined &&
239 eventThreadId !== t.codexThreadId;
240 const threadMismatchError = hasThreadMismatch
241 ? `Thread identity mismatch: expected ${t.codexThreadId}, received ${eventThreadId}.`
242 : null;
243 return {
244 codexThreadId: t.codexThreadId ?? eventThreadId ?? null,
245 error:
246 threadMismatchError ??
247 (event.kind === "error" && event.message
248 ? event.message
249 : t.error),
250 };
251 })(),
226 session: t.session ? evolveSession(t.session, event) : t.session,252 session: t.session ? evolveSession(t.session, event) : t.session,
227 messages: applyEventToMessages(253 messages: applyEventToMessages(
228 t.messages,254 t.messages,
...@@ -230,8 +256,6 @@ function reducer(state: AppState, action: Action): AppState {...@@ -230,8 +256,6 @@ function reducer(state: AppState, action: Action): AppState {
230 activeAssistantItemRef,256 activeAssistantItemRef,
231 ),257 ),
232 events: [event, ...t.events],258 events: [event, ...t.events],
233 error:
234 event.kind === "error" && event.message ? event.message : t.error,
235 ...updateTurnFields(t, event),259 ...updateTurnFields(t, event),
236 })),260 })),
237 };261 };
...@@ -243,6 +267,7 @@ function reducer(state: AppState, action: Action): AppState {...@@ -243,6 +267,7 @@ function reducer(state: AppState, action: Action): AppState {
243 threads: updateThread(state.threads, action.threadId, (t) => ({267 threads: updateThread(state.threads, action.threadId, (t) => ({
244 ...t,268 ...t,
245 session: action.session,269 session: action.session,
270 codexThreadId: action.session.threadId ?? t.codexThreadId,
246 events: [],271 events: [],
247 error: null,272 error: null,
248 latestTurnId: undefined,273 latestTurnId: undefined,
apps/renderer/src/types.ts+1
...@@ -22,6 +22,7 @@ export interface Project {...@@ -22,6 +22,7 @@ export interface Project {
2222
23export interface Thread {23export interface Thread {
24 id: string;24 id: string;
25 codexThreadId: string | null;
25 projectId: string;26 projectId: string;
26 title: string;27 title: string;
27 model: string;28 model: string;
packages/contracts/src/provider.test.ts+15
...@@ -14,6 +14,21 @@ describe("providerSessionStartInputSchema", () => {...@@ -14,6 +14,21 @@ describe("providerSessionStartInputSchema", () => {
14 expect(parsed.approvalPolicy).toBe("never");14 expect(parsed.approvalPolicy).toBe("never");
15 expect(parsed.sandboxMode).toBe("workspace-write");15 expect(parsed.sandboxMode).toBe("workspace-write");
16 });16 });
17
18 it("accepts optional resumeThreadId", () => {
19 const parsed = providerSessionStartInputSchema.parse({
20 resumeThreadId: "thread_123",
21 });
22 expect(parsed.resumeThreadId).toBe("thread_123");
23 });
24
25 it("rejects blank resumeThreadId", () => {
26 expect(() =>
27 providerSessionStartInputSchema.parse({
28 resumeThreadId: " ",
29 }),
30 ).toThrow();
31 });
17});32});
1833
19describe("providerSendTurnInputSchema", () => {34describe("providerSendTurnInputSchema", () => {
packages/contracts/src/provider.ts+4-1
...@@ -49,13 +49,16 @@ export const providerSessionStartInputSchema = z.object({...@@ -49,13 +49,16 @@ export const providerSessionStartInputSchema = z.object({
49 provider: providerKindSchema.default("codex"),49 provider: providerKindSchema.default("codex"),
50 cwd: z.string().min(1).optional(),50 cwd: z.string().min(1).optional(),
51 model: z.string().trim().min(1).optional(),51 model: z.string().trim().min(1).optional(),
52 resumeThreadId: z.string().trim().min(1).optional(),
52 approvalPolicy: providerApprovalPolicySchema.default("never"),53 approvalPolicy: providerApprovalPolicySchema.default("never"),
53 sandboxMode: providerSandboxModeSchema.default("workspace-write"),54 sandboxMode: providerSandboxModeSchema.default("workspace-write"),
54});55});
5556
57export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
58
56export const providerSendTurnInputSchema = z.object({59export const providerSendTurnInputSchema = z.object({
57 sessionId: z.string().min(1),60 sessionId: z.string().min(1),
58 input: z.string().trim().min(1).max(120_000),61 input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS),
59 model: z.string().trim().min(1).optional(),62 model: z.string().trim().min(1).optional(),
60 effort: z.string().trim().min(1).optional(),63 effort: z.string().trim().min(1).optional(),
61});64});