From c5e3e80041a763143934c271d2524ac555d48d2a Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 6 Jul 2026 22:05:05 +0800 Subject: [PATCH] feat(web): render AgentSwarm as an inline tool card (#1425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): render AgentSwarm as an inline tool card Replace the bottom SwarmCard footer and the messagesToTurns live-skip with one dedicated inline tool card for AgentSwarm. The card shows a phase overview plus a per-subagent accordion: live progress while it runs, parsed aggregated result once it completes (and after a refresh that has already dropped the live tasks). Refresh and resync keep member identity metadata (swarmIndex, parentToolCallId, subagentType, runInBackground) stable across skeleton task replacement in the reducer, and the .content-wrap flex layout is hardened against the overflow compression that previously displaced the footer. * fix(web): handle swarm review feedback - SwarmTool: when AgentSwarm fails before producing a structured agent_swarm_result (e.g. argument validation), render the raw tool output instead of the "waiting for subagents" placeholder so the failure cause is visible. - resolveSwarmMembers: source live members from the AppTask store keyed by parentToolCallId rather than buildSwarmGroups, which filters out single-member groups. A resume-only AgentSwarm now streams its live progress before the final result arrives. The badge counter still relies on buildSwarmGroups's filter. * fix(web): carry streamed subagent text into swarm rows Swarm subagents that stream normal assistant output accumulate it on AppTask.text (text-kind taskProgress), not outputLines. The new live member map was dropping `text`, so a still-composing subagent rendered an empty / stale row until the structured result arrived. - Add `text` to SwarmMember and thread it through buildSwarmGroups and swarmMembersByToolCall. - SwarmTool: prefer member.text for both the row activity preview and the expanded body; fall back to outputLines / summary. - Tests cover text propagation through both helpers. * fix(web): merge swarm result rows and fall back to raw output Address the two latest swarm review comments: - Rows: when a parsed agent_swarm_result coexists with live AppTasks (which the detail panel also depends on), the inline card previously only rendered the live members. Interrupted swarms can carry state="not_started" / outcome="aborted" result entries for items that never spawned a task; those rows were dropped until a refresh cleared the live tasks. Extract the row model into buildSwarmCardRows and merge result-only aborted/not-started rows with the live member rows. - Fallback: when the tool is no longer running but produced no structured result (argument validation, parser miss, or legacy legacy transcript), render the raw tool output instead of "Waiting for subagents…" so the final text / failure cause is visible to the user. * fix(web): parse swarm result subagent bodies defensively Producer writes subagent body unescaped, so a subagent that analyzes or emits an AgentSwarm snippet can include a literal "" inside its body. The non-greedy regex treated that as the row close and truncated the body in the result-only path (post-refresh where the AppTask store is gone). Rewrite the parser to scan opening tags, then resolve each row's body as everything up to the last "" before the next row's opening tag (or document end), preserving embedded close-tag strings. Add tests for a literal "" within a single body and across sibling rows. * fix(web): only count top-level subagent result tags A subagent body that contains a literal `` tag — for example emitting an AgentSwarm/XML snippet — was being pre-collected as another result row, splitting the real body and producing duplicate / bogus subagents after refresh where the AppTask store is gone. Rewrite parseSubagents with a depth-tracking tokenizer: scan every `` / `` token in order, push a real row frame only at the outermost level, and treat openings / closings while nested inside another body as body text. Drop the now-inaccurate "literal without matching open" regression tests; replace with tests that verify a balanced nested snippet stays inside the parent body and does not register as a separate row. --- .changeset/web-swarm-inline-tool-card.md | 5 + apps/kimi-web/src/App.vue | 11 +- apps/kimi-web/src/api/daemon/eventReducer.ts | 11 +- .../kimi-web/src/components/chat/ChatPane.vue | 20 +- .../src/components/chat/ConversationPane.vue | 34 +- .../src/components/chat/SwarmCard.vue | 208 -------- .../components/chat/tool-calls/SwarmTool.vue | 481 ++++++++++++++++++ .../chat/tool-calls/toolRegistry.ts | 2 + .../src/composables/client/useSideChat.ts | 1 - .../src/composables/messagesToTurns.ts | 41 -- apps/kimi-web/src/composables/swarmGroups.ts | 39 ++ .../src/composables/useKimiWebClient.ts | 11 +- apps/kimi-web/src/i18n/locales/en/tools.ts | 12 + apps/kimi-web/src/i18n/locales/zh/tools.ts | 12 + apps/kimi-web/src/lib/parseSwarmResult.ts | 128 +++++ apps/kimi-web/src/lib/swarmCardRows.ts | 100 ++++ apps/kimi-web/src/lib/toolMeta.ts | 2 + apps/kimi-web/test/event-reducer.test.ts | 32 ++ apps/kimi-web/test/swarm-card-rows.test.ts | 115 +++++ apps/kimi-web/test/swarm-groups.test.ts | 127 +++++ apps/kimi-web/test/swarm-result.test.ts | 72 +++ apps/kimi-web/test/turn-logic.test.ts | 60 +-- 22 files changed, 1163 insertions(+), 361 deletions(-) create mode 100644 .changeset/web-swarm-inline-tool-card.md delete mode 100644 apps/kimi-web/src/components/chat/SwarmCard.vue create mode 100644 apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue create mode 100644 apps/kimi-web/src/lib/parseSwarmResult.ts create mode 100644 apps/kimi-web/src/lib/swarmCardRows.ts create mode 100644 apps/kimi-web/test/swarm-card-rows.test.ts create mode 100644 apps/kimi-web/test/swarm-groups.test.ts create mode 100644 apps/kimi-web/test/swarm-result.test.ts diff --git a/.changeset/web-swarm-inline-tool-card.md b/.changeset/web-swarm-inline-tool-card.md new file mode 100644 index 000000000..cb1b55185 --- /dev/null +++ b/.changeset/web-swarm-inline-tool-card.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +web: Replace the swarm footer with a single inline tool card that shows live subagent progress and the aggregated result, and keep swarm badges stable after refresh. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index f8e28149f..2e6133f3f 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -34,6 +34,7 @@ import { useFilePreview, type DetailTarget } from './composables/useFilePreview' import { useDetailPanel } from './composables/useDetailPanel'; import { useIsMobile } from './composables/useIsMobile'; import { openDialogCount } from './composables/dialogStack'; +import type { SwarmMember } from './composables/swarmGroups'; import ServerAuthDialog from './components/ServerAuthDialog.vue'; import { initServerAuth, onAuthRequired } from './api/daemon/serverAuth'; import type { AppConfig, ThinkingLevel } from './api/types'; @@ -55,6 +56,15 @@ const showServerAuth = computed( () => !client.dangerousBypassAuth.value && authRequired.value, ); provide('resolveImage', client.resolveImageUrl); +// Live swarm member roster for the inline AgentSwarm tool card. Sourced from the +// AppTask store so the card shows each subagent's live phase; on refresh the +// tasks are gone and the card falls back to the parsed tool result. Includes +// single-member "swarms" (e.g. AgentSwarm with one resume_agent_ids entry), +// which buildSwarmGroups filters out for the badge counter. +provide( + 'resolveSwarmMembers', + (toolCallId: string): SwarmMember[] => client.swarmMembersByToolCallId.value.get(toolCallId) ?? [], +); const { t } = useI18n(); // KAP/daemon debug panel — opt-in via ?debug=1 or localStorage kimi-web.debug=1. @@ -681,7 +691,6 @@ function openPr(url: string): void { :tasks="client.tasks.value" :todos="client.todos.value" :goal="client.goal.value" - :swarms="client.swarms.value" :activation-badges="client.activationBadges.value" :status="client.status.value" :thinking="client.thinking.value" diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 5bec65098..69b400038 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -539,12 +539,19 @@ export function reduceAppEvent( next.tasksBySession[sid] = [...list, event.task]; } else { const patched = [...list]; + const previous = list[idx]!; // The projected task does not carry reducer-owned accumulated progress; // preserve it across the replacement so subagent output keeps growing. + // A resync also rebuilds skeleton tasks without their identity metadata, + // so keep the previous value when the projected task omits it. patched[idx] = { ...event.task, - outputLines: list[idx]!.outputLines, - text: list[idx]!.text, + outputLines: previous.outputLines, + text: previous.text, + swarmIndex: event.task.swarmIndex ?? previous.swarmIndex, + parentToolCallId: event.task.parentToolCallId ?? previous.parentToolCallId, + subagentType: event.task.subagentType ?? previous.subagentType, + runInBackground: event.task.runInBackground ?? previous.runInBackground, }; next.tasksBySession[sid] = patched; } diff --git a/apps/kimi-web/src/components/chat/ChatPane.vue b/apps/kimi-web/src/components/chat/ChatPane.vue index 3b9335546..5ea1f1990 100644 --- a/apps/kimi-web/src/components/chat/ChatPane.vue +++ b/apps/kimi-web/src/components/chat/ChatPane.vue @@ -541,7 +541,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): @@ -1193,7 +1167,6 @@ defineExpose({ loadComposerForEdit, focusComposer }); @open-btw="emit('command', '/btw')" @create-goal="emit('createGoal', $event)" @focus-goal="focusGoal" - @focus-swarm="focusSwarm" @compact="emit('compact')" @pick-model="emit('pickModel')" @select-model="emit('selectModel', $event)" @@ -1269,6 +1242,7 @@ defineExpose({ loadComposerForEdit, focusComposer }); min-height: 100%; display: flex; flex-direction: column; + flex-shrink: 0; } .content-wrap.align-center { margin-left: auto; margin-right: auto; } .content-wrap.align-left { margin-left: 0; margin-right: auto; } @@ -1288,12 +1262,6 @@ defineExpose({ loadComposerForEdit, focusComposer }); min-width: 0; } } -.swarm-stack { - padding: 0 18px 16px; -} -.content-wrap.align-mobile .swarm-stack { - padding: 0 14px 18px; -} /* Empty-workspace spacers: push the centred Composer to the vertical middle. */ .empty-spacer { flex: 1; } diff --git a/apps/kimi-web/src/components/chat/SwarmCard.vue b/apps/kimi-web/src/components/chat/SwarmCard.vue deleted file mode 100644 index bb9d70c0b..000000000 --- a/apps/kimi-web/src/components/chat/SwarmCard.vue +++ /dev/null @@ -1,208 +0,0 @@ - - - - - diff --git a/apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue b/apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue new file mode 100644 index 000000000..15e0ce2ad --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue @@ -0,0 +1,481 @@ + + + + + + + diff --git a/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts b/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts index f21e6857d..96e8f4e78 100644 --- a/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts +++ b/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts @@ -7,6 +7,7 @@ import AskUserTool from './AskUserTool.vue'; import EditTool from './EditTool.vue'; import GenericTool from './GenericTool.vue'; import MediaTool from './MediaTool.vue'; +import SwarmTool from './SwarmTool.vue'; type ToolRenderer = Component; @@ -20,6 +21,7 @@ export function resolveToolRenderer(tool: ToolCall): ToolRenderer { // `task` — `agent` here would be dead code and route subagent calls to // GenericTool, dropping the inline "Open" button for the detail panel. if (name === 'task') return AgentTool; + if (name === 'agentswarm') return SwarmTool; if (name === 'askuserquestion') return AskUserTool; return GenericTool; } diff --git a/apps/kimi-web/src/composables/client/useSideChat.ts b/apps/kimi-web/src/composables/client/useSideChat.ts index 547ee52c2..1655666ab 100644 --- a/apps/kimi-web/src/composables/client/useSideChat.ts +++ b/apps/kimi-web/src/composables/client/useSideChat.ts @@ -66,7 +66,6 @@ export function useSideChat(rawState: ExtendedState, deps: UseSideChatDeps) { [], (fileId) => getKimiWebApi().getFileUrl(fileId), sideChatRunning.value, - [], ); }); diff --git a/apps/kimi-web/src/composables/messagesToTurns.ts b/apps/kimi-web/src/composables/messagesToTurns.ts index 6f3f11035..2d51bc33f 100644 --- a/apps/kimi-web/src/composables/messagesToTurns.ts +++ b/apps/kimi-web/src/composables/messagesToTurns.ts @@ -12,7 +12,6 @@ import type { AppMessage, AppApprovalRequest, AppTask, CompactionMarkerMetadata } from '../api/types'; import { COMPACTION_MARKER_METADATA_KEY } from '../api/types'; import type { AgentMember, ApprovalBlock, ChatTurn, DiffLine, ToolCall, ToolMedia, TurnBlock } from '../types'; -import { phaseForTask } from './swarmGroups'; const READ_MEDIA_TOOL_RE = /^read[_-]?media(?:file)?$/i; const DATA_URL_RE = /^data:([^;]+);base64,(.*)$/s; @@ -189,13 +188,6 @@ export function toAgentMember(task: AppTask): AgentMember { }; } -function sortAgentTasks(a: AppTask, b: AppTask): number { - const ai = a.swarmIndex ?? Number.MAX_SAFE_INTEGER; - const bi = b.swarmIndex ?? Number.MAX_SAFE_INTEGER; - if (ai !== bi) return ai - bi; - return a.createdAt.localeCompare(b.createdAt); -} - // --------------------------------------------------------------------------- // Inline buildApprovalBlock (mirrors the one in useKimiWebClient.ts; kept // here to avoid a circular import when tests import this module directly). @@ -412,7 +404,6 @@ export function messagesToTurns( * spinning forever after the turn already finished. */ sessionActive = true, - subagentTasks: AppTask[] = [], /** Preserved `plan_review` displays keyed by toolCallId — used to link the * ExitPlanMode tool card back to the plan file after the approval resolves. */ planReviewByToolCallId: Record = {}, @@ -426,20 +417,6 @@ export function messagesToTurns( approvalByTool.set(a.toolCallId, a); } - const subagentsByTool = new Map(); - for (const task of subagentTasks) { - if (task.kind !== 'subagent') continue; - const keys = [task.parentToolCallId, task.id].filter((key): key is string => typeof key === 'string' && key.length > 0); - for (const key of keys) { - const list = subagentsByTool.get(key) ?? []; - list.push(task); - subagentsByTool.set(key, list); - } - } - for (const [key, list] of subagentsByTool.entries()) { - subagentsByTool.set(key, list.toSorted(sortAgentTasks)); - } - let pendingGroup: Group | null = null; function flushGroup(final = false): void { @@ -497,24 +474,6 @@ export function messagesToTurns( else g.blocks.push({ kind: 'thinking', thinking: c.thinking }); } } else if (c.type === 'toolUse') { - // A multi-member LIVE swarm renders as its OWN SwarmCard footer while - // any member is still active (see buildSwarmGroups / activeSwarms). - // Don't ALSO render it inline, or the swarm shows up twice. Once every - // member has finished, the footer is removed and we fall through to - // render the AgentSwarm call as a normal tool card — the same thing a - // refresh shows, when the live subagent tasks are gone. - const agentTasks = subagentsByTool.get(c.toolCallId); - if (agentTasks && agentTasks.length > 0) { - const swarmMembers = agentTasks.filter((t) => t.swarmIndex !== undefined); - if (swarmMembers.length > 1) { - const live = swarmMembers.some((t) => { - const phase = phaseForTask(t); - return phase !== 'completed' && phase !== 'failed'; - }); - if (live) continue; - } - } - // Single `Agent` subagent spawns and all other tools render as a normal // tool card: the card shows the fixed args (prompt / description) plus // the final result when expanded, while a subagent's live progress diff --git a/apps/kimi-web/src/composables/swarmGroups.ts b/apps/kimi-web/src/composables/swarmGroups.ts index 8eced2687..ce7d55955 100644 --- a/apps/kimi-web/src/composables/swarmGroups.ts +++ b/apps/kimi-web/src/composables/swarmGroups.ts @@ -7,6 +7,9 @@ export interface SwarmMember { phase: AppSubagentPhase; summary?: string; outputLines?: string[]; + /** Accumulated streaming text (text-kind taskProgress) — preferred over + * outputLines/summary when rendering the live swarm row activity/body. */ + text?: string; suspendedReason?: string; swarmIndex: number; } @@ -53,6 +56,7 @@ export function buildSwarmGroups(tasks: AppTask[]): SwarmGroup[] { phase: phaseForTask(task), summary: task.outputPreview, outputLines: task.outputLines, + text: task.text, suspendedReason: task.suspendedReason, swarmIndex: task.swarmIndex, }); @@ -86,3 +90,38 @@ export function countSwarmMembers(groups: SwarmGroup[]): { done: number; total: } return { done, total }; } + +/** + * Bucket foreground/background subagent tasks by their spawning tool call and + * return one member list per AgentSwarm call. Unlike buildSwarmGroups this keeps + * single-member "swarms" (e.g. AgentSwarm used with one resume_agent_ids entry), + * so the inline card can show a resumed subagent's live progress before the + * structured result arrives. Also includes subagents without a swarmIndex so a + * late-synced task still appears (sorted last). + */ +export function swarmMembersByToolCall(tasks: AppTask[]): Map { + const buckets = new Map(); + for (const task of tasks) { + if (task.kind !== 'subagent' || !task.parentToolCallId) continue; + const list = buckets.get(task.parentToolCallId) ?? []; + list.push({ + id: task.id, + name: task.description, + subagentType: task.subagentType, + phase: phaseForTask(task), + summary: task.outputPreview, + outputLines: task.outputLines, + text: task.text, + suspendedReason: task.suspendedReason, + swarmIndex: task.swarmIndex ?? Number.MAX_SAFE_INTEGER, + }); + buckets.set(task.parentToolCallId, list); + } + for (const [key, members] of buckets) { + buckets.set( + key, + members.toSorted((a, b) => a.swarmIndex - b.swarmIndex || a.id.localeCompare(b.id)), + ); + } + return buckets; +} diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index 2b1e7b6d0..f65fd0647 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -65,8 +65,8 @@ import { toAppEvent } from '../api/daemon/mappers'; import { messagesToTurns } from './messagesToTurns'; import { latestTodos } from './latestTodos'; -import { buildSwarmGroups, countSwarmMembers } from './swarmGroups'; -import type { SwarmGroup } from './swarmGroups'; +import { buildSwarmGroups, countSwarmMembers, swarmMembersByToolCall } from './swarmGroups'; +import type { SwarmGroup, SwarmMember } from './swarmGroups'; import type { ActivityState, ActivationBadges, @@ -1656,7 +1656,6 @@ const turns = computed(() => { approvals, (fileId) => getKimiWebApi().getFileUrl(fileId), activity.value !== 'idle', - activeAppTasks.value, rawState.planReviewByToolCallId, ); }); @@ -1668,6 +1667,11 @@ const tasks = computed(() => { }); const swarms = computed(() => buildSwarmGroups(activeAppTasks.value)); +// Foreground/background subagents keyed by their spawning tool call id — used by +// the inline AgentSwarm tool card to stream each subagent's live progress. +const swarmMembersByToolCallId = computed>(() => + swarmMembersByToolCall(activeAppTasks.value), +); const goal = computed(() => { const sid = rawState.activeSessionId; @@ -2386,6 +2390,7 @@ export function useKimiWebClient() { todos, goal, swarms, + swarmMembersByToolCallId, activationBadges, compaction, status, diff --git a/apps/kimi-web/src/i18n/locales/en/tools.ts b/apps/kimi-web/src/i18n/locales/en/tools.ts index fe81c4acd..aaddeb334 100644 --- a/apps/kimi-web/src/i18n/locales/en/tools.ts +++ b/apps/kimi-web/src/i18n/locales/en/tools.ts @@ -11,8 +11,20 @@ export default { search: 'Search', todo: 'Todo', task: 'Task', + swarm: 'Swarm', ask_user: 'Question', }, + swarm: { + progress: '{done} / {total}', + runningSub: '{count} in progress', + doneSub: '{completed} completed · {failed} failed', + phaseQueued: 'Queued', + phaseWorking: 'Working', + phaseSuspended: 'Suspended', + phaseCompleted: 'Completed', + phaseFailed: 'Failed', + waiting: 'Waiting for subagents…', + }, chip: { lines: '{count} lines', results: '{count} results', diff --git a/apps/kimi-web/src/i18n/locales/zh/tools.ts b/apps/kimi-web/src/i18n/locales/zh/tools.ts index 1fe96fda9..0cee01058 100644 --- a/apps/kimi-web/src/i18n/locales/zh/tools.ts +++ b/apps/kimi-web/src/i18n/locales/zh/tools.ts @@ -11,8 +11,20 @@ export default { search: '搜索', todo: '待办', task: '任务', + swarm: 'Swarm', ask_user: '提问', }, + swarm: { + progress: '{done} / {total}', + runningSub: '{count} 个进行中', + doneSub: '完成 {completed} · 失败 {failed}', + phaseQueued: '排队', + phaseWorking: '运行中', + phaseSuspended: '暂停', + phaseCompleted: '完成', + phaseFailed: '失败', + waiting: '等待子任务加入…', + }, chip: { lines: '{count} 行', results: '{count} 结果', diff --git a/apps/kimi-web/src/lib/parseSwarmResult.ts b/apps/kimi-web/src/lib/parseSwarmResult.ts new file mode 100644 index 000000000..33b3ba434 --- /dev/null +++ b/apps/kimi-web/src/lib/parseSwarmResult.ts @@ -0,0 +1,128 @@ +// apps/kimi-web/src/lib/parseSwarmResult.ts +// Parse the `` payload returned by the AgentSwarm tool +// (see packages/agent-core/.../agent-swarm.ts renderSwarmResults). The result +// arrives as a plain string inside the toolResult output; the swarm card turns +// it into a structured aggregate view. Defensive: never throws. + +export interface SwarmResultSubagent { + outcome: string; + item?: string; + agentId?: string; + mode?: string; + state?: string; + body: string; +} + +export interface SwarmResult { + /** Raw summary line, e.g. `completed: 8, failed: 2`. */ + summary: string; + completed: number; + failed: number; + aborted: number; + total: number; + subagents: SwarmResultSubagent[]; + resumeHint?: string; +} + +const SUMMARY_RE = /([\s\S]*?)<\/summary>/; +const RESUME_HINT_RE = /([\s\S]*?)<\/resume_hint>/; +// Marks either a subagent opening tag (captures attributes) or a `` +// closing tag. Body parsing tracks a depth so literal `` / +// `` text inside a row's body (e.g. a subagent emitting an +// AgentSwarm snippet) does not register as a top-level row — producer writes +// body unescaped. +const TOKEN_RE = /]*)>|<\/subagent>/g; +const SUBAGENT_CLOSE = ''; +const COUNT_RE = /(completed|failed|aborted):\s*(\d+)/g; +const ATTR_RE = /([a-z_]+)="([^"]*)"/g; + +function unescapeAttr(value: string): string { + return value + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); +} + +function parseAttrs(raw: string): Record { + const attrs: Record = {}; + ATTR_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = ATTR_RE.exec(raw)) !== null) { + attrs[m[1]!] = unescapeAttr(m[2]!); + } + return attrs; +} + +function parseCounts(summary: string): Pick { + const counts = { completed: 0, failed: 0, aborted: 0 }; + COUNT_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = COUNT_RE.exec(summary)) !== null) { + const key = m[1] as 'completed' | 'failed' | 'aborted'; + counts[key] = Number(m[2]); + } + return counts; +} + +type RowFrame = { attrs: string; bodyStart: number }; + +function parseSubagent(attrs: string, body: string): SwarmResultSubagent { + const parsed = parseAttrs(attrs); + return { + outcome: parsed['outcome'] ?? 'completed', + item: parsed['item'], + agentId: parsed['agent_id'], + mode: parsed['mode'], + state: parsed['state'], + body: body.trim(), + }; +} + +function parseSubagents(text: string): SwarmResultSubagent[] { + const subs: SwarmResultSubagent[] = []; + // Each stack frame is either a real top-level row (carries attrs + the body + // start offset) or `null` for a nested literal `` matched inside + // another row's body so nested tags don't register as their own result row. + const stack: (RowFrame | null)[] = []; + TOKEN_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = TOKEN_RE.exec(text)) !== null) { + if (m[0] === SUBAGENT_CLOSE) { + if (stack.length === 0) continue; + const frame = stack.pop()!; + // Pop balances this close with its matching opening. A frame is real only + // when it sits on a then-empty stack, i.e. a top-level row. + if (frame && stack.length === 0) { + subs.push(parseSubagent(frame.attrs, text.slice(frame.bodyStart, m.index))); + } + } else if (stack.length === 0) { + stack.push({ attrs: m[1] ?? '', bodyStart: TOKEN_RE.lastIndex }); + } else { + stack.push(null); + } + } + return subs; +} + +export function parseSwarmResult(output: string[] | string | undefined | null): SwarmResult | null { + if (output === undefined || output === null) return null; + const text = Array.isArray(output) ? output.join('\n') : output; + if (!text.includes('')) return null; + + const summary = SUMMARY_RE.exec(text)?.[1]?.trim() ?? ''; + const { completed, failed, aborted } = parseCounts(summary); + const resumeHint = RESUME_HINT_RE.exec(text)?.[1]?.trim(); + const subagents = parseSubagents(text); + + const totalFromSummary = completed + failed + aborted; + return { + summary, + completed, + failed, + aborted, + total: totalFromSummary > 0 ? totalFromSummary : subagents.length, + subagents, + resumeHint, + }; +} diff --git a/apps/kimi-web/src/lib/swarmCardRows.ts b/apps/kimi-web/src/lib/swarmCardRows.ts new file mode 100644 index 000000000..f4d945870 --- /dev/null +++ b/apps/kimi-web/src/lib/swarmCardRows.ts @@ -0,0 +1,100 @@ +// apps/kimi-web/src/lib/swarmCardRows.ts +// Build the accordion row model for the AgentSwarm inline tool card. Pure +// function of live members (AppTask store, real-time phase) and the parsed +// `` payload (terminal result) — kept in plain TS so it can +// be unit-tested without mounting the component. + +import type { AppSubagentPhase } from '../api/types'; +import type { SwarmMember } from '../composables/swarmGroups'; +import type { SwarmResult, SwarmResultSubagent } from './parseSwarmResult'; + +export interface SwarmCardRow { + id: string; + name: string; + activity: string; + phase: AppSubagentPhase; + body: string; +} + +function lastNonEmptyLine(text: string | undefined): string { + if (!text) return ''; + return text.split('\n').map((l) => l.trimEnd()).filter(Boolean).at(-1) ?? ''; +} + +export function swarmMemberActivity(member: SwarmMember): string { + // Prefer streamed subagent text so a still-composing agent shows its latest + // line instead of an empty / last-summary row. + return ( + member.suspendedReason || + lastNonEmptyLine(member.text) || + lastNonEmptyLine(member.outputLines?.join('\n')) || + member.summary || + '' + ); +} + +function swarmMemberBody(member: SwarmMember): string { + if (member.suspendedReason) return member.suspendedReason; + if (member.text) return member.text; + if (member.outputLines && member.outputLines.length > 0) return member.outputLines.join('\n'); + return member.summary ?? ''; +} + +function outcomeToPhase(outcome: string): AppSubagentPhase { + if (outcome === 'completed') return 'completed'; + if (outcome === 'failed' || outcome === 'aborted') return 'failed'; + return 'working'; +} + +function resultRow(sub: SwarmResultSubagent, index: number): SwarmCardRow { + return { + id: sub.agentId ?? sub.item ?? `result-${index}`, + name: sub.item ?? `subagent ${index + 1}`, + activity: sub.body.split('\n')[0] ?? '', + phase: outcomeToPhase(sub.outcome), + body: sub.body, + }; +} + +/** + * Whether a live member already accounts for a result subagent. Members may + * come from the projector (task id / description) while the result references + * agent_id / item; the two ids don't always match, so also treat item ⊆ + * description as a match. + */ +function memberCoversResult(member: SwarmMember, sub: SwarmResultSubagent): boolean { + if (sub.agentId && member.id === sub.agentId) return true; + if (sub.item && member.name.includes(sub.item)) return true; + return false; +} + +/** + * Merge the live members with the agent_swarm_result payload into one row list. + * + * - Members are authoritative while present (real-time phase + streamed text). + * - When a parsed result is also present, append result rows that no member + * covers — e.g. interrupted swarms emit `state="not_started"` / + * `outcome="aborted"` entries for items that never spawned a task, which + * would otherwise be invisible until a refresh dropped the live tasks. + * - When no members are present (post-refresh), fall back to result-only rows. + */ +export function buildSwarmCardRows(members: SwarmMember[], result: SwarmResult | null): SwarmCardRow[] { + const memberRows = members.map((m) => ({ + id: m.id, + name: m.name, + activity: swarmMemberActivity(m), + phase: m.phase, + body: swarmMemberBody(m), + })); + if (!result) return memberRows; + + const resultOnly = result.subagents + .filter( + (sub) => + (sub.outcome === 'aborted' || sub.state === 'not_started') && + !members.some((m) => memberCoversResult(m, sub)), + ) + .map((sub, i) => resultRow(sub, i)); + + return memberRows.length > 0 ? [...memberRows, ...resultOnly] : result.subagents.map((s, i) => resultRow(s, i)); +} diff --git a/apps/kimi-web/src/lib/toolMeta.ts b/apps/kimi-web/src/lib/toolMeta.ts index 38bc2e453..b504b4108 100644 --- a/apps/kimi-web/src/lib/toolMeta.ts +++ b/apps/kimi-web/src/lib/toolMeta.ts @@ -23,6 +23,7 @@ const TOOL_LABEL_KEYS: Record = { search: 'tools.label.search', todo: 'tools.label.todo', task: 'tools.label.task', + agentswarm: 'tools.label.swarm', askuserquestion: 'tools.label.ask_user', }; @@ -90,6 +91,7 @@ const TOOL_GLYPH: Record = { web_fetch: 'globe', todo: 'check-list', task: 'sparkles', + agentswarm: 'git-pull-request', askuserquestion: 'help-circle', }; diff --git a/apps/kimi-web/test/event-reducer.test.ts b/apps/kimi-web/test/event-reducer.test.ts index 338acf0c8..1efb430f6 100644 --- a/apps/kimi-web/test/event-reducer.test.ts +++ b/apps/kimi-web/test/event-reducer.test.ts @@ -233,6 +233,38 @@ describe('reduceAppEvent taskProgress', () => { ); expect(next.tasksBySession['s1']?.[0]?.text).toBe('partial'); }); + + it('preserves subagent identity metadata across a taskCreated replacement with omitted fields', () => { + const state = { + ...createInitialState(), + tasksBySession: { + 's1': [ + { + ...makeSubagentTask('t1', 's1'), + parentToolCallId: 'call-1', + swarmIndex: 2, + subagentType: 'explore', + runInBackground: true, + outputLines: ['old line'], + text: 'partial', + }, + ], + }, + }; + const next = reduceAppEvent( + state, + { type: 'taskCreated', sessionId: 's1', task: makeSubagentTask('t1', 's1') }, + { sessionId: 's1', seq: 1 }, + ); + expect(next.tasksBySession['s1']?.[0]).toMatchObject({ + parentToolCallId: 'call-1', + swarmIndex: 2, + subagentType: 'explore', + runInBackground: true, + outputLines: ['old line'], + text: 'partial', + }); + }); }); describe('reduceAppEvent sessions reference stability', () => { diff --git a/apps/kimi-web/test/swarm-card-rows.test.ts b/apps/kimi-web/test/swarm-card-rows.test.ts new file mode 100644 index 000000000..957e8e56e --- /dev/null +++ b/apps/kimi-web/test/swarm-card-rows.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import type { AppSubagentPhase } from '../src/api/types'; +import type { SwarmMember } from '../src/composables/swarmGroups'; +import type { SwarmResult } from '../src/lib/parseSwarmResult'; +import { buildSwarmCardRows, swarmMemberActivity } from '../src/lib/swarmCardRows'; + +function member( + id: string, + name: string, + opts: { + phase?: AppSubagentPhase; + text?: string; + outputLines?: string[]; + summary?: string; + suspendedReason?: string; + } = {}, +): SwarmMember { + return { + id, + name, + phase: opts.phase ?? 'working', + text: opts.text, + outputLines: opts.outputLines, + summary: opts.summary, + suspendedReason: opts.suspendedReason, + swarmIndex: 0, + }; +} + +function result(subagents: SwarmResult['subagents']): SwarmResult { + return { + summary: `${subagents.length}`, + completed: subagents.filter((s) => s.outcome === 'completed').length, + failed: subagents.filter((s) => s.outcome === 'failed').length, + aborted: subagents.filter((s) => s.outcome === 'aborted').length, + total: subagents.length, + subagents, + }; +} + +describe('swarmMemberActivity', () => { + it('prefers streamed subagent text over outputLines and summary', () => { + const m = member('a', '子任务', { + text: 'line 1\nline 2', + outputLines: ['tool call output'], + summary: 'final summary', + }); + expect(swarmMemberActivity(m)).toBe('line 2'); + }); + + it('falls back to the last outputLines entry when no text is streaming', () => { + const m = member('a', '子任务', { outputLines: ['one', 'two'], summary: 'summary' }); + expect(swarmMemberActivity(m)).toBe('two'); + }); + + it('falls back to summary', () => { + expect(swarmMemberActivity(member('a', '子任务', { summary: 'sum' }))).toBe('sum'); + }); +}); + +describe('buildSwarmCardRows', () => { + it('builds rows from live members when no parsed result exists', () => { + const rows = buildSwarmCardRows( + [member('a', '子任务 A', { text: 'streaming' })], + null, + ); + expect(rows).toEqual([{ id: 'a', name: '子任务 A', activity: 'streaming', phase: 'working', body: 'streaming' }]); + }); + + it('builds rows from result subagents when no members are present', () => { + const rows = buildSwarmCardRows( + [], + result([ + { outcome: 'completed', item: 'A', body: 'A body' }, + { outcome: 'failed', item: 'B', body: 'B body' }, + ]), + ); + expect(rows.map((r) => r.name)).toEqual(['A', 'B']); + expect(rows.map((r) => r.phase)).toEqual(['completed', 'failed']); + }); + + it('appends result-only aborted not_started rows on top of live members', () => { + const rows = buildSwarmCardRows( + [ + member('a1', '子任务 A', { phase: 'completed' }), + member('a2', '子任务 B', { phase: 'working' }), + ], + result([ + { outcome: 'completed', item: 'A', agentId: 'a1', body: 'A body' }, + { outcome: 'completed', item: 'B', agentId: 'a2', body: 'B body' }, + { outcome: 'aborted', item: 'C', state: 'not_started', body: 'C never started' }, + ]), + ); + expect(rows.map((r) => r.id)).toEqual(['a1', 'a2', 'C']); + expect(rows[2]?.phase).toBe('failed'); + expect(rows[2]?.body).toBe('C never started'); + }); + + it('does not duplicate a result row that a live member already covers', () => { + const rows = buildSwarmCardRows( + [member('a1', '子任务 A', { phase: 'failed' })], + result([{ outcome: 'aborted', item: 'A', agentId: 'a1', body: 'A body' }]), + ); + expect(rows.map((r) => r.id)).toEqual(['a1']); + expect(rows[0]?.phase).toBe('failed'); + }); + + it('matches by item substring when agent ids disagree', () => { + const rows = buildSwarmCardRows( + [member('a1', 'find unused exports in src', { phase: 'completed' })], + result([{ outcome: 'aborted', item: 'unused exports', state: 'not_started', body: 'x' }]), + ); + expect(rows.map((r) => r.id)).toEqual(['a1']); + }); +}); diff --git a/apps/kimi-web/test/swarm-groups.test.ts b/apps/kimi-web/test/swarm-groups.test.ts new file mode 100644 index 000000000..a806de323 --- /dev/null +++ b/apps/kimi-web/test/swarm-groups.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import type { AppTask } from '../src/api/types'; +import { + buildSwarmGroups, + countSwarmMembers, + swarmMembersByToolCall, +} from '../src/composables/swarmGroups'; + +function subagentTask( + id: string, + parentToolCallId: string | undefined, + opts: { + swarmIndex?: number; + status?: AppTask['status']; + subagentPhase?: AppTask['subagentPhase']; + text?: string; + outputLines?: string[]; + } = {}, +): AppTask { + return { + id, + sessionId: 'session-1', + kind: 'subagent', + description: `subagent ${id}`, + status: opts.status ?? 'running', + createdAt: '2026-01-01T00:00:00.000Z', + parentToolCallId, + swarmIndex: opts.swarmIndex, + text: opts.text, + outputLines: opts.outputLines, + subagentPhase: opts.subagentPhase ?? 'working', + }; +} + +function bashTask(id: string): AppTask { + return { + id, + sessionId: 'session-1', + kind: 'bash', + description: `bash ${id}`, + status: 'running', + createdAt: '2026-01-01T00:00:00.000Z', + }; +} + +describe('buildSwarmGroups', () => { + it('emits a group only when two or more members share a swarmIndex', () => { + const groups = buildSwarmGroups([ + subagentTask('a', 'swarm-1', { swarmIndex: 1 }), + subagentTask('b', 'swarm-1', { swarmIndex: 2 }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0]?.id).toBe('swarm-1'); + expect(groups[0]?.members.map((m) => m.id)).toEqual(['a', 'b']); + }); + + it('filters single-member groups (used for the badge counter)', () => { + const groups = buildSwarmGroups([subagentTask('a', 'swarm-1', { swarmIndex: 1 })]); + expect(groups).toHaveLength(0); + }); + + it('ignores subagents without a swarmIndex', () => { + const groups = buildSwarmGroups([ + subagentTask('a', 'swarm-1'), + subagentTask('b', 'swarm-1'), + ]); + expect(groups).toHaveLength(0); + }); +}); + +describe('countSwarmMembers', () => { + it('counts completed + failed as done across groups', () => { + const groups = buildSwarmGroups([ + subagentTask('a', 'swarm-1', { swarmIndex: 1, subagentPhase: 'completed', status: 'completed' }), + subagentTask('b', 'swarm-1', { swarmIndex: 2, subagentPhase: 'failed', status: 'failed' }), + subagentTask('c', 'swarm-2', { swarmIndex: 1, subagentPhase: 'working' }), + subagentTask('d', 'swarm-2', { swarmIndex: 2, subagentPhase: 'queued' }), + ]); + expect(countSwarmMembers(groups)).toEqual({ done: 2, total: 4 }); + }); +}); + +describe('swarmMembersByToolCall', () => { + it('keeps single-member swarms so a resume-only AgentSwarm gets live progress', () => { + const map = swarmMembersByToolCall([subagentTask('a', 'swarm-1', { swarmIndex: 1 })]); + expect(map.get('swarm-1')?.map((m) => m.id)).toEqual(['a']); + }); + + it('groups every subagent with the same parentToolCallId, ignoring swarmIndex', () => { + const map = swarmMembersByToolCall([ + subagentTask('b', 'swarm-1'), + subagentTask('a', 'swarm-1'), + subagentTask('c', 'swarm-2'), + ]); + expect(map.get('swarm-1')?.map((m) => m.id)).toEqual(['a', 'b']); + expect(map.get('swarm-2')?.map((m) => m.id)).toEqual(['c']); + }); + + it('ignores non-subagent tasks and subagents without a parentToolCallId', () => { + const map = swarmMembersByToolCall([ + bashTask('b-1'), + subagentTask('orphan', undefined), + subagentTask('a', 'swarm-1'), + ]); + expect([...map.keys()]).toEqual(['swarm-1']); + }); + + it('carries task.text so live rows can show still-composing subagent output', () => { + const map = swarmMembersByToolCall([ + subagentTask('a', 'swarm-1', { text: 'Hello, world!' }), + subagentTask('b', 'swarm-1', { outputLines: ['tool line'] }), + ]); + const rows = map.get('swarm-1') ?? []; + expect(rows[0]).toMatchObject({ id: 'a', text: 'Hello, world!' }); + expect(rows[1]).toMatchObject({ id: 'b', outputLines: ['tool line'] }); + }); +}); + +describe('buildSwarmGroups preserves streamed text', () => { + it('carries task.text into each group member', () => { + const groups = buildSwarmGroups([ + subagentTask('a', 'swarm-1', { swarmIndex: 1, text: 'first line' }), + subagentTask('b', 'swarm-1', { swarmIndex: 2, text: 'second line' }), + ]); + expect(groups[0]?.members.map((m) => m.text)).toEqual(['first line', 'second line']); + }); +}); diff --git a/apps/kimi-web/test/swarm-result.test.ts b/apps/kimi-web/test/swarm-result.test.ts new file mode 100644 index 000000000..deb6e0f64 --- /dev/null +++ b/apps/kimi-web/test/swarm-result.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { parseSwarmResult } from '../src/lib/parseSwarmResult'; + +describe('parseSwarmResult', () => { + it('returns null when the payload is not an agent_swarm_result', () => { + expect(parseSwarmResult('all done')).toBeNull(); + expect(parseSwarmResult(undefined)).toBeNull(); + expect(parseSwarmResult([])).toBeNull(); + }); + + it('parses the summary counts and each subagent outcome', () => { + const output = [ + '', + 'completed: 2, failed: 1', + 'first body', + 'second body', + 'boom', + '', + ]; + const result = parseSwarmResult(output); + expect(result).not.toBeNull(); + expect(result?.summary).toBe('completed: 2, failed: 1'); + expect(result?.completed).toBe(2); + expect(result?.failed).toBe(1); + expect(result?.aborted).toBe(0); + expect(result?.total).toBe(3); + expect(result?.subagents).toEqual([ + { outcome: 'completed', item: 'alpha', agentId: 'a1', body: 'first body' }, + { outcome: 'completed', item: 'beta', agentId: 'a2', body: 'second body' }, + { outcome: 'failed', item: 'gamma', body: 'boom' }, + ]); + }); + + it('unescapes the item attribute and captures the resume hint', () => { + const text = [ + '', + 'completed: 0, failed: 1, aborted: 0', + 'Call AgentSwarm with resume_agent_ids using the agent_id values in this result to continue unfinished work.', + 'err', + '', + ].join('\n'); + const result = parseSwarmResult(text); + expect(result?.resumeHint).toContain('resume_agent_ids'); + expect(result?.subagents[0]?.item).toBe('a & b'); + expect(result?.subagents[0]?.mode).toBe('resume'); + expect(result?.subagents[0]?.state).toBe('started'); + }); + + it('does not count a literal "" tag inside a body as a top-level row', () => { + const snippet = 'inner body'; + const body = 'example result below: ' + snippet; + const text = `completed: 1${body}`; + const result = parseSwarmResult(text); + expect(result?.subagents).toHaveLength(1); + expect(result?.subagents[0]?.item).toBe('outer'); + expect(result?.subagents[0]?.body).toContain(snippet); + }); + + it('keeps sibling top-level rows when one body contains a nested subagent snippet', () => { + const text = [ + 'completed: 2', + 'A snippet: inner done', + 'just B', + '', + ].join(''); + const result = parseSwarmResult(text); + expect(result?.subagents.map((s) => s.item)).toEqual(['a', 'b']); + expect(result?.subagents[0]?.body).toContain(' { it('merges an assistant turn and folds tool results into it', () => { const turns = messagesToTurns( @@ -58,7 +34,6 @@ describe('messagesToTurns', () => { [], undefined, false, - [], ); expect(turns).toHaveLength(2); @@ -81,7 +56,6 @@ describe('messagesToTurns', () => { [], undefined, false, - [], ); expect(turns.map((turn) => turn.text)).toEqual(['one', 'two']); @@ -97,13 +71,12 @@ describe('messagesToTurns', () => { [], undefined, false, - [], ); expect(turns).toMatchObject([{ role: 'compaction', text: 'summary' }]); }); - it('suppresses an inline tool card for a live multi-member swarm', () => { + it('renders a live multi-member swarm inline as a tool card', () => { const turns = messagesToTurns( [ message('u1', 'user', [{ type: 'text', text: 'run a swarm' }]), @@ -114,16 +87,11 @@ describe('messagesToTurns', () => { [], undefined, true, - [ - subagentTask('a-1', 'swarm-1', 1, 'working'), - subagentTask('a-2', 'swarm-1', 2, 'queued'), - subagentTask('a-3', 'swarm-1', 3, 'completed'), - ], ); const assistant = turns.at(-1); - expect(assistant?.tools ?? []).not.toContainEqual( - expect.objectContaining({ id: 'swarm-1' }), + expect(assistant?.tools).toContainEqual( + expect.objectContaining({ id: 'swarm-1', name: 'AgentSwarm', status: 'running' }), ); expect(assistant?.blocks ?? []).not.toContainEqual( expect.objectContaining({ kind: 'agentGroup' }), @@ -142,11 +110,6 @@ describe('messagesToTurns', () => { [], undefined, false, - [ - subagentTask('b-1', 'swarm-2', 1, 'completed'), - subagentTask('b-2', 'swarm-2', 2, 'completed'), - subagentTask('b-3', 'swarm-2', 3, 'failed'), - ], ); const assistant = turns.at(-1); @@ -159,17 +122,6 @@ describe('messagesToTurns', () => { }); it('renders a single subagent spawn as a tool card, not an agent block', () => { - const singleSubagent: AppTask = { - id: 'sub-1', - sessionId: 'session-1', - kind: 'subagent', - description: 'explore the repo', - status: 'completed', - createdAt: '2026-01-01T00:00:00.000Z', - parentToolCallId: 'agent-call-1', - subagentPhase: 'completed', - runInBackground: false, - }; const turns = messagesToTurns( [ message('u1', 'user', [{ type: 'text', text: 'go explore' }]), @@ -186,7 +138,6 @@ describe('messagesToTurns', () => { [], undefined, false, - [singleSubagent], ); const assistant = turns.at(-1); @@ -217,7 +168,6 @@ describe('messagesToTurns', () => { [], (id) => `/api/v1/files/${id}`, false, - [], ); expect(turns).toHaveLength(1); @@ -235,7 +185,6 @@ describe('messagesToTurns', () => { [], undefined, false, - [], ); expect(turns[0]).toMatchObject({ role: 'user', text: tag }); @@ -252,7 +201,6 @@ describe('messagesToTurns', () => { [], (id) => `/api/v1/files/${id}`, false, - [], ); expect(turns[0]).toMatchObject({ role: 'user', text: tag });