fix(server): delete ephemeral Claude session transcript on close
Claude Code ignores --no-session-persistence outside --print mode, so the SDK's persistSession=false is silently dropped in stream-json mode and the metadata/branch-name generator sessions kept showing up as resumable. Sweep the transcript on close when persistSession=false so internal generator runs no longer leak into `claude` session listings.
This commit is contained in:
@@ -1,3 +1,6 @@
|
|||||||
|
import * as fs from "node:fs/promises";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||||
|
|
||||||
@@ -701,6 +704,133 @@ describe("ClaudeAgentSession context window usage", () => {
|
|||||||
expect(persistedQueryFactory.mock.calls[0]?.[0].options.persistSession).toBe(true);
|
expect(persistedQueryFactory.mock.calls[0]?.[0].options.persistSession).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("deletes the persisted session jsonl on close when persistSession=false", async () => {
|
||||||
|
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-persist-"));
|
||||||
|
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = tmpConfigDir;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionId = "session-ephemeral";
|
||||||
|
const cwd = "/tmp/paseo-test-claude";
|
||||||
|
const sanitized = cwd.replace(/[\\/._:]/g, "-");
|
||||||
|
const projectDir = path.join(tmpConfigDir, "projects", sanitized);
|
||||||
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
|
const sessionFile = path.join(projectDir, `${sessionId}.jsonl`);
|
||||||
|
|
||||||
|
const queryFactory = createQueryFactoryForTurns([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "system",
|
||||||
|
subtype: "init",
|
||||||
|
session_id: sessionId,
|
||||||
|
permissionMode: "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
duration_ms: 10,
|
||||||
|
duration_api_ms: 8,
|
||||||
|
is_error: false,
|
||||||
|
num_turns: 1,
|
||||||
|
result: "done",
|
||||||
|
stop_reason: null,
|
||||||
|
total_cost_usd: 0,
|
||||||
|
usage: {},
|
||||||
|
permission_denials: [],
|
||||||
|
uuid: `${sessionId}-result`,
|
||||||
|
session_id: sessionId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory,
|
||||||
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
|
});
|
||||||
|
const session = await client.createSession({ provider: "claude", cwd }, undefined, {
|
||||||
|
persistSession: false,
|
||||||
|
});
|
||||||
|
await session.run("turn");
|
||||||
|
|
||||||
|
// Simulate the claude binary writing a session transcript even though we
|
||||||
|
// asked the SDK for ephemeral mode (the CLI ignores --no-session-persistence
|
||||||
|
// outside --print, see issue context).
|
||||||
|
await fs.writeFile(sessionFile, '{"type":"summary"}\n', "utf-8");
|
||||||
|
|
||||||
|
await session.close();
|
||||||
|
|
||||||
|
await expect(fs.access(sessionFile)).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
if (previousConfigDir === undefined) {
|
||||||
|
delete process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
||||||
|
}
|
||||||
|
await fs.rm(tmpConfigDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves the persisted session jsonl on close when persistSession is undefined", async () => {
|
||||||
|
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-persist-"));
|
||||||
|
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = tmpConfigDir;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionId = "session-persistent";
|
||||||
|
const cwd = "/tmp/paseo-test-claude";
|
||||||
|
const sanitized = cwd.replace(/[\\/._:]/g, "-");
|
||||||
|
const projectDir = path.join(tmpConfigDir, "projects", sanitized);
|
||||||
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
|
const sessionFile = path.join(projectDir, `${sessionId}.jsonl`);
|
||||||
|
|
||||||
|
const queryFactory = createQueryFactoryForTurns([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "system",
|
||||||
|
subtype: "init",
|
||||||
|
session_id: sessionId,
|
||||||
|
permissionMode: "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "result",
|
||||||
|
subtype: "success",
|
||||||
|
duration_ms: 10,
|
||||||
|
duration_api_ms: 8,
|
||||||
|
is_error: false,
|
||||||
|
num_turns: 1,
|
||||||
|
result: "done",
|
||||||
|
stop_reason: null,
|
||||||
|
total_cost_usd: 0,
|
||||||
|
usage: {},
|
||||||
|
permission_denials: [],
|
||||||
|
uuid: `${sessionId}-result`,
|
||||||
|
session_id: sessionId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory,
|
||||||
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
|
});
|
||||||
|
const session = await client.createSession({ provider: "claude", cwd });
|
||||||
|
await session.run("turn");
|
||||||
|
|
||||||
|
await fs.writeFile(sessionFile, '{"type":"summary"}\n', "utf-8");
|
||||||
|
|
||||||
|
await session.close();
|
||||||
|
|
||||||
|
await expect(fs.access(sessionFile)).resolves.toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
if (previousConfigDir === undefined) {
|
||||||
|
delete process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
||||||
|
}
|
||||||
|
await fs.rm(tmpConfigDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage as initial fallback", async () => {
|
test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage as initial fallback", async () => {
|
||||||
const session = await createSessionForTest();
|
const session = await createSessionForTest();
|
||||||
|
|
||||||
|
|||||||
@@ -1892,6 +1892,23 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
await this.awaitWithTimeout(this.query?.return?.(), "close query return");
|
await this.awaitWithTimeout(this.query?.return?.(), "close query return");
|
||||||
this.query = null;
|
this.query = null;
|
||||||
this.input = null;
|
this.input = null;
|
||||||
|
if (this.persistSession === false && this.claudeSessionId) {
|
||||||
|
// Claude Code currently ignores --no-session-persistence outside --print mode
|
||||||
|
// (see `claude --help`), so the SDK's persistSession=false is silently dropped
|
||||||
|
// in stream-json mode. Sweep the transcript ourselves so ephemeral runs
|
||||||
|
// (metadata generator, branch-name generator) don't show up as resumable.
|
||||||
|
const historyPath = this.resolveHistoryPath(this.claudeSessionId);
|
||||||
|
if (historyPath) {
|
||||||
|
try {
|
||||||
|
await promises.rm(historyPath, { force: true });
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
{ err: error, historyPath, claudeSessionId: this.claudeSessionId },
|
||||||
|
"Failed to delete ephemeral Claude session transcript",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{ claudeSessionId: this.claudeSessionId, turnState: this.turnState },
|
{ claudeSessionId: this.claudeSessionId, turnState: this.turnState },
|
||||||
"Claude session close: completed",
|
"Claude session close: completed",
|
||||||
|
|||||||
Reference in New Issue
Block a user