Add adapter session codecs with cwd-aware resume and unknown-session retry
Introduce AdapterSessionCodec interface for structured session serialization, deserialization, and display ID extraction. Implement codecs for claude_local and codex_local adapters with cwd validation — sessions saved for a different working directory are not resumed. Both adapters now return sessionParams and sessionDisplayId alongside legacy sessionId. Add isCodexUnknownSessionError detection and automatic retry with fresh session for codex_local (matching existing claude_local behavior). Inject approval context env vars (PAPERCLIP_APPROVAL_ID, PAPERCLIP_APPROVAL_STATUS, PAPERCLIP_LINKED_ISSUE_IDS) into adapter environments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ export type {
|
|||||||
AdapterExecutionResult,
|
AdapterExecutionResult,
|
||||||
AdapterInvocationMeta,
|
AdapterInvocationMeta,
|
||||||
AdapterExecutionContext,
|
AdapterExecutionContext,
|
||||||
|
AdapterSessionCodec,
|
||||||
ServerAdapterModule,
|
ServerAdapterModule,
|
||||||
TranscriptEntry,
|
TranscriptEntry,
|
||||||
StdoutLineParser,
|
StdoutLineParser,
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ export interface AdapterAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AdapterRuntime {
|
export interface AdapterRuntime {
|
||||||
|
/**
|
||||||
|
* Legacy single session id view. Prefer `sessionParams` + `sessionDisplayId`.
|
||||||
|
*/
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
|
sessionParams: Record<string, unknown> | null;
|
||||||
|
sessionDisplayId: string | null;
|
||||||
|
taskKey: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -30,7 +36,12 @@ export interface AdapterExecutionResult {
|
|||||||
timedOut: boolean;
|
timedOut: boolean;
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
usage?: UsageSummary;
|
usage?: UsageSummary;
|
||||||
|
/**
|
||||||
|
* Legacy single session id output. Prefer `sessionParams` + `sessionDisplayId`.
|
||||||
|
*/
|
||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
|
sessionParams?: Record<string, unknown> | null;
|
||||||
|
sessionDisplayId?: string | null;
|
||||||
provider?: string | null;
|
provider?: string | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
costUsd?: number | null;
|
costUsd?: number | null;
|
||||||
@@ -39,6 +50,12 @@ export interface AdapterExecutionResult {
|
|||||||
clearSession?: boolean;
|
clearSession?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdapterSessionCodec {
|
||||||
|
deserialize(raw: unknown): Record<string, unknown> | null;
|
||||||
|
serialize(params: Record<string, unknown> | null): Record<string, unknown> | null;
|
||||||
|
getDisplayId?: (params: Record<string, unknown> | null) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdapterInvocationMeta {
|
export interface AdapterInvocationMeta {
|
||||||
adapterType: string;
|
adapterType: string;
|
||||||
command: string;
|
command: string;
|
||||||
@@ -63,6 +80,7 @@ export interface AdapterExecutionContext {
|
|||||||
export interface ServerAdapterModule {
|
export interface ServerAdapterModule {
|
||||||
type: string;
|
type: string;
|
||||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
||||||
|
sessionCodec?: AdapterSessionCodec;
|
||||||
supportsLocalAgentJwt?: boolean;
|
supportsLocalAgentJwt?: boolean;
|
||||||
models?: { id: string; label: string }[];
|
models?: { id: string; label: string }[];
|
||||||
agentConfigurationDoc?: string;
|
agentConfigurationDoc?: string;
|
||||||
|
|||||||
@@ -119,7 +119,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
})();
|
})();
|
||||||
const skillsDir = await buildSkillsDir();
|
const skillsDir = await buildSkillsDir();
|
||||||
|
|
||||||
const sessionId = runtime.sessionId;
|
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||||
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||||
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||||
|
const canResumeSession =
|
||||||
|
runtimeSessionId.length > 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 template = sessionId ? promptTemplate : bootstrapTemplate;
|
||||||
const prompt = renderTemplate(template, {
|
const prompt = renderTemplate(template, {
|
||||||
company: { id: agent.companyId },
|
company: { id: agent.companyId },
|
||||||
@@ -230,6 +242,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
const resolvedSessionId =
|
const resolvedSessionId =
|
||||||
parsedStream.sessionId ??
|
parsedStream.sessionId ??
|
||||||
(asString(parsed.session_id, opts.fallbackSessionId ?? "") || opts.fallbackSessionId);
|
(asString(parsed.session_id, opts.fallbackSessionId ?? "") || opts.fallbackSessionId);
|
||||||
|
const resolvedSessionParams = resolvedSessionId
|
||||||
|
? ({ sessionId: resolvedSessionId, cwd } as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exitCode: proc.exitCode,
|
exitCode: proc.exitCode,
|
||||||
@@ -241,6 +256,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
: describeClaudeFailure(parsed) ?? `Claude exited with code ${proc.exitCode ?? -1}`,
|
: describeClaudeFailure(parsed) ?? `Claude exited with code ${proc.exitCode ?? -1}`,
|
||||||
usage,
|
usage,
|
||||||
sessionId: resolvedSessionId,
|
sessionId: resolvedSessionId,
|
||||||
|
sessionParams: resolvedSessionParams,
|
||||||
|
sessionDisplayId: resolvedSessionId,
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
model: parsedStream.model || asString(parsed.model, model),
|
model: parsedStream.model || asString(parsed.model, model),
|
||||||
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
|
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
|
||||||
@@ -267,7 +284,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return toAdapterResult(initial, { fallbackSessionId: runtime.sessionId });
|
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
|
||||||
} finally {
|
} finally {
|
||||||
fs.rm(skillsDir, { recursive: true, force: true }).catch(() => {});
|
fs.rm(skillsDir, { recursive: true, force: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,35 @@
|
|||||||
export { execute } from "./execute.js";
|
export { execute } from "./execute.js";
|
||||||
export { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.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<string, unknown>;
|
||||||
|
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<string, unknown> | 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<string, unknown> | null) {
|
||||||
|
if (!params) return null;
|
||||||
|
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import path from "node:path";
|
||||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip/adapter-utils";
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip/adapter-utils";
|
||||||
import {
|
import {
|
||||||
asString,
|
asString,
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
renderTemplate,
|
renderTemplate,
|
||||||
runChildProcess,
|
runChildProcess,
|
||||||
} from "@paperclip/adapter-utils/server-utils";
|
} from "@paperclip/adapter-utils/server-utils";
|
||||||
import { parseCodexJsonl } from "./parse.js";
|
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||||
|
|
||||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||||
@@ -86,7 +87,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
return asStringArray(config.args);
|
return asStringArray(config.args);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const sessionId = runtime.sessionId;
|
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||||
|
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||||
|
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||||
|
const canResumeSession =
|
||||||
|
runtimeSessionId.length > 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 template = sessionId ? promptTemplate : bootstrapTemplate;
|
||||||
const prompt = renderTemplate(template, {
|
const prompt = renderTemplate(template, {
|
||||||
company: { id: agent.companyId },
|
company: { id: agent.companyId },
|
||||||
@@ -95,63 +108,104 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
context,
|
context,
|
||||||
});
|
});
|
||||||
|
|
||||||
const args = ["exec", "--json"];
|
const buildArgs = (resumeSessionId: string | null) => {
|
||||||
if (search) args.unshift("--search");
|
const args = ["exec", "--json"];
|
||||||
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
if (search) args.unshift("--search");
|
||||||
if (model) args.push("--model", model);
|
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
if (model) args.push("--model", model);
|
||||||
if (sessionId) args.push("resume", sessionId, prompt);
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
else args.push(prompt);
|
if (resumeSessionId) args.push("resume", resumeSessionId, prompt);
|
||||||
|
else args.push(prompt);
|
||||||
if (onMeta) {
|
return args;
|
||||||
await onMeta({
|
|
||||||
adapterType: "codex_local",
|
|
||||||
command,
|
|
||||||
cwd,
|
|
||||||
commandArgs: args.map((value, idx) => {
|
|
||||||
if (!sessionId && idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
|
||||||
if (sessionId && idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
|
||||||
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 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 `<prompt ${prompt.length} chars>`;
|
||||||
|
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<typeof parseCodexJsonl> },
|
||||||
|
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<string, unknown>)
|
||||||
|
: 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,35 @@
|
|||||||
export { execute } from "./execute.js";
|
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<string, unknown>;
|
||||||
|
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<string, unknown> | 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<string, unknown> | null) {
|
||||||
|
if (!params) return null;
|
||||||
|
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -45,3 +45,14 @@ export function parseCodexJsonl(stdout: string) {
|
|||||||
usage,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
64
server/src/__tests__/adapter-session-codecs.test.ts
Normal file
64
server/src/__tests__/adapter-session-codecs.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ export type {
|
|||||||
AdapterExecutionContext,
|
AdapterExecutionContext,
|
||||||
AdapterExecutionResult,
|
AdapterExecutionResult,
|
||||||
AdapterInvocationMeta,
|
AdapterInvocationMeta,
|
||||||
|
AdapterSessionCodec,
|
||||||
UsageSummary,
|
UsageSummary,
|
||||||
AdapterAgent,
|
AdapterAgent,
|
||||||
AdapterRuntime,
|
AdapterRuntime,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ServerAdapterModule } from "./types.js";
|
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 { 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 { agentConfigurationDoc as codexAgentConfigurationDoc, models as codexModels } from "@paperclip/adapter-codex-local";
|
||||||
import { processAdapter } from "./process/index.js";
|
import { processAdapter } from "./process/index.js";
|
||||||
import { httpAdapter } from "./http/index.js";
|
import { httpAdapter } from "./http/index.js";
|
||||||
@@ -9,6 +9,7 @@ import { httpAdapter } from "./http/index.js";
|
|||||||
const claudeLocalAdapter: ServerAdapterModule = {
|
const claudeLocalAdapter: ServerAdapterModule = {
|
||||||
type: "claude_local",
|
type: "claude_local",
|
||||||
execute: claudeExecute,
|
execute: claudeExecute,
|
||||||
|
sessionCodec: claudeSessionCodec,
|
||||||
models: claudeModels,
|
models: claudeModels,
|
||||||
supportsLocalAgentJwt: true,
|
supportsLocalAgentJwt: true,
|
||||||
agentConfigurationDoc: claudeAgentConfigurationDoc,
|
agentConfigurationDoc: claudeAgentConfigurationDoc,
|
||||||
@@ -17,6 +18,7 @@ const claudeLocalAdapter: ServerAdapterModule = {
|
|||||||
const codexLocalAdapter: ServerAdapterModule = {
|
const codexLocalAdapter: ServerAdapterModule = {
|
||||||
type: "codex_local",
|
type: "codex_local",
|
||||||
execute: codexExecute,
|
execute: codexExecute,
|
||||||
|
sessionCodec: codexSessionCodec,
|
||||||
models: codexModels,
|
models: codexModels,
|
||||||
supportsLocalAgentJwt: true,
|
supportsLocalAgentJwt: true,
|
||||||
agentConfigurationDoc: codexAgentConfigurationDoc,
|
agentConfigurationDoc: codexAgentConfigurationDoc,
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ export type {
|
|||||||
AdapterExecutionResult,
|
AdapterExecutionResult,
|
||||||
AdapterInvocationMeta,
|
AdapterInvocationMeta,
|
||||||
AdapterExecutionContext,
|
AdapterExecutionContext,
|
||||||
|
AdapterSessionCodec,
|
||||||
ServerAdapterModule,
|
ServerAdapterModule,
|
||||||
} from "@paperclip/adapter-utils";
|
} from "@paperclip/adapter-utils";
|
||||||
|
|||||||
Reference in New Issue
Block a user