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