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;