diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 6869c1ea..67e51dc6 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -5,6 +5,7 @@ export type { AdapterExecutionResult, AdapterInvocationMeta, AdapterExecutionContext, + AdapterSessionCodec, ServerAdapterModule, TranscriptEntry, StdoutLineParser, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index d8253edd..80d6a52e 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -11,7 +11,13 @@ export interface AdapterAgent { } export interface AdapterRuntime { + /** + * Legacy single session id view. Prefer `sessionParams` + `sessionDisplayId`. + */ sessionId: string | null; + sessionParams: Record | null; + sessionDisplayId: string | null; + taskKey: string | null; } // --------------------------------------------------------------------------- @@ -30,7 +36,12 @@ export interface AdapterExecutionResult { timedOut: boolean; errorMessage?: string | null; usage?: UsageSummary; + /** + * Legacy single session id output. Prefer `sessionParams` + `sessionDisplayId`. + */ sessionId?: string | null; + sessionParams?: Record | null; + sessionDisplayId?: string | null; provider?: string | null; model?: string | null; costUsd?: number | null; @@ -39,6 +50,12 @@ export interface AdapterExecutionResult { clearSession?: boolean; } +export interface AdapterSessionCodec { + deserialize(raw: unknown): Record | null; + serialize(params: Record | null): Record | null; + getDisplayId?: (params: Record | null) => string | null; +} + export interface AdapterInvocationMeta { adapterType: string; command: string; @@ -63,6 +80,7 @@ export interface AdapterExecutionContext { export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; + sessionCodec?: AdapterSessionCodec; supportsLocalAgentJwt?: boolean; models?: { id: string; label: string }[]; agentConfigurationDoc?: string; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index d279fcfc..e3306694 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -119,7 +119,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + const sessionId = canResumeSession ? runtimeSessionId : null; + if (runtimeSessionId && !canResumeSession) { + await onLog( + "stderr", + `[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + ); + } const template = sessionId ? promptTemplate : bootstrapTemplate; const prompt = renderTemplate(template, { company: { id: agent.companyId }, @@ -230,6 +242,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise) + : null; return { exitCode: proc.exitCode, @@ -241,6 +256,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise {}); } diff --git a/packages/adapters/claude-local/src/server/index.ts b/packages/adapters/claude-local/src/server/index.ts index bf3566b5..870b25e0 100644 --- a/packages/adapters/claude-local/src/server/index.ts +++ b/packages/adapters/claude-local/src/server/index.ts @@ -1,2 +1,35 @@ export { execute } from "./execute.js"; export { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js"; +import type { AdapterSessionCodec } from "@paperclip/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id); + if (!sessionId) return null; + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + return cwd ? { sessionId, cwd } : { sessionId }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); + if (!sessionId) return null; + const cwd = + readNonEmptyString(params.cwd) ?? + readNonEmptyString(params.workdir) ?? + readNonEmptyString(params.folder); + return cwd ? { sessionId, cwd } : { sessionId }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); + }, +}; diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index b7b705d0..3fb942af 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip/adapter-utils"; import { asString, @@ -13,7 +14,7 @@ import { renderTemplate, runChildProcess, } from "@paperclip/adapter-utils/server-utils"; -import { parseCodexJsonl } from "./parse.js"; +import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; export async function execute(ctx: AdapterExecutionContext): Promise { const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; @@ -86,7 +87,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + const sessionId = canResumeSession ? runtimeSessionId : null; + if (runtimeSessionId && !canResumeSession) { + await onLog( + "stderr", + `[paperclip] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + ); + } const template = sessionId ? promptTemplate : bootstrapTemplate; const prompt = renderTemplate(template, { company: { id: agent.companyId }, @@ -95,63 +108,104 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push(...extraArgs); - if (sessionId) args.push("resume", sessionId, prompt); - else args.push(prompt); - - if (onMeta) { - await onMeta({ - adapterType: "codex_local", - command, - cwd, - commandArgs: args.map((value, idx) => { - if (!sessionId && idx === args.length - 1) return ``; - if (sessionId && idx === args.length - 1) return ``; - return value; - }), - env: redactEnvForLogs(env), - prompt, - context, - }); - } - - const proc = await runChildProcess(runId, command, args, { - cwd, - env, - timeoutSec, - graceSec, - onLog, - }); - - if (proc.timedOut) { - return { - exitCode: proc.exitCode, - signal: proc.signal, - timedOut: true, - errorMessage: `Timed out after ${timeoutSec}s`, - }; - } - - const parsed = parseCodexJsonl(proc.stdout); - - return { - exitCode: proc.exitCode, - signal: proc.signal, - timedOut: false, - errorMessage: (proc.exitCode ?? 0) === 0 ? null : `Codex exited with code ${proc.exitCode ?? -1}`, - usage: parsed.usage, - sessionId: parsed.sessionId ?? runtime.sessionId, - provider: "openai", - model, - costUsd: null, - resultJson: { - stdout: proc.stdout, - stderr: proc.stderr, - }, - summary: parsed.summary, + const buildArgs = (resumeSessionId: string | null) => { + const args = ["exec", "--json"]; + if (search) args.unshift("--search"); + if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox"); + if (model) args.push("--model", model); + if (extraArgs.length > 0) args.push(...extraArgs); + if (resumeSessionId) args.push("resume", resumeSessionId, prompt); + else args.push(prompt); + return args; }; + + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + if (onMeta) { + await onMeta({ + adapterType: "codex_local", + command, + cwd, + commandArgs: args.map((value, idx) => { + if (idx === args.length - 1) return ``; + return value; + }), + env: redactEnvForLogs(env), + prompt, + context, + }); + } + + const proc = await runChildProcess(runId, command, args, { + cwd, + env, + timeoutSec, + graceSec, + onLog, + }); + return { + proc, + parsed: parseCodexJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; parsed: ReturnType }, + clearSessionOnMissingSession = false, + ): AdapterExecutionResult => { + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + clearSession: clearSessionOnMissingSession, + }; + } + + const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null; + const resolvedSessionParams = resolvedSessionId + ? ({ sessionId: resolvedSessionId, cwd } as Record) + : null; + + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: + (attempt.proc.exitCode ?? 0) === 0 + ? null + : `Codex exited with code ${attempt.proc.exitCode ?? -1}`, + usage: attempt.parsed.usage, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: "openai", + model, + costUsd: null, + resultJson: { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }, + summary: attempt.parsed.summary, + clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId), + }; + }; + + const initial = await runAttempt(sessionId); + if ( + sessionId && + !initial.proc.timedOut && + (initial.proc.exitCode ?? 0) !== 0 && + isCodexUnknownSessionError(initial.proc.stdout, initial.proc.stderr) + ) { + await onLog( + "stderr", + `[paperclip] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true); + } + + return toResult(initial); } diff --git a/packages/adapters/codex-local/src/server/index.ts b/packages/adapters/codex-local/src/server/index.ts index 32b56e7c..d5fe5d1c 100644 --- a/packages/adapters/codex-local/src/server/index.ts +++ b/packages/adapters/codex-local/src/server/index.ts @@ -1,2 +1,35 @@ export { execute } from "./execute.js"; -export { parseCodexJsonl } from "./parse.js"; +export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; +import type { AdapterSessionCodec } from "@paperclip/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id); + if (!sessionId) return null; + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + return cwd ? { sessionId, cwd } : { sessionId }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); + if (!sessionId) return null; + const cwd = + readNonEmptyString(params.cwd) ?? + readNonEmptyString(params.workdir) ?? + readNonEmptyString(params.folder); + return cwd ? { sessionId, cwd } : { sessionId }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); + }, +}; diff --git a/packages/adapters/codex-local/src/server/parse.ts b/packages/adapters/codex-local/src/server/parse.ts index 0d2b6dbb..a14950f1 100644 --- a/packages/adapters/codex-local/src/server/parse.ts +++ b/packages/adapters/codex-local/src/server/parse.ts @@ -45,3 +45,14 @@ export function parseCodexJsonl(stdout: string) { usage, }; } + +export function isCodexUnknownSessionError(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + return /unknown (session|thread)|session .* not found|thread .* not found|conversation .* not found/i.test( + haystack, + ); +} diff --git a/server/src/__tests__/adapter-session-codecs.test.ts b/server/src/__tests__/adapter-session-codecs.test.ts new file mode 100644 index 00000000..33cffc14 --- /dev/null +++ b/server/src/__tests__/adapter-session-codecs.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { sessionCodec as claudeSessionCodec } from "@paperclip/adapter-claude-local/server"; +import { sessionCodec as codexSessionCodec, isCodexUnknownSessionError } from "@paperclip/adapter-codex-local/server"; + +describe("adapter session codecs", () => { + it("normalizes claude session params with cwd", () => { + const parsed = claudeSessionCodec.deserialize({ + session_id: "claude-session-1", + folder: "/tmp/workspace", + }); + expect(parsed).toEqual({ + sessionId: "claude-session-1", + cwd: "/tmp/workspace", + }); + + const serialized = claudeSessionCodec.serialize(parsed); + expect(serialized).toEqual({ + sessionId: "claude-session-1", + cwd: "/tmp/workspace", + }); + expect(claudeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("claude-session-1"); + }); + + it("normalizes codex session params with cwd", () => { + const parsed = codexSessionCodec.deserialize({ + sessionId: "codex-session-1", + cwd: "/tmp/codex", + }); + expect(parsed).toEqual({ + sessionId: "codex-session-1", + cwd: "/tmp/codex", + }); + + const serialized = codexSessionCodec.serialize(parsed); + expect(serialized).toEqual({ + sessionId: "codex-session-1", + cwd: "/tmp/codex", + }); + expect(codexSessionCodec.getDisplayId?.(serialized ?? null)).toBe("codex-session-1"); + }); +}); + +describe("codex resume recovery detection", () => { + it("detects unknown session errors from codex output", () => { + expect( + isCodexUnknownSessionError( + '{"type":"error","message":"Unknown session id abc"}', + "", + ), + ).toBe(true); + expect( + isCodexUnknownSessionError( + "", + "thread 123 not found", + ), + ).toBe(true); + expect( + isCodexUnknownSessionError( + '{"type":"result","ok":true}', + "", + ), + ).toBe(false); + }); +}); diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index 11637410..d02700d1 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -4,6 +4,7 @@ export type { AdapterExecutionContext, AdapterExecutionResult, AdapterInvocationMeta, + AdapterSessionCodec, UsageSummary, AdapterAgent, AdapterRuntime, diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 452bc572..de3b3880 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,7 +1,7 @@ import type { ServerAdapterModule } from "./types.js"; -import { execute as claudeExecute } from "@paperclip/adapter-claude-local/server"; +import { execute as claudeExecute, sessionCodec as claudeSessionCodec } from "@paperclip/adapter-claude-local/server"; import { agentConfigurationDoc as claudeAgentConfigurationDoc, models as claudeModels } from "@paperclip/adapter-claude-local"; -import { execute as codexExecute } from "@paperclip/adapter-codex-local/server"; +import { execute as codexExecute, sessionCodec as codexSessionCodec } from "@paperclip/adapter-codex-local/server"; import { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclip/adapter-codex-local"; import { processAdapter } from "./process/index.js"; import { httpAdapter } from "./http/index.js"; @@ -9,6 +9,7 @@ import { httpAdapter } from "./http/index.js"; const claudeLocalAdapter: ServerAdapterModule = { type: "claude_local", execute: claudeExecute, + sessionCodec: claudeSessionCodec, models: claudeModels, supportsLocalAgentJwt: true, agentConfigurationDoc: claudeAgentConfigurationDoc, @@ -17,6 +18,7 @@ const claudeLocalAdapter: ServerAdapterModule = { const codexLocalAdapter: ServerAdapterModule = { type: "codex_local", execute: codexExecute, + sessionCodec: codexSessionCodec, models: codexModels, supportsLocalAgentJwt: true, agentConfigurationDoc: codexAgentConfigurationDoc, diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index 3cd72846..76bd13bd 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -8,5 +8,6 @@ export type { AdapterExecutionResult, AdapterInvocationMeta, AdapterExecutionContext, + AdapterSessionCodec, ServerAdapterModule, } from "@paperclip/adapter-utils";