From 20422d519a3ad1ead04a4bdeccdee469b53891c1 Mon Sep 17 00:00:00 2001 From: Teo | Nexcore Date: Sat, 26 Sep 2026 19:46:03 +0300 Subject: [PATCH] fix(terminal): restore each Codex card to its own conversation --- src/agent-runtime/hook-helper.mjs | 9 + src/agent-runtime/runtime-client.mjs | 13 +- src/main/index.ts | 3 +- src/main/services/TerminalManager.ts | 33 +++- src/main/services/TerminalSessionStore.ts | 27 ++- .../services/agent-runtime/RuntimeGateway.ts | 18 +- src/main/services/terminalLaunch.ts | 24 ++- tests/agent-runtime-gateway.test.mjs | 123 +++++++++++++ tests/terminal-launch.test.mjs | 84 ++++++++- tests/terminal-session-exact-resume.test.mjs | 171 ++++++++++++++++++ tests/terminal-session-restore.test.mjs | 3 +- tests/terminal-session-store.test.mjs | 69 ++++++- 12 files changed, 559 insertions(+), 18 deletions(-) create mode 100644 tests/terminal-session-exact-resume.test.mjs diff --git a/src/agent-runtime/hook-helper.mjs b/src/agent-runtime/hook-helper.mjs index a60a655e..7cbe023d 100644 --- a/src/agent-runtime/hook-helper.mjs +++ b/src/agent-runtime/hook-helper.mjs @@ -40,6 +40,14 @@ const turnId = firstString( input?.prompt_id, input?.promptId ); +const codexThreadId = firstString( + input?.session_id, + input?.sessionId, + input?.thread_id, + input?.threadId, + input?.conversation_id, + input?.conversationId +); const finalAnswer = state === "idle" && event === "Stop" && typeof input?.last_assistant_message === "string" ? input.last_assistant_message : null; @@ -55,6 +63,7 @@ await reportLifecycle({ state, event, turnId, + ...(codexThreadId ? { codexThreadId } : {}), ...(result === undefined ? {} : { result }), ...(lastAssistantMessage === undefined ? {} : { lastAssistantMessage }) }); diff --git a/src/agent-runtime/runtime-client.mjs b/src/agent-runtime/runtime-client.mjs index aae729c9..88d1d8b5 100644 --- a/src/agent-runtime/runtime-client.mjs +++ b/src/agent-runtime/runtime-client.mjs @@ -11,7 +11,7 @@ import { const CONNECT_TIMEOUT_MS = 1_000; -export async function reportLifecycle({ state, event, turnId = null, result, lastAssistantMessage }) { +export async function reportLifecycle({ state, event, turnId = null, codexThreadId, result, lastAssistantMessage }) { if (!RUNTIME_STATES.includes(state)) return false; if (typeof event !== "string" || event.length === 0 || event.length > 80) return false; const address = process.env[AGENT_RUNTIME_ENV.address]; @@ -20,6 +20,10 @@ export async function reportLifecycle({ state, event, turnId = null, result, las const capabilityToken = process.env[AGENT_RUNTIME_ENV.capabilityToken]; if (!address || !terminalSessionId || !provider || !capabilityToken) return false; + const validCodexThreadId = provider === "codex" && typeof codexThreadId === "string" && isCanonicalUuid(codexThreadId) + ? codexThreadId.toLowerCase() + : undefined; + const message = { v: RUNTIME_PROTOCOL_VERSION, type: "lifecycle", @@ -29,6 +33,7 @@ export async function reportLifecycle({ state, event, turnId = null, result, las state, event, turnId: normalizedId(turnId), + ...(validCodexThreadId !== undefined ? { codexThreadId: validCodexThreadId } : {}), ...(result === undefined ? {} : { result }) }; const answerCaptureExpiresAt = Number(process.env[CAPTURE_ANSWER_EXPIRES_AT_ENV]); @@ -98,3 +103,9 @@ function sendMessage(address, payload, accepted) { function normalizedId(value) { return typeof value === "string" && value.length > 0 && value.length <= 160 ? value : null; } + +const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isCanonicalUuid(value) { + return typeof value === "string" && CANONICAL_UUID_RE.test(value); +} diff --git a/src/main/index.ts b/src/main/index.ts index ee3eb3a3..b8d11de7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -341,7 +341,8 @@ async function initializeServices(): Promise { terminalManager?.applyProviderSignal(terminalSessionId, { kind: "lifecycle", state: signal.state, - ...(signal.turnId ? { requestId: signal.turnId } : {}) + ...(signal.turnId ? { requestId: signal.turnId } : {}), + ...(signal.codexThreadId ? { codexThreadId: signal.codexThreadId } : {}) }); agentControl?.onSignal(terminalSessionId, signal); if (signal.lastAssistantMessage !== undefined && signal.answerCaptureGrantExpiresAt !== undefined) { diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 66e43358..148fdd94 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -83,6 +83,8 @@ interface ManagedSession { lifecycle: ProviderLifecycleParser | null; awaitingInitialResize: boolean; resumeOnLaunch: boolean; + resumeThreadId?: string; + codexThreadId?: string; captureResult: boolean; } @@ -90,6 +92,7 @@ export interface ProviderLifecycleSignal { kind: "lifecycle"; state: "idle" | "working" | "needs_approval"; requestId?: string; + codexThreadId?: string; } /** @@ -324,14 +327,18 @@ export class TerminalManager { : null; session.awaitingInitialResize = true; session.resumeOnLaunch = false; + delete session.resumeThreadId; + delete session.codexThreadId; session.metadata.startedAt = Date.now(); session.metadata.status = initialSessionStatus(session.metadata.provider); session.metadata.exitCode = null; session.metadata.failureDetails = null; this.emitSession(session.metadata); + this.schedulePersistence(); return snapshot(session); } + delete session.codexThreadId; session.agentOrchestration?.cleanup(); const launched = this.spawnProcess( id, @@ -368,6 +375,7 @@ export class TerminalManager { if (runtimeStatus) session.metadata.status = runtimeStatus; } this.emitSession(session.metadata, failureOrigin); + this.schedulePersistence(); return snapshot(session); } @@ -432,6 +440,12 @@ export class TerminalManager { const session = this.sessions.get(id); if (!this.lifecycleHooksEnabled || !session || session.metadata.status === "done" || session.metadata.status === "failed") return; + if (signal.codexThreadId && session.metadata.provider === "codex" + && signal.codexThreadId !== session.codexThreadId) { + session.codexThreadId = signal.codexThreadId; + this.schedulePersistence(); + } + const nextStatus = signal.state; if (session.metadata.status === nextStatus) return; session.metadata.status = nextStatus; @@ -580,7 +594,9 @@ export class TerminalManager { INITIAL_TERMINAL_ROWS, descriptor.provider !== "terminal", false, - descriptor.role + descriptor.role, + undefined, + descriptor.codexThreadId ); process = launched.process; agentBrowser = launched.agentBrowser; @@ -613,6 +629,7 @@ export class TerminalManager { : null, awaitingInitialResize: awaitMeasuredGrid, resumeOnLaunch: awaitMeasuredGrid && descriptor.provider !== "terminal", + ...(descriptor.codexThreadId ? { resumeThreadId: descriptor.codexThreadId, codexThreadId: descriptor.codexThreadId } : {}), captureResult: false }; this.sessions.set(descriptor.id, session); @@ -631,7 +648,7 @@ export class TerminalManager { return Promise.resolve(); } return this.sessionStore.replace( - [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata)) + [...this.sessions.values()].map((session) => persistedTerminalSession(session.metadata, session.codexThreadId)) ); } @@ -654,6 +671,8 @@ export class TerminalManager { session.awaitingInitialResize = false; const resumePrevious = session.resumeOnLaunch; session.resumeOnLaunch = false; + const resumeThreadId = session.resumeThreadId; + delete session.resumeThreadId; try { const launched = this.spawnProcess( id, @@ -664,7 +683,9 @@ export class TerminalManager { session.rows, resumePrevious, session.captureResult, - session.metadata.role + session.metadata.role, + undefined, + resumeThreadId ); session.process = launched.process; session.agentBrowser = launched.agentBrowser; @@ -701,7 +722,8 @@ export class TerminalManager { resumePrevious = false, captureResult = false, role: SessionRole = "agent", - answerCaptureGrantExpiresAt?: number + answerCaptureGrantExpiresAt?: number, + resumeThreadId?: string ): { process: IPty | null; agentBrowser: PreparedAgentBrowserPtyLaunch | null; @@ -755,7 +777,8 @@ export class TerminalManager { const launch = resolveTerminalLaunch(provider, profile, providerArgs, { environment: { ...baseEnvironment, ...providerEnvironment }, ...(providerCli ? { providerCli } : {}), - resumePrevious + resumePrevious, + ...(resumeThreadId ? { resumeThreadId } : {}) }); return { process: this.spawnPty(launch.command, launch.args, { diff --git a/src/main/services/TerminalSessionStore.ts b/src/main/services/TerminalSessionStore.ts index e8389abc..25f462b0 100644 --- a/src/main/services/TerminalSessionStore.ts +++ b/src/main/services/TerminalSessionStore.ts @@ -40,6 +40,7 @@ export interface PersistedTerminalSession { position: Point; size: Size; parentSessionId?: string; + codexThreadId?: string; } interface PersistedTerminalSessionState { @@ -106,7 +107,20 @@ export class TerminalSessionStore { } } -export function persistedTerminalSession(metadata: SessionMetadata): PersistedTerminalSession { +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function normalizeCodexThreadId(provider: ProviderId, candidate: unknown): string | undefined { + if (provider !== "codex") return undefined; + if (typeof candidate !== "string") return undefined; + const trimmed = candidate.trim().toLowerCase(); + return UUID_REGEX.test(trimmed) ? trimmed : undefined; +} + +export function persistedTerminalSession( + metadata: SessionMetadata, + codexThreadId?: unknown +): PersistedTerminalSession { + const normalizedCodexThreadId = normalizeCodexThreadId(metadata.provider, codexThreadId); return { id: metadata.id, provider: metadata.provider, @@ -117,7 +131,8 @@ export function persistedTerminalSession(metadata: SessionMetadata): PersistedTe cwd: metadata.cwd, position: { ...metadata.position }, size: { ...metadata.size }, - ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}) + ...(metadata.parentSessionId !== undefined ? { parentSessionId: metadata.parentSessionId } : {}), + ...(normalizedCodexThreadId !== undefined ? { codexThreadId: normalizedCodexThreadId } : {}) }; } @@ -150,6 +165,11 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste : undefined; if (session.parentSessionId !== undefined && parentSessionId === undefined) continue; if (role === "subagent" && parentSessionId === undefined) continue; + // A damaged or obsolete conversation ID must not make the whole card disappear. + // It can still restore with Codex's interactive resume picker. + const codexThreadId = session.provider === "codex" && typeof session.codexThreadId === "string" && UUID_REGEX.test(session.codexThreadId.trim()) + ? session.codexThreadId.trim().toLowerCase() + : undefined; sessions.push({ id: session.id, provider: session.provider as ProviderId, @@ -163,7 +183,8 @@ export function normalizePersistedTerminalSessions(candidate: unknown): Persiste width: clamp(session.size.width, 420, 1_600), height: clamp(session.size.height, 260, 1_100) }, - ...(parentSessionId !== undefined ? { parentSessionId } : {}) + ...(parentSessionId !== undefined ? { parentSessionId } : {}), + ...(codexThreadId !== undefined ? { codexThreadId } : {}) }); ids.add(session.id); } diff --git a/src/main/services/agent-runtime/RuntimeGateway.ts b/src/main/services/agent-runtime/RuntimeGateway.ts index a575cd75..7fd4f551 100644 --- a/src/main/services/agent-runtime/RuntimeGateway.ts +++ b/src/main/services/agent-runtime/RuntimeGateway.ts @@ -29,6 +29,7 @@ export interface RuntimeLifecycleSignal { state: RuntimeLifecycleState; event: string; turnId: string | null; + codexThreadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; answerCaptureGrantExpiresAt?: number; @@ -58,6 +59,7 @@ interface ParsedLifecycleMessage { state: RuntimeLifecycleState; event: string; turnId: string | null; + codexThreadId?: string; result?: { text: string; truncated: boolean }; lastAssistantMessage?: string; } @@ -314,6 +316,7 @@ export class RuntimeGateway { state: message.state, event: message.event, turnId: message.turnId, + ...(message.codexThreadId === undefined ? {} : { codexThreadId: message.codexThreadId }), ...(message.result === undefined ? {} : { result: message.result }), ...(message.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: message.lastAssistantMessage }) }; @@ -321,7 +324,12 @@ export class RuntimeGateway { signal.answerCaptureGrantExpiresAt = lease.answerCaptureGrantExpiresAt; } // Captured text is delivered once and never stored in the lifecycle lease. - lease.latest = { state: signal.state, event: signal.event, turnId: signal.turnId }; + lease.latest = { + state: signal.state, + event: signal.event, + turnId: signal.turnId, + ...(signal.codexThreadId === undefined ? {} : { codexThreadId: signal.codexThreadId }) + }; this.onSignal?.(message.terminalSessionId, signal); } } @@ -337,6 +345,7 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { "capabilityToken", "event", "provider", "state", "terminalSessionId", "turnId", "type", "v" ]; if (value.result !== undefined) expected.push("result"); + if (value.codexThreadId !== undefined) expected.push("codexThreadId"); expected.sort(); if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) { throw new Error("Runtime message has an invalid schema."); @@ -359,6 +368,11 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { || value.event.length > 80 || (value.turnId !== null && (typeof value.turnId !== "string" || value.turnId.length > 160)) ) throw new Error("Runtime message fields are invalid."); + if (value.codexThreadId !== undefined && ( + value.provider !== "codex" + || typeof value.codexThreadId !== "string" + || !CANONICAL_UUID_RE.test(value.codexThreadId) + )) throw new Error("Runtime codexThreadId is invalid."); if (value.result !== undefined && ( value.state !== "idle" || value.event !== "Stop" || !isRecord(value.result) || Object.keys(value.result).sort().join(",") !== "text,truncated" @@ -372,6 +386,8 @@ function parseLifecycleMessage(value: unknown): ParsedLifecycleMessage { return value as unknown as ParsedLifecycleMessage; } +const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + function isTurnStart(event: string): boolean { return event === "UserPromptSubmit" || event === "TurnStarted" diff --git a/src/main/services/terminalLaunch.ts b/src/main/services/terminalLaunch.ts index 0f4e795c..53cbee8a 100644 --- a/src/main/services/terminalLaunch.ts +++ b/src/main/services/terminalLaunch.ts @@ -19,8 +19,11 @@ interface LaunchResolutionOptions { fileExists?: (path: string) => boolean; providerCli?: ProviderCliResolution; resumePrevious?: boolean; + resumeThreadId?: string; } +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const WINDOWS_NATIVE_EXTENSIONS = [".exe", ".com"]; export function resolveTerminalLaunch( @@ -51,7 +54,7 @@ export function resolveTerminalLaunch( const providerArgs = [ ...(profile === "yolo" && provider !== "opencode" ? DANGEROUS_ARGUMENTS[provider] : []), ...agentBrowserArgs, - ...(options.resumePrevious ? RESUME_ARGUMENTS[provider] : []) + ...(options.resumePrevious ? resolveResumeArguments(provider, options.resumeThreadId) : []) ]; const combinedEnvironment = { ...providerCli.environment, @@ -72,11 +75,26 @@ export function resolveTerminalLaunch( }; } +function resolveResumeArguments( + provider: Exclude, + resumeThreadId?: string +): string[] { + if (provider === "codex") { + if (resumeThreadId) { + if (!UUID_REGEX.test(resumeThreadId)) { + throw new Error(`Invalid Codex thread ID format: "${resumeThreadId}". Expected a canonical UUID.`); + } + return ["resume", resumeThreadId.toLowerCase()]; + } + return ["resume"]; + } + return RESUME_ARGUMENTS[provider]; +} + // Per-provider instead of a fallthrough: the old `return ["--continue"]` default would // have handed an unverified flag to whatever provider was added next. A missing entry is // now a compile error. -const RESUME_ARGUMENTS: Record, string[]> = { - codex: ["resume", "--last"], +const RESUME_ARGUMENTS: Record, string[]> = { claude: ["--continue"], qwen: ["--continue"], kimi: ["--continue"], diff --git a/tests/agent-runtime-gateway.test.mjs b/tests/agent-runtime-gateway.test.mjs index f60e0861..89992067 100644 --- a/tests/agent-runtime-gateway.test.mjs +++ b/tests/agent-runtime-gateway.test.mjs @@ -107,6 +107,129 @@ test("RuntimeGateway rejects a wrong capability and ignores a stale turn complet assert.equal(gateway.currentStatus("terminal-two"), "working"); }); +test("RuntimeGateway propagates codexThreadId for codex sessions with canonical UUID", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { + const root = await fixture(t); + const signals = []; + const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); + await gateway.start(); + t.after(() => gateway.close()); + const capability = gateway.registerSession("terminal-codex-thread", "codex"); + + const validUuid = "12345678-1234-1234-1234-123456789abc"; + const helper = new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url); + const child = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { + env: { + ...process.env, + [AGENT_RUNTIME_ENV.address]: capability.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: capability.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: capability.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: capability.capabilityToken + }, + stdio: ["pipe", "ignore", "pipe"] + }); + child.stdin.end(JSON.stringify({ turn_id: "turn-codex-1", session_id: validUuid })); + const result = await childResult(child); + assert.equal(result.code, 0, result.stderr); + assert.equal(signals.length, 1); + assert.equal(signals[0].signal.turnId, "turn-codex-1"); + assert.equal(signals[0].signal.codexThreadId, validUuid); +}); + +test("RuntimeGateway normalizes uppercase UUID to lowercase canonical UUID for codex", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { + const root = await fixture(t); + const signals = []; + const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); + await gateway.start(); + t.after(() => gateway.close()); + const capability = gateway.registerSession("terminal-codex-upper", "codex"); + + const upperUuid = "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D"; + const lowerUuid = upperUuid.toLowerCase(); + const helper = new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url); + const child = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { + env: { + ...process.env, + [AGENT_RUNTIME_ENV.address]: capability.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: capability.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: capability.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: capability.capabilityToken + }, + stdio: ["pipe", "ignore", "pipe"] + }); + child.stdin.end(JSON.stringify({ turn_id: "turn-codex-upper", session_id: upperUuid })); + const result = await childResult(child); + assert.equal(result.code, 0, result.stderr); + assert.equal(signals.length, 1); + assert.equal(signals[0].signal.codexThreadId, lowerUuid); +}); + +test("RuntimeGateway ignores codexThreadId when non-canonical or cross-provider", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { + const root = await fixture(t); + const signals = []; + const gateway = new RuntimeGateway({ runtimeDirectory: root, onSignal: (id, signal) => signals.push({ id, signal }) }); + await gateway.start(); + t.after(() => gateway.close()); + + // 1. Cross-provider: claude provider with session_id UUID in hook input + const claudeCap = gateway.registerSession("terminal-claude-test", "claude"); + const validUuid = "12345678-1234-1234-1234-123456789abc"; + const helper = new URL("../src/agent-runtime/hook-helper.mjs", import.meta.url); + + const claudeChild = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { + env: { + ...process.env, + [AGENT_RUNTIME_ENV.address]: claudeCap.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: claudeCap.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: claudeCap.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: claudeCap.capabilityToken + }, + stdio: ["pipe", "ignore", "pipe"] + }); + claudeChild.stdin.end(JSON.stringify({ turn_id: "turn-claude-1", session_id: validUuid })); + const claudeResult = await childResult(claudeChild); + assert.equal(claudeResult.code, 0, claudeResult.stderr); + assert.equal(signals.length, 1); + assert.equal(signals[0].signal.codexThreadId, undefined); + + // 2. Malformed UUID: not canonical format (e.g. invalid chars, wrong length, path traversal) + const codexCap = gateway.registerSession("terminal-codex-malformed", "codex"); + const malformedInputs = [ + "not-a-uuid", + "12345678-1234-1234-1234-123456789abz", // 'z' is not hex + "12345678123412341234123456789abc", // no hyphens + "../../../etc/passwd", + "12345678-1234-1234-1234-123456789abc\n", + "Bearer token123456" + ]; + for (const badId of malformedInputs) { + const childBad = spawn(process.execPath, [helper.pathname, "working", "UserPromptSubmit"], { + env: { + ...process.env, + [AGENT_RUNTIME_ENV.address]: codexCap.address, + [AGENT_RUNTIME_ENV.terminalSessionId]: codexCap.terminalSessionId, + [AGENT_RUNTIME_ENV.provider]: codexCap.provider, + [AGENT_RUNTIME_ENV.capabilityToken]: codexCap.capabilityToken + }, + stdio: ["pipe", "ignore", "pipe"] + }); + childBad.stdin.end(JSON.stringify({ turn_id: "turn-bad", session_id: badId })); + const badRes = await childResult(childBad); + assert.equal(badRes.code, 0, badRes.stderr); + } + // All malformed ones should either be omitted or ignored without codexThreadId + for (let i = 1; i < signals.length; i++) { + assert.equal(signals[i].signal.codexThreadId, undefined); + } + + // 3. Direct protocol injection with cross-provider codexThreadId is rejected + await send(claudeCap.address, { + ...message(claudeCap, "working", "UserPromptSubmit", "turn-claude-direct"), + codexThreadId: validUuid + }); + // Signal should not be delivered or accepted + assert.equal(signals.filter((s) => s.id === "terminal-claude-test").length, 1); +}); + test("ordinary Codex Stop reports omit answer text without an explicit capture grant", POSIX_RUNTIME_GATEWAY_TEST, async (t) => { const root = await fixture(t); const signals = []; diff --git a/tests/terminal-launch.test.mjs b/tests/terminal-launch.test.mjs index f542a3da..0f621a01 100644 --- a/tests/terminal-launch.test.mjs +++ b/tests/terminal-launch.test.mjs @@ -68,8 +68,7 @@ test("restored agent windows use each provider's native continue mode", () => { assert.deepEqual(codex.args, [ "--dangerously-bypass-approvals-and-sandbox", "--bridge", - "resume", - "--last" + "resume" ]); for (const provider of ["claude", "qwen", "kimi", "opencode", "hermes", "grok", "omp", "pi", "cursor", "minimax", "devin"]) { @@ -87,6 +86,87 @@ test("restored agent windows use each provider's native continue mode", () => { assert.deepEqual(restored.args, ["--bridge"]); }); +test("Codex restore with resumeThreadId launches codex resume ", () => { + const uuid1 = "12345678-1234-4234-8234-123456789abc"; + const uuid2 = "abcdef01-abcd-4def-9abc-def012345678"; + + const launch1 = resolveTerminalLaunch("codex", "normal", ["--bridge"], { + providerCli: available("codex", "/resolved/codex"), + resumePrevious: true, + resumeThreadId: uuid1 + }); + assert.deepEqual(launch1.args, ["--bridge", "resume", uuid1]); + + const launch2 = resolveTerminalLaunch("codex", "yolo", [], { + providerCli: available("codex", "/resolved/codex"), + resumePrevious: true, + resumeThreadId: uuid2.toUpperCase() + }); + assert.deepEqual(launch2.args, [ + "--dangerously-bypass-approvals-and-sandbox", + "resume", + uuid2.toLowerCase() + ]); +}); + +test("Codex restore without resumeThreadId launches interactive resume chooser", () => { + const launch = resolveTerminalLaunch("codex", "normal", [], { + providerCli: available("codex", "/resolved/codex"), + resumePrevious: true + }); + assert.deepEqual(launch.args, ["resume"]); +}); + +test("Codex restore throws on malformed resumeThreadId", () => { + for (const malformed of ["not-a-uuid", "12345678-1234-1234-1234", "12345678-1234-1234-1234-123456789abc-extra", "../escape"]) { + assert.throws( + () => + resolveTerminalLaunch("codex", "normal", [], { + providerCli: available("codex", "/resolved/codex"), + resumePrevious: true, + resumeThreadId: malformed + }), + /Invalid Codex thread ID format/u + ); + } +}); + +test("Other providers ignore resumeThreadId and retain standard continue flags", () => { + const uuid = "12345678-1234-4234-8234-123456789abc"; + const claude = resolveTerminalLaunch("claude", "normal", ["--bridge"], { + providerCli: available("claude", "/resolved/claude"), + resumePrevious: true, + resumeThreadId: uuid + }); + assert.deepEqual(claude.args, ["--bridge", "--continue"]); + + const qwen = resolveTerminalLaunch("qwen", "yolo", [], { + providerCli: available("qwen", "/resolved/qwen"), + resumePrevious: true, + resumeThreadId: uuid + }); + assert.deepEqual(qwen.args, ["--yolo", "--continue"]); +}); + +test("Windows batch quoting with Codex resume thread UUID", () => { + const commandPrompt = "C:\\Windows\\System32\\cmd.exe"; + const uuid = "12345678-1234-4234-8234-123456789abc"; + const providerCli = available( + "codex", + "C:\\Users\\Kisa\\AppData\\Roaming\\npm\\codex.cmd", + { launcher: "batch", commandPrompt, environment: { Path: "C:\\resolved" } } + ); + const launch = resolveTerminalLaunch("codex", "normal", ["--bridge"], { + platform: "win32", + providerCli, + resumePrevious: true, + resumeThreadId: uuid + }); + assert.equal(launch.command, commandPrompt); + assert.match(launch.args, /codex\.cmd/u); + assert.match(launch.args, new RegExp(uuid, "u")); +}); + test("OMP and Pi use their documented dangerous flags instead of the legacy default", () => { const omp = resolveTerminalLaunch("omp", "yolo", [], { providerCli: available("omp", "/resolved/omp") }); assert.deepEqual(omp.args, ["--auto-approve"]); diff --git a/tests/terminal-session-exact-resume.test.mjs b/tests/terminal-session-exact-resume.test.mjs new file mode 100644 index 00000000..fa55a463 --- /dev/null +++ b/tests/terminal-session-exact-resume.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { TerminalSessionStore } from "../src/main/services/TerminalSessionStore.ts"; + +const FIRST_THREAD = "11111111-1111-4111-8111-111111111111"; +const SECOND_THREAD = "22222222-2222-4222-8222-222222222222"; + +function registry() { + return { + get(provider) { + return { + state: "available", + provider, + executable: `/resolved/${provider}`, + launcher: "native", + environment: {}, + checked: [] + }; + }, + snapshot() { return {}; } + }; +} + +function spawner(calls) { + return (command, args, options) => { + calls.push({ command, args, options }); + return { + pid: 10_000 + calls.length, + process: command, + write() {}, + resize() {}, + kill() {}, + pause() {}, + resume() {}, + onData() { return { dispose() {} }; }, + onExit() { return { dispose() {} }; } + }; + }; +} + +function manager(directory, calls) { + const instance = new TerminalManager( + () => undefined, + registry(), + undefined, + undefined, + true, + spawner(calls) + ); + instance.configureSessionPersistence(new TerminalSessionStore(directory), true); + return instance; +} + +test("restoring two Codex cards in one cwd resumes their own conversations", async () => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-exact-resume-")); + try { + const initial = manager(directory, []); + await initial.restorePersistedSessions(); + const first = initial.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + const second = initial.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 20, y: 20 } }); + // SessionStart is idle while a new card is already idle. The ID must still persist. + initial.applyProviderSignal(first.id, { kind: "lifecycle", state: "idle", codexThreadId: FIRST_THREAD }); + initial.applyProviderSignal(second.id, { kind: "lifecycle", state: "idle", codexThreadId: SECOND_THREAD }); + await initial.shutdown(); + + const calls = []; + const restored = manager(directory, calls); + await restored.restorePersistedSessions(); + assert.equal(calls.length, 2); + assert.deepEqual(calls.map((call) => call.args.slice(-2)), [ + ["resume", FIRST_THREAD], + ["resume", SECOND_THREAD] + ]); + assert.deepEqual(restored.list().map((session) => session.id), [first.id, second.id]); + await restored.shutdown(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("legacy Codex card without an ID opens the resume picker", async () => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-legacy-resume-")); + try { + const store = new TerminalSessionStore(directory); + await store.replace([{ + id: "legacy-codex-card", + provider: "codex", + profile: "normal", + role: "agent", + title: "Legacy card", + titleCustomized: true, + cwd: process.cwd(), + position: { x: 0, y: 0 }, + size: { width: 700, height: 430 } + }]); + const calls = []; + const restored = manager(directory, calls); + await restored.restorePersistedSessions(); + assert.equal(calls.length, 1); + assert.equal(calls[0].args.at(-1), "resume"); + assert.equal(calls[0].args.includes("--last"), false); + await restored.shutdown(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("restarting a Codex card after an exit clears stale thread ID and launches fresh", async () => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-restart-codex-")); + try { + const calls = []; + let exitHandler = null; + const customSpawner = (command, args, options) => { + calls.push({ command, args, options }); + return { + pid: 30_000 + calls.length, + process: command, + write() {}, + resize() {}, + kill() {}, + pause() {}, + resume() {}, + onData() { return { dispose() {} }; }, + onExit(handler) { + exitHandler = handler; + return { dispose() {} }; + } + }; + }; + + const inst = new TerminalManager( + () => undefined, + registry(), + undefined, + undefined, + true, + customSpawner + ); + const store = new TerminalSessionStore(directory); + inst.configureSessionPersistence(store, true); + await inst.restorePersistedSessions(); + + const created = inst.create({ provider: "codex", profile: "normal", cwd: process.cwd(), position: { x: 0, y: 0 } }); + inst.applyProviderSignal(created.id, { kind: "lifecycle", state: "idle", codexThreadId: FIRST_THREAD }); + + // Verify stored + assert.equal(store.get()[0]?.codexThreadId, FIRST_THREAD); + + // Simulate exit + exitHandler?.({ exitCode: 0 }); + + // Restart the session + inst.restart(created.id); + + // Fresh restart: second spawn call shouldn't have "resume" or thread ID + assert.equal(calls.length, 2); + assert.equal(calls[1].args.includes("resume"), false); + assert.equal(calls[1].args.includes(FIRST_THREAD), false); + + // Stored session should no longer have the stale thread ID + assert.equal(store.get()[0]?.codexThreadId, undefined); + + await inst.shutdown(); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/terminal-session-restore.test.mjs b/tests/terminal-session-restore.test.mjs index 1a38ff78..19bd3558 100644 --- a/tests/terminal-session-restore.test.mjs +++ b/tests/terminal-session-restore.test.mjs @@ -80,7 +80,8 @@ test("opt-in restore preserves card identity and relaunches the agent in native await restored.restorePersistedSessions(); assert.equal(restoredCalls.length, 1); - assert.deepEqual(restoredCalls[0].args.slice(-2), ["resume", "--last"]); + assert.deepEqual(restoredCalls[0].args.slice(-1), ["resume"]); + assert.equal(restoredCalls[0].args.includes("--last"), false); assert.deepEqual(restored.list().map(({ buffer, revision, status, startedAt, exitCode, failureDetails, ...session }) => session), [{ id: created.id, provider: "codex", diff --git a/tests/terminal-session-store.test.mjs b/tests/terminal-session-store.test.mjs index 6dd822e6..630ca2b5 100644 --- a/tests/terminal-session-store.test.mjs +++ b/tests/terminal-session-store.test.mjs @@ -5,7 +5,8 @@ import { join } from "node:path"; import test from "node:test"; import { TerminalSessionStore, - normalizePersistedTerminalSessions + normalizePersistedTerminalSessions, + persistedTerminalSession } from "../src/main/services/TerminalSessionStore.ts"; const descriptor = { @@ -91,3 +92,69 @@ test("legacy roles restore as agents while unknown roles are dropped", async () await rm(directory, { recursive: true, force: true }); } }); + +test("codexThreadId persists and normalizes to canonical lower-case UUID for codex provider", async () => { + const threadIdUpper = "A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D"; + const threadIdCanonical = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"; + + const sessionMetadata = { + ...descriptor, + revision: 1, + status: "idle", + startedAt: Date.now(), + exitCode: null, + failureDetails: null + }; + + // persistedTerminalSession helper round-trips valid codexThreadId + const persisted = persistedTerminalSession(sessionMetadata, ` ${threadIdUpper} `); + assert.equal(persisted.codexThreadId, threadIdCanonical); + + // Non-codex provider ignores codexThreadId in persistedTerminalSession helper + const claudePersisted = persistedTerminalSession({ ...sessionMetadata, provider: "claude" }, threadIdUpper); + assert.equal(claudePersisted.codexThreadId, undefined); + + // Malformed thread IDs are ignored in persistedTerminalSession helper + assert.equal(persistedTerminalSession(sessionMetadata, "not-a-uuid").codexThreadId, undefined); + assert.equal(persistedTerminalSession(sessionMetadata, 12345).codexThreadId, undefined); + + // Store persistence and load round-trip + const directory = await mkdtemp(join(tmpdir(), "canvastty-terminal-state-codex-thread-")); + try { + const store = new TerminalSessionStore(directory); + await store.replace([persisted]); + const reloaded = await store.load(); + assert.equal(reloaded.length, 1); + assert.equal(reloaded[0].codexThreadId, threadIdCanonical); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("normalizePersistedTerminalSessions preserves cards with malformed or foreign thread IDs", () => { + const validUuid = "11111111-2222-3333-4444-555555555555"; + const normalized = normalizePersistedTerminalSessions({ + version: 1, + sessions: [ + // Valid codex thread ID + { ...descriptor, id: "valid-codex", provider: "codex", codexThreadId: validUuid }, + // Valid codex session without codexThreadId (backward compatibility) + { ...descriptor, id: "valid-codex-no-thread", provider: "codex" }, + // Malformed thread ID on codex session -> card survives without that ID + { ...descriptor, id: "bad-uuid", provider: "codex", codexThreadId: "invalid-uuid" }, + // Non-string thread ID on codex session -> card survives without that ID + { ...descriptor, id: "bad-type-uuid", provider: "codex", codexThreadId: 12345 }, + // codexThreadId attached to non-codex provider -> ignored + { ...descriptor, id: "claude-with-thread", provider: "claude", codexThreadId: validUuid }, + { ...descriptor, id: "terminal-with-thread", provider: "terminal", codexThreadId: validUuid } + ] + }); + + assert.deepEqual(normalized.sessions.map((s) => s.id), [ + "valid-codex", "valid-codex-no-thread", "bad-uuid", "bad-type-uuid", + "claude-with-thread", "terminal-with-thread" + ]); + assert.equal(normalized.sessions[0].codexThreadId, validUuid); + assert.equal(normalized.sessions[1].codexThreadId, undefined); + assert.ok(normalized.sessions.slice(2).every((session) => session.codexThreadId === undefined)); +});