diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index ee7bb1c84..1bafc7015 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -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 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); }); + 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 () => { const session = await createSessionForTest(); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index dd9cfd56d..e759a3e3b 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1892,6 +1892,23 @@ class ClaudeAgentSession implements AgentSession { await this.awaitWithTimeout(this.query?.return?.(), "close query return"); this.query = 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( { claudeSessionId: this.claudeSessionId, turnState: this.turnState }, "Claude session close: completed",