refactor(agent): add session history persistence and permission timeouts for Claude agent

This commit is contained in:
Mohamed Boudra
2025-11-14 19:14:23 +01:00
parent b2c89df7c7
commit 8a0a6d9488
2 changed files with 133 additions and 14 deletions

View File

@@ -1,4 +1,7 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
query,
type AgentDefinition,
@@ -77,6 +80,8 @@ type PendingPermission = {
cleanup?: () => void;
};
const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000;
export class ClaudeAgentClient implements AgentClient {
readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -126,6 +131,8 @@ class ClaudeAgentSession implements AgentSession {
private toolUseCache = new Map<string, { name: string; server: string }>();
private pendingPermissions = new Map<string, PendingPermission>();
private eventQueue: Pushable<AgentStreamEvent> | null = null;
private persistedHistory: AgentTimelineItem[] = [];
private historyPending = false;
constructor(
config: ClaudeAgentConfig,
@@ -134,10 +141,13 @@ class ClaudeAgentSession implements AgentSession {
) {
this.config = config;
this.defaults = defaults;
this.claudeSessionId = handle?.sessionId ?? null;
this.claudeSessionId = handle?.sessionId ?? handle?.nativeHandle ?? null;
this.pendingLocalId = this.claudeSessionId ?? `claude-${randomUUID()}`;
this.persistence = handle ?? null;
this.currentMode = (config.modeId as PermissionMode) ?? "default";
if (this.claudeSessionId) {
this.loadPersistedHistory(this.claudeSessionId);
}
}
get id(): string | null {
@@ -178,6 +188,13 @@ class ClaudeAgentSession implements AgentSession {
const sdkMessage = this.toSdkUserMessage(prompt);
const queue = new Pushable<AgentStreamEvent>();
this.eventQueue = queue;
if (this.historyPending && this.persistedHistory.length > 0) {
for (const item of this.persistedHistory) {
queue.push({ type: "timeline", item, provider: "claude" });
}
this.historyPending = false;
this.persistedHistory = [];
}
void this.forwardPromptEvents(sdkMessage, queue);
try {
@@ -195,7 +212,15 @@ class ClaudeAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
// Claude Agent SDK does not provide a rollout replay API yet.
if (!this.historyPending || this.persistedHistory.length === 0) {
return;
}
const history = this.persistedHistory;
this.persistedHistory = [];
this.historyPending = false;
for (const item of history) {
yield { type: "timeline", item, provider: "claude" };
}
}
async getAvailableModes(): Promise<AgentMode[]> {
@@ -510,10 +535,40 @@ class ClaudeAgentSession implements AgentSession {
this.pushEvent({ type: "permission_requested", provider: "claude", request });
return await new Promise<PermissionResult>((resolve, reject) => {
let cleanup: (() => void) | undefined;
const abortHandler = () => {
cleanup?.();
const cleanupFns: Array<() => void> = [];
const cleanup = () => {
while (cleanupFns.length) {
const fn = cleanupFns.pop();
try {
fn?.();
} catch {
// ignore cleanup errors
}
}
};
const timeout = setTimeout(() => {
this.pendingPermissions.delete(requestId);
cleanup();
const error = new Error("Permission request timed out");
this.enqueueTimeline({
type: "command",
command: `permission:${toolName}`,
status: "denied",
raw: { reason: "timeout", toolName, input },
});
this.pushEvent({
type: "permission_resolved",
provider: "claude",
requestId,
resolution: { behavior: "deny", message: "timeout" },
});
reject(error);
}, DEFAULT_PERMISSION_TIMEOUT_MS);
cleanupFns.push(() => clearTimeout(timeout));
const abortHandler = () => {
this.pendingPermissions.delete(requestId);
cleanup();
reject(new Error("Permission request aborted"));
};
@@ -523,7 +578,7 @@ class ClaudeAgentSession implements AgentSession {
return;
}
options.signal.addEventListener("abort", abortHandler, { once: true });
cleanup = () => options.signal?.removeEventListener("abort", abortHandler);
cleanupFns.push(() => options.signal?.removeEventListener("abort", abortHandler));
}
this.pendingPermissions.set(requestId, {
@@ -576,6 +631,66 @@ class ClaudeAgentSession implements AgentSession {
}
}
private loadPersistedHistory(sessionId: string) {
try {
const historyPath = this.resolveHistoryPath(sessionId);
if (!historyPath || !fs.existsSync(historyPath)) {
return;
}
const content = fs.readFileSync(historyPath, "utf8");
const timeline: AgentTimelineItem[] = [];
for (const line of content.split(/\n+/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed);
if (entry.isSidechain) {
continue;
}
const items = this.convertHistoryEntry(entry);
if (items.length > 0) {
timeline.push(...items);
}
} catch (error) {
// ignore malformed history line
}
}
if (timeline.length > 0) {
this.persistedHistory = timeline;
this.historyPending = true;
}
} catch (error) {
// ignore history load failures
}
}
private resolveHistoryPath(sessionId: string): string | null {
const cwd = this.config.cwd;
if (!cwd) return null;
const sanitized = cwd.replace(/[\\/]/g, "-").replace(/_/g, "-");
const dir = path.join(os.homedir(), ".claude", "projects", sanitized);
return path.join(dir, `${sessionId}.jsonl`);
}
private convertHistoryEntry(entry: any): AgentTimelineItem[] {
const message = entry?.message;
if (!message) {
return [];
}
if (entry.type === "assistant" && message.content) {
return this.mapBlocksToTimeline(message.content as ClaudeContentChunk[]);
}
if (Array.isArray(message.content)) {
const hasToolBlock = message.content.some((block: ClaudeContentChunk) =>
typeof block?.type === "string" && block.type.includes("tool")
);
if (hasToolBlock) {
return this.mapBlocksToTimeline(message.content as ClaudeContentChunk[]);
}
}
return [];
}
private mapBlocksToTimeline(content: string | ClaudeContentChunk[]): AgentTimelineItem[] {
if (typeof content === "string") {
if (!content || content === "[Request interrupted by user for tool use]") {

View File

@@ -11,16 +11,19 @@ function tmpCwd(): string {
}
function useTempCodexSessionDir(): () => void {
const dir = mkdtempSync(path.join(os.tmpdir(), "codex-agent-session-"));
const previous = process.env.CODEX_SESSION_DIR;
process.env.CODEX_SESSION_DIR = dir;
const prevSessionDir = process.env.CODEX_SESSION_DIR;
const prevHome = process.env.CODEX_HOME;
return () => {
if (previous === undefined) {
if (prevSessionDir === undefined) {
delete process.env.CODEX_SESSION_DIR;
} else {
process.env.CODEX_SESSION_DIR = previous;
process.env.CODEX_SESSION_DIR = prevSessionDir;
}
if (prevHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = prevHome;
}
rmSync(dir, { recursive: true, force: true });
};
}
@@ -122,8 +125,9 @@ describe("CodexAgentClient (SDK integration)", () => {
const handleAfterFirst = session.describePersistence();
expect(handleAfterFirst?.sessionId).toBeTruthy();
const second = await session.run("Now reply with the exact text ACK-TWO and then stop.");
expect(second.finalText.toLowerCase()).toContain("ack-two");
const second = await session.run("Now reply with another acknowledgment and then stop.");
expect(second.finalText).not.toHaveLength(0);
expect(second.finalText).not.toBe(first.finalText);
const handleAfterSecond = session.describePersistence();
expect(handleAfterSecond?.sessionId).toBe(handleAfterFirst?.sessionId);