From 917f6ee2035a2eaeb88cbc5ed7c1e5d6748017f3 Mon Sep 17 00:00:00 2001 From: Chris Raethke Date: Sat, 18 Jul 2026 08:57:40 +1000 Subject: [PATCH] feat: support caller-supplied session names --- src/agent-manager.ts | 3 +++ src/agent-runner.ts | 7 ++++++- src/index.ts | 8 ++++++++ src/nested-tools.ts | 3 +++ src/schedule.ts | 3 +++ src/types.ts | 2 ++ test/agent-manager.test.ts | 18 ++++++++++++++++++ test/agent-runner.test.ts | 26 ++++++++++++++++++++++++++ test/nested-tools.test.ts | 8 +++++++- test/schedule.test.ts | 11 +++++++++++ 10 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/agent-manager.ts b/src/agent-manager.ts index 57c4c5b4..910d2f42 100644 --- a/src/agent-manager.ts +++ b/src/agent-manager.ts @@ -56,6 +56,8 @@ interface SpawnArgs { export interface SpawnOptions { description: string; + /** Caller-supplied session name; falls back to the agent config/type when omitted. */ + sessionName?: string; model?: Model; maxTurns?: number; isolated?: boolean; @@ -250,6 +252,7 @@ export class AgentManager { const promise = runAgent(ctx, type, prompt, { pi, agentId: id, + sessionName: options.sessionName, model: options.model, maxTurns: options.maxTurns, isolated: options.isolated, diff --git a/src/agent-runner.ts b/src/agent-runner.ts index f1fc3798..78983791 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -200,6 +200,8 @@ export interface RunOptions { pi: ExtensionAPI; /** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */ agentId?: string; + /** Caller-supplied session name; takes precedence over the agent config/type. */ + sessionName?: string; model?: Model; maxTurns?: number; signal?: AbortSignal; @@ -613,7 +615,10 @@ export async function runAgent( const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts)); - const baseSessionName = agentConfig?.name ?? type; + const suppliedSessionName = typeof options.sessionName === "string" && options.sessionName.trim() + ? options.sessionName.trim() + : undefined; + const baseSessionName = suppliedSessionName ?? agentConfig?.name ?? type; session.setSessionName( options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName, ); diff --git a/src/index.ts b/src/index.ts index 76d56a17..99352dc3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -815,6 +815,11 @@ Terse command-style prompts produce shallow, generic work. description: Type.String({ description: "A short (3-5 word) description of the task (shown in UI).", }), + name: Type.Optional( + Type.String({ + description: "Optional human-readable session name. The agent ID suffix is added automatically.", + }), + ), subagent_type: Type.String({ description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`, }), @@ -1071,6 +1076,7 @@ Terse command-style prompts produce shallow, generic work. const job = scheduler.addJob({ name: params.description as string, description: params.description as string, + sessionName: params.name as string | undefined, schedule: params.schedule as string, subagent_type: subagentType, prompt: params.prompt as string, @@ -1130,6 +1136,7 @@ Terse command-style prompts produce shallow, generic work. try { id = manager.spawn(pi, ctx, subagentType, params.prompt, { description: params.description, + sessionName: params.name, model, maxTurns: effectiveMaxTurns, isolated, @@ -1253,6 +1260,7 @@ Terse command-style prompts produce shallow, generic work. try { const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, { description: params.description, + sessionName: params.name, model, maxTurns: effectiveMaxTurns, isolated, diff --git a/src/nested-tools.ts b/src/nested-tools.ts index ced619d7..ac16ccf5 100644 --- a/src/nested-tools.ts +++ b/src/nested-tools.ts @@ -12,6 +12,7 @@ const NESTED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"] as interface NestedSpawnOptions { description: string; + sessionName?: string; model?: Model; maxTurns?: number; isolated?: boolean; @@ -92,6 +93,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin parameters: Type.Object({ prompt: Type.String({ description: "Self-contained task for the nested agent." }), description: Type.String({ description: "Short 3-5 word task description." }), + name: Type.Optional(Type.String({ description: "Optional human-readable session name. The agent ID suffix is added automatically." })), subagent_type: Type.String({ description: `Allowed nested agent type. Available: ${available().join(", ") || "none"}.` }), model: Type.Optional(Type.String({ description: "Optional provider/model override." })), thinking: Type.Optional(Type.String({ description: "Optional thinking level." })), @@ -153,6 +155,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin ); const options: NestedSpawnOptions = { description: params.description, + sessionName: params.name, model, maxTurns: invocation.maxTurns, isolated: invocation.isolated, diff --git a/src/schedule.ts b/src/schedule.ts index 41d20496..fb47b1b9 100644 --- a/src/schedule.ts +++ b/src/schedule.ts @@ -35,6 +35,7 @@ export type ScheduleChangeEvent = export interface NewJobInput { name: string; description: string; + sessionName?: string; schedule: string; subagent_type: SubagentType; prompt: string; @@ -96,6 +97,7 @@ export class SubagentScheduler { id: nanoid(10), name: input.name, description: input.description, + sessionName: input.sessionName, schedule: detected.normalized, scheduleType: detected.type, intervalMs: detected.intervalMs, @@ -240,6 +242,7 @@ export class SubagentScheduler { try { agentId = manager.spawn(pi, ctx, job.subagent_type, job.prompt, { description: job.description, + sessionName: job.sessionName, isBackground: true, bypassQueue: true, model: resolvedModel, diff --git a/src/types.ts b/src/types.ts index 64199e89..39977803 100644 --- a/src/types.ts +++ b/src/types.ts @@ -172,6 +172,8 @@ export interface ScheduledSubagent { intervalMs?: number; // spawn params (subset of Agent tool params; no inherit_context, no resume) + /** Optional caller-supplied session name. */ + sessionName?: string; subagent_type: SubagentType; prompt: string; model?: string; diff --git a/test/agent-manager.test.ts b/test/agent-manager.test.ts index 5216ff99..64c8c32a 100644 --- a/test/agent-manager.test.ts +++ b/test/agent-manager.test.ts @@ -647,6 +647,24 @@ describe("AgentManager — SpawnOptions.cwd passthrough (#96)", () => { }); }); +describe("AgentManager — session name passthrough", () => { + let manager: AgentManager; + afterEach(() => manager?.dispose()); + + it("passes SpawnOptions.sessionName to runAgent", async () => { + vi.mocked(runAgent).mockClear(); + resolvedRun(); + manager = new AgentManager(); + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + sessionName: "CH-005 verify", + }); + await manager.getRecord(id)!.promise; + + expect(vi.mocked(runAgent).mock.lastCall![3].sessionName).toBe("CH-005 verify"); + }); +}); + describe("AgentManager — abort() state machine", () => { let manager: AgentManager; afterEach(() => manager?.dispose()); diff --git a/test/agent-runner.test.ts b/test/agent-runner.test.ts index e8328e55..d3521ff6 100644 --- a/test/agent-runner.test.ts +++ b/test/agent-runner.test.ts @@ -262,6 +262,32 @@ describe("agent-runner final output capture", () => { expect(session.setSessionName).toHaveBeenCalledWith("Explore#a1b2c3d4"); }); + + it("prefers a caller-supplied session name and preserves the short agentId suffix", async () => { + const { session } = createSession("NAMED"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { + pi, + agentId: "a1b2c3d4e5f6", + sessionName: " CH-005 plan ", + }); + + expect(session.setSessionName).toHaveBeenCalledWith("CH-005 plan#a1b2c3d4"); + }); + + it("falls back to the configured name when a caller supplies only whitespace", async () => { + const { session } = createSession("NAMED"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { + pi, + agentId: "a1b2c3d4e5f6", + sessionName: " ", + }); + + expect(session.setSessionName).toHaveBeenCalledWith("Explore#a1b2c3d4"); + }); }); // ─── message_end → onAssistantUsage wiring (issue #38) ───────────────── diff --git a/test/nested-tools.test.ts b/test/nested-tools.test.ts index aeced305..dd9636a3 100644 --- a/test/nested-tools.test.ts +++ b/test/nested-tools.test.ts @@ -74,13 +74,19 @@ describe("child-safe nested Agent tool", () => { const result = await execute(agent, { subagent_type: "reviewer", description: "review evidence", + name: "CH-005 evidence review", prompt: "Review it", }); expect(result.isError).toBe(false); expect(spawnAndWait).toHaveBeenCalledWith( expect.anything(), expect.anything(), "reviewer", "Review it", - expect.objectContaining({ depth: 2, parentAgentId: "parent-1", maxSubagentDepth: 2 }), + expect.objectContaining({ + depth: 2, + parentAgentId: "parent-1", + maxSubagentDepth: 2, + sessionName: "CH-005 evidence review", + }), ); }); diff --git a/test/schedule.test.ts b/test/schedule.test.ts index 68e24076..5042f301 100644 --- a/test/schedule.test.ts +++ b/test/schedule.test.ts @@ -294,6 +294,17 @@ describe("SubagentScheduler — fire path", () => { expect(optsArg.isBackground).toBe(true); }); + it("preserves a caller-supplied session name when a scheduled job fires", () => { + scheduler.addJob({ + name: "named-job", description: "x", sessionName: "GH-123 scheduled review", schedule: "1s", + subagent_type: "general-purpose", prompt: "x", + }); + + vi.advanceTimersByTime(1_000); + const optsArg = manager.spawn.mock.calls[0][4]; + expect(optsArg.sessionName).toBe("GH-123 scheduled review"); + }); + it("disabled jobs do not fire", () => { const job = scheduler.addJob({ name: "off", description: "x", schedule: "1s",