Make structured response generation ephemeral end-to-end
Plumbs persistSession through the structured response pipeline so metadata, branch name, commit message, and PR text generators no longer leave provider-side artifacts behind: - Codex: passes ephemeral: true on thread/start (no rollout file) - OpenCode: deletes the session via session.delete on close - Claude: forwards persistSession: false to the SDK query Adds a real-credentials e2e test per provider that asserts the generated title is applied AND no persistence artifact is produced (rollout for Codex, project JSONL for Claude, leftover session for OpenCode).
This commit is contained in:
@@ -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<AgentSession> {
|
||||
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");
|
||||
|
||||
@@ -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<string, string>;
|
||||
workspaceId?: string;
|
||||
initialPrompt?: string;
|
||||
persistSession?: boolean;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
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(
|
||||
|
||||
@@ -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<string> {
|
||||
const results = new Set<string>();
|
||||
try {
|
||||
statSync(root);
|
||||
} catch {
|
||||
return results;
|
||||
}
|
||||
const stack: string[] = [root];
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()!;
|
||||
let entries: ReturnType<typeof readdirSync<{ withFileTypes: true }>>;
|
||||
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<string> {
|
||||
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<string> {
|
||||
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);
|
||||
});
|
||||
@@ -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<WorktreeConfig> {
|
||||
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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface StructuredAgentGenerationOptions<T> {
|
||||
manager: AgentManager;
|
||||
agentConfig: AgentSessionConfig;
|
||||
agentId?: string;
|
||||
persistSession?: boolean;
|
||||
prompt: string;
|
||||
schema: z.ZodType<T> | JsonSchema;
|
||||
maxRetries?: number;
|
||||
@@ -86,6 +87,7 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
|
||||
AgentSessionConfig,
|
||||
"provider" | "cwd" | "model" | "thinkingOptionId"
|
||||
>;
|
||||
persistSession?: boolean;
|
||||
maxRetries?: number;
|
||||
schemaName?: string;
|
||||
runner?: <TResult>(options: StructuredAgentGenerationOptions<TResult>) => Promise<TResult>;
|
||||
@@ -339,12 +341,15 @@ export async function getStructuredAgentResponse<T>(
|
||||
export async function generateStructuredAgentResponse<T>(
|
||||
options: StructuredAgentGenerationOptions<T>,
|
||||
): Promise<T> {
|
||||
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<T>(
|
||||
schema,
|
||||
providers,
|
||||
agentConfigOverrides,
|
||||
persistSession,
|
||||
maxRetries,
|
||||
schemaName,
|
||||
runner,
|
||||
@@ -423,6 +429,7 @@ export async function generateStructuredAgentResponseWithFallback<T>(
|
||||
schema,
|
||||
maxRetries,
|
||||
schemaName,
|
||||
persistSession,
|
||||
agentConfig: {
|
||||
...agentConfigOverrides,
|
||||
provider: candidate.provider,
|
||||
|
||||
@@ -472,6 +472,14 @@ export interface AgentLaunchContext {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
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<AgentSession>;
|
||||
resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<AgentSession> {
|
||||
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<string, string>;
|
||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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<void> }).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<void> }).ensureThread();
|
||||
|
||||
const startCall = requests.find((req) => req.method === "thread/start");
|
||||
expect(startCall).toBeDefined();
|
||||
expect((startCall!.params as Record<string, unknown>).ephemeral).toBeUndefined();
|
||||
});
|
||||
|
||||
test("disposes an unresponsive app-server child with SIGKILL", async () => {
|
||||
vi.useFakeTimers();
|
||||
const child = new EventEmitter() as ChildProcessWithoutNullStreams;
|
||||
|
||||
@@ -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<ChildProcessWithoutNullStreams>,
|
||||
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<AgentSession> {
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AgentSession> {
|
||||
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<string, OpenCodeToolPartEventPart[]>();
|
||||
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<string, number> = 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<void> {
|
||||
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) {
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user