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
This commit is contained in:
Mohamed Boudra
2026-01-08 15:35:18 +07:00
parent d233115ab9
commit f51db38e81
2 changed files with 89 additions and 25 deletions
@@ -763,31 +763,84 @@ describe("ClaudeAgentClient (SDK integration)", () => {
); );
test( test(
"resumes a persisted session", "resumes a persisted session with context preserved",
async () => { async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const client = new ClaudeAgentClient(); const client = new ClaudeAgentClient();
const config = buildConfig(cwd, { maxThinkingTokens: 1024 }); const config = buildConfig(cwd, { maxThinkingTokens: 1024 });
const session = await client.createSession(config); const session = await client.createSession(config);
const first = await session.run("Say READY and then stop."); // Store a specific word in a file to create history and enable recall
expect(first.finalText.toLowerCase()).toContain("ready"); 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(); let storedResponse = "";
expect(handle).toBeTruthy(); 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(); await session.close();
const resumed = await client.resumeSession(handle!, { cwd }); const handle = session.describePersistence();
const resumedResult = await resumed.run( expect(handle).toBeTruthy();
"Respond with the single word RESUMED." expect(handle!.sessionId).toBeTruthy();
);
expect(resumedResult.finalText.toLowerCase()).toContain("resumed");
await resumed.close();
// 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 }); rmSync(cwd, { recursive: true, force: true });
}, },
150_000 180_000
); );
test( test(
@@ -1186,7 +1239,8 @@ function sanitizeClaudeProjectName(cwd: string): string {
function resolveClaudeHistoryPath(cwd: string, sessionId: string): string { function resolveClaudeHistoryPath(cwd: string, sessionId: string): string {
const sanitized = sanitizeClaudeProjectName(cwd); 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[] { function getClaudeHistoryPaths(cwd: string, sessionId: string): string[] {
@@ -363,7 +363,6 @@ class ClaudeAgentSession implements AgentSession {
private query: Query | null = null; private query: Query | null = null;
private input: Pushable<SDKUserMessage> | null = null; private input: Pushable<SDKUserMessage> | null = null;
private claudeSessionId: string | null; private claudeSessionId: string | null;
private pendingLocalId: string;
private persistence: AgentPersistenceHandle | null; private persistence: AgentPersistenceHandle | null;
private currentMode: PermissionMode; private currentMode: PermissionMode;
private availableModes: AgentMode[] = DEFAULT_MODES; private availableModes: AgentMode[] = DEFAULT_MODES;
@@ -394,9 +393,18 @@ class ClaudeAgentSession implements AgentSession {
this.config = config; this.config = config;
this.defaults = options?.defaults; this.defaults = options?.defaults;
const handle = options?.handle; const handle = options?.handle;
this.claudeSessionId = handle?.sessionId ?? handle?.nativeHandle ?? null;
this.pendingLocalId = this.claudeSessionId ?? `claude-${randomUUID()}`; if (handle) {
this.persistence = handle ?? null; 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 // Validate mode if provided
if (config.modeId && !VALID_CLAUDE_MODES.has(config.modeId)) { 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"; this.currentMode = isPermissionMode(config.modeId) ? config.modeId : "default";
if (this.claudeSessionId) {
this.loadPersistedHistory(this.claudeSessionId);
}
} }
get id(): string | null { get id(): string | null {
@@ -422,7 +427,7 @@ class ClaudeAgentSession implements AgentSession {
} }
const info: AgentRuntimeInfo = { const info: AgentRuntimeInfo = {
provider: "claude", provider: "claude",
sessionId: this.claudeSessionId ?? this.pendingLocalId ?? null, sessionId: this.claudeSessionId,
model: this.lastOptionsModel, model: this.lastOptionsModel,
modeId: this.currentMode ?? null, modeId: this.currentMode ?? null,
}; };
@@ -451,13 +456,17 @@ class ClaudeAgentSession implements AgentSession {
this.cachedRuntimeInfo = { this.cachedRuntimeInfo = {
provider: "claude", provider: "claude",
sessionId: this.claudeSessionId ?? this.pendingLocalId ?? null, sessionId: this.claudeSessionId,
model: this.lastOptionsModel, model: this.lastOptionsModel,
modeId: this.currentMode ?? null, modeId: this.currentMode ?? null,
}; };
if (!this.claudeSessionId) {
throw new Error("Session ID not set after run completed");
}
return { return {
sessionId: this.claudeSessionId ?? this.pendingLocalId, sessionId: this.claudeSessionId,
finalText, finalText,
usage, usage,
timeline, timeline,
@@ -811,7 +820,7 @@ class ClaudeAgentSession implements AgentSession {
content, content,
}, },
parent_tool_use_id: null, 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; const cwd = this.config.cwd;
if (!cwd) return null; if (!cwd) return null;
const sanitized = cwd.replace(/[\\/]/g, "-").replace(/_/g, "-"); 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`); return path.join(dir, `${sessionId}.jsonl`);
} }