diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index bb4f76feb..ca078559c 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -9,6 +9,7 @@ import { AgentManager } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; import type { AgentClient, + AgentCreateSessionOptions, AgentFeature, AgentLaunchContext, AgentProvider, @@ -705,6 +706,48 @@ test("createAgent passes daemon launch env through the provider launch context", }); }); +test("createAgent passes persistSession to provider create options", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class CaptureClient extends TestAgentClient { + lastCreateOptions: AgentCreateSessionOptions | undefined; + + override async createSession( + config: AgentSessionConfig, + _launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, + ): Promise { + this.lastCreateOptions = options; + return new TestAgentSession(config); + } + } + + const client = new CaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000104", + }); + + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { persistSession: false }, + ); + + expect(client.lastCreateOptions).toEqual({ persistSession: false }); + + rmSync(workdir, { recursive: true, force: true }); +}); + test("createAgent injects paseo MCP server when manager has an MCP base URL", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 88a2788ea..72ddab85a 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -12,6 +12,7 @@ import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { AgentCapabilityFlags, AgentClient, + AgentCreateSessionOptions, AgentFeature, AgentLaunchContext, AgentSlashCommand, @@ -727,6 +728,7 @@ export class AgentManager { labels?: Record; workspaceId?: string; initialPrompt?: string; + persistSession?: boolean; }, ): Promise { const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); @@ -749,13 +751,22 @@ export class AgentManager { const client = await this.requireAvailableClient({ provider: normalizedConfig.provider, }); - const session = await client.createSession(normalizedConfig, launchContext); + const createOptions = this.buildCreateSessionOptions(options); + const session = await client.createSession(normalizedConfig, launchContext, createOptions); return this.registerSession(session, normalizedConfig, resolvedAgentId, { labels: options?.labels, workspaceId: options?.workspaceId, }); } + private buildCreateSessionOptions(options?: { + persistSession?: boolean; + }): AgentCreateSessionOptions | undefined { + return options?.persistSession === undefined + ? undefined + : { persistSession: options.persistSession }; + } + // Reconstruct an agent from provider persistence. Callers should explicitly // hydrate timeline history after resume. async resumeAgentFromPersistence( diff --git a/packages/server/src/server/agent/agent-metadata-generator.real.e2e.test.ts b/packages/server/src/server/agent/agent-metadata-generator.real.e2e.test.ts new file mode 100644 index 000000000..7407c0168 --- /dev/null +++ b/packages/server/src/server/agent/agent-metadata-generator.real.e2e.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { mkdtempSync, readdirSync, rmSync, statSync, realpathSync } from "fs"; +import { homedir, tmpdir } from "os"; +import path from "path"; +import pino from "pino"; +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; + +import { AgentManager } from "./agent-manager.js"; +import { AgentStorage } from "./agent-storage.js"; +import { createAllClients, shutdownProviders } from "./provider-registry.js"; +import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js"; +import { isProviderAvailable } from "../daemon-e2e/agent-configs.js"; +import { OpenCodeServerManager } from "./providers/opencode-agent.js"; + +const CODEX_TEST_MODEL = "gpt-5.4-mini"; +const CODEX_TEST_THINKING_OPTION_ID = "low"; + +function collectFilesRecursively(root: string, filter: (name: string) => boolean): Set { + const results = new Set(); + try { + statSync(root); + } catch { + return results; + } + const stack: string[] = [root]; + while (stack.length > 0) { + const dir = stack.pop()!; + let entries: ReturnType>; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(full); + } else if (entry.isFile() && filter(entry.name)) { + results.add(full); + } + } + } + return results; +} + +function collectCodexRolloutFiles(): Set { + const codexHome = process.env.CODEX_HOME ?? path.join(homedir(), ".codex"); + return collectFilesRecursively(path.join(codexHome, "sessions"), (name) => + name.startsWith("rollout-"), + ); +} + +function encodeClaudeProjectDir(cwd: string): string { + return cwd.replaceAll("/", "-"); +} + +function collectClaudeProjectFiles(cwd: string): Set { + const dir = path.join(homedir(), ".claude", "projects", encodeClaudeProjectDir(cwd)); + return collectFilesRecursively(dir, (name) => name.endsWith(".jsonl")); +} + +function tmpCwd(prefix: string): string { + return realpathSync(mkdtempSync(path.join(tmpdir(), prefix))); +} + +describe("agent metadata generation (real agents)", () => { + const logger = pino({ level: "silent" }); + let cwd: string; + let paseoHome: string; + let manager: AgentManager; + let storage: AgentStorage; + let codexAvailable = false; + let claudeAvailable = false; + let opencodeAvailable = false; + + beforeAll(async () => { + [codexAvailable, claudeAvailable, opencodeAvailable] = await Promise.all([ + isProviderAvailable("codex"), + isProviderAvailable("claude"), + isProviderAvailable("opencode"), + ]); + }); + + beforeEach(() => { + cwd = tmpCwd("metadata-cwd-"); + paseoHome = tmpCwd("metadata-paseo-home-"); + storage = new AgentStorage(path.join(paseoHome, "agents"), logger); + manager = new AgentManager({ + clients: createAllClients(logger), + registry: storage, + logger, + }); + }); + + afterEach(async () => { + await shutdownProviders(logger); + rmSync(cwd, { recursive: true, force: true }); + rmSync(paseoHome, { recursive: true, force: true }); + }, 60000); + + test("generates a title using a real Codex agent without persisting a rollout", async (ctx) => { + if (!codexAvailable) { + ctx.skip(); + } + const agent = await manager.createAgent( + { + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + modeId: "auto", + cwd: cwd, + title: "Main Agent", + }, + "4e0a4508-e522-4fe9-8384-cf3bf889f16d", + ); + + const rolloutsBefore = collectCodexRolloutFiles(); + + await generateAndApplyAgentMetadata({ + agentManager: manager, + agentId: agent.id, + cwd: cwd, + initialPrompt: "Use the exact title 'Metadata Title E2E'.", + explicitTitle: null, + paseoHome, + logger, + }); + + await storage.flush(); + const record = await storage.get(agent.id); + expect(record?.title).toBe("Metadata Title E2E"); + + const rolloutsAfter = collectCodexRolloutFiles(); + const newRollouts = [...rolloutsAfter].filter((file) => !rolloutsBefore.has(file)); + expect(newRollouts).toEqual([]); + + await manager.closeAgent(agent.id); + }, 180000); + + test("generates a title using a real Claude agent without persisting a session", async (ctx) => { + if (!claudeAvailable) { + ctx.skip(); + } + const agent = await manager.createAgent( + { + provider: "claude", + model: "haiku", + thinkingOptionId: "on", + cwd: cwd, + title: "Main Claude Agent", + }, + "5e1b5619-f633-5fea-9495-d04bf990f27e", + ); + + const sessionsBefore = collectClaudeProjectFiles(cwd); + + await generateAndApplyAgentMetadata({ + agentManager: manager, + agentId: agent.id, + cwd: cwd, + initialPrompt: "Use the exact title 'Claude Metadata Title'.", + explicitTitle: null, + paseoHome, + logger, + }); + + await storage.flush(); + const record = await storage.get(agent.id); + expect(record?.title).toBe("Claude Metadata Title"); + + const sessionsAfter = collectClaudeProjectFiles(cwd); + const newSessions = [...sessionsAfter].filter((file) => !sessionsBefore.has(file)); + expect(newSessions).toEqual([]); + + await manager.closeAgent(agent.id); + }, 180000); + + test("generates a title using a real OpenCode agent and deletes the ephemeral session", async (ctx) => { + if (!opencodeAvailable) { + ctx.skip(); + } + const agent = await manager.createAgent( + { + provider: "opencode", + model: "opencode/gpt-5-nano", + modeId: "build", + cwd: cwd, + title: "Main OpenCode Agent", + }, + "6e2c6720-e744-6fdb-a5a6-e15cf0a1f380", + ); + + const acquisition = await OpenCodeServerManager.getInstance(logger).acquire({ force: false }); + const inspectClient = createOpencodeClient({ + baseUrl: acquisition.server.url, + directory: cwd, + }); + try { + const sessionsBeforeRes = await inspectClient.session.list({ directory: cwd }); + const sessionIdsBefore = new Set((sessionsBeforeRes.data ?? []).map((session) => session.id)); + + await generateAndApplyAgentMetadata({ + agentManager: manager, + agentId: agent.id, + cwd: cwd, + initialPrompt: "Use the exact title 'OpenCode Metadata Title'.", + explicitTitle: null, + paseoHome, + logger, + }); + + await storage.flush(); + const record = await storage.get(agent.id); + expect(record?.title).toBe("OpenCode Metadata Title"); + + const sessionsAfterRes = await inspectClient.session.list({ directory: cwd }); + const newSessions = (sessionsAfterRes.data ?? []).filter( + (session) => !sessionIdsBefore.has(session.id), + ); + expect(newSessions).toEqual([]); + } finally { + acquisition.release(); + await manager.closeAgent(agent.id); + } + }, 180000); +}); diff --git a/packages/server/src/server/agent/agent-metadata-generator.test.ts b/packages/server/src/server/agent/agent-metadata-generator.test.ts deleted file mode 100644 index be619b148..000000000 --- a/packages/server/src/server/agent/agent-metadata-generator.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { execSync } from "child_process"; -import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "fs"; -import { tmpdir } from "os"; -import path from "path"; -import pino from "pino"; - -import { AgentManager } from "./agent-manager.js"; -import { AgentStorage } from "./agent-storage.js"; -import { createAllClients, shutdownProviders } from "./provider-registry.js"; -import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js"; -import { - createWorktree as createWorktreePrimitive, - validateBranchSlug, - type CreateWorktreeOptions, - type WorktreeConfig, -} from "../../utils/worktree.js"; - -interface LegacyCreateWorktreeTestOptions { - branchName: string; - cwd: string; - baseBranch: string; - worktreeSlug: string; - runSetup?: boolean; - paseoHome?: string; -} - -function createLegacyWorktreeForTest( - options: CreateWorktreeOptions | LegacyCreateWorktreeTestOptions, -): Promise { - if ("source" in options) { - return createWorktreePrimitive(options); - } - - return createWorktreePrimitive({ - cwd: options.cwd, - worktreeSlug: options.worktreeSlug, - source: { - kind: "branch-off", - baseBranch: options.baseBranch, - branchName: options.branchName, - }, - runSetup: options.runSetup ?? true, - paseoHome: options.paseoHome, - }); -} - -const CODEX_TEST_MODEL = "gpt-5.4-mini"; -const CODEX_TEST_THINKING_OPTION_ID = "low"; - -const shouldRun = !process.env.CI && !!process.env.OPENAI_API_KEY; - -function tmpCwd(prefix: string): string { - return realpathSync(mkdtempSync(path.join(tmpdir(), prefix))); -} - -function initGitRepo(repoDir: string): void { - execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.email 'paseo-test@example.com'", { - cwd: repoDir, - stdio: "pipe", - }); - execSync("git config user.name 'Paseo Test'", { - cwd: repoDir, - stdio: "pipe", - }); - writeFileSync(path.join(repoDir, "README.md"), "init\n"); - execSync("git add README.md", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { - cwd: repoDir, - stdio: "pipe", - }); -} - -(shouldRun ? describe : describe.skip)("agent metadata generation (real agents)", () => { - const logger = pino({ level: "silent" }); - let repoDir: string; - let paseoHome: string; - let storagePath: string; - let manager: AgentManager; - let storage: AgentStorage; - let codexSessionDir: string; - let previousCodexSessionDir: string | undefined; - - beforeEach(() => { - repoDir = tmpCwd("metadata-repo-"); - initGitRepo(repoDir); - paseoHome = tmpCwd("metadata-paseo-home-"); - storagePath = path.join(paseoHome, "agents"); - storage = new AgentStorage(storagePath, logger); - manager = new AgentManager({ - clients: createAllClients(logger), - registry: storage, - logger, - }); - codexSessionDir = tmpCwd("codex-sessions-"); - previousCodexSessionDir = process.env.CODEX_SESSION_DIR; - process.env.CODEX_SESSION_DIR = codexSessionDir; - }); - - afterEach(async () => { - process.env.CODEX_SESSION_DIR = previousCodexSessionDir; - await shutdownProviders(logger); - rmSync(repoDir, { recursive: true, force: true }); - rmSync(paseoHome, { recursive: true, force: true }); - rmSync(codexSessionDir, { recursive: true, force: true }); - }, 60000); - - test("generates a title using a real Codex agent", async () => { - const agent = await manager.createAgent( - { - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - modeId: "auto", - cwd: repoDir, - title: "Main Agent", - }, - "4e0a4508-e522-4fe9-8384-cf3bf889f16d", - ); - - await generateAndApplyAgentMetadata({ - agentManager: manager, - agentId: agent.id, - cwd: repoDir, - initialPrompt: "Use the exact title 'Metadata Title E2E'.", - explicitTitle: null, - paseoHome, - logger, - }); - - await storage.flush(); - const record = await storage.get(agent.id); - expect(record?.title).toBe("Metadata Title E2E"); - - await manager.closeAgent(agent.id); - }, 180000); - - test("renames the worktree branch using a real Codex agent", async () => { - const worktreeSlug = "metadata-worktree"; - const worktree = await createLegacyWorktreeForTest({ - branchName: worktreeSlug, - cwd: repoDir, - baseBranch: "main", - worktreeSlug, - paseoHome, - }); - - const agent = await manager.createAgent( - { - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - modeId: "auto", - cwd: worktree.worktreePath, - title: "Worktree Agent", - }, - "32bb765d-f637-44a2-9820-f2efd5261418", - ); - - await generateAndApplyAgentMetadata({ - agentManager: manager, - agentId: agent.id, - cwd: worktree.worktreePath, - initialPrompt: "Use the exact branch 'feat/metadata-worktree'.", - explicitTitle: "Explicit Title", - paseoHome, - logger, - }); - - const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { - cwd: worktree.worktreePath, - stdio: "pipe", - }) - .toString() - .trim(); - - const validation = validateBranchSlug(currentBranch); - expect(validation.valid).toBe(true); - expect(currentBranch).toBe("feat/metadata-worktree"); - - await manager.closeAgent(agent.id); - }, 180000); -}); diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts index 0b388a110..b4ed8a67e 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -115,6 +115,7 @@ export async function generateAndApplyAgentMetadata( schemaName: "AgentMetadata", maxRetries: 2, providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + persistSession: false, agentConfigOverrides: { title: "Agent metadata generator", internal: true, diff --git a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts index 40a222af2..4c3b603e4 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts @@ -86,7 +86,10 @@ describe("agent metadata generator auto-title", () => { }); expect(generateStructured).toHaveBeenCalledWith( - expect.objectContaining({ cwd: "/tmp/repo/metadata-worktree" }), + expect.objectContaining({ + cwd: "/tmp/repo/metadata-worktree", + persistSession: false, + }), ); expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title"); }); diff --git a/packages/server/src/server/agent/agent-response-loop.test.ts b/packages/server/src/server/agent/agent-response-loop.test.ts index be894c7a5..5e2b84d59 100644 --- a/packages/server/src/server/agent/agent-response-loop.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.test.ts @@ -142,7 +142,7 @@ describe("generateStructuredAgentResponseWithFallback", () => { } it("uses the first available provider in the waterfall", async () => { - const calls: Array<{ provider: string; model?: string }> = []; + const calls: Array<{ provider: string; model?: string; persistSession?: boolean }> = []; const manager = createManager([ { provider: "claude", available: true, error: null }, { provider: "codex", available: true, error: null }, @@ -158,17 +158,19 @@ describe("generateStructuredAgentResponseWithFallback", () => { { provider: "claude", model: "haiku" }, { provider: "codex", model: "gpt-5.4-mini" }, ], + persistSession: false, runner: async (options) => { calls.push({ provider: options.agentConfig.provider, model: options.agentConfig.model ?? undefined, + persistSession: options.persistSession, }); return { summary: "ok" }; }, }); expect(result).toEqual({ summary: "ok" }); - expect(calls).toEqual([{ provider: "claude", model: "haiku" }]); + expect(calls).toEqual([{ provider: "claude", model: "haiku", persistSession: false }]); }); it("skips unavailable providers and uses the next available one", async () => { diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts index f517017c8..7c6b2435d 100644 --- a/packages/server/src/server/agent/agent-response-loop.ts +++ b/packages/server/src/server/agent/agent-response-loop.ts @@ -70,6 +70,7 @@ export interface StructuredAgentGenerationOptions { manager: AgentManager; agentConfig: AgentSessionConfig; agentId?: string; + persistSession?: boolean; prompt: string; schema: z.ZodType | JsonSchema; maxRetries?: number; @@ -86,6 +87,7 @@ export interface StructuredAgentGenerationWithFallbackOptions { AgentSessionConfig, "provider" | "cwd" | "model" | "thinkingOptionId" >; + persistSession?: boolean; maxRetries?: number; schemaName?: string; runner?: (options: StructuredAgentGenerationOptions) => Promise; @@ -339,12 +341,15 @@ export async function getStructuredAgentResponse( export async function generateStructuredAgentResponse( options: StructuredAgentGenerationOptions, ): Promise { - const { manager, agentConfig, agentId, prompt, schema, maxRetries, schemaName } = options; + const { manager, agentConfig, agentId, persistSession, prompt, schema, maxRetries, schemaName } = + options; const modeId = agentConfig.modeId ?? getAgentProviderDefinition(agentConfig.provider).defaultModeId ?? undefined; - const agent = await manager.createAgent({ ...agentConfig, modeId }, agentId); + const agent = await manager.createAgent({ ...agentConfig, modeId }, agentId, { + persistSession, + }); try { const caller: AgentCaller = async (nextPrompt) => { const result = await manager.runAgent(agent.id, nextPrompt); @@ -388,6 +393,7 @@ export async function generateStructuredAgentResponseWithFallback( schema, providers, agentConfigOverrides, + persistSession, maxRetries, schemaName, runner, @@ -423,6 +429,7 @@ export async function generateStructuredAgentResponseWithFallback( schema, maxRetries, schemaName, + persistSession, agentConfig: { ...agentConfigOverrides, provider: candidate.provider, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 81d7a86be..5b2b5fc11 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -472,6 +472,14 @@ export interface AgentLaunchContext { env?: Record; } +export interface AgentCreateSessionOptions { + /** + * Whether the provider should leave a durable native session behind. + * Defaults to true. Providers that cannot honor false should no-op. + */ + persistSession?: boolean; +} + /** * Returned by respondToPermission when the permission resolution requires * a follow-up turn (e.g. Codex plan approval → implementation). @@ -523,6 +531,7 @@ export interface AgentClient { createSession( config: AgentSessionConfig, launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, ): Promise; resumeSession( handle: AgentPersistenceHandle, 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 302f95107..22076c3d5 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -439,6 +439,68 @@ describe("ClaudeAgentSession context window usage", () => { }); } + test("passes persistSession through to the Claude SDK query options", async () => { + const createResultTurn = (sessionId: string) => [ + { + 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 nonPersistedQueryFactory = createQueryFactoryForTurns([createResultTurn("session-1")]); + const nonPersistedClient = new ClaudeAgentClient({ + logger, + queryFactory: nonPersistedQueryFactory, + }); + const nonPersistedSession = await nonPersistedClient.createSession( + { + provider: "claude", + cwd: process.cwd(), + }, + undefined, + { persistSession: false }, + ); + await nonPersistedSession.run("turn"); + await nonPersistedSession.close(); + + expect(nonPersistedQueryFactory.mock.calls[0]?.[0].options.persistSession).toBe(false); + + const persistedQueryFactory = createQueryFactoryForTurns([createResultTurn("session-2")]); + const persistedClient = new ClaudeAgentClient({ + logger, + queryFactory: persistedQueryFactory, + }); + const persistedSession = await persistedClient.createSession( + { + provider: "claude", + cwd: process.cwd(), + }, + undefined, + { persistSession: true }, + ); + await persistedSession.run("turn"); + await persistedSession.close(); + + expect(persistedQueryFactory.mock.calls[0]?.[0].options.persistSession).toBe(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 6dbb0059d..e51d31c79 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -49,6 +49,7 @@ import type { AgentPermissionAction, AgentCapabilityFlags, AgentClient, + AgentCreateSessionOptions, AgentLaunchContext, AgentMetadata, AgentMode, @@ -177,6 +178,7 @@ interface ClaudeAgentSessionOptions { runtimeSettings?: ProviderRuntimeSettings; handle?: AgentPersistenceHandle; launchEnv?: Record; + persistSession?: boolean; logger: Logger; queryFactory?: typeof query; } @@ -316,6 +318,7 @@ interface ClaudeOptionsLogSummary { hasSpawnOverride: boolean; hasStderrHandler: boolean; pathToClaudeCodeExecutable: string | null; + persistSession: boolean | null; } const MAX_RECENT_STDERR_CHARS = 4000; @@ -363,6 +366,7 @@ function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogS typeof options.pathToClaudeCodeExecutable === "string" ? options.pathToClaudeCodeExecutable : null, + persistSession: typeof options.persistSession === "boolean" ? options.persistSession : null, }; } @@ -1151,12 +1155,14 @@ export class ClaudeAgentClient implements AgentClient { async createSession( config: AgentSessionConfig, launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, ): Promise { const claudeConfig = this.assertConfig(config); return new ClaudeAgentSession(claudeConfig, { defaults: this.defaults, runtimeSettings: this.runtimeSettings, launchEnv: launchContext?.env, + persistSession: options?.persistSession, logger: this.logger, queryFactory: this.queryFactory, }); @@ -1441,6 +1447,7 @@ class ClaudeAgentSession implements AgentSession { private readonly launchEnv?: Record; private readonly defaults?: { agents?: Record }; private readonly runtimeSettings?: ProviderRuntimeSettings; + private readonly persistSession?: boolean; private readonly logger: Logger; private readonly queryFactory: typeof query; private query: Query | null = null; @@ -1488,6 +1495,7 @@ class ClaudeAgentSession implements AgentSession { this.launchEnv = options.launchEnv; this.defaults = options.defaults; this.runtimeSettings = options.runtimeSettings; + this.persistSession = options.persistSession; this.logger = options.logger; this.queryFactory = options.queryFactory ?? query; const handle = options.handle; @@ -2216,6 +2224,7 @@ class ClaudeAgentSession implements AgentSession { ...(thinking ? { thinking } : {}), ...(effort ? { effort } : {}), ...extraClaudeOptions, + ...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }), env: sdkEnv, }; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 669a97f8e..d94b20899 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -83,6 +83,66 @@ function asInternals(session: CodexTestSession): CodexSessionTestAccess { } describe("Codex app-server provider", () => { + test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => { + const requests: Array<{ method: string; params: unknown }> = []; + const fakeClient: CodexClientLike = { + async request(method: string, params?: unknown) { + requests.push({ method, params }); + if (method === "thread/start") { + return { thread: { id: "ephemeral-thread" } }; + } + return null; + }, + }; + + const session = new __codexAppServerInternals.CodexAppServerAgentSession( + createConfig({ thinkingOptionId: "medium" }), + null, + createTestLogger(), + () => { + throw new Error("Test session cannot spawn Codex app-server"); + }, + {}, + true, + ); + (session as unknown as { client: CodexClientLike }).client = fakeClient; + + await (session as unknown as { ensureThread: () => Promise }).ensureThread(); + + const startCall = requests.find((req) => req.method === "thread/start"); + expect(startCall).toBeDefined(); + expect(startCall?.params).toMatchObject({ ephemeral: true }); + }); + + test("omits ephemeral from thread/start by default", async () => { + const requests: Array<{ method: string; params: unknown }> = []; + const fakeClient: CodexClientLike = { + async request(method: string, params?: unknown) { + requests.push({ method, params }); + if (method === "thread/start") { + return { thread: { id: "persistent-thread" } }; + } + return null; + }, + }; + + const session = new __codexAppServerInternals.CodexAppServerAgentSession( + createConfig({ thinkingOptionId: "medium" }), + null, + createTestLogger(), + () => { + throw new Error("Test session cannot spawn Codex app-server"); + }, + ); + (session as unknown as { client: CodexClientLike }).client = fakeClient; + + await (session as unknown as { ensureThread: () => Promise }).ensureThread(); + + const startCall = requests.find((req) => req.method === "thread/start"); + expect(startCall).toBeDefined(); + expect((startCall!.params as Record).ephemeral).toBeUndefined(); + }); + test("disposes an unresponsive app-server child with SIGKILL", async () => { vi.useFakeTimers(); const child = new EventEmitter() as ChildProcessWithoutNullStreams; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 162a4b6c0..e0bf6221f 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -2,6 +2,7 @@ import type { AgentPermissionAction, AgentCapabilityFlags, AgentClient, + AgentCreateSessionOptions, AgentFeature, AgentLaunchContext, AgentMode, @@ -2617,6 +2618,7 @@ class CodexAppServerAgentSession implements AgentSession { logger: Logger, private readonly spawnAppServer: () => Promise, private readonly deps: CodexAppServerAgentDeps = {}, + private readonly ephemeral: boolean = false, ) { this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER }); if (config.modeId === undefined) { @@ -3480,6 +3482,7 @@ class CodexAppServerAgentSession implements AgentSession { ? { developerInstructions: this.config.systemPrompt.trim() } : {}), ...(innerConfig ? { config: innerConfig } : {}), + ...(this.ephemeral ? { ephemeral: true } : {}), })) as CodexThreadStartResponse; const threadId = response?.thread?.id; if (!threadId) { @@ -4460,6 +4463,7 @@ export class CodexAppServerAgentClient implements AgentClient { async createSession( config: AgentSessionConfig, launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, ): Promise { const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER }; const session = new CodexAppServerAgentSession( @@ -4468,6 +4472,7 @@ export class CodexAppServerAgentClient implements AgentClient { this.logger, () => this.spawnAppServer(launchContext?.env), this.deps, + options?.persistSession === false, ); await session.connect(); return session; diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 0c94767c9..37dcf7b81 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -566,6 +566,54 @@ describe("OpenCode adapter context-window normalization", () => { }); describe("OpenCode adapter startTurn error handling", () => { + test("deletes provider session on close when persistence is disabled", async () => { + const fakeClient = { + session: { + abort: vi.fn().mockResolvedValue({ error: null }), + update: vi.fn().mockResolvedValue({ error: null }), + delete: vi.fn().mockResolvedValue({ error: null }), + }, + } as never; + + const session = new __openCodeInternals.OpenCodeAgentSession( + { provider: "opencode", cwd: "/tmp/test" }, + fakeClient, + "ses_unit_test", + createTestLogger(), + new Map(), + undefined, + false, + ); + + await session.close(); + + expect(fakeClient.session.delete).toHaveBeenCalledWith({ + sessionID: "ses_unit_test", + directory: "/tmp/test", + }); + }); + + test("does not delete provider session on close by default", async () => { + const fakeClient = { + session: { + abort: vi.fn().mockResolvedValue({ error: null }), + update: vi.fn().mockResolvedValue({ error: null }), + delete: vi.fn().mockResolvedValue({ error: null }), + }, + } as never; + + const session = new __openCodeInternals.OpenCodeAgentSession( + { provider: "opencode", cwd: "/tmp/test" }, + fakeClient, + "ses_unit_test", + createTestLogger(), + ); + + await session.close(); + + expect(fakeClient.session.delete).not.toHaveBeenCalled(); + }); + test("emits turn_failed when client.session.promptAsync throws synchronously", async () => { // Async iterable that never yields and never resolves. The IIFE in // startTurn synchronously hits the promptAsync throw and finishes the diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index ea6209aae..9b02eb5f2 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -16,6 +16,7 @@ import { z } from "zod"; import type { AgentCapabilityFlags, AgentClient, + AgentCreateSessionOptions, AgentLaunchContext, AgentMode, AgentModelDefinition, @@ -1039,6 +1040,7 @@ export class OpenCodeAgentClient implements AgentClient { async createSession( config: AgentSessionConfig, _launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, ): Promise { const openCodeConfig = this.assertConfig(config); const acquisition = await this.serverManager.acquire({ force: false }); @@ -1073,6 +1075,7 @@ export class OpenCodeAgentClient implements AgentClient { this.logger, new Map(this.modelContextWindows), acquisition.release, + options?.persistSession, ); } catch (error) { acquisition.release(); @@ -2218,6 +2221,8 @@ class OpenCodeAgentSession implements AgentSession { private pendingChildToolPartsBySessionId = new Map(); private selectedModelContextWindowMaxTokens: number | undefined; private releaseServer: (() => void) | null; + private readonly persistSession: boolean; + private deletedFromProvider = false; constructor( config: OpenCodeAgentConfig, client: OpencodeClient, @@ -2225,6 +2230,7 @@ class OpenCodeAgentSession implements AgentSession { logger: Logger, modelContextWindowsByModelKey: ReadonlyMap = new Map(), releaseServer?: () => void, + persistSession = true, ) { this.config = config; this.client = client; @@ -2233,6 +2239,7 @@ class OpenCodeAgentSession implements AgentSession { this.modelContextWindowsByModelKey = modelContextWindowsByModelKey; this.currentMode = normalizeOpenCodeModeId(config.modeId); this.releaseServer = releaseServer ?? null; + this.persistSession = persistSession; this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens( config.model, ); @@ -2831,6 +2838,7 @@ class OpenCodeAgentSession implements AgentSession { directory: this.config.cwd, logger: this.logger, }); + await this.deleteProviderSessionIfEphemeral(); this.subscribers.clear(); this.activeForegroundTurnId = null; } finally { @@ -2839,6 +2847,27 @@ class OpenCodeAgentSession implements AgentSession { } } + private async deleteProviderSessionIfEphemeral(): Promise { + if (this.persistSession || this.deletedFromProvider) { + return; + } + this.deletedFromProvider = true; + try { + const response = await this.client.session.delete({ + sessionID: this.sessionId, + directory: this.config.cwd, + }); + if (response.error) { + throw new Error(`OpenCode session.delete failed: ${JSON.stringify(response.error)}`); + } + } catch (error) { + this.logger.debug( + { err: error, sessionId: this.sessionId }, + "Failed to delete non-persistent OpenCode session", + ); + } + } + private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null { const trimmed = text.trim(); if (!trimmed.startsWith("/") || trimmed.length <= 1) { diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index ab52e7ca5..bab990bf3 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1292,6 +1292,15 @@ describe("session checkout commit handling", () => { mode: "uncommitted", includeStructured: true, }); + expect(agentResponseMocks.generateStructuredAgentResponseWithFallback).toHaveBeenCalledWith( + expect.objectContaining({ + persistSession: false, + agentConfigOverrides: expect.objectContaining({ + title: "Commit generator", + internal: true, + }), + }), + ); expect(checkoutGitMocks.commitChanges).toHaveBeenCalledWith("/tmp/request-worktree", { message: "Update file", addAll: true, @@ -1381,6 +1390,15 @@ describe("session checkout pull request creation", () => { baseRef: "main", includeStructured: true, }); + expect(agentResponseMocks.generateStructuredAgentResponseWithFallback).toHaveBeenCalledWith( + expect.objectContaining({ + persistSession: false, + agentConfigOverrides: expect.objectContaining({ + title: "PR generator", + internal: true, + }), + }), + ); expect(checkoutGitMocks.createPullRequest).toHaveBeenCalledWith( "/tmp/request-worktree", { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 79e3ec957..96f54bf80 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3897,6 +3897,7 @@ export class Session { schemaName: "CommitMessage", maxRetries: 2, providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + persistSession: false, agentConfigOverrides: { title: "Commit generator", internal: true, @@ -3963,6 +3964,7 @@ export class Session { schemaName: "PullRequest", maxRetries: 2, providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + persistSession: false, agentConfigOverrides: { title: "PR generator", internal: true, diff --git a/packages/server/src/server/worktree-branch-name-generator.ts b/packages/server/src/server/worktree-branch-name-generator.ts index 0c27e2371..abab970e5 100644 --- a/packages/server/src/server/worktree-branch-name-generator.ts +++ b/packages/server/src/server/worktree-branch-name-generator.ts @@ -61,6 +61,7 @@ export async function generateBranchNameFromFirstAgentContext( schemaName: "BranchName", maxRetries: 2, providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + persistSession: false, agentConfigOverrides: { title: "Branch name generator", internal: true,