mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-06 15:26:26 +00:00
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:
parent
013203421d
commit
7b2784b9b7
39 changed files with 742 additions and 33 deletions
5
.changeset/subagent-model-display.md
Normal file
5
.changeset/subagent-model-display.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@moonshot-ai/kimi-code': patch
|
||||
---
|
||||
|
||||
Subagent UIs now show each subagent's bound model and thinking effort.
|
||||
5
.changeset/subagent-model-sdk-fields.md
Normal file
5
.changeset/subagent-model-sdk-fields.md
Normal 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.
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue