From f51db38e8167c3fb070e353b3e47ab9d1307ae2d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 8 Jan 2026 15:35:18 +0700 Subject: [PATCH] fix: validate sessionId on resume and respect CLAUDE_CONFIG_DIR - Constructor now throws if resuming with handle that has no sessionId - Removed fallback chain (sessionId ?? nativeHandle ?? null) - Removed pendingLocalId - no more random UUID generation on resume - Added validation after run() - throws if sessionId still null - resolveHistoryPath() now respects CLAUDE_CONFIG_DIR env var - Added comprehensive resume test that verifies context preservation - Fixed test helper to also respect CLAUDE_CONFIG_DIR --- .../agent/providers/claude-agent.test.ts | 80 ++++++++++++++++--- .../server/agent/providers/claude-agent.ts | 34 +++++--- 2 files changed, 89 insertions(+), 25 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index efe545a32..9adebeb9a 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -763,31 +763,84 @@ describe("ClaudeAgentClient (SDK integration)", () => { ); test( - "resumes a persisted session", + "resumes a persisted session with context preserved", async () => { const cwd = tmpCwd(); const client = new ClaudeAgentClient(); const config = buildConfig(cwd, { maxThinkingTokens: 1024 }); const session = await client.createSession(config); - const first = await session.run("Say READY and then stop."); - expect(first.finalText.toLowerCase()).toContain("ready"); + // Store a specific word in a file to create history and enable recall + const timestamp = Date.now(); + const secretWord = `XYZZY${timestamp}PLUGH`; + const secretFile = path.join(cwd, "secret.txt"); + const prompt = `Write exactly this word to a file called secret.txt: ${secretWord}. Then respond only with "STORED".`; - const handle = session.describePersistence(); - expect(handle).toBeTruthy(); + let storedResponse = ""; + for await (const event of session.stream(prompt)) { + await autoApprove(session, event); + if (event.type === "timeline" && event.item.type === "assistant_message") { + storedResponse = event.item.text; + } + if (event.type === "turn_completed" || event.type === "turn_failed") { + break; + } + } + expect(storedResponse.toLowerCase()).toContain("stored"); + expect(existsSync(secretFile)).toBe(true); await session.close(); - const resumed = await client.resumeSession(handle!, { cwd }); - const resumedResult = await resumed.run( - "Respond with the single word RESUMED." - ); - expect(resumedResult.finalText.toLowerCase()).toContain("resumed"); - await resumed.close(); + const handle = session.describePersistence(); + expect(handle).toBeTruthy(); + expect(handle!.sessionId).toBeTruthy(); + // Wait for history file to be written + const historyPaths = getClaudeHistoryPaths(cwd, handle!.sessionId); + expect(await waitForHistoryFile(historyPaths)).toBe(true); + + // Resume and verify context is preserved + const resumed = await client.resumeSession(handle!, { cwd }); + + // Verify history is emitted on resume + const historyEvents: AgentStreamEvent[] = []; + for await (const event of resumed.streamHistory()) { + historyEvents.push(event); + } + + // Should have timeline events from previous session + const timelineEvents = historyEvents.filter((e) => e.type === "timeline"); + expect(timelineEvents.length).toBeGreaterThan(0); + + // Should include the user message with the secret word + const userMessages = timelineEvents.filter( + (e) => e.type === "timeline" && e.item.type === "user_message" + ); + expect(userMessages.length).toBeGreaterThan(0); + const hasSecretWord = userMessages.some( + (e) => + e.type === "timeline" && + e.item.type === "user_message" && + e.item.text.includes(secretWord) + ); + expect(hasSecretWord).toBe(true); + + // Ask the agent to recall what it wrote - this verifies context is actually preserved + const resumedResult = await resumed.run( + "What word did you write to secret.txt? Reply with only that exact word." + ); + // The model should recall some part of the unique word we stored + // (models sometimes truncate or modify, so we check for any part of our unique token) + const recalledSomething = + resumedResult.finalText.includes(String(timestamp)) || + resumedResult.finalText.includes("XYZZY") || + resumedResult.finalText.includes("PLUGH"); + expect(recalledSomething).toBe(true); + + await resumed.close(); rmSync(cwd, { recursive: true, force: true }); }, - 150_000 + 180_000 ); test( @@ -1186,7 +1239,8 @@ function sanitizeClaudeProjectName(cwd: string): string { function resolveClaudeHistoryPath(cwd: string, sessionId: string): string { const sanitized = sanitizeClaudeProjectName(cwd); - return path.join(os.homedir(), ".claude", "projects", sanitized, `${sessionId}.jsonl`); + const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); + return path.join(configDir, "projects", sanitized, `${sessionId}.jsonl`); } function getClaudeHistoryPaths(cwd: string, sessionId: string): string[] { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 242642e89..a93540124 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -363,7 +363,6 @@ class ClaudeAgentSession implements AgentSession { private query: Query | null = null; private input: Pushable | null = null; private claudeSessionId: string | null; - private pendingLocalId: string; private persistence: AgentPersistenceHandle | null; private currentMode: PermissionMode; private availableModes: AgentMode[] = DEFAULT_MODES; @@ -394,9 +393,18 @@ class ClaudeAgentSession implements AgentSession { this.config = config; this.defaults = options?.defaults; const handle = options?.handle; - this.claudeSessionId = handle?.sessionId ?? handle?.nativeHandle ?? null; - this.pendingLocalId = this.claudeSessionId ?? `claude-${randomUUID()}`; - this.persistence = handle ?? null; + + if (handle) { + if (!handle.sessionId) { + throw new Error("Cannot resume: persistence handle has no sessionId"); + } + this.claudeSessionId = handle.sessionId; + this.persistence = handle; + this.loadPersistedHistory(handle.sessionId); + } else { + this.claudeSessionId = null; + this.persistence = null; + } // Validate mode if provided if (config.modeId && !VALID_CLAUDE_MODES.has(config.modeId)) { @@ -407,9 +415,6 @@ class ClaudeAgentSession implements AgentSession { } this.currentMode = isPermissionMode(config.modeId) ? config.modeId : "default"; - if (this.claudeSessionId) { - this.loadPersistedHistory(this.claudeSessionId); - } } get id(): string | null { @@ -422,7 +427,7 @@ class ClaudeAgentSession implements AgentSession { } const info: AgentRuntimeInfo = { provider: "claude", - sessionId: this.claudeSessionId ?? this.pendingLocalId ?? null, + sessionId: this.claudeSessionId, model: this.lastOptionsModel, modeId: this.currentMode ?? null, }; @@ -451,13 +456,17 @@ class ClaudeAgentSession implements AgentSession { this.cachedRuntimeInfo = { provider: "claude", - sessionId: this.claudeSessionId ?? this.pendingLocalId ?? null, + sessionId: this.claudeSessionId, model: this.lastOptionsModel, modeId: this.currentMode ?? null, }; + if (!this.claudeSessionId) { + throw new Error("Session ID not set after run completed"); + } + return { - sessionId: this.claudeSessionId ?? this.pendingLocalId, + sessionId: this.claudeSessionId, finalText, usage, timeline, @@ -811,7 +820,7 @@ class ClaudeAgentSession implements AgentSession { content, }, parent_tool_use_id: null, - session_id: this.claudeSessionId ?? this.pendingLocalId, + session_id: this.claudeSessionId ?? "", }; } @@ -1184,7 +1193,8 @@ class ClaudeAgentSession implements AgentSession { 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); + const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); + const dir = path.join(configDir, "projects", sanitized); return path.join(dir, `${sessionId}.jsonl`); }