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 @@
11import { describe, expect, it } from "vitest";
22
3import { classifyCodexStderrLine, normalizeCodexModelSlug } from "./codexAppServerManager";
3import {
4 classifyCodexStderrLine,
5 isRecoverableThreadResumeError,
6 normalizeCodexModelSlug,
7} from "./codexAppServerManager";
48
59describe("classifyCodexStderrLine", () => {
610 it("ignores empty lines", () => {
......@@ -49,3 +53,29 @@ describe("normalizeCodexModelSlug", () => {
4953 expect(normalizeCodexModelSlug("gpt-5.2")).toBe("gpt-5.2");
5054 });
5155});
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 = [
7575 "state db missing rollout path for thread",
7676 "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back",
7777];
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
7886export function normalizeCodexModelSlug(
7987 model: string | undefined | null,
8088 preferredId?: string,
......@@ -113,6 +121,19 @@ export function classifyCodexStderrLine(rawLine: string): { message: string } |
113121 return { message: line };
114122}
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
116137export interface CodexAppServerManagerEvents {
117138 event: [event: ProviderEvent];
118139}
......@@ -170,17 +191,66 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
170191
171192 this.writeMessage(context, { method: "initialized" });
172193
173 const threadStart = await this.sendRequest(context, "thread/start", {
174 model: null,
194 const normalizedModel = normalizeCodexModelSlug(input.model);
195 const sessionOverrides = {
196 model: normalizedModel ?? null,
175197 cwd: input.cwd ?? null,
176198 approvalPolicy: input.approvalPolicy,
177199 sandbox: input.sandboxMode,
200 };
201
202 const threadStartParams = {
203 ...sessionOverrides,
178204 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");
182250 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 );
184254 }
185255
186256 this.updateSession(context, {
apps/renderer/src/components/ChatView.tsx+58-10
......@@ -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";
26import {
37 type FormEvent,
48 Fragment,
......@@ -10,6 +14,7 @@ import {
1014} from "react";
1115
1216import { EDITORS, type EditorId } from "@acme/contracts";
17import { buildBootstrapInput } from "../historyBootstrap";
1318import {
1419 DEFAULT_MODEL,
1520 DEFAULT_REASONING,
......@@ -60,6 +65,14 @@ interface PendingApprovalCard {
6065 detail?: string;
6166}
6267
68type SessionContinuityState = "resumed" | "new" | "fallback_new";
69
70interface EnsuredSessionInfo {
71 sessionId: string;
72 resolvedThreadId: string | null;
73 continuityState: SessionContinuityState;
74}
75
6376function asRecord(value: unknown): Record<string, unknown> | undefined {
6477 if (!value || typeof value !== "object") return undefined;
6578 return value as Record<string, unknown>;
......@@ -240,8 +253,7 @@ export default function ChatView() {
240253 const sessionIds = state.threads
241254 .map((t) => t.session)
242255 .filter(
243 (s): s is NonNullable<typeof s> =>
244 s !== null && s.status !== "closed",
256 (s): s is NonNullable<typeof s> => s !== null && s.status !== "closed",
245257 )
246258 .map((s) => s.sessionId);
247259
......@@ -345,18 +357,31 @@ export default function ChatView() {
345357 setIsEditorMenuOpen(false);
346358 };
347359
348 const ensureSession = async (): Promise<string | null> => {
360 const ensureSession = async (): Promise<EnsuredSessionInfo | null> => {
349361 if (!api || !activeThread || !activeProject) return null;
350362 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;
352375 }
353376
377 const priorCodexThreadId = activeThread.codexThreadId;
354378 setIsConnecting(true);
355379 try {
356380 const session = await api.providers.startSession({
357381 provider: "codex",
358382 cwd: activeProject.cwd || undefined,
359383 model: selectedModel || undefined,
384 resumeThreadId: priorCodexThreadId ?? undefined,
360385 approvalPolicy: runtimeSessionConfig.approvalPolicy,
361386 sandboxMode: runtimeSessionConfig.sandboxMode,
362387 });
......@@ -365,7 +390,18 @@ export default function ChatView() {
365390 threadId: activeThread.id,
366391 session,
367392 });
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 };
369405 } catch (err) {
370406 dispatch({
371407 type: "SET_ERROR",
......@@ -405,16 +441,28 @@ export default function ChatView() {
405441 id: crypto.randomUUID(),
406442 text: trimmed,
407443 });
444 const previousMessages = activeThread.messages;
408445 setPrompt("");
409446
410 const sessionId = await ensureSession();
411 if (!sessionId) return;
447 const sessionInfo = await ensureSession();
448 if (!sessionInfo) return;
412449
413450 setIsSending(true);
414451 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;
415463 await api.providers.sendTurn({
416 sessionId,
417 input: trimmed,
464 sessionId: sessionInfo.sessionId,
465 input,
418466 model: selectedModel || undefined,
419467 effort: selectedEffort || undefined,
420468 });
apps/renderer/src/components/Sidebar.tsx+1-2
......@@ -7,8 +7,6 @@ import { useStore } from "../store";
77import type { Project } from "../types";
88
99const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
10
11
1210function formatRelativeTime(iso: string): string {
1311 const diff = Date.now() - new Date(iso).getTime();
1412 const minutes = Math.floor(diff / 60_000);
......@@ -57,6 +55,7 @@ export default function Sidebar() {
5755 type: "ADD_THREAD",
5856 thread: {
5957 id: crypto.randomUUID(),
58 codexThreadId: null,
6059 projectId,
6160 title: "New thread",
6261 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", () => {
4646 expect(hydrated).not.toBeNull();
4747 expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL);
4848 expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL);
49 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
4950 expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false);
5051 expect(hydrated?.runtimeMode).toBe("full-access");
5152 });
......@@ -85,6 +86,7 @@ describe("hydratePersistedState", () => {
8586 const hydrated = hydratePersistedState(payload, false);
8687 expect(hydrated).not.toBeNull();
8788 expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]);
89 expect(hydrated?.threads[0]?.codexThreadId).toBeNull();
8890 expect(hydrated?.activeThreadId).toBe("t-1");
8991 expect(hydrated?.runtimeMode).toBe("full-access");
9092 });
......@@ -112,9 +114,10 @@ describe("hydratePersistedState", () => {
112114});
113115
114116describe("toPersistedState", () => {
115 it("writes v3 payload and strips non-persisted thread fields", () => {
117 it("writes v4 payload and strips non-persisted thread fields", () => {
116118 const thread: Thread = {
117119 id: "t-1",
120 codexThreadId: "thr_1",
118121 projectId: "p-1",
119122 title: "Thread",
120123 model: "gpt-5.3-codex",
......@@ -148,10 +151,11 @@ describe("toPersistedState", () => {
148151 runtimeMode: "full-access",
149152 });
150153
151 expect(persisted.version).toBe(3);
154 expect(persisted.version).toBe(4);
152155 expect(persisted.runtimeMode).toBe("full-access");
153156 expect(persisted.threads[0]).toEqual({
154157 id: "t-1",
158 codexThreadId: "thr_1",
155159 projectId: "p-1",
156160 title: "Thread",
157161 model: "gpt-5.3-codex",
apps/renderer/src/persistenceSchema.ts+11-2
......@@ -28,6 +28,7 @@ const persistedMessageSchema = z.object({
2828
2929const persistedThreadSchema = z.object({
3030 id: z.string().min(1),
31 codexThreadId: z.string().min(1).nullable().default(null),
3132 projectId: z.string().min(1),
3233 title: z.string().min(1),
3334 model: z.string().min(1),
......@@ -56,7 +57,13 @@ export const persistedStateV3Schema = persistedStateBodySchema.extend({
5657 version: z.literal(3).optional(),
5758});
5859
60export const persistedStateV4Schema = persistedStateBodySchema.extend({
61 runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE),
62 version: z.literal(4).optional(),
63});
64
5965const persistedStateSchema = z.union([
66 persistedStateV4Schema,
6067 persistedStateV3Schema,
6168 persistedStateV2Schema,
6269 persistedStateV1Schema,
......@@ -93,6 +100,7 @@ function hydrateThread(
93100): Thread {
94101 return {
95102 id: thread.id,
103 codexThreadId: thread.codexThreadId,
96104 projectId: thread.projectId,
97105 title: thread.title,
98106 model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)),
......@@ -150,12 +158,13 @@ export function hydratePersistedState(
150158
151159export function toPersistedState(
152160 state: PersistedStoreSnapshot,
153): z.infer<typeof persistedStateV3Schema> {
161): z.infer<typeof persistedStateV4Schema> {
154162 return {
155 version: 3,
163 version: 4,
156164 projects: state.projects,
157165 threads: state.threads.map((thread) => ({
158166 id: thread.id,
167 codexThreadId: thread.codexThreadId,
159168 projectId: thread.projectId,
160169 title: thread.title,
161170 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 {
5454 diffOpen: boolean;
5555}
5656
57const PERSISTED_STATE_KEY = "codething:renderer-state:v3";
57const PERSISTED_STATE_KEY = "codething:renderer-state:v4";
5858const LEGACY_PERSISTED_STATE_KEYS = [
59 "codething:renderer-state:v3",
5960 "codething:renderer-state:v2",
6061 "codething:renderer-state:v1",
6162] as const;
......@@ -75,14 +76,15 @@ function readPersistedState(): AppState {
7576
7677 try {
7778 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);
7981 const rawLegacyV2 = window.localStorage.getItem(legacyV2Key);
8082 const rawLegacyV1 = window.localStorage.getItem(legacyV1Key);
81 const raw = rawCurrent ?? rawLegacyV2 ?? rawLegacyV1;
83 const raw = rawCurrent ?? rawLegacyV3 ?? rawLegacyV2 ?? rawLegacyV1;
8284 if (!raw) return initialState;
8385 const hydrated = hydratePersistedState(
8486 raw,
85 !rawCurrent && !rawLegacyV2 && Boolean(rawLegacyV1),
87 !rawCurrent && !rawLegacyV3 && !rawLegacyV2 && Boolean(rawLegacyV1),
8688 );
8789 if (!hydrated) return initialState;
8890
......@@ -127,6 +129,12 @@ function getEventTurnId(event: ProviderEvent): string | undefined {
127129 return asString(turn?.id);
128130}
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
130138function durationMs(startIso: string, endIso: string): number | undefined {
131139 const start = Date.parse(startIso);
132140 const end = Date.parse(endIso);
......@@ -173,7 +181,7 @@ function updateTurnFields(
173181
174182// ── Reducer ──────────────────────────────────────────────────────────
175183
176function reducer(state: AppState, action: Action): AppState {
184export function reducer(state: AppState, action: Action): AppState {
177185 switch (action.type) {
178186 case "ADD_PROJECT":
179187 return {
......@@ -223,6 +231,24 @@ function reducer(state: AppState, action: Action): AppState {
223231 ...state,
224232 threads: updateThread(state.threads, target.id, (t) => ({
225233 ...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 })(),
226252 session: t.session ? evolveSession(t.session, event) : t.session,
227253 messages: applyEventToMessages(
228254 t.messages,
......@@ -230,8 +256,6 @@ function reducer(state: AppState, action: Action): AppState {
230256 activeAssistantItemRef,
231257 ),
232258 events: [event, ...t.events],
233 error:
234 event.kind === "error" && event.message ? event.message : t.error,
235259 ...updateTurnFields(t, event),
236260 })),
237261 };
......@@ -243,6 +267,7 @@ function reducer(state: AppState, action: Action): AppState {
243267 threads: updateThread(state.threads, action.threadId, (t) => ({
244268 ...t,
245269 session: action.session,
270 codexThreadId: action.session.threadId ?? t.codexThreadId,
246271 events: [],
247272 error: null,
248273 latestTurnId: undefined,
apps/renderer/src/types.ts+1
......@@ -22,6 +22,7 @@ export interface Project {
2222
2323export interface Thread {
2424 id: string;
25 codexThreadId: string | null;
2526 projectId: string;
2627 title: string;
2728 model: string;
packages/contracts/src/provider.test.ts+15
......@@ -14,6 +14,21 @@ describe("providerSessionStartInputSchema", () => {
1414 expect(parsed.approvalPolicy).toBe("never");
1515 expect(parsed.sandboxMode).toBe("workspace-write");
1616 });
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 });
1732});
1833
1934describe("providerSendTurnInputSchema", () => {
packages/contracts/src/provider.ts+4-1
......@@ -49,13 +49,16 @@ export const providerSessionStartInputSchema = z.object({
4949 provider: providerKindSchema.default("codex"),
5050 cwd: z.string().min(1).optional(),
5151 model: z.string().trim().min(1).optional(),
52 resumeThreadId: z.string().trim().min(1).optional(),
5253 approvalPolicy: providerApprovalPolicySchema.default("never"),
5354 sandboxMode: providerSandboxModeSchema.default("workspace-write"),
5455});
5556
57export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
58
5659export const providerSendTurnInputSchema = z.object({
5760 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),
5962 model: z.string().trim().min(1).optional(),
6063 effort: z.string().trim().min(1).optional(),
6164});