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:
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user