feat: surface the bound model and thinking effort on subagent UIs (#2679)

* 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.
This commit is contained in:
liruifengv 2026-08-06 16:59:45 +08:00 committed by GitHub
parent 013203421d
commit 7b2784b9b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 742 additions and 33 deletions

View file

@ -0,0 +1,5 @@
---
'@moonshot-ai/kimi-code': patch
---
Subagent UIs now show each subagent's bound model and thinking effort.

View file

@ -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.

View file

@ -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) {

View file

@ -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));

View file

@ -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);

View file

@ -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<typeof setInterval> | 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));

View file

@ -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',

View file

@ -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);
}
}

View file

@ -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';

View file

@ -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,

View file

@ -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<string, Background
export function replayBackgroundProjection(
background: readonly BackgroundTaskInfo[],
availableModels?: AppState['availableModels'],
): ReplayBackgroundProjection {
const backgroundAgentMetadata = new Map<string, BackgroundAgentMetadata>();
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 };

View file

@ -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();

View file

@ -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<string, unknown> = {}): 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();
});
});

View file

@ -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<TasksBrowserProps> = {
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({

View file

@ -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';

View file

@ -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,

View file

@ -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);

View file

@ -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 })),
};
}

View file

@ -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<SubagentCompletion>;
};
@ -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<void> {
@ -109,6 +129,8 @@ export class SubagentTask implements AgentTask {
kind: 'agent',
agentId: this.agentId,
subagentType: this.subagentType,
model: this.model,
thinkingEffort: this.thinkingEffort,
};
}
}

View file

@ -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(

View file

@ -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<SecondaryModelConfig | undefined>(SECONDARY_MODEL_SECTION)?.model ?? boundAlias
);
}
export function buildSubagentModelDescriptions(

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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);

View file

@ -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;
}

View file

@ -224,6 +224,8 @@ interface AgentLifecycleStub extends IAgentLifecycleService, ISessionSubagentSer
readonly create: ReturnType<typeof vi.fn<IAgentLifecycleService['create']>>;
readonly run: ReturnType<typeof vi.fn<ISessionSubagentService['run']>>;
readonly get: ReturnType<typeof vi.fn<IAgentLifecycleService['get']>>;
/** 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<string, string>();
const handles = new Map<string, IAgentScopeHandle>();
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({

View file

@ -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 {

View file

@ -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<SubagentSpawnedEvent>;
export const subagentStartedEventSchema = z.object({

View file

@ -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<typeof taskSchema>;

View file

@ -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;

View file

@ -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;

View file

@ -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',
}),
]);

View file

@ -29,7 +29,7 @@ function spawn(subagentId: string, extra: Record<string, unknown> = {}): 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',
}),
]);
});

View file

@ -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();

View file

@ -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({

View file

@ -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<AgentTaskInfo>;
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<SubagentSpawnedEvent>;
export const subagentStartedEventSchema = z.object({

View file

@ -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<typeof taskSchema>;