Skip to content

Commit f36c127

Browse files
authored
fix: hide AgentSwarm calls rejected before any subagent starts (#4414)
While swarm mode is off the core rejects AgentSwarm before any subagent starts, but the TUI still drew the call as a swarm whose members all failed, and resumed sessions replayed it as a failed swarm card. - Do not create the swarm panel while swarm mode is off, and remove the panel when a call ends before any subagent started (swarm mode off, input validation, a rejected approval, or a blocking hook). - Create the panel when the first subagent spawns, so a swarm that does run is never hidden when the TUI's swarm state lags behind. - Skip such calls when replaying a session. - Tell the model to point the user to /swarm only when they asked for a swarm, and otherwise to continue without mentioning swarm mode.
1 parent b8dd3a1 commit f36c127

12 files changed

Lines changed: 318 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
Stop showing a failed swarm for `AgentSwarm` calls rejected before any subagent starts, such as while swarm mode is off; after such a rejection, the agent points you to `/swarm` if you asked for a swarm.

‎apps/kimi-code/src/tui/components/messages/agent-swarm-progress.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,10 @@ export class AgentSwarmProgressComponent implements Component {
295295
return !this.inputComplete;
296296
}
297297

298+
hasSubagents(): boolean {
299+
return this.membersByAgentId.size > 0;
300+
}
301+
298302
updateArgs(
299303
args: Record<string, unknown>,
300304
options: { readonly streamingArguments?: string | undefined } = {},
@@ -1192,6 +1196,20 @@ export function agentSwarmResultSummaryFromOutput(output: string): AgentSwarmRes
11921196
};
11931197
}
11941198

1199+
/**
1200+
* Whether the output of a failed AgentSwarm call is a rejection that ended the
1201+
* call before any subagent started, such as a call made while swarm mode is
1202+
* off or one that failed input validation. The rejection is addressed to the
1203+
* model, so the transcript shows no swarm for it. Cancellations keep their
1204+
* aborted rendering.
1205+
*/
1206+
export function isAgentSwarmRejection(output: string): boolean {
1207+
return (
1208+
!/\b(?:aborted|cancelled|interrupted)\b/i.test(output) &&
1209+
!agentSwarmResultSummaryFromOutput(output).parsed
1210+
);
1211+
}
1212+
11951213
function parseAgentSwarmResultStatuses(output: string): AgentSwarmResultStatus[] {
11961214
const xmlStatuses = parseAgentSwarmXmlResultStatuses(output);
11971215
if (xmlStatuses.length > 0) return xmlStatuses;

‎apps/kimi-code/src/tui/controllers/session-replay.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
ToolCall,
99
} from '@moonshot-ai/kimi-code-sdk';
1010

11+
import { isAgentSwarmRejection } from '../components/messages/agent-swarm-progress';
1112
import { ToolCallComponent } from '../components/messages/tool-call';
1213
import { ShellRunComponent } from '../components/messages/shell-run';
1314
import { ReplayTurnBoundaryComponent } from '../components/messages/user-message';
@@ -511,6 +512,10 @@ export class SessionReplayRenderer {
511512
output: toolResultOutput(message.content),
512513
is_error: message.isError,
513514
};
515+
if (call.name === 'AgentSwarm' && result.is_error === true && isAgentSwarmRejection(result.output)) {
516+
this.host.streamingUI.discardToolCall(toolCallId);
517+
return;
518+
}
514519
call.result = result;
515520
this.applyStepContext(context);
516521
this.host.streamingUI.onToolCallEnd(toolCallId, result);

‎apps/kimi-code/src/tui/controllers/streaming-ui.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,18 @@ export class StreamingUIController {
193193
}
194194
}
195195

