mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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(
|
||||
"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[] {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user