From 7b2784b9b7bf4749058da48923ecbbc8019eb7af Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 6 Aug 2026 16:59:45 +0800 Subject: [PATCH] feat: surface the bound model and thinking effort on subagent UIs (#2679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: surface the bound model on subagent UIs The subagent.spawned event now carries the display-normalized model alias (the derived __secondary__ entry resolves to its base alias), so clients can show which model a subagent is bound to. The TUI subagent card, swarm panel header, and background-agent entry show it at spawn; the WS snapshot roster and REST /tasks (background/detached subagents) carry it too, keeping the model visible across client reconnects. * feat: carry the subagent thinking effort alongside the model The spawned event, snapshot roster, and REST /tasks now also carry the child's effective thinking effort (read from the child profile at spawn, the same vocabulary as agent.status.updated). UIs show it only when it diverges from the main session's current effort — an inherited level adds no information, and 'off' is never shown. * feat(tui): show the bound model and effort in the /tasks browser The task browser's Detail pane renders Model and Effort rows for agent tasks (raw alias and level — it is the inspector surface, so no diff filtering), and its minimum height grows to fit the new rows. The values were already persisted on SubagentTaskInfo; the TaskInfo union, its zod schemas (protocol, kap-server, klient contract), and the v1 type declaration now carry them so nothing strips them in transit. * feat(tui): show concrete subagent effort levels unconditionally Display rule simplified: any concrete effort tier (low/high/max/…) is shown next to the model — including when it matches the main session's level. Only the boolean states stay hidden: 'off' (no thinking) and 'on' (generic thinking) carry no level information. * docs: trim the changeset entry * fix(tui): keep the model and effort on background-agent entries across resume replayBackgroundProjection only copied agentId/parentToolCallId/ description, so a background subagent that outlived a resume lost its model/effort on the later terminal transcript entry. The projection now threads the persisted values (catalog-mapped model; boolean effort states dropped), and session replay passes the loaded model catalog through. * fix(agent-core-v2): normalize the derived secondary alias regardless of the flag A child bound while the secondary-model experiment was on keeps __secondary__ in its persisted binding; if the flag is later switched off with the recipe still configured, resolveSecondaryModel() gated the normalization and the sentinel leaked back onto resumed subagents. subagentDisplayModel now reads the recipe straight from config (the flag gates new bindings, not the interpretation of existing ones), which also drops SessionSwarmService's now-unused IFlagService dependency. Also adds the SDK package to the release: the new SubagentSpawnedEvent/AgentTaskInfo fields are SDK-visible types. * fix(agent-core-v2): normalize the status-frame model at the source A derived-bound child republishes agent.status.updated right after spawn with its raw modelAlias, which overwrote the spawned event's normalized display model on single-subagent cards (swarm headers were first-wins and escaped). emitStatusUpdated now maps through subagentDisplayModel, a no-op for the never-derived main agent. Also moves the inline comments added by this branch into top-of-file headers per the v2 comment convention. * fix(tui): clamp the /tasks detail frame to the available body At terminals near the minimum height the forced 10-row detail frame overflowed the body and truncated the preview frame's border. The detail height now caps out at whatever leaves the preview its borders plus one content row, with a regression test at exactly MIN_HEIGHT. * fix: normalize inherited derived aliases and keep model/effort on replayed terminal entries - resolveSubagentBinding's caller-fallback branch also maps through subagentDisplayModel: a caller itself bound to the derived entry (a resumed subagent making a nested Agent call) no longer publishes __secondary__. - The replayed background-task terminal notification builds its metadata with the persisted model (catalog-mapped) and concrete effort, matching the live completion path. - Drops the inline comments this branch added inside v2 test bodies; the scenario context lives in the source file headers. --- .changeset/subagent-model-display.md | 5 + .changeset/subagent-model-sdk-fields.md | 5 + .../tui/components/dialogs/tasks-browser.ts | 17 +- .../tui/components/messages/agent-group.ts | 1 + .../messages/agent-swarm-progress.ts | 19 +- .../src/tui/components/messages/tool-call.ts | 10 + .../src/tui/controllers/session-replay.ts | 16 +- .../tui/controllers/subagent-event-handler.ts | 33 +++ apps/kimi-code/src/tui/types.ts | 4 + .../src/tui/utils/background-agent-status.ts | 9 +- .../kimi-code/src/tui/utils/message-replay.ts | 16 ++ .../test/tui/kimi-tui-message-flow.test.ts | 200 ++++++++++++++++++ .../kimi-code/test/tui/message-replay.test.ts | 57 +++++ apps/kimi-code/test/tui/tasks-browser.test.ts | 49 +++++ .../agent-core-v2/docs/state-manifest.d.ts | 2 + .../src/agent/profile/profileService.ts | 6 +- .../agent/tools/agent-swarm/agentSwarmTool.ts | 3 +- .../src/agent/tools/agent/agentTool.ts | 17 +- .../src/agent/tools/agent/subagent-task.ts | 22 ++ .../session/sessionInit/sessionInitService.ts | 1 + .../src/session/subagent/configSection.ts | 34 ++- .../src/session/subagent/mirrorAgentRun.ts | 21 +- .../src/session/swarm/sessionSwarmService.ts | 13 +- .../test/agent/profile/config-state.test.ts | 14 ++ .../test/app/config/config.test.ts | 38 ++++ .../session/sessionInit/sessionInit.test.ts | 5 +- .../test/session/swarm/sessionSwarm.test.ts | 57 +++++ packages/agent-core-v2/test/tool/tool.test.ts | 39 +++- .../src/agent/background/agent-task.ts | 4 + .../kap-server/src/protocol/events-zod.ts | 4 + packages/kap-server/src/protocol/task.ts | 5 + packages/kap-server/src/routes/tasks.ts | 6 + .../transport/ws/v1/subagentRosterTracker.ts | 2 + .../test/sessionEventBroadcaster.test.ts | 4 + .../test/subagentRosterTracker.test.ts | 4 +- packages/kap-server/test/tasks.test.ts | 11 +- packages/klient/src/contract/agent/rpc.ts | 2 + packages/protocol/src/events.ts | 15 ++ packages/protocol/src/task.ts | 5 + 39 files changed, 742 insertions(+), 33 deletions(-) create mode 100644 .changeset/subagent-model-display.md create mode 100644 .changeset/subagent-model-sdk-fields.md diff --git a/.changeset/subagent-model-display.md b/.changeset/subagent-model-display.md new file mode 100644 index 000000000..7a6e83ade --- /dev/null +++ b/.changeset/subagent-model-display.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kimi-code': patch +--- + +Subagent UIs now show each subagent's bound model and thinking effort. diff --git a/.changeset/subagent-model-sdk-fields.md b/.changeset/subagent-model-sdk-fields.md new file mode 100644 index 000000000..78b02ff19 --- /dev/null +++ b/.changeset/subagent-model-sdk-fields.md @@ -0,0 +1,5 @@ +--- +'@moonshot-ai/kimi-code-sdk': patch +--- + +Subagent lifecycle events and background task info now carry the subagent's bound model and thinking effort. diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts index 7902e81e1..1874d0e7a 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -516,9 +516,14 @@ export class TasksBrowserApp extends Container implements Focusable { // ── right: detail + preview stack ──────────────────────────────────── private renderRightStack(width: number, height: number): string[] { - // Detail gets ~8 rows (or 40% of body, whichever is larger). Preview - // takes the rest. Both rendered as separate frames stacked vertically. - const detailHeight = Math.max(8, Math.min(Math.floor(height * 0.4), height - 5)); + // Detail wants ~10 rows (or 40% of body, whichever is larger) — agent tasks + // carry Task ID / Status / Description / Agent ID / Agent type / Model / + // Effort / Time. Clamp it so the preview frame keeps its borders plus one + // content row even near the minimum terminal height. + const detailHeight = Math.min( + Math.max(10, Math.min(Math.floor(height * 0.4), height - 5)), + Math.max(3, height - 3), + ); const previewHeight = height - detailHeight; return [ ...this.renderDetailFrame(width, detailHeight), @@ -553,6 +558,12 @@ export class TasksBrowserApp extends Container implements Focusable { if (task.kind === 'agent' && task.subagentType !== undefined) { lines.push(`${label('Agent type:')}${value(task.subagentType)}`); } + if (task.kind === 'agent' && task.model !== undefined) { + lines.push(`${label('Model:')}${value(task.model)}`); + } + if (task.kind === 'agent' && task.thinkingEffort !== undefined) { + lines.push(`${label('Effort:')}${value(task.thinkingEffort)}`); + } if (task.kind === 'question') { lines.push(`${label('Questions:')}${currentTheme.fg('textMuted', String(task.questionCount))}`); if (task.toolCallId !== undefined) { diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index 1fe4961e9..983624eec 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -303,6 +303,7 @@ function formatBreakdownParts(counts: PhaseCounts): string[] { function formatStats(snap: ToolCallSubagentSnapshot): string { const parts: string[] = []; if (snap.model !== undefined) parts.push(snap.model); + if (snap.effort !== undefined) parts.push(snap.effort); parts.push(`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`); if (snap.elapsedSeconds !== undefined) parts.push(formatElapsed(snap.elapsedSeconds)); if (snap.tokens > 0) parts.push(formatTokens(snap.tokens)); diff --git a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts index ec2e49962..41fab4a6c 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts @@ -191,6 +191,7 @@ export class AgentSwarmProgressComponent implements Component { private readonly requestRender: (() => void) | undefined; private readonly availableGridHeight: (() => number | undefined) | undefined; private modelDisplay = ''; + private effortDisplay = ''; private inputComplete = false; private failed = false; private aborted = false; @@ -235,6 +236,16 @@ export class AgentSwarmProgressComponent implements Component { this.modelDisplay = modelDisplay; } + /** + * Show the thinking effort next to the model, same first-wins rule. Only + * ever called with a concrete level (the handler filters the boolean + * states), so its presence already implies a real effort tier. + */ + setEffortDisplay(effortDisplay: string): void { + if (this.effortDisplay.length > 0 || effortDisplay.length === 0) return; + this.effortDisplay = effortDisplay; + } + markToolCallEnded(): void { this.toolCallActive = false; this.activitySpinnerText = undefined; @@ -492,9 +503,13 @@ export class AgentSwarmProgressComponent implements Component { this.description.length > 0 ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.text)(this.description) : ''; + const modelText = + this.effortDisplay.length > 0 + ? `${this.modelDisplay} · ${this.effortDisplay}` + : this.modelDisplay; const model = - this.modelDisplay.length > 0 - ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.textDim)(this.modelDisplay) + modelText.length > 0 + ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.textDim)(modelText) : ''; const prefixText = '─ '; const labelWidth = Math.max(1, width - visibleWidth(prefixText) - 1); diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index a669f6299..3a30649e8 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -95,6 +95,8 @@ export interface ToolCallSubagentSnapshot { readonly agentName: string | undefined; /** Display name of the model the subagent is bound to, when known (live only). */ readonly model?: string; + /** Thinking effort, present only for concrete levels (on/off hidden). */ + readonly effort?: string; readonly phase: SubagentPhase | undefined; readonly toolCount: number; readonly elapsedSeconds: number | undefined; @@ -599,6 +601,8 @@ export class ToolCallComponent extends Container { private subagentUsage: TokenUsage | undefined; /** Display name of the model the subagent is bound to (from its `agent.status.updated`). */ private subagentModel: string | undefined; + /** Thinking effort, set only for concrete levels (boolean on/off hidden). */ + private subagentEffort: string | undefined; private subagentResultSummary: string | undefined; private subagentError: string | undefined; private streamingProgressTimer: ReturnType | undefined; @@ -903,6 +907,7 @@ export class ToolCallComponent extends Container { toolCallDescription: str(this.toolCall.args['description']) || str(this.toolCall.description), agentName: this.subagentAgentName, model: this.subagentModel, + effort: this.subagentEffort, phase: derivedPhase, toolCount: finished, elapsedSeconds: this.getSubagentElapsedSeconds(), @@ -1168,6 +1173,7 @@ export class ToolCallComponent extends Container { contextTokens?: number | undefined; usage?: TokenUsage | undefined; modelDisplay?: string | undefined; + effortDisplay?: string | undefined; }): void { if (payload.contextTokens !== undefined && payload.contextTokens > 0) { this.subagentContextTokens = payload.contextTokens; @@ -1178,6 +1184,9 @@ export class ToolCallComponent extends Container { if (payload.modelDisplay !== undefined) { this.subagentModel = payload.modelDisplay; } + if (payload.effortDisplay !== undefined) { + this.subagentEffort = payload.effortDisplay; + } this.headerText.setText(this.buildHeader()); this.invalidate(); this.notifySnapshotChange(); @@ -1795,6 +1804,7 @@ export class ToolCallComponent extends Container { private formatSingleSubagentStatsText(): string { const parts: string[] = []; if (this.subagentModel !== undefined) parts.push(this.subagentModel); + if (this.subagentEffort !== undefined) parts.push(this.subagentEffort); parts.push(`${String(this.subToolActivities.size)} tool${this.subToolActivities.size === 1 ? '' : 's'}`); const elapsed = this.getSubagentElapsedSeconds(); if (elapsed !== undefined) parts.push(formatElapsed(elapsed)); diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index e809defcd..1eebd5a72 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -21,6 +21,7 @@ import type { import { formatErrorMessage, isTodoItemShape } from '../utils/event-payload'; import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; +import { modelDisplayName } from '../components/dialogs/model-selector'; import { buildGoalCompletionMessage } from '../utils/goal-completion'; import { formatBashOutputForDisplay } from '../utils/shell-output'; import { markTranscriptComponent } from '../utils/transcript-component-metadata'; @@ -167,7 +168,7 @@ export class SessionReplayRenderer { private hydrateBackgroundState(agent: ResumedAgentState): void { const { state, sessionEventHandler } = this.host; - const projection = replayBackgroundProjection(agent.background); + const projection = replayBackgroundProjection(agent.background, state.appState.availableModels); sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata = new Map( projection.backgroundAgentMetadata, ); @@ -686,6 +687,19 @@ export class SessionReplayRenderer { agentId: origin.taskId, parentToolCallId: origin.taskId, description: task?.description, + model: + task?.model === undefined + ? undefined + : modelDisplayName( + task.model, + this.host.state.appState.availableModels[task.model], + ), + effort: + task?.thinkingEffort === undefined || + task.thinkingEffort === 'off' || + task.thinkingEffort === 'on' + ? undefined + : task.thinkingEffort, }; let status = formatBackgroundAgentTranscript( origin.status === 'completed' ? 'completed' : 'failed', diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index 4368ea541..d8acc0cab 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -134,6 +134,7 @@ export class SubAgentEventHandler { event.model === undefined ? undefined : modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]), + effortDisplay: this.subagentEffortDisplay(event.thinkingEffort), }); } return true; @@ -376,6 +377,8 @@ export class SubAgentEventHandler { parentToolCallId: event.parentToolCallId, agentName: event.subagentName, description: typeof description === 'string' ? description : undefined, + model: this.spawnedModelDisplay(event), + effort: this.subagentEffortDisplay(event.thinkingEffort), }; } @@ -411,11 +414,19 @@ export class SubAgentEventHandler { private handleForegroundSubagentSpawned( event: SubagentLifecycleEventOf<'subagent.spawned'>, ): void { + // The spawned event carries the display-normalized bound alias (newer + // cores) — show it at spawn instead of waiting for the child's first + // status frame. The `agent.status.updated` channel below stays as the + // in-run update/fallback path. + const modelDisplay = this.spawnedModelDisplay(event); + const effortDisplay = this.subagentEffortDisplay(event.thinkingEffort); if (this.updateAgentSwarmProgress(event.parentToolCallId, (progress) => { progress.registerSubagent({ agentId: event.subagentId, swarmIndex: event.swarmIndex, }); + if (modelDisplay !== undefined) progress.setModelDisplay(modelDisplay); + if (effortDisplay !== undefined) progress.setEffortDisplay(effortDisplay); })) { return; } @@ -428,6 +439,26 @@ export class SubAgentEventHandler { agentName: event.subagentName, runInBackground: event.runInBackground, }); + if (modelDisplay !== undefined || effortDisplay !== undefined) { + tc.updateSubagentMetrics({ modelDisplay, effortDisplay }); + } + } + + /** Map the spawned event's bound alias to a display name via the loaded + * model catalog; falls back to the alias itself for unknown entries. */ + private spawnedModelDisplay( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): string | undefined { + if (event.model === undefined) return undefined; + return modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]); + } + + /** Concrete effort levels are always shown; the boolean states carry no + * level information — 'off' (no thinking) and 'on' (generic thinking) are + * both hidden. */ + private subagentEffortDisplay(effort: string | undefined): string | undefined { + if (effort === undefined || effort === 'off' || effort === 'on') return undefined; + return effort; } private handleForegroundSubagentStarted( @@ -520,6 +551,8 @@ export class SubAgentEventHandler { progress.setModelDisplay( modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]), ); + const effortDisplay = this.subagentEffortDisplay(event.thinkingEffort); + if (effortDisplay !== undefined) progress.setEffortDisplay(effortDisplay); } } diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 755fa9575..d423aec70 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -130,6 +130,10 @@ export interface BackgroundAgentMetadata { readonly parentToolCallId: string; readonly agentName?: string; readonly description?: string; + /** Display name of the model the agent is bound to (resolved at spawn). */ + readonly model?: string; + /** Thinking effort, set only for concrete levels (boolean on/off hidden). */ + readonly effort?: string; } export type BackgroundAgentStatusPhase = 'started' | 'completed' | 'failed'; diff --git a/apps/kimi-code/src/tui/utils/background-agent-status.ts b/apps/kimi-code/src/tui/utils/background-agent-status.ts index a54257a97..aa740fc6c 100644 --- a/apps/kimi-code/src/tui/utils/background-agent-status.ts +++ b/apps/kimi-code/src/tui/utils/background-agent-status.ts @@ -28,9 +28,12 @@ export function formatBackgroundAgentTranscript( ? `${subject} completed in background` : `${subject} failed in background`; const tail = phase === 'failed' ? normalizeBackgroundField(extras?.error) : undefined; - const detailParts = [normalizeBackgroundField(meta.description), tail].filter( - (part): part is string => part !== undefined, - ); + const detailParts = [ + normalizeBackgroundField(meta.model), + normalizeBackgroundField(meta.effort), + normalizeBackgroundField(meta.description), + tail, + ].filter((part): part is string => part !== undefined); return { phase, diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index cf9cef570..c068ac106 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -18,6 +18,7 @@ import type { TranscriptEntry, } from '#/tui/types'; +import { modelDisplayName } from '../components/dialogs/model-selector'; import { mediaUrlPartToText } from './media-url'; import { nextTranscriptId } from './transcript-id'; @@ -101,6 +102,7 @@ export function countActiveBackgroundTasks(tasks: ReadonlyMap(); for (const info of background) { @@ -111,6 +113,20 @@ export function replayBackgroundProjection( agentId, parentToolCallId: info.taskId, description: info.description, + // The persisted task record carries the spawn-time model/effort (v2); + // keep them across a resume so the terminal transcript entry can show + // them. Model maps through the catalog like the live path; boolean + // effort states carry no level and are dropped. + model: + info.model === undefined + ? undefined + : modelDisplayName(info.model, availableModels?.[info.model]), + effort: + info.thinkingEffort === undefined || + info.thinkingEffort === 'off' || + info.thinkingEffort === 'on' + ? undefined + : info.thinkingEffort, }); } return { backgroundAgentMetadata }; diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index c6137d934..acd25f34a 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -4639,6 +4639,206 @@ command = "vim" expect(transcript).toContain('✗ The user manually interrupted this subagent x.'); }); + it('shows the spawned model on the subagent card at spawn, mapped through the model catalog', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + driver.state.appState.availableModels = { + 'k2-cheap': { + provider: 'managed:kimi-code', + model: 'kimi-k2-cheap', + maxContextSize: 100_000, + displayName: 'Kimi K2 Cheap', + capabilities: [], + }, + }; + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_agent', + subagentId: 'agent-1', + subagentName: 'explore', + description: 'explore project', + runInBackground: false, + model: 'k2-cheap', + } as Event, + sendQueued, + ); + + expect(stripSgr(renderTranscript(driver))).toContain('Kimi K2 Cheap'); + }); + + it('falls back to the raw alias when the spawned model is missing from the catalog', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_agent', + subagentId: 'agent-1', + subagentName: 'explore', + description: 'explore project', + runInBackground: false, + model: 'k2-cheap', + } as Event, + sendQueued, + ); + + expect(stripSgr(renderTranscript(driver))).toContain('k2-cheap'); + }); + + it('shows any concrete spawned effort, same as the session or not', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + driver.state.appState.thinkingEffort = 'high'; + + // Same level as the main session — still shown (level info is level info). + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_agent', + subagentId: 'agent-1', + subagentName: 'explore', + description: 'explore project', + runInBackground: false, + model: 'k2-cheap', + thinkingEffort: 'high', + } as Event, + sendQueued, + ); + expect(stripSgr(renderTranscript(driver))).toContain('· high'); + }); + + it('hides the boolean effort states on and off', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + for (const effort of ['on', 'off']) { + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: `call_agent_${effort}`, + subagentId: `agent-${effort}`, + subagentName: 'explore', + description: `explore ${effort}`, + runInBackground: false, + model: 'k2-cheap', + thinkingEffort: effort, + } as Event, + sendQueued, + ); + } + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('· on'); + expect(transcript).not.toContain('· off'); + }); + + it('keeps the child status update as the model fallback when spawned omits it', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_agent', + subagentId: 'agent-1', + subagentName: 'explore', + description: 'explore project', + runInBackground: false, + } as Event, + sendQueued, + ); + expect(stripSgr(renderTranscript(driver))).not.toContain('k2-cheap'); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'agent-1', + sessionId: 'ses-1', + model: 'k2-cheap', + } as Event, + sendQueued, + ); + expect(stripSgr(renderTranscript(driver))).toContain('k2-cheap'); + }); + + it('shows the spawned model in the swarm panel header at spawn', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_swarm', + name: 'AgentSwarm', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts'], + }, + } as Event, + sendQueued, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_swarm', + subagentId: 'agent-1', + subagentName: 'coder', + description: 'Review changed files #1 (coder)', + swarmIndex: 1, + runInBackground: false, + model: 'k2-cheap', + } as Event, + sendQueued, + ); + + const progress = driver.state.transcriptContainer.children.find( + (child): child is AgentSwarmProgressComponent => child instanceof AgentSwarmProgressComponent, + ); + if (progress === undefined) throw new Error('expected AgentSwarm progress'); + expect(stripSgr(progress.render(118).join('\n'))).toContain('k2-cheap'); + }); + + it('includes the spawned model in the background-agent transcript entry', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_agent', + subagentId: 'agent-1', + subagentName: 'explore', + description: 'explore project', + runInBackground: true, + model: 'k2-cheap', + } as Event, + sendQueued, + ); + + expect(stripSgr(renderTranscript(driver))).toContain('k2-cheap'); + }); + it('does not let later transcript entries reduce the AgentSwarm grid height', async () => { const { driver } = await makeDriver(); const sendQueued = vi.fn(); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index c901374a2..be5446a08 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -25,6 +25,7 @@ import { } from '#/tui/utils/transcript-window'; import { ToolCallComponent } from '#/tui/components/messages/tool-call'; import { ReadGroupComponent } from '#/tui/components/messages/read-group'; +import { replayBackgroundProjection } from '#/tui/utils/message-replay'; import type { TaskNotificationOrigin } from '#/tui/utils/message-replay'; vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); @@ -1353,3 +1354,59 @@ describe('KimiTUI resume message replay', () => { expect(transcript).toContain('final text 4'); }); }); + +describe('replayBackgroundProjection', () => { + function agentTask(overrides: Record = {}): BackgroundTaskInfo { + return { + taskId: 'agent-task1', + kind: 'agent', + agentId: 'agent-1', + description: 'background job', + status: 'running', + startedAt: 1, + endedAt: null, + ...overrides, + } as BackgroundTaskInfo; + } + + it('threads the persisted model (catalog-mapped) and concrete effort into the metadata', () => { + const projection = replayBackgroundProjection( + [agentTask({ model: 'k2-cheap', thinkingEffort: 'low' })], + { + 'k2-cheap': { + provider: 'managed:kimi-code', + model: 'kimi-k2-cheap', + displayName: 'Kimi K2 Cheap', + }, + } as never, + ); + expect(projection.backgroundAgentMetadata.get('agent-1')).toMatchObject({ + model: 'Kimi K2 Cheap', + effort: 'low', + }); + }); + + it('falls back to the raw alias and drops boolean effort states', () => { + const projection = replayBackgroundProjection([ + agentTask({ model: 'k2-cheap', thinkingEffort: 'on' }), + agentTask({ + taskId: 'agent-task2', + agentId: 'agent-2', + model: 'k2-cheap', + thinkingEffort: 'off', + }), + ]); + expect(projection.backgroundAgentMetadata.get('agent-1')).toMatchObject({ + model: 'k2-cheap', + effort: undefined, + }); + expect(projection.backgroundAgentMetadata.get('agent-2')?.effort).toBeUndefined(); + }); + + it('omits model and effort for records that predate the fields', () => { + const projection = replayBackgroundProjection([agentTask()]); + const meta = projection.backgroundAgentMetadata.get('agent-1'); + expect(meta?.model).toBeUndefined(); + expect(meta?.effort).toBeUndefined(); + }); +}); diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index dacf75f5f..331389a56 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -102,6 +102,28 @@ describe('TasksBrowserApp — full-screen rendering', () => { expect(big.length).toBe(40); }); + it('clamps the detail frame to the body at the minimum terminal height', () => { + const props = makeProps({ + tasks: [ + task({ + taskId: 'agent-aaaaaaaa', + kind: 'agent', + status: 'running', + agentId: 'agent-1', + subagentType: 'explore', + model: 'kimi-code/k3-256k', + thinkingEffort: 'low', + }), + ], + selectedTaskId: 'agent-aaaaaaaa', + }); + // 10 rows = the smallest terminal that still renders the full layout; the + // render must emit exactly that many lines (no overflow truncation). + const lines = new TasksBrowserApp(props, fakeTerminal(10, 120)).render(120); + expect(lines.length).toBe(10); + expect(strip(lines.join('\n'))).toContain('Preview Output'); + }); + it('shows the header row with TASK BROWSER title and counts', () => { const props: Partial = { tasks: [ @@ -176,6 +198,33 @@ describe('TasksBrowserApp — full-screen rendering', () => { expect(out).toContain('call_question'); }); + it('shows the bound model and effort for agent tasks in the Detail pane', () => { + const out = strip( + makeApp({ + tasks: [ + task({ + taskId: 'agent-aaaaaaaa', + kind: 'agent', + description: 'explore project', + agentId: 'agent-1', + subagentType: 'explore', + model: 'kimi-code/k3-256k', + thinkingEffort: 'low', + }), + ], + selectedTaskId: 'agent-aaaaaaaa', + }) + .render(120) + .join('\n'), + ); + expect(out).toContain('Agent type:'); + expect(out).toContain('explore'); + expect(out).toContain('Model:'); + expect(out).toContain('kimi-code/k3-256k'); + expect(out).toContain('Effort:'); + expect(out).toContain('low'); + }); + it('renders tail output in the Preview Output pane', () => { const out = strip( makeApp({ diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 9dddfee7b..01994365b 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1159,6 +1159,8 @@ export interface AgentStateSnapshot { readonly kind: 'agent'; readonly agentId?: string; readonly subagentType?: string; + readonly model?: string; + readonly thinkingEffort?: string; readonly taskId: string; readonly description: string; readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index a533a824c..d84a60a8c 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -108,6 +108,7 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import type { ToolSource } from '#/tool/toolContract'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { subagentDisplayModel } from '#/session/subagent/configSection'; import { ISessionInstructionsProvider } from '#/session/sessionInstructions/instructionsProvider'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { @@ -724,7 +725,8 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ custom(); return; } - if (!this.hasModel()) return; + const modelAlias = this.modelAlias; + if (modelAlias === undefined) return; // An alias that no longer resolves (e.g. the model entry was removed from // config) yields UNKNOWN_CAPABILITY whose max_context_tokens is 0 — the // "unknown" marker, not a real limit. Omit the field instead of pushing 0. @@ -732,7 +734,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ const maxContextTokens = capabilities?.max_input_tokens ?? capabilities?.max_context_tokens; this.eventBus.publish({ type: 'agent.status.updated', - model: this.modelAlias, + model: subagentDisplayModel(this.config, modelAlias), thinkingEffort: includeThinkingEffort ? this.getEffectiveThinkingLevel() : undefined, diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts index 50011159d..a05c5d46c 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts @@ -191,12 +191,13 @@ export class AgentSwarmTool implements IAgentSwarmTool { }); } if (own.modelAlias !== undefined) { - binding = resolveSubagentBinding( + const resolved = resolveSubagentBinding( this.config, this.flags, { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, args.model ?? targetProfile.modelPreference, ); + binding = { model: resolved.model, thinking: resolved.thinking }; } } const timeoutMs = resolveSubagentTimeoutMs(this.config); diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index e617d3a38..efcb7305b 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -85,6 +85,7 @@ import { resolveSubagentBinding, resolveSubagentTimeoutMs, stripSubagentModelParameter, + subagentDisplayModel, wrapSubagentModelError, } from '#/session/subagent/configSection'; import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; @@ -254,6 +255,7 @@ export class SubagentTool implements ISubagentTool { let agentId: string; let profileName: string; + let displayModel: string | undefined; let promptText = args.prompt; if (isResume) { const target = this.lifecycle.get(resumeAgentId); @@ -264,8 +266,12 @@ export class SubagentTool implements ISubagentTool { } await this.ensureOwnedIdleSubagent(resumeAgentId, target); agentId = target.id; - profileName = - target.accessor.get(IAgentProfileService).data().profileName ?? RESUMED_LABEL; + const resumed = target.accessor.get(IAgentProfileService).data(); + profileName = resumed.profileName ?? RESUMED_LABEL; + displayModel = + resumed.modelAlias === undefined + ? undefined + : subagentDisplayModel(this.config, resumed.modelAlias); } else { const requestedProfileName = args.subagent_type?.length ? args.subagent_type @@ -317,6 +323,7 @@ export class SubagentTool implements ISubagentTool { .inheritUserTools(requester.accessor.get(IAgentUserToolService)); agentId = created.id; profileName = profile.name; + displayModel = binding.displayModel; promptText = await applyProfilePromptPrefix(profile, args.prompt, { cwd: this.workspace.workDir, runner: this.processRunner, @@ -330,6 +337,7 @@ export class SubagentTool implements ISubagentTool { parentToolCallId: toolCallId, description: args.description, runInBackground, + model: displayModel, }); const run = await this.subagents.run( @@ -348,6 +356,11 @@ export class SubagentTool implements ISubagentTool { return { agentId, profileName, + model: displayModel, + thinkingEffort: this.lifecycle + .get(agentId) + ?.accessor.get(IAgentProfileService) + .getEffectiveThinkingLevel(), completion: mirrored.then((r) => ({ result: r.summary, usage: r.usage })), }; } diff --git a/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts b/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts index 970624731..38cb41e0f 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts @@ -1,3 +1,15 @@ +/** + * `agent/tools/agent` — the background-task embodiment of a subagent run. + * + * Wraps a `SubagentHandle` as an `AgentTask` so the run registers in the + * owning agent's task store (a foreground run may detach into it later): + * aborts flow through the task signal, completion settles the task and + * appends the result as its output. `toInfo` also carries the display-facing + * facts (subagent type, normalized model alias, effective thinking effort) + * onto the task record, which the spawned-event / snapshot / REST surfaces + * read back after a client reload. + */ + import type { TokenUsage } from '#/kosong/contract/usage'; import { isAbortError } from '#/_base/utils/abort'; @@ -15,6 +27,8 @@ type SubagentCompletion = { export type SubagentHandle = { readonly agentId: string; readonly profileName: string; + readonly model?: string; + readonly thinkingEffort?: string; readonly completion: Promise; }; @@ -22,6 +36,8 @@ export interface SubagentTaskInfo extends AgentTaskInfoBase { readonly kind: 'agent'; readonly agentId?: string; readonly subagentType?: string; + readonly model?: string; + readonly thinkingEffort?: string; } declare module '#/agent/task/types' { @@ -68,6 +84,8 @@ export class SubagentTask implements AgentTask { readonly idPrefix: string = 'agent'; readonly agentId: string; readonly subagentType: string; + readonly model?: string; + readonly thinkingEffort?: string; constructor( private readonly handle: SubagentHandle, @@ -76,6 +94,8 @@ export class SubagentTask implements AgentTask { ) { this.agentId = handle.agentId; this.subagentType = handle.profileName; + this.model = handle.model; + this.thinkingEffort = handle.thinkingEffort; } async start(sink: AgentTaskSink): Promise { @@ -109,6 +129,8 @@ export class SubagentTask implements AgentTask { kind: 'agent', agentId: this.agentId, subagentType: this.subagentType, + model: this.model, + thinkingEffort: this.thinkingEffort, }; } } diff --git a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts index 05919d379..dfc2e5977 100644 --- a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts +++ b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts @@ -93,6 +93,7 @@ export class SessionInitService implements ISessionInitService { parentToolCallId: INIT_PARENT_TOOL_CALL_ID, description: INIT_DESCRIPTION, runInBackground: false, + model: own.modelAlias, }); const run = await this.subagents.run( diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index 89ae21f9a..e98989ade 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -29,8 +29,12 @@ * and wrap spawn failures with * `wrapSubagentModelError`; while the experiment is off they also strip the * no-op `model` parameter from their advertised schemas via - * `stripSubagentModelParameter`. Self-registered at module load via - * `registerConfigSection`. + * `stripSubagentModelParameter`. Spawn reporting reads the display-facing + * alias from `subagentDisplayModel`: the derived entry id means nothing to a + * user, so it resolves back to the recipe's base alias — flag-independent on + * purpose, since interpreting an already-persisted derived binding (resume) + * must keep working after the experiment is switched off. Self-registered + * at module load via `registerConfigSection`. */ import { z } from 'zod'; @@ -114,18 +118,32 @@ export function resolveSubagentBinding( flags: IFlagService, own: { modelAlias: string; thinkingLevel: string }, requested?: SubagentModelChoice, -): { model: string; thinking?: string } { +): { model: string; thinking?: string; displayModel: string } { const secondary = resolveSecondaryModel(config, flags); if (requested !== 'primary' && secondary?.model !== undefined) { + const model = + secondaryModelPatch(secondary) === undefined ? secondary.model : SECONDARY_DERIVED_MODEL_ID; return { - model: - secondaryModelPatch(secondary) === undefined - ? secondary.model - : SECONDARY_DERIVED_MODEL_ID, + model, thinking: secondary.defaultEffort, + displayModel: subagentDisplayModel(config, model), }; } - return { model: own.modelAlias, thinking: own.thinkingLevel }; + return { + model: own.modelAlias, + thinking: own.thinkingLevel, + displayModel: subagentDisplayModel(config, own.modelAlias), + }; +} + +export function subagentDisplayModel( + config: IConfigService, + boundAlias: string, +): string { + if (boundAlias !== SECONDARY_DERIVED_MODEL_ID) return boundAlias; + return ( + config.get(SECONDARY_MODEL_SECTION)?.model ?? boundAlias + ); } export function buildSubagentModelDescriptions( diff --git a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts index 9c2f0c7e1..095a1cb70 100644 --- a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts @@ -15,7 +15,11 @@ * * Wire shape note: the signals are still named `subagent.spawned / started / * completed / failed` and telemetry still tracks `subagent_created` so existing - * session recordings and dashboards stay valid. + * session recordings and dashboards stay valid. The spawned signal also + * reports the child's display-normalized model alias (the derived secondary + * entry resolves to its base alias) and its effective thinking effort, so + * clients can render both at spawn instead of waiting for the first + * `agent.status.updated` frame. */ import type { IAgentScopeHandle } from '#/_base/di/scope'; @@ -42,6 +46,8 @@ export interface SubagentSpawnedEvent { readonly description?: string; readonly swarmIndex?: number; readonly runInBackground: boolean; + readonly model?: string; + readonly thinkingEffort?: string; } export interface SubagentStartedEvent { @@ -79,6 +85,7 @@ export interface AgentRunSpawnedMeta { readonly description?: string; readonly swarmIndex?: number; readonly runInBackground?: boolean; + readonly model?: string; } export interface MirrorAgentRunOptions { @@ -94,6 +101,10 @@ export function emitAgentRunSpawned( targetAgentId: string, meta: AgentRunSpawnedMeta, ): void { + const childProfile = requester.accessor + .get(IAgentLifecycleService) + ?.get(targetAgentId) + ?.accessor.get(IAgentProfileService); requester.accessor.get(IEventBus)?.publish({ type: 'subagent.spawned', subagentId: targetAgentId, @@ -105,12 +116,10 @@ export function emitAgentRunSpawned( description: meta.description, swarmIndex: meta.swarmIndex, runInBackground: meta.runInBackground ?? false, + model: meta.model, + thinkingEffort: childProfile?.getEffectiveThinkingLevel(), }); - requester.accessor - .get(IAgentLifecycleService) - ?.get(targetAgentId) - ?.accessor.get(IAgentProfileService) - ?.republishStatus(); + childProfile?.republishStatus(); requester.accessor.get(ITelemetryService)?.track2('subagent_created', { subagent_name: meta.profileName, run_in_background: meta.runInBackground ?? false, diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 3f41f07a4..e3eb5475c 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -30,6 +30,7 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IEventBus } from '#/app/event/eventBus'; +import { IConfigService } from '#/app/config/config'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -41,7 +42,10 @@ import { } from '#/session/agentLifecycle/subagentMetadata'; import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; import { ISessionSubagentService } from '#/session/subagent/subagent'; -import { wrapSubagentModelError } from '#/session/subagent/configSection'; +import { + subagentDisplayModel, + wrapSubagentModelError, +} from '#/session/subagent/configSection'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata, type AgentMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionProcessRunner } from '#/session/process/processRunner'; @@ -90,6 +94,7 @@ export class SessionSwarmService implements ISessionSwarmService { @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, @ILogService private readonly log: ILogService, @IModelCatalog private readonly modelCatalog: IModelCatalog, + @IConfigService private readonly config: IConfigService, ) {} async getSwarmItem(args: { @@ -187,6 +192,7 @@ export class SessionSwarmService implements ISessionSwarmService { description: options.description, swarmIndex: options.swarmIndex, runInBackground: options.runInBackground, + model: subagentDisplayModel(this.config, binding.model), }); const promptText = await applyProfilePromptPrefix(profile, options.prompt, { cwd: this.sessionContext.cwd, @@ -213,6 +219,7 @@ export class SessionSwarmService implements ISessionSwarmService { const profileName = child.accessor.get(IAgentProfileService).data().profileName ?? RESUMED_PROFILE_FALLBACK; if (!retryTurn) { + const resumedModel = child.accessor.get(IAgentProfileService).data().modelAlias; emitAgentRunSpawned(caller, agentId, { profileName, parentToolCallId: options.parentToolCallId, @@ -220,6 +227,10 @@ export class SessionSwarmService implements ISessionSwarmService { description: options.description, swarmIndex: options.swarmIndex, runInBackground: options.runInBackground, + model: + resumedModel === undefined + ? undefined + : subagentDisplayModel(this.config, resumedModel), }); } const request = retryTurn diff --git a/packages/agent-core-v2/test/agent/profile/config-state.test.ts b/packages/agent-core-v2/test/agent/profile/config-state.test.ts index 5516868aa..510681f7a 100644 --- a/packages/agent-core-v2/test/agent/profile/config-state.test.ts +++ b/packages/agent-core-v2/test/agent/profile/config-state.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { SECONDARY_DERIVED_MODEL_ID } from '#/app/kosongConfig/secondaryModelOverlay'; import type { ModelRecord } from '#/kosong/model/model'; import { configServices, @@ -119,6 +120,19 @@ describe('ConfigState model capabilities', () => { }); }); + it('reports the recipe base alias when bound to the derived secondary entry', () => { + kimiConfig = { + providers: {}, + secondaryModel: { model: 'provider/secondary', defaultEffort: 'low' }, + } as TestKimiConfig; + + profile.update({ modelAlias: SECONDARY_DERIVED_MODEL_ID }); + + const statuses = ctx.allEvents.filter((entry) => entry.event === 'agent.status.updated'); + const last = statuses.at(-1)?.args as { model?: string }; + expect(last.model).toBe('provider/secondary'); + }); + it('omits maxContextTokens when the bound model no longer resolves', () => { // `update` accepts an alias without validating resolvability; a model entry // removed from config afterwards lands in the same state. The capabilities diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index d3a3b1000..8db191aef 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -80,6 +80,7 @@ import { resolveSubagentTimeoutMs, SUBAGENT_SECTION, SUBAGENT_TIMEOUT_ENV, + subagentDisplayModel, type SubagentConfig, wrapSubagentModelError, } from '#/session/subagent/configSection'; @@ -1746,10 +1747,12 @@ describe('subagent config section', () => { expect(resolveSubagentBinding(noModel.config, secondaryModelFlags(), own)).toEqual({ model: 'provider/main', thinking: 'medium', + displayModel: 'provider/main', }); expect(resolveSubagentBinding(noModel.config, secondaryModelFlags(), own, 'secondary')).toEqual({ model: 'provider/main', thinking: 'medium', + displayModel: 'provider/main', }); noModel.disposables.dispose(); @@ -1759,10 +1762,12 @@ describe('subagent config section', () => { expect(resolveSubagentBinding(withModel.config, secondaryModelFlags(), own)).toEqual({ model: 'provider/secondary', thinking: undefined, + displayModel: 'provider/secondary', }); expect(resolveSubagentBinding(withModel.config, secondaryModelFlags(), own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', + displayModel: 'provider/main', }); withModel.disposables.dispose(); @@ -1775,11 +1780,13 @@ describe('subagent config section', () => { expect(resolveSubagentBinding(withEffort.config, secondaryModelFlags(), own)).toEqual({ model: SECONDARY_DERIVED_MODEL_ID, thinking: 'low', + displayModel: 'provider/secondary', }); // default_effort only applies together with the secondary model. expect(resolveSubagentBinding(withEffort.config, secondaryModelFlags(), own, 'primary')).toEqual({ model: 'provider/main', thinking: 'medium', + displayModel: 'provider/main', }); withEffort.disposables.dispose(); @@ -1790,6 +1797,7 @@ describe('subagent config section', () => { expect(resolveSubagentBinding(withFactPatch.config, secondaryModelFlags(), own)).toEqual({ model: SECONDARY_DERIVED_MODEL_ID, thinking: undefined, + displayModel: 'provider/secondary', }); withFactPatch.disposables.dispose(); }); @@ -1804,11 +1812,41 @@ describe('subagent config section', () => { expect(resolveSubagentBinding(config, secondaryModelFlags(false), own)).toEqual({ model: 'provider/main', thinking: 'medium', + displayModel: 'provider/main', }); disposables.dispose(); }); + it('normalizes the derived entry to the recipe base alias regardless of the flag', async () => { + const withRecipe = await createConfig( + {}, + '[secondary_model]\nmodel = "provider/secondary"\ndefault_effort = "low"\n', + ); + expect(subagentDisplayModel(withRecipe.config, SECONDARY_DERIVED_MODEL_ID)).toBe( + 'provider/secondary', + ); + expect(subagentDisplayModel(withRecipe.config, 'provider/main')).toBe('provider/main'); + withRecipe.disposables.dispose(); + + const bare = await createConfig({}); + expect(subagentDisplayModel(bare.config, SECONDARY_DERIVED_MODEL_ID)).toBe( + SECONDARY_DERIVED_MODEL_ID, + ); + bare.disposables.dispose(); + }); + + it('normalizes an inherited derived alias on the caller-fallback branch', async () => { + const withRecipe = await createConfig({}, '[secondary_model]\nmodel = "provider/secondary"\n'); + const own = { modelAlias: SECONDARY_DERIVED_MODEL_ID, thinkingLevel: 'medium' }; + expect(resolveSubagentBinding(withRecipe.config, secondaryModelFlags(false), own)).toEqual({ + model: SECONDARY_DERIVED_MODEL_ID, + thinking: 'medium', + displayModel: 'provider/secondary', + }); + withRecipe.disposables.dispose(); + }); + it('preserves the coded error contract when adding secondary-model guidance', () => { const cause = new Error2( ErrorCodes.CONFIG_INVALID, diff --git a/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts b/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts index df80d910d..b77971926 100644 --- a/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts +++ b/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts @@ -97,7 +97,8 @@ describe('SessionInitService', () => { accessor: { get: (id: unknown) => { if (id === IAgentPermissionModeService) return permissionMode; - if (id === IAgentProfileService) return { republishStatus }; + if (id === IAgentProfileService) + return { republishStatus, getEffectiveThinkingLevel: () => 'off' }; return undefined; }, }, @@ -168,6 +169,8 @@ describe('SessionInitService', () => { subagentName: 'coder', parentToolCallId: 'generate-agents-md', callerAgentId: 'main', + model: 'mock-model', + thinkingEffort: 'off', }), ); expect(republishStatus).toHaveBeenCalledTimes(1); diff --git a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts index 9d02b6c1d..7c58badef 100644 --- a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts @@ -13,6 +13,10 @@ import { IAgentProfileService, type ProfileData } from '#/agent/profile/profile' import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IEventBus, type DomainEvent } from '#/app/event/eventBus'; +import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; +import { SECONDARY_MODEL_SECTION } from '#/app/kosongConfig/configSection'; +import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { normalizeAgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { APIProviderRateLimitError } from '#/kosong/contract/errors'; @@ -53,6 +57,8 @@ import { ConfigErrors } from '#/app/config/errors'; import { SessionSwarmService } from '#/session/swarm/sessionSwarmService'; import { stubLog } from '../../_base/log/stubs'; +import { stubFlag } from '../../app/flag/stubs'; +import { StubConfigService } from '../../kosong/stubs'; describe('resolveSwarmMaxConcurrency', () => { it('returns undefined when the variable is unset', () => { @@ -936,6 +942,8 @@ describe('SessionSwarmService metadata compatibility', () => { }, }); ix.stub(ILogService, stubLog()); + ix.stub(IConfigService, new StubConfigService({})); + ix.stub(IFlagService, stubFlag(() => false)); ix.stub(IModelCatalog, { _serviceBrand: undefined, get: (alias: string) => { @@ -1126,6 +1134,14 @@ describe('SessionSwarmService metadata compatibility', () => { // No realign: resume must not drag the child back to the parent's model. expect(child.accessor.get(IAgentProfileService).data().modelAlias).toBe('stale-model'); + expect(eventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'subagent.spawned', + subagentId: 'agent-existing', + model: 'stale-model', + thinkingEffort: 'medium', + }), + ); expect(runAgent).toHaveBeenCalledWith( 'agent-existing', { kind: 'prompt', prompt: 'Continue' }, @@ -1157,6 +1173,46 @@ describe('SessionSwarmService metadata compatibility', () => { }, }), ); + expect(eventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'subagent.spawned', + subagentId: 'agent-new', + model: 'provider/secondary', + thinkingEffort: 'low', + }), + ); + }); + + it('emits the recipe base alias (never the derived entry id) as the spawned display model', async () => { + ix.stub( + IConfigService, + new StubConfigService({ + [SECONDARY_MODEL_SECTION]: { model: 'provider/base', defaultEffort: 'low' }, + }), + ); + ix.stub(IFlagService, stubFlag((id) => id === SECONDARY_MODEL_FLAG_ID)); + const service = ix.get(ISessionSwarmService); + const spawnTask: SessionSwarmSpawnTask = { + ...spawnSessionTask('src/a.ts'), + kind: 'spawn', + binding: { model: '__secondary__', thinking: 'low' }, + }; + + await expect( + service.run({ + callerAgentId: 'main', + tasks: [spawnTask], + }), + ).resolves.toMatchObject([{ status: 'completed', agentId: 'agent-new' }]); + + expect(eventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'subagent.spawned', + subagentId: 'agent-new', + model: 'provider/base', + thinkingEffort: 'low', + }), + ); }); it('points at the secondary model config when a spawn task binding is invalid', async () => { @@ -1411,6 +1467,7 @@ function profileService(data: ProfileData): IAgentProfileService { current = { ...current, ...changed }; }, republishStatus: () => {}, + getEffectiveThinkingLevel: () => current.thinkingLevel, } as IAgentProfileService; } diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 905367a5a..183e2cb43 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -224,6 +224,8 @@ interface AgentLifecycleStub extends IAgentLifecycleService, ISessionSubagentSer readonly create: ReturnType>; readonly run: ReturnType>; readonly get: ReturnType>; + /** Domain events published through any handle's event-bus stub. */ + readonly publishedEvents: DomainEvent[]; addHandle( agentId: string, profileName: string, @@ -237,6 +239,7 @@ function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): Agen const profileByAgentId = new Map(); const handles = new Map(); const servicesByAgentId = new Map(options.handleServices); + const publishedEvents: DomainEvent[] = []; const handle = (agentId: string): IAgentScopeHandle => ({ id: agentId, kind: LifecycleScope.Agent, @@ -264,6 +267,7 @@ function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): Agen data: () => ({ profileName: profileByAgentId.get(agentId) }), update: () => {}, republishStatus: () => {}, + getEffectiveThinkingLevel: () => 'off', isToolActive: () => false, } as never; } @@ -299,7 +303,9 @@ function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): Agen if (serviceId === IEventBus) { return { _serviceBrand: undefined, - publish: () => {}, + publish: (event: DomainEvent) => { + publishedEvents.push(event); + }, subscribe: () => noopDisposable(), } as never; } @@ -366,6 +372,7 @@ function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): Agen if (services !== undefined) servicesByAgentId.set(agentId, services); handles.set(agentId, handle(agentId)); }, + publishedEvents, }; return lifecycle; } @@ -1036,6 +1043,32 @@ describe('Agent tool execution contract', () => { ); }); + it('reports the display-normalized model on the spawned signal', async () => { + const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); + const context = createAgentToolContext( + lifecycle, + secondaryModelFlags(), + { + initialConfig: { + secondaryModel: { model: 'provider/secondary', defaultEffort: 'low' }, + }, + }, + ); + + await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + }); + + expect(lifecycle.publishedEvents).toContainEqual( + expect.objectContaining({ + type: 'subagent.spawned', + subagentId: 'agent-child', + model: 'provider/secondary', + }), + ); + }); + it('binds the pointed entry directly with natural thinking when the recipe has no patch', async () => { const lifecycle = createAgentLifecycleStub({ createAgentIds: ['agent-child'] }); const context = createAgentToolContext(lifecycle, secondaryModelFlags(), { @@ -1251,6 +1284,7 @@ describe('Agent tool execution contract', () => { profileName: 'explore', parentToolCallId: 'call_agent', runInBackground: false, + model: 'provider/secondary', }); await mirrorAgentRun( requester, @@ -1269,6 +1303,8 @@ describe('Agent tool execution contract', () => { expect(events.find((event) => event.type === 'subagent.spawned')).toMatchObject({ parentAgentId: 'main', callerAgentId: 'main', + model: 'provider/secondary', + thinkingEffort: 'off', }); expect(telemetryRecords).toContainEqual({ event: 'subagent_created', @@ -1464,6 +1500,7 @@ describe('Agent tool execution contract', () => { data: () => ({ profileName: 'explore', modelAlias: 'stale-model' }), update: vi.fn(), republishStatus: vi.fn(), + getEffectiveThinkingLevel: () => 'medium', isToolActive: () => false, } as unknown as IAgentProfileService; const lifecycle = createAgentLifecycleStub({ diff --git a/packages/agent-core/src/agent/background/agent-task.ts b/packages/agent-core/src/agent/background/agent-task.ts index c31796803..35c5d1a2f 100644 --- a/packages/agent-core/src/agent/background/agent-task.ts +++ b/packages/agent-core/src/agent/background/agent-task.ts @@ -12,6 +12,10 @@ export interface AgentBackgroundTaskInfo extends BackgroundTaskInfoBase { readonly agentId?: string; /** Subagent profile name. */ readonly subagentType?: string; + /** Display-normalized bound model alias (populated by the v2 engine). */ + readonly model?: string; + /** The subagent's effective thinking effort at spawn (v2 engine). */ + readonly thinkingEffort?: string; } export class AgentBackgroundTask implements BackgroundTask { diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 3fa18caa9..bb8361779 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -434,6 +434,8 @@ export const agentTaskInfoSchema = taskInfoBaseSchema.extend({ kind: z.literal('agent'), agentId: z.string().optional(), subagentType: z.string().optional(), + model: z.string().optional(), + thinkingEffort: z.string().optional(), }); export const questionTaskInfoSchema = taskInfoBaseSchema.extend({ @@ -811,6 +813,8 @@ export const subagentSpawnedEventSchema = z.object({ description: z.string().optional(), swarmIndex: z.number().optional(), runInBackground: z.boolean(), + model: z.string().optional(), + thinkingEffort: z.string().optional(), }) satisfies z.ZodType; export const subagentStartedEventSchema = z.object({ diff --git a/packages/kap-server/src/protocol/task.ts b/packages/kap-server/src/protocol/task.ts index 7d64135e8..f1a84ed65 100644 --- a/packages/kap-server/src/protocol/task.ts +++ b/packages/kap-server/src/protocol/task.ts @@ -25,5 +25,10 @@ export const taskSchema = z.object({ completed_at: isoDateTimeSchema.optional(), output_preview: z.string().optional(), output_bytes: z.number().int().nonnegative().optional(), + /** Subagent tasks only: the display-normalized model alias the child agent + * is bound to. */ + model: z.string().optional(), + /** Subagent tasks only: the child's effective thinking effort at spawn. */ + thinking_effort: z.string().optional(), }); export type Task = z.infer; diff --git a/packages/kap-server/src/routes/tasks.ts b/packages/kap-server/src/routes/tasks.ts index e2f6e6804..e83bf9a0d 100644 --- a/packages/kap-server/src/routes/tasks.ts +++ b/packages/kap-server/src/routes/tasks.ts @@ -369,6 +369,12 @@ function toWireTask( if (info.kind === 'process' && 'command' in info && typeof info.command === 'string') { base.command = info.command; } + if (info.kind === 'agent' && info.model !== undefined) { + base.model = info.model; + } + if (info.kind === 'agent' && info.thinkingEffort !== undefined) { + base.thinking_effort = info.thinkingEffort; + } if (output !== undefined) { base.output_preview = output.preview; base.output_bytes = output.bytes; diff --git a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts index 756ad8ab2..c64c93229 100644 --- a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts +++ b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts @@ -71,6 +71,8 @@ export class SubagentRosterTracker { parent_tool_call_id: event.parentToolCallId === '' ? undefined : event.parentToolCallId, swarm_index: event.swarmIndex, run_in_background: event.runInBackground, + model: event.model, + thinking_effort: event.thinkingEffort, created_at: new Date().toISOString(), }); return; diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 2cd0025b5..660f10a54 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -943,6 +943,8 @@ describe('SessionEventBroadcaster', () => { description: 'task agent-1', swarmIndex: 0, runInBackground: false, + model: 'provider/secondary', + thinkingEffort: 'low', }), ); main.bus.emit(agentEvent('subagent.started', { subagentId: 'agent-1' })); @@ -957,6 +959,8 @@ describe('SessionEventBroadcaster', () => { parent_tool_call_id: 'tc_swarm_1', swarm_index: 0, run_in_background: false, + model: 'provider/secondary', + thinking_effort: 'low', }), ]); diff --git a/packages/kap-server/test/subagentRosterTracker.test.ts b/packages/kap-server/test/subagentRosterTracker.test.ts index dbfb0ec01..bfdebbcc9 100644 --- a/packages/kap-server/test/subagentRosterTracker.test.ts +++ b/packages/kap-server/test/subagentRosterTracker.test.ts @@ -29,7 +29,7 @@ function spawn(subagentId: string, extra: Record = {}): Event { describe('SubagentRosterTracker', () => { it('seeds a roster entry from subagent.spawned with the swarm identity metadata', () => { const t = new SubagentRosterTracker(); - t.apply(SID, spawn('agent-1', { swarmIndex: 2 })); + t.apply(SID, spawn('agent-1', { swarmIndex: 2, model: 'provider/secondary', thinkingEffort: 'low' })); expect(t.get(SID)).toEqual([ expect.objectContaining({ @@ -43,6 +43,8 @@ describe('SubagentRosterTracker', () => { parent_tool_call_id: 'tc_swarm_1', swarm_index: 2, run_in_background: false, + model: 'provider/secondary', + thinking_effort: 'low', }), ]); }); diff --git a/packages/kap-server/test/tasks.test.ts b/packages/kap-server/test/tasks.test.ts index 8a56fc007..e70ddf50b 100644 --- a/packages/kap-server/test/tasks.test.ts +++ b/packages/kap-server/test/tasks.test.ts @@ -153,7 +153,14 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => { case 'process': return { ...base, kind: 'process', command: 'echo hi', pid: 0, exitCode: null }; case 'agent': - return { ...base, kind: 'agent', agentId: 'sub-1', subagentType: 'explore' }; + return { + ...base, + kind: 'agent', + agentId: 'sub-1', + subagentType: 'explore', + model: 'provider/secondary', + thinkingEffort: 'low', + }; case 'question': return { ...base, kind: 'question', questionCount: 1 }; } @@ -205,6 +212,8 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => { session_id: id, kind: 'subagent', // agent → subagent status: 'running', + model: 'provider/secondary', // subagent tasks expose the bound display model + thinking_effort: 'low', // …and its effective thinking effort }); expect(byId.get(agentId)?.command).toBeUndefined(); diff --git a/packages/klient/src/contract/agent/rpc.ts b/packages/klient/src/contract/agent/rpc.ts index a61def2b4..53a5a4a67 100644 --- a/packages/klient/src/contract/agent/rpc.ts +++ b/packages/klient/src/contract/agent/rpc.ts @@ -175,6 +175,8 @@ export const agentTaskInfoSchema = z.discriminatedUnion('kind', [ kind: z.literal('agent'), agentId: z.string().optional(), subagentType: z.string().optional(), + model: z.string().optional(), + thinkingEffort: z.string().optional(), ...taskInfoBaseFields, }), z.object({ diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 6a90a6cc9..f630df5ac 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -373,6 +373,10 @@ export interface AgentTaskInfo extends TaskInfoBase { readonly kind: 'agent'; readonly agentId?: string; readonly subagentType?: string; + /** Display-normalized bound model alias (populated by the v2 engine). */ + readonly model?: string; + /** The subagent's effective thinking effort at spawn (v2 engine). */ + readonly thinkingEffort?: string; } export interface QuestionTaskInfo extends TaskInfoBase { @@ -794,6 +798,13 @@ export interface SubagentSpawnedEvent { readonly description?: string; readonly swarmIndex?: number; readonly runInBackground: boolean; + /** Model alias the child is bound to, display-normalized (the derived + * `__secondary__` entry resolves to its base alias). Optional so older + * producers/consumers stay wire-compatible. */ + readonly model?: string; + /** The child's effective thinking effort at spawn (same vocabulary as + * `agent.status.updated`). Optional for cross-version tolerance. */ + readonly thinkingEffort?: string; } export interface SubagentStartedEvent { @@ -1335,6 +1346,8 @@ export const agentTaskInfoSchema = taskInfoBaseSchema.extend({ kind: z.literal('agent'), agentId: z.string().optional(), subagentType: z.string().optional(), + model: z.string().optional(), + thinkingEffort: z.string().optional(), }) satisfies z.ZodType; export const questionTaskInfoSchema = taskInfoBaseSchema.extend({ @@ -1691,6 +1704,8 @@ export const subagentSpawnedEventSchema = z.object({ description: z.string().optional(), swarmIndex: z.number().optional(), runInBackground: z.boolean(), + model: z.string().optional(), + thinkingEffort: z.string().optional(), }) satisfies z.ZodType; export const subagentStartedEventSchema = z.object({ diff --git a/packages/protocol/src/task.ts b/packages/protocol/src/task.ts index bc08eec71..e18f69817 100644 --- a/packages/protocol/src/task.ts +++ b/packages/protocol/src/task.ts @@ -25,6 +25,11 @@ export const taskSchema = z.object({ completed_at: isoDateTimeSchema.optional(), output_preview: z.string().optional(), output_bytes: z.number().int().nonnegative().optional(), + /** Subagent tasks only: the display-normalized model alias the child agent + * is bound to. */ + model: z.string().optional(), + /** Subagent tasks only: the child's effective thinking effort at spawn. */ + thinking_effort: z.string().optional(), }); export type Task = z.infer;