196+
/** Drops a tool call together with its card, as if it had never rendered. */
197+
discardToolCall(toolCallId: string): void {
198+
const component = this._pendingToolComponents.get(toolCallId);
199+
this._pendingToolComponents.delete(toolCallId);
200+
this._activeToolCalls.delete(toolCallId);
201+
if (component === undefined) return;
202+
const children = this.host.state.transcriptContainer.children;
203+
const index = children.indexOf(component);
204+
if (index >= 0) children.splice(index, 1);
205+
this.host.state.ui.requestRender();
206+
}
207+
196208
/**
197209
* Push the actual terminal status of a background agent task into the
198210
* matching `Agent` tool call component so its snapshot phase no longer

‎apps/kimi-code/src/tui/controllers/subagent-event-handler.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
AgentSwarmProgressComponent,
99
agentSwarmDescriptionFromArgs,
1010
agentSwarmGridHeightForTerminalRows,
11+
isAgentSwarmRejection,
1112
} from '../components/messages/agent-swarm-progress';
1213
import { modelDisplayName } from '../components/dialogs/model-selector';
1314
import { MAIN_AGENT_ID } from '../constant/kimi-tui';
@@ -213,6 +214,7 @@ export class SubAgentEventHandler {
213214
toolCallId: string,
214215
args: Record<string, unknown>,
215216
): void {
217+
if (!this.showsAgentSwarmProgress(toolCallId)) return;
216218
const progress = this.ensureAgentSwarmProgress(toolCallId, args);
217219
progress.markInputComplete();
218220
this.requestRender();
@@ -223,6 +225,7 @@ export class SubAgentEventHandler {
223225
args: Record<string, unknown>,
224226
options: { readonly streamingArguments?: string | undefined },
225227
): void {
228+
if (!this.showsAgentSwarmProgress(toolCallId)) return;
226229
this.ensureAgentSwarmProgress(toolCallId, args, options);
227230
this.requestRender();
228231
}
@@ -235,7 +238,9 @@ export class SubAgentEventHandler {
235238
const progress = this.agentSwarmProgress.get(toolCallId);
236239
if (progress === undefined) return;
237240

238-
if (isError && isUserCancelledSubagentError(resultData.output)) {
241+
if (isError && !progress.hasSubagents() && isAgentSwarmRejection(resultData.output)) {
242+
this.removeAgentSwarmProgress(toolCallId, progress);
243+
} else if (isError && isUserCancelledSubagentError(resultData.output)) {
239244
if (progress.isRequestStreaming()) {
240245
this.removeAgentSwarmProgress(toolCallId, progress);
241246
} else {
@@ -514,6 +519,7 @@ export class SubAgentEventHandler {
514519
// in-run update/fallback path.
515520
const modelDisplay = this.spawnedModelDisplay(event);
516521
const effortDisplay = this.subagentEffortDisplay(event.thinkingEffort);
522+
this.ensureStartedAgentSwarmProgress(event.parentToolCallId);
517523
if (this.updateAgentSwarmProgress(event.parentToolCallId, (progress) => {
518524
progress.registerSubagent({
519525
agentId: event.subagentId,
@@ -678,6 +684,24 @@ export class SubAgentEventHandler {
678684
return true;
679685
}
680686

687+
/**
688+
* While swarm mode is off the core rejects AgentSwarm before any subagent
689+
* starts, so a new call gets no panel; a panel already on screen keeps
690+
* following its call.
691+
*/
692+
private showsAgentSwarmProgress(toolCallId: string): boolean {
693+
return this.host.state.appState.swarmMode || this.agentSwarmProgress.has(toolCallId);
694+
}
695+
696+
/** A swarm that spawns subagents always gets a panel, even when its call
697+
* streamed while swarm mode still showed as off. */
698+
private ensureStartedAgentSwarmProgress(parentToolCallId: string): void {
699+
if (this.agentSwarmProgress.has(parentToolCallId)) return;
700+
const toolCall = this.host.streamingUI.getActiveToolCall(parentToolCallId);
701+
if (toolCall?.name !== 'AgentSwarm') return;
702+
this.ensureAgentSwarmProgress(parentToolCallId, toolCall.args).markInputComplete();
703+
}
704+
681705
private ensureAgentSwarmProgress(
682706
toolCallId: string,
683707
args: Record<string, unknown>,

‎apps/kimi-code/test/tui/activity-pane.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ function makeDriverWithTerminalProgress(): {
5959

6060
function startSwarmProgress(driver: ActivityDriver, state: TUIState): AgentSwarmProgressComponent {
6161
const handler = driver.sessionEventHandler.subAgentEventHandler;
62+
state.appState = { ...state.appState, swarmMode: true };
6263
handler.handleAgentSwarmToolCallStarted('call_swarm', {
6364
description: 'Review changed files',
6465
});

‎apps/kimi-code/test/tui/components/messages/agent-swarm-progress.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
agentSwarmPartialItemsCountFromArguments,
1212
agentSwarmPartialItemsFromArguments,
1313
calculateAgentSwarmGridLayout,
14+
isAgentSwarmRejection,
1415
} from '#/tui/components/messages/agent-swarm-progress';
1516
import { AgentSwarmProgressEstimator } from '#/tui/components/messages/agent-swarm-progress-estimator';
1617
import { currentTheme, darkColors, lightColors } from '#/tui/theme';
@@ -156,6 +157,28 @@ describe('calculateAgentSwarmGridLayout', () => {
156157
});
157158
});
158159

160+
describe('isAgentSwarmRejection', () => {
161+
it('matches failures that ended the call before any subagent started', () => {
162+
expect(isAgentSwarmRejection(
163+
'Swarm mode is off, so AgentSwarm did not run and no subagents were started.',
164+
)).toBe(true);
165+
expect(isAgentSwarmRejection(
166+
'AgentSwarm requires at least 2 items unless resume_agent_ids is provided.',
167+
)).toBe(true);
168+
});
169+
170+
it('does not match per-subagent results or cancellations', () => {
171+
expect(isAgentSwarmRejection([
172+
'<agent_swarm_result>',
173+
'<summary>failed: 1</summary>',
174+
'<subagent index="1" outcome="failed">Agent timed out.</subagent>',
175+
'</agent_swarm_result>',
176+
].join('\n'))).toBe(false);
177+
expect(isAgentSwarmRejection('Aborted by the user')).toBe(false);
178+
expect(isAgentSwarmRejection('The user manually interrupted this subagent batch.')).toBe(false);
179+
});
180+
});
181+
159182
describe('AgentSwarmProgressComponent', () => {
160183
it('renders an orchestrating panel before subagents spawn', () => {
161184
const component = createComponent();

‎apps/kimi-code/test/tui/controllers/subagent-event-handler.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ function makeSwarmHandler() {
1313
const dockChild = { render: vi.fn(() => ['dock line']) };
1414
const host = {
1515
state: {
16-
appState: { availableModels: {} },
16+
appState: { availableModels: {}, swarmMode: true },
1717
ui: {
1818
requestRender,
1919
terminal: { rows: 40, columns: 120 },

0 commit comments

Comments
 (0)