diff --git a/src/agent-manager.ts b/src/agent-manager.ts index bfc3cc93..0a94a0ba 100644 --- a/src/agent-manager.ts +++ b/src/agent-manager.ts @@ -311,6 +311,11 @@ export class AgentManager { }, onSessionCreated: (session) => { record.session = session; + record.invocation ??= {}; + record.invocation.modelName = session.model + ? `${session.model.provider}/${session.model.id}` + : undefined; + record.invocation.thinking = session.thinkingLevel; // Flush any steers that arrived before the session was ready if (record.pendingSteers?.length) { for (const msg of record.pendingSteers) { diff --git a/src/index.ts b/src/index.ts index 8736dd23..c10576b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,7 @@ import { formatTurns, getDisplayName, getPromptModeLabel, + prepareModelNameForDisplay, SPINNER, type Theme, type UICtx, @@ -179,6 +180,16 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number): stri ].filter(Boolean).join('\n'); } +/** Read current runtime model/thinking, falling back to the pre-session invocation snapshot. */ +function getRuntimeInvocation(record: Pick | undefined): AgentInvocation | undefined { + if (!record?.session?.model) return record?.invocation; + return { + ...record.invocation, + modelName: `${record.session.model.provider}/${record.session.model.id}`, + thinking: record.session.thinkingLevel, + }; +} + /** Build AgentDetails from a base + record-specific fields. */ function buildDetails( base: Pick, @@ -976,10 +987,11 @@ Terse command-style prompts produce shallow, generic work. return new Text(text, 0, 0); } - // Helper: build "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string + // Helper: build "model · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string const stats = (d: AgentDetails) => { const parts: string[] = []; - if (d.modelName) parts.push(d.modelName); + const modelName = prepareModelNameForDisplay(d.modelName); + if (modelName) parts.push(modelName); if (d.tags) parts.push(...d.tags); if (d.turnCount != null && d.turnCount > 0) { parts.push(formatTurns(d.turnCount, d.maxTurns)); @@ -998,7 +1010,9 @@ Terse command-style prompts produce shallow, generic work. // ---- Background agent launched ---- if (details.status === "background") { - return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`), 0, 0); + const s = stats(details); + const line = (s ? `${s}\n` : "") + theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`); + return new Text(line, 0, 0); } // ---- Completed / Steered ---- @@ -1134,11 +1148,7 @@ Terse command-style prompts produce shallow, generic work. writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd); }; - const parentModelId = ctx.model?.id; - const effectiveModelId = model?.id; - const modelName = effectiveModelId && effectiveModelId !== parentModelId - ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase() - : undefined; + const modelName = model ? `${model.provider}/${model.id}` : undefined; const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns()); const agentInvocation: AgentInvocation = { modelName, @@ -1219,14 +1229,22 @@ Terse command-style prompts produce shallow, generic work. if (!record) { return textResult(`Failed to resume agent "${params.resume}".`); } + const resumedInvocation = buildInvocationTags(getRuntimeInvocation(record)); + const resumedDetails = { + displayName: getDisplayName(record.type), + description: record.description, + subagentType: record.type, + modelName: resumedInvocation.modelName, + tags: resumedInvocation.tags.length > 0 ? resumedInvocation.tags : undefined, + }; // A failed resume surfaces the error, plus any partial output THIS // resume produced (never the previous turn's answer, #144). if (record.status === "error") { - return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBase, record)); + return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(resumedDetails, record)); } return textResult( record.result?.trim() || "No output.", - buildDetails(detailBase, record), + buildDetails(resumedDetails, record), ); } @@ -1301,6 +1319,12 @@ Terse command-style prompts produce shallow, generic work. }); const isQueued = record?.status === "queued"; + const runtimeInvocation = buildInvocationTags(getRuntimeInvocation(record)); + const backgroundDetails = { + ...detailBase, + modelName: runtimeInvocation.modelName ?? detailBase.modelName, + tags: runtimeInvocation.tags.length > 0 ? runtimeInvocation.tags : detailBase.tags, + }; return textResult( `${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` + `Agent ID: ${id}\n` + @@ -1311,7 +1335,7 @@ Terse command-style prompts produce shallow, generic work. `\nYou will be notified when this agent completes.\n` + `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` + `Do not duplicate this agent's work.`, - { ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id }, + { ...backgroundDetails, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id }, ); } @@ -1321,8 +1345,11 @@ Terse command-style prompts produce shallow, generic work. let fgId: string | undefined; const streamUpdate = () => { + const runtimeInvocation = buildInvocationTags(getRuntimeInvocation(fgId ? manager.getRecord(fgId) : undefined)); const details: AgentDetails = { ...detailBase, + modelName: runtimeInvocation.modelName ?? detailBase.modelName, + tags: runtimeInvocation.tags.length > 0 ? runtimeInvocation.tags : detailBase.tags, toolUses: fgState.toolUses, tokens: formatLifetimeTokens(fgState), turnCount: fgState.turnCount, @@ -1411,7 +1438,13 @@ Terse command-style prompts produce shallow, generic work. // Get final token count const tokenText = formatLifetimeTokens(fgState); - const details = buildDetails(detailBase, record, fgState, { tokens: tokenText }); + const runtimeInvocation = buildInvocationTags(getRuntimeInvocation(record)); + const runtimeDetails = { + ...detailBase, + modelName: runtimeInvocation.modelName ?? detailBase.modelName, + tags: runtimeInvocation.tags.length > 0 ? runtimeInvocation.tags : detailBase.tags, + }; + const details = buildDetails(runtimeDetails, record, fgState, { tokens: tokenText }); if (record.status === "error") { // Error headline + any partial output the run produced before failing. diff --git a/src/schedule.ts b/src/schedule.ts index 4a7f71b7..50c2e5b0 100644 --- a/src/schedule.ts +++ b/src/schedule.ts @@ -256,6 +256,14 @@ export class SubagentScheduler { isolated: job.isolated, thinkingLevel: job.thinking, isolation: job.isolation, + invocation: { + modelName: resolvedModel ? `${resolvedModel.provider}/${resolvedModel.id}` : undefined, + thinking: job.thinking, + maxTurns: job.max_turns, + isolated: job.isolated, + runInBackground: true, + isolation: job.isolation, + }, }); } catch (err) { const error = err instanceof Error ? err.message : String(err); diff --git a/src/types.ts b/src/types.ts index 3d566aa4..156e152b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -148,9 +148,10 @@ export interface AgentRecord { } export interface AgentInvocation { - /** Short display name, e.g. "haiku" — only set when different from parent. */ + /** Canonical runtime model identifier (`provider/modelId`). */ modelName?: string; - thinking?: ThinkingLevel; + /** Effective runtime thinking level after Pi applies defaults and model clamping. */ + thinking?: string; maxTurns?: number; isolated?: boolean; inheritContext?: boolean; diff --git a/src/ui/agent-widget.ts b/src/ui/agent-widget.ts index 3cbe9cc8..e545a3bc 100644 --- a/src/ui/agent-widget.ts +++ b/src/ui/agent-widget.ts @@ -5,6 +5,7 @@ * Uses the callback form of setWidget for themed rendering. */ +import { stripVTControlCharacters } from "node:util"; import { truncateToWidth } from "@earendil-works/pi-tui"; import type { AgentManager } from "../agent-manager.js"; import { getConfig } from "../agent-types.js"; @@ -76,7 +77,7 @@ export interface AgentDetails { activity?: string; /** Current spinner frame index (for animated running indicator). */ spinnerFrame?: number; - /** Short model name if different from parent (e.g. "haiku", "sonnet"). */ + /** Effective model display name used by this run. */ modelName?: string; /** Notable config tags (e.g. ["thinking: high", "isolated"]). */ tags?: string[]; @@ -160,6 +161,12 @@ export function getPromptModeLabel(type: SubagentType): string | undefined { return config.promptMode === "append" ? "twin" : undefined; } +/** Make a model name safe for single-line terminal display. */ +export function prepareModelNameForDisplay(modelName: string | undefined): string | undefined { + if (!modelName) return undefined; + return stripVTControlCharacters(modelName).replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim(); +} + /** Mode label is not included — callers add it where they want it. */ export function buildInvocationTags( invocation: AgentInvocation | undefined, @@ -172,7 +179,7 @@ export function buildInvocationTags( if (invocation.inheritContext) tags.push("inherit context"); if (invocation.runInBackground) tags.push("background"); if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`); - return { modelName: invocation.modelName, tags }; + return { modelName: prepareModelNameForDisplay(invocation.modelName), tags }; } /** Truncate text to a single line, max `len` chars. */ @@ -305,8 +312,43 @@ export class AgentWidget { } } + /** Model and thinking metadata paired for the current run. */ + private invocationStats( + a: { + invocation?: AgentInvocation; + session?: { model?: { provider: string; id: string }; thinkingLevel?: string }; + }, + theme: Theme, + ): string | undefined { + const runtimeInvocation = a.session?.model + ? { + ...a.invocation, + modelName: `${a.session.model.provider}/${a.session.model.id}`, + thinking: a.session.thinkingLevel, + } + : a.invocation; + const { modelName, tags } = buildInvocationTags(runtimeInvocation); + const safeModelName = prepareModelNameForDisplay(modelName); + const parts = safeModelName ? [safeModelName, ...tags.filter(tag => tag.startsWith("thinking: "))] : []; + return parts.length > 0 ? theme.fg("dim", parts.join(" · ")) : undefined; + } + /** Render a finished agent line. */ - private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string }, theme: Theme): string { + private renderFinishedLine( + a: { + id: string; + type: SubagentType; + status: string; + description: string; + toolUses: number; + startedAt: number; + completedAt?: number; + error?: string; + invocation?: AgentInvocation; + session?: { model?: { provider: string; id: string }; thinkingLevel?: string }; + }, + theme: Theme, + ): string { const name = getDisplayName(a.type); const modeLabel = getPromptModeLabel(a.type); const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt); @@ -333,6 +375,8 @@ export class AgentWidget { } const parts: string[] = []; + const invocationStats = this.invocationStats(a, theme); + if (invocationStats) parts.push(invocationStats); const activity = this.agentActivity.get(a.id); if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns)); if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`); @@ -389,6 +433,8 @@ export class AgentWidget { const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : ""; const parts: string[] = []; + const invocationStats = this.invocationStats(a, theme); + if (invocationStats) parts.push(invocationStats); if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns)); if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`); if (tokenText) parts.push(tokenText); @@ -403,13 +449,19 @@ export class AgentWidget { ]); } - const queuedLine = queued.length > 0 - ? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`) - : undefined; + const queuedLines = queued.map(a => { + const invocationStats = this.invocationStats(a, theme); + const suffix = invocationStats ? ` · ${invocationStats}` : ""; + return truncate( + theme.fg("dim", "├─") + + ` ${theme.fg("muted", "◦")} ${theme.bold(getDisplayName(a.type))} ${theme.fg("muted", a.description)}` + + theme.fg("dim", suffix), + ); + }); // Assemble with overflow cap (heading + overflow indicator = 2 reserved lines). const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line - const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0); + const totalBody = finishedLines.length + runningLines.length * 2 + queuedLines.length; const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))]; @@ -417,7 +469,7 @@ export class AgentWidget { // Everything fits — add all lines and fix up connectors for the last item. lines.push(...finishedLines); for (const pair of runningLines) lines.push(...pair); - if (queuedLine) lines.push(queuedLine); + lines.push(...queuedLines); // Fix last connector: swap ├─ → └─ and │ → space for activity lines. if (lines.length > 1) { @@ -425,7 +477,7 @@ export class AgentWidget { lines[last] = lines[last].replace("├─", "└─"); // If last item is a running agent activity line, fix indent of that line // and fix the header line above it. - if (runningLines.length > 0 && !queuedLine) { + if (runningLines.length > 0 && queuedLines.length === 0) { // The last two lines are the last running agent's header + activity. if (last >= 2) { lines[last - 1] = lines[last - 1].replace("├─", "└─"); @@ -438,6 +490,7 @@ export class AgentWidget { // Reserve 1 line for overflow indicator. let budget = maxBody - 1; let hiddenRunning = 0; + let hiddenQueued = 0; let hiddenFinished = 0; // 1. Running agents (2 lines each) @@ -450,10 +503,14 @@ export class AgentWidget { } } - // 2. Queued line - if (queuedLine && budget >= 1) { - lines.push(queuedLine); - budget--; + // 2. Queued agents + for (const line of queuedLines) { + if (budget >= 1) { + lines.push(line); + budget--; + } else { + hiddenQueued++; + } } // 3. Finished agents @@ -469,9 +526,10 @@ export class AgentWidget { // Overflow summary const overflowParts: string[] = []; if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`); + if (hiddenQueued > 0) overflowParts.push(`${hiddenQueued} queued`); if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`); const overflowText = overflowParts.join(", "); - lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`) + lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenQueued + hiddenFinished} more (${overflowText})`)}`) ); } diff --git a/src/ui/conversation-viewer.ts b/src/ui/conversation-viewer.ts index e482344e..ec4fed90 100644 --- a/src/ui/conversation-viewer.ts +++ b/src/ui/conversation-viewer.ts @@ -274,7 +274,14 @@ export class ConversationViewer implements Component { } private invocationLine(): string | undefined { - const { modelName, tags } = buildInvocationTags(this.record.invocation); + const runtimeInvocation = this.session.model + ? { + ...this.record.invocation, + modelName: `${this.session.model.provider}/${this.session.model.id}`, + thinking: this.session.thinkingLevel, + } + : this.record.invocation; + const { modelName, tags } = buildInvocationTags(runtimeInvocation); const parts = modelName ? [modelName, ...tags] : tags; if (parts.length === 0) return undefined; return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`); diff --git a/test/agent-widget.test.ts b/test/agent-widget.test.ts index 6bed6cf1..eb452346 100644 --- a/test/agent-widget.test.ts +++ b/test/agent-widget.test.ts @@ -1,7 +1,26 @@ import { describe, expect, it } from "vitest"; import { renderRunningAgentStatus } from "../src/index.js"; import type { WidgetMode } from "../src/types.js"; -import { type AgentActivity, AgentWidget, fgPreservingNestedStyles, formatSessionTokens } from "../src/ui/agent-widget.js"; +import { type AgentActivity, AgentWidget, buildInvocationTags, fgPreservingNestedStyles, formatSessionTokens, prepareModelNameForDisplay } from "../src/ui/agent-widget.js"; + +describe("prepareModelNameForDisplay", () => { + it("keeps model names on one printable terminal line", () => { + expect(prepareModelNameForDisplay("openai\u001b[2J\n/gpt-5.6-sol")).toBe("openai /gpt-5.6-sol"); + }); +}); + +describe("buildInvocationTags", () => { + it("keeps the effective model with its model-dependent thinking level", () => { + expect(buildInvocationTags({ + modelName: "gpt-5.6 sol", + thinking: "max", + maxTurns: 60, + })).toEqual({ + modelName: "gpt-5.6 sol", + tags: ["thinking: max", "max turns: 60"], + }); + }); +}); describe("formatSessionTokens", () => { const theme = { fg: (c: string, s: string) => `<${c}>${s}`, bold: (s: string) => s }; @@ -77,6 +96,7 @@ describe("AgentWidget", () => { startedAt: Date.now(), lifetimeUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, compactionCount: 0, + invocation: { modelName: "openai-codex/gpt-5.6-sol", thinking: "xhigh" }, isBackground: opts.isBackground, parentAgentId: opts.parentAgentId, }; @@ -129,6 +149,21 @@ describe("AgentWidget", () => { const lines = renderLines(manager, "background", () => "background"); expect(lines).toContain("Agents"); expect(lines).toContain("background description"); + expect(lines).toContain("openai-codex/gpt-5.6-sol · thinking: xhigh"); + }); + + it("keeps the effective model and thinking in a finished row", () => { + const record = makeRecord("finished", { isBackground: true }); + record.status = "completed"; + record.completedAt = Date.now(); + const manager = { listAgents: () => [record] }; + const widget = new AgentWidget(manager as any, new Map([[record.id, makeActivity()]]), () => "background"); + let factory: any; + widget.setUICtx({ setStatus: () => {}, setWidget: (_key, content) => { factory = content; } }); + widget.markFinished(record.id); + widget.update(); + const lines = factory({ terminal: { columns: 120 }, requestRender: () => {} }, theme).render().join("\n"); + expect(lines).toContain("openai-codex/gpt-5.6-sol · thinking: xhigh"); }); // 'background' excludes only agents *known* to be foreground; one with no @@ -138,6 +173,15 @@ describe("AgentWidget", () => { expect(renderLines(manager, "unflagged", () => "background")).toContain("unflagged description"); }); + it("shows each queued agent with its effective model and thinking", () => { + const queued = makeRecord("queued", { isBackground: true }); + queued.status = "queued"; + const manager = { listAgents: () => [queued] }; + const lines = renderLines(manager, "queued", () => "background"); + expect(lines).toContain("queued description"); + expect(lines).toContain("openai-codex/gpt-5.6-sol · thinking: xhigh"); + }); + // "off" hides the widget entirely — even a background agent renders nothing. it("renders nothing in 'off' mode", () => { const manager = { listAgents: () => [makeRecord("background", { isBackground: true })] }; diff --git a/test/conversation-viewer.test.ts b/test/conversation-viewer.test.ts index d1aedad2..1256ba9f 100644 --- a/test/conversation-viewer.test.ts +++ b/test/conversation-viewer.test.ts @@ -1,3 +1,4 @@ +import { stripVTControlCharacters } from "node:util"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentRecord } from "../src/types.js"; @@ -34,9 +35,11 @@ function mockTui(rows = 40, columns = 80) { } as any; } -function mockSession(messages: any[] = []) { +function mockSession(messages: any[] = [], runtime?: { provider: string; id: string; thinkingLevel: string }) { return { messages, + model: runtime ? { provider: runtime.provider, id: runtime.id } : undefined, + thinkingLevel: runtime?.thinkingLevel, subscribe: vi.fn(() => vi.fn()), dispose: vi.fn(), getSessionStats: () => ({ tokens: { input: 0, output: 0, cacheWrite: 0 } }), @@ -76,6 +79,27 @@ beforeEach(() => { }); describe("ConversationViewer", () => { + it("shows the live runtime model ID with its model-dependent thinking level", () => { + const viewer = new ConversationViewer( + mockTui(30, 120), + mockSession([], { provider: "openai-codex", id: "gpt-5.6-sol", thinkingLevel: "xhigh" }), + mockRecord({ + invocation: { + modelName: "stale/primary", + thinking: "max", + maxTurns: 60, + }, + }), + undefined, + ansiTheme(), + vi.fn(), + ); + + const rendered = viewer.render(120).map(line => stripVTControlCharacters(line)).join("\n"); + expect(rendered).toContain("openai-codex/gpt-5.6-sol · thinking: xhigh · max turns: 60"); + expect(rendered).not.toContain("stale/primary"); + }); + describe("render width safety", () => { const widths = [40, 80, 120, 216]; diff --git a/test/schedule.test.ts b/test/schedule.test.ts index 56b9f22e..c9515fbc 100644 --- a/test/schedule.test.ts +++ b/test/schedule.test.ts @@ -312,10 +312,10 @@ describe("SubagentScheduler — fire path", () => { expect(manager.spawn).toHaveBeenCalledTimes(1); }); - it("fire passes bypassQueue: true to manager.spawn", () => { + it("fire passes background runtime metadata to manager.spawn", () => { scheduler.addJob({ name: "every-1s", description: "x", schedule: "1s", - subagent_type: "general-purpose", prompt: "x", + subagent_type: "general-purpose", prompt: "x", thinking: "high", }); vi.advanceTimersByTime(1_000); @@ -323,6 +323,10 @@ describe("SubagentScheduler — fire path", () => { const optsArg = manager.spawn.mock.calls[0][4]; expect(optsArg.bypassQueue).toBe(true); expect(optsArg.isBackground).toBe(true); + expect(optsArg.invocation).toEqual(expect.objectContaining({ + thinking: "high", + runInBackground: true, + })); }); it("disabled jobs do not fire", () => { diff --git a/test/status-note-wiring.test.ts b/test/status-note-wiring.test.ts index 50ef7648..8bc4b254 100644 --- a/test/status-note-wiring.test.ts +++ b/test/status-note-wiring.test.ts @@ -59,6 +59,145 @@ function ctx() { const textOf = (r: any): string => r.content[0].text; +function modelCtx() { + const context = ctx(); + context.model = { + provider: "openai-codex", + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + }; + return context; +} + +function runtimeSession(provider = "openai-codex", id = "gpt-5.6-sol", thinkingLevel = "xhigh") { + return { + model: { provider, id, name: "Ambiguous Display Name" }, + thinkingLevel, + dispose: vi.fn(), + } as never; +} + +describe("Agent tool model display", () => { + afterEach(() => vi.restoreAllMocks()); + + it("shows the canonical runtime model with its effective thinking in collapsed and expanded results", async () => { + const session = runtimeSession(); + vi.mocked(runAgent).mockImplementation(async (_ctx, _type, _prompt, options) => { + options.onSessionCreated?.(session); + return { responseText: "done", session, aborted: false, steered: false }; + }); + const { pi, tools } = makePi(); + subagentsExtension(pi); + const tool = tools.get("Agent"); + const onUpdate = vi.fn(); + + const result = await tool.execute( + "tc-model", + { prompt: "go", description: "d", subagent_type: "general-purpose", thinking: "max" }, + undefined, + onUpdate, + modelCtx(), + ); + + for (const candidate of [onUpdate.mock.calls.at(-1)?.[0], result]) { + const collapsed = tool.renderResult(candidate, { expanded: false, isPartial: false }, { + fg: (_color: string, text: string) => text, + }).render(120).join("\n"); + const expanded = tool.renderResult(candidate, { expanded: true, isPartial: false }, { + fg: (_color: string, text: string) => text, + }).render(120).join("\n"); + expect(collapsed).toContain("openai-codex/gpt-5.6-sol"); + expect(expanded).toContain("openai-codex/gpt-5.6-sol"); + expect(collapsed).toContain("thinking:"); + expect(expanded).toContain("thinking:"); + expect(collapsed).not.toContain("Ambiguous Display Name"); + } + expect(result.details.tags).toContain("thinking: xhigh"); + }); + + it("shows the canonical pre-session model in the immediate background result", async () => { + let releaseSession: (() => void) | undefined; + vi.mocked(runAgent).mockImplementation(async (_ctx, _type, _prompt, options) => { + await new Promise(resolve => { releaseSession = resolve; }); + const session = runtimeSession("anthropic", "claude-sonnet-4-6", "high"); + options.onSessionCreated?.(session); + return { responseText: "done", session, aborted: false, steered: false }; + }); + const { pi, tools } = makePi(); + subagentsExtension(pi); + const tool = tools.get("Agent"); + const result = await tool.execute( + "tc-background-model", + { + prompt: "go", + description: "d", + subagent_type: "general-purpose", + run_in_background: true, + thinking: "max", + }, + undefined, + undefined, + modelCtx(), + ); + + for (const expanded of [false, true]) { + const rendered = tool.renderResult(result, { expanded, isPartial: false }, { + fg: (_color: string, text: string) => text, + }).render(120).join("\n"); + expect(rendered).toContain("openai-codex/gpt-5.6-sol"); + expect(rendered).toContain("thinking:"); + expect(rendered).not.toContain("anthropic/claude-sonnet-4-6"); + } + releaseSession?.(); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + it("uses the existing session metadata when resuming", async () => { + const session = runtimeSession("anthropic", "claude-opus-4-6", "high"); + vi.mocked(runAgent).mockImplementation(async (_ctx, _type, _prompt, options) => { + options.onSessionCreated?.(session); + return { responseText: "first", session, aborted: false, steered: false }; + }); + const { pi, tools } = makePi(); + subagentsExtension(pi); + const tool = tools.get("Agent"); + const first = await tool.execute( + "tc-resume-first", + { prompt: "go", description: "original", subagent_type: "general-purpose", run_in_background: true }, + undefined, + undefined, + modelCtx(), + ); + await new Promise(resolve => setTimeout(resolve, 0)); + const agentId = first.details.agentId; + const conflictingCtx = modelCtx(); + conflictingCtx.model = { provider: "google", id: "gemini-3-pro", name: "Gemini" }; + + session.model = { provider: "anthropic", id: "claude-sonnet-4-6", name: "Switched" }; + session.thinkingLevel = "medium"; + const resumed = await tool.execute( + "tc-resume-second", + { + prompt: "continue", + description: "changed", + subagent_type: "Explore", + model: "google/gemini-3-pro", + thinking: "minimal", + resume: agentId, + }, + undefined, + undefined, + conflictingCtx, + ); + const rendered = tool.renderResult(resumed, { expanded: false, isPartial: false }, { + fg: (_color: string, text: string) => text, + }).render(120).join("\n"); + expect(rendered).toContain("anthropic/claude-sonnet-4-6"); + expect(rendered).toContain("thinking: medium"); + expect(rendered).not.toContain("google/gemini-3-pro"); + }); +}); + describe("status note reaches the parent through the real handlers", () => { afterEach(() => { delete (globalThis as any)[Symbol.for("pi-subagents:manager")];