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

View File

@@ -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[] {

View File

@@ -363,7 +363,6 @@ class ClaudeAgentSession implements AgentSession {
private query: Query | null = null;
private input: Pushable<SDKUserMessage> | 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`);
}