diff --git a/apps/desktop/src/codexAppServerManager.test.ts b/apps/desktop/src/codexAppServerManager.test.ts index 162f1896cdbaed067574d78f3beb3f9df9f5a857..b611e4d4de4d62389902dd6bd7450becc468749f 100644 --- a/apps/desktop/src/codexAppServerManager.test.ts +++ b/apps/desktop/src/codexAppServerManager.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { classifyCodexStderrLine, normalizeCodexModelSlug } from "./codexAppServerManager"; +import { + classifyCodexStderrLine, + isRecoverableThreadResumeError, + normalizeCodexModelSlug, +} from "./codexAppServerManager"; describe("classifyCodexStderrLine", () => { it("ignores empty lines", () => { @@ -49,3 +53,29 @@ describe("normalizeCodexModelSlug", () => { expect(normalizeCodexModelSlug("gpt-5.2")).toBe("gpt-5.2"); }); }); + +describe("isRecoverableThreadResumeError", () => { + it("matches not-found resume errors", () => { + expect( + isRecoverableThreadResumeError( + new Error("thread/resume failed: thread not found"), + ), + ).toBe(true); + }); + + it("ignores non-resume errors", () => { + expect( + isRecoverableThreadResumeError( + new Error("thread/start failed: permission denied"), + ), + ).toBe(false); + }); + + it("ignores non-recoverable resume errors", () => { + expect( + isRecoverableThreadResumeError( + new Error("thread/resume failed: timed out waiting for server"), + ), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/codexAppServerManager.ts b/apps/desktop/src/codexAppServerManager.ts index 55a4947d8807e9c819641a39fc967583b60165a0..1564883e6515eb6f39f22e3e1be247318b63a8b0 100644 --- a/apps/desktop/src/codexAppServerManager.ts +++ b/apps/desktop/src/codexAppServerManager.ts @@ -75,6 +75,14 @@ const BENIGN_ERROR_LOG_SNIPPETS = [ "state db missing rollout path for thread", "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", ]; +const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ + "not found", + "missing thread", + "no such thread", + "unknown thread", + "does not exist", +]; + export function normalizeCodexModelSlug( model: string | undefined | null, preferredId?: string, @@ -113,6 +121,19 @@ export function classifyCodexStderrLine(rawLine: string): { message: string } | return { message: line }; } +export function isRecoverableThreadResumeError(error: unknown): boolean { + const message = ( + error instanceof Error ? error.message : String(error) + ).toLowerCase(); + if (!message.includes("thread/resume")) { + return false; + } + + return RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS.some((snippet) => + message.includes(snippet), + ); +} + export interface CodexAppServerManagerEvents { event: [event: ProviderEvent]; } @@ -170,17 +191,66 @@ export class CodexAppServerManager extends EventEmitter | undefined { if (!value || typeof value !== "object") return undefined; return value as Record; @@ -240,8 +253,7 @@ export default function ChatView() { const sessionIds = state.threads .map((t) => t.session) .filter( - (s): s is NonNullable => - s !== null && s.status !== "closed", + (s): s is NonNullable => s !== null && s.status !== "closed", ) .map((s) => s.sessionId); @@ -345,18 +357,31 @@ export default function ChatView() { setIsEditorMenuOpen(false); }; - const ensureSession = async (): Promise => { + const ensureSession = async (): Promise => { if (!api || !activeThread || !activeProject) return null; if (activeThread.session && activeThread.session.status !== "closed") { - return activeThread.session.sessionId; + const sessionThreadId = activeThread.session.threadId ?? null; + const continuityState: SessionContinuityState = + activeThread.codexThreadId === null + ? "new" + : sessionThreadId === activeThread.codexThreadId + ? "resumed" + : "fallback_new"; + return { + sessionId: activeThread.session.sessionId, + resolvedThreadId: sessionThreadId, + continuityState, + } satisfies EnsuredSessionInfo; } + const priorCodexThreadId = activeThread.codexThreadId; setIsConnecting(true); try { const session = await api.providers.startSession({ provider: "codex", cwd: activeProject.cwd || undefined, model: selectedModel || undefined, + resumeThreadId: priorCodexThreadId ?? undefined, approvalPolicy: runtimeSessionConfig.approvalPolicy, sandboxMode: runtimeSessionConfig.sandboxMode, }); @@ -365,7 +390,18 @@ export default function ChatView() { threadId: activeThread.id, session, }); - return session.sessionId; + const resolvedThreadId = session.threadId ?? null; + const continuityState: SessionContinuityState = + priorCodexThreadId === null + ? "new" + : resolvedThreadId === priorCodexThreadId + ? "resumed" + : "fallback_new"; + return { + sessionId: session.sessionId, + resolvedThreadId, + continuityState, + }; } catch (err) { dispatch({ type: "SET_ERROR", @@ -405,16 +441,28 @@ export default function ChatView() { id: crypto.randomUUID(), text: trimmed, }); + const previousMessages = activeThread.messages; setPrompt(""); - const sessionId = await ensureSession(); - if (!sessionId) return; + const sessionInfo = await ensureSession(); + if (!sessionInfo) return; setIsSending(true); try { + const shouldBootstrap = + previousMessages.length > 0 && + (sessionInfo.continuityState === "new" || + sessionInfo.continuityState === "fallback_new"); + const input = shouldBootstrap + ? buildBootstrapInput( + previousMessages, + trimmed, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + ).text + : trimmed; await api.providers.sendTurn({ - sessionId, - input: trimmed, + sessionId: sessionInfo.sessionId, + input, model: selectedModel || undefined, effort: selectedEffort || undefined, }); diff --git a/apps/renderer/src/components/Sidebar.tsx b/apps/renderer/src/components/Sidebar.tsx index 6dd24c3954659551bfe0cb6e14a7620495345634..85f82788adf831cf3a8a89c894f58fda81254b30 100644 --- a/apps/renderer/src/components/Sidebar.tsx +++ b/apps/renderer/src/components/Sidebar.tsx @@ -7,8 +7,6 @@ import { useStore } from "../store"; import type { Project } from "../types"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; - - function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const minutes = Math.floor(diff / 60_000); @@ -57,6 +55,7 @@ export default function Sidebar() { type: "ADD_THREAD", thread: { id: crypto.randomUUID(), + codexThreadId: null, projectId, title: "New thread", model: diff --git a/apps/renderer/src/historyBootstrap.test.ts b/apps/renderer/src/historyBootstrap.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb3b6929d62188fcdb3c0a4d0b5cc95882a28f09 --- /dev/null +++ b/apps/renderer/src/historyBootstrap.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import { buildBootstrapInput } from "./historyBootstrap"; + +describe("buildBootstrapInput", () => { + it("includes full transcript when under budget", () => { + const result = buildBootstrapInput( + [ + { + id: "u-1", + role: "user", + text: "hello", + createdAt: "2026-02-09T00:00:00.000Z", + streaming: false, + }, + { + id: "a-1", + role: "assistant", + text: "world", + createdAt: "2026-02-09T00:00:01.000Z", + streaming: false, + }, + ], + "what's next?", + 1_500, + ); + + expect(result.includedCount).toBe(2); + expect(result.omittedCount).toBe(0); + expect(result.truncated).toBe(false); + expect(result.text).toContain("USER:\nhello"); + expect(result.text).toContain("ASSISTANT:\nworld"); + expect(result.text).toContain("Latest user request (answer this now):"); + expect(result.text).toContain("what's next?"); + }); + + it("truncates older transcript messages when over budget", () => { + const result = buildBootstrapInput( + [ + { + id: "u-1", + role: "user", + text: "first question with details", + createdAt: "2026-02-09T00:00:00.000Z", + streaming: false, + }, + { + id: "a-1", + role: "assistant", + text: "first answer with details", + createdAt: "2026-02-09T00:00:01.000Z", + streaming: false, + }, + { + id: "u-2", + role: "user", + text: "second question with details", + createdAt: "2026-02-09T00:00:02.000Z", + streaming: false, + }, + ], + "final request", + 320, + ); + + expect(result.truncated).toBe(true); + expect(result.omittedCount).toBeGreaterThan(0); + expect(result.includedCount).toBeLessThan(3); + expect(result.text).toContain("omitted to stay within input limits"); + expect(result.text.length).toBeLessThanOrEqual(320); + }); + + it("preserves the latest prompt when prompt-only fallback is required", () => { + const latestPrompt = "Please keep this exact latest prompt."; + const result = buildBootstrapInput( + [ + { + id: "u-1", + role: "user", + text: "old context", + createdAt: "2026-02-09T00:00:00.000Z", + streaming: false, + }, + ], + latestPrompt, + latestPrompt.length + 3, + ); + + expect(result.text).toBe(latestPrompt); + expect(result.includedCount).toBe(0); + expect(result.omittedCount).toBe(1); + expect(result.truncated).toBe(true); + }); +}); diff --git a/apps/renderer/src/historyBootstrap.ts b/apps/renderer/src/historyBootstrap.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d0f504d33fa536cb8371bfaef8adb294f566a2a --- /dev/null +++ b/apps/renderer/src/historyBootstrap.ts @@ -0,0 +1,120 @@ +import type { ChatMessage } from "./types"; + +export interface BootstrapInputResult { + text: string; + includedCount: number; + omittedCount: number; + truncated: boolean; +} + +const BOOTSTRAP_PREAMBLE = + "Continue this conversation using the transcript context below. The final section is the latest user request to answer now."; +const TRANSCRIPT_HEADER = "Transcript context:"; +const LATEST_PROMPT_HEADER = "Latest user request (answer this now):"; +const OMITTED_SUMMARY = (count: number) => + `[${count} earlier message(s) omitted to stay within input limits.]`; + +function messageRoleLabel(message: ChatMessage): "USER" | "ASSISTANT" { + return message.role === "assistant" ? "ASSISTANT" : "USER"; +} + +function buildMessageBlock(message: ChatMessage): string { + return `${messageRoleLabel(message)}:\n${message.text}`; +} + +function finalizeWithPrompt( + transcriptBody: string, + latestPrompt: string, + maxChars: number, +): string | null { + const text = `${BOOTSTRAP_PREAMBLE}\n\n${TRANSCRIPT_HEADER}\n${transcriptBody}\n\n${LATEST_PROMPT_HEADER}\n${latestPrompt}`; + return text.length <= maxChars ? text : null; +} + +export function buildBootstrapInput( + previousMessages: ChatMessage[], + latestPrompt: string, + maxChars: number, +): BootstrapInputResult { + const budget = Number.isFinite(maxChars) + ? Math.max(1, Math.floor(maxChars)) + : 1; + const promptOnly = + latestPrompt.length <= budget + ? latestPrompt + : latestPrompt.slice(0, budget); + + if (previousMessages.length === 0) { + return { + text: promptOnly, + includedCount: 0, + omittedCount: 0, + truncated: promptOnly.length !== latestPrompt.length, + }; + } + + const newestFirstBlocks: string[] = []; + for (let index = previousMessages.length - 1; index >= 0; index -= 1) { + const message = previousMessages[index]; + if (!message) continue; + newestFirstBlocks.push(buildMessageBlock(message)); + } + + if (newestFirstBlocks.length === 0) { + return { + text: promptOnly, + includedCount: 0, + omittedCount: previousMessages.length, + truncated: true, + }; + } + + // Include a contiguous suffix from newest to oldest, then reverse to chronological. + let includedNewestFirst: string[] = []; + for (const block of newestFirstBlocks) { + const nextNewestFirst = [...includedNewestFirst, block]; + const nextChronological = [...nextNewestFirst].reverse(); + const omittedCount = newestFirstBlocks.length - nextChronological.length; + const transcriptBody = + omittedCount > 0 + ? `${OMITTED_SUMMARY(omittedCount)}\n\n${nextChronological.join("\n\n")}` + : nextChronological.join("\n\n"); + if (!finalizeWithPrompt(transcriptBody, latestPrompt, budget)) { + break; + } + includedNewestFirst = nextNewestFirst; + } + + let includedChronological = [...includedNewestFirst].reverse(); + while (true) { + const omittedCount = + newestFirstBlocks.length - includedChronological.length; + const transcriptBody = + omittedCount > 0 + ? includedChronological.length > 0 + ? `${OMITTED_SUMMARY(omittedCount)}\n\n${includedChronological.join("\n\n")}` + : OMITTED_SUMMARY(omittedCount) + : includedChronological.join("\n\n"); + const finalized = finalizeWithPrompt(transcriptBody, latestPrompt, budget); + if (finalized) { + return { + text: finalized, + includedCount: includedChronological.length, + omittedCount, + truncated: + omittedCount > 0 || latestPrompt.length !== promptOnly.length, + }; + } + + if (includedChronological.length === 0) { + return { + text: promptOnly, + includedCount: 0, + omittedCount: previousMessages.length, + truncated: true, + }; + } + + includedChronological = includedChronological.slice(1); + } +} diff --git a/apps/renderer/src/persistenceSchema.test.ts b/apps/renderer/src/persistenceSchema.test.ts index 500217addd0cdd6654bea50947ea6426217084dd..5ed9a4900ba5ae86d7c347825bebc4b69df88578 100644 --- a/apps/renderer/src/persistenceSchema.test.ts +++ b/apps/renderer/src/persistenceSchema.test.ts @@ -46,6 +46,7 @@ describe("hydratePersistedState", () => { expect(hydrated).not.toBeNull(); expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL); expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL); + expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); expect(hydrated?.runtimeMode).toBe("full-access"); }); @@ -85,6 +86,7 @@ describe("hydratePersistedState", () => { const hydrated = hydratePersistedState(payload, false); expect(hydrated).not.toBeNull(); expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]); + expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); expect(hydrated?.activeThreadId).toBe("t-1"); expect(hydrated?.runtimeMode).toBe("full-access"); }); @@ -112,9 +114,10 @@ describe("hydratePersistedState", () => { }); describe("toPersistedState", () => { - it("writes v3 payload and strips non-persisted thread fields", () => { + it("writes v4 payload and strips non-persisted thread fields", () => { const thread: Thread = { id: "t-1", + codexThreadId: "thr_1", projectId: "p-1", title: "Thread", model: "gpt-5.3-codex", @@ -148,10 +151,11 @@ describe("toPersistedState", () => { runtimeMode: "full-access", }); - expect(persisted.version).toBe(3); + expect(persisted.version).toBe(4); expect(persisted.runtimeMode).toBe("full-access"); expect(persisted.threads[0]).toEqual({ id: "t-1", + codexThreadId: "thr_1", projectId: "p-1", title: "Thread", model: "gpt-5.3-codex", diff --git a/apps/renderer/src/persistenceSchema.ts b/apps/renderer/src/persistenceSchema.ts index ed533301ebff7b65e7f871873dc7419b81d6a608..e6b4983a21353f7e865bc9c7fdcddf346524bd7c 100644 --- a/apps/renderer/src/persistenceSchema.ts +++ b/apps/renderer/src/persistenceSchema.ts @@ -28,6 +28,7 @@ const persistedMessageSchema = z.object({ const persistedThreadSchema = z.object({ id: z.string().min(1), + codexThreadId: z.string().min(1).nullable().default(null), projectId: z.string().min(1), title: z.string().min(1), model: z.string().min(1), @@ -56,7 +57,13 @@ export const persistedStateV3Schema = persistedStateBodySchema.extend({ version: z.literal(3).optional(), }); +export const persistedStateV4Schema = persistedStateBodySchema.extend({ + runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), + version: z.literal(4).optional(), +}); + const persistedStateSchema = z.union([ + persistedStateV4Schema, persistedStateV3Schema, persistedStateV2Schema, persistedStateV1Schema, @@ -93,6 +100,7 @@ function hydrateThread( ): Thread { return { id: thread.id, + codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)), @@ -150,12 +158,13 @@ export function hydratePersistedState( export function toPersistedState( state: PersistedStoreSnapshot, -): z.infer { +): z.infer { return { - version: 3, + version: 4, projects: state.projects, threads: state.threads.map((thread) => ({ id: thread.id, + codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, model: thread.model, diff --git a/apps/renderer/src/store.test.ts b/apps/renderer/src/store.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..666f11c70fbd127658240a3e6688a703668e065f --- /dev/null +++ b/apps/renderer/src/store.test.ts @@ -0,0 +1,111 @@ +import type { ProviderEvent, ProviderSession } from "@acme/contracts"; +import { describe, expect, it } from "vitest"; + +import { type AppState, reducer } from "./store"; +import type { Thread } from "./types"; + +function makeSession( + overrides: Partial = {}, +): ProviderSession { + return { + sessionId: "sess-1", + provider: "codex", + status: "ready", + createdAt: "2026-02-09T00:00:00.000Z", + updatedAt: "2026-02-09T00:00:00.000Z", + ...overrides, + }; +} + +function makeEvent(overrides: Partial = {}): ProviderEvent { + return { + id: "evt-1", + kind: "notification", + provider: "codex", + sessionId: "sess-1", + createdAt: "2026-02-09T00:00:01.000Z", + method: "thread/started", + ...overrides, + }; +} + +function makeThread(overrides: Partial = {}): Thread { + return { + id: "thread-local-1", + codexThreadId: null, + projectId: "project-1", + title: "Thread", + model: "gpt-5.3-codex", + session: makeSession(), + messages: [], + events: [], + error: null, + createdAt: "2026-02-09T00:00:00.000Z", + ...overrides, + }; +} + +function makeState(thread: Thread): AppState { + return { + projects: [ + { + id: "project-1", + name: "Project", + cwd: "/tmp/project", + model: "gpt-5.3-codex", + expanded: true, + }, + ], + threads: [thread], + activeThreadId: thread.id, + runtimeMode: "full-access", + diffOpen: false, + }; +} + +describe("store reducer thread continuity", () => { + it("stores codexThreadId from UPDATE_SESSION", () => { + const state = makeState( + makeThread({ + session: null, + }), + ); + const next = reducer(state, { + type: "UPDATE_SESSION", + threadId: "thread-local-1", + session: makeSession({ threadId: "thr_123" }), + }); + + expect(next.threads[0]?.codexThreadId).toBe("thr_123"); + }); + + it("backfills codexThreadId from routed provider events", () => { + const state = makeState(makeThread({ codexThreadId: null })); + const next = reducer(state, { + type: "APPLY_EVENT", + event: makeEvent({ + method: "thread/started", + payload: { thread: { id: "thr_backfilled" } }, + }), + activeAssistantItemRef: { current: null }, + }); + + expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled"); + }); + + it("surfaces thread id mismatches without overwriting stored identity", () => { + const state = makeState(makeThread({ codexThreadId: "thr_expected" })); + const next = reducer(state, { + type: "APPLY_EVENT", + event: makeEvent({ + method: "turn/started", + threadId: "thr_unexpected", + payload: { turn: { id: "turn-1" } }, + }), + activeAssistantItemRef: { current: null }, + }); + + expect(next.threads[0]?.codexThreadId).toBe("thr_expected"); + expect(next.threads[0]?.error).toContain("Thread identity mismatch"); + }); +}); diff --git a/apps/renderer/src/store.ts b/apps/renderer/src/store.ts index de6f4d7e83d62cd245963ee3dcadee17e1913722..1363a9a62420ea235904399866d5d5a8b53da6a6 100644 --- a/apps/renderer/src/store.ts +++ b/apps/renderer/src/store.ts @@ -54,8 +54,9 @@ export interface AppState { diffOpen: boolean; } -const PERSISTED_STATE_KEY = "codething:renderer-state:v3"; +const PERSISTED_STATE_KEY = "codething:renderer-state:v4"; const LEGACY_PERSISTED_STATE_KEYS = [ + "codething:renderer-state:v3", "codething:renderer-state:v2", "codething:renderer-state:v1", ] as const; @@ -75,14 +76,15 @@ function readPersistedState(): AppState { try { const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY); - const [legacyV2Key, legacyV1Key] = LEGACY_PERSISTED_STATE_KEYS; + const [legacyV3Key, legacyV2Key, legacyV1Key] = LEGACY_PERSISTED_STATE_KEYS; + const rawLegacyV3 = window.localStorage.getItem(legacyV3Key); const rawLegacyV2 = window.localStorage.getItem(legacyV2Key); const rawLegacyV1 = window.localStorage.getItem(legacyV1Key); - const raw = rawCurrent ?? rawLegacyV2 ?? rawLegacyV1; + const raw = rawCurrent ?? rawLegacyV3 ?? rawLegacyV2 ?? rawLegacyV1; if (!raw) return initialState; const hydrated = hydratePersistedState( raw, - !rawCurrent && !rawLegacyV2 && Boolean(rawLegacyV1), + !rawCurrent && !rawLegacyV3 && !rawLegacyV2 && Boolean(rawLegacyV1), ); if (!hydrated) return initialState; @@ -127,6 +129,12 @@ function getEventTurnId(event: ProviderEvent): string | undefined { return asString(turn?.id); } +function getEventThreadId(event: ProviderEvent): string | undefined { + if (event.threadId) return event.threadId; + const payload = asObject(event.payload); + return asString(payload?.threadId) ?? asString(asObject(payload?.thread)?.id); +} + function durationMs(startIso: string, endIso: string): number | undefined { const start = Date.parse(startIso); const end = Date.parse(endIso); @@ -173,7 +181,7 @@ function updateTurnFields( // ── Reducer ────────────────────────────────────────────────────────── -function reducer(state: AppState, action: Action): AppState { +export function reducer(state: AppState, action: Action): AppState { switch (action.type) { case "ADD_PROJECT": return { @@ -223,6 +231,24 @@ function reducer(state: AppState, action: Action): AppState { ...state, threads: updateThread(state.threads, target.id, (t) => ({ ...t, + ...(() => { + const eventThreadId = getEventThreadId(event); + const hasThreadMismatch = + t.codexThreadId !== null && + eventThreadId !== undefined && + eventThreadId !== t.codexThreadId; + const threadMismatchError = hasThreadMismatch + ? `Thread identity mismatch: expected ${t.codexThreadId}, received ${eventThreadId}.` + : null; + return { + codexThreadId: t.codexThreadId ?? eventThreadId ?? null, + error: + threadMismatchError ?? + (event.kind === "error" && event.message + ? event.message + : t.error), + }; + })(), session: t.session ? evolveSession(t.session, event) : t.session, messages: applyEventToMessages( t.messages, @@ -230,8 +256,6 @@ function reducer(state: AppState, action: Action): AppState { activeAssistantItemRef, ), events: [event, ...t.events], - error: - event.kind === "error" && event.message ? event.message : t.error, ...updateTurnFields(t, event), })), }; @@ -243,6 +267,7 @@ function reducer(state: AppState, action: Action): AppState { threads: updateThread(state.threads, action.threadId, (t) => ({ ...t, session: action.session, + codexThreadId: action.session.threadId ?? t.codexThreadId, events: [], error: null, latestTurnId: undefined, diff --git a/apps/renderer/src/types.ts b/apps/renderer/src/types.ts index 85e3ea69fe575d51c77c75bd8fa0f448e89387d1..1683f6739eb130fa89bfd825031ad5636cb91cad 100644 --- a/apps/renderer/src/types.ts +++ b/apps/renderer/src/types.ts @@ -22,6 +22,7 @@ export interface Project { export interface Thread { id: string; + codexThreadId: string | null; projectId: string; title: string; model: string; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 6cc2dad9f5c499c0da3e255ccea74c1c78d147a7..50b1f55623904df7162dd2fd841c113717b6edde 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -14,6 +14,21 @@ describe("providerSessionStartInputSchema", () => { expect(parsed.approvalPolicy).toBe("never"); expect(parsed.sandboxMode).toBe("workspace-write"); }); + + it("accepts optional resumeThreadId", () => { + const parsed = providerSessionStartInputSchema.parse({ + resumeThreadId: "thread_123", + }); + expect(parsed.resumeThreadId).toBe("thread_123"); + }); + + it("rejects blank resumeThreadId", () => { + expect(() => + providerSessionStartInputSchema.parse({ + resumeThreadId: " ", + }), + ).toThrow(); + }); }); describe("providerSendTurnInputSchema", () => { diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 2fabe7ee9c34272adf350ca7f62fb8859f7ae592..0d0c30a1a85c1b61a51c370d1a5269da09fd89cc 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -49,13 +49,16 @@ export const providerSessionStartInputSchema = z.object({ provider: providerKindSchema.default("codex"), cwd: z.string().min(1).optional(), model: z.string().trim().min(1).optional(), + resumeThreadId: z.string().trim().min(1).optional(), approvalPolicy: providerApprovalPolicySchema.default("never"), sandboxMode: providerSandboxModeSchema.default("workspace-write"), }); +export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; + export const providerSendTurnInputSchema = z.object({ sessionId: z.string().min(1), - input: z.string().trim().min(1).max(120_000), + input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), model: z.string().trim().min(1).optional(), effort: z.string().trim().min(1).optional(), });