Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/swarm-roster-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Fix the AgentSwarm member list disappearing after a page refresh while subagents are still running.
2 changes: 2 additions & 0 deletions apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,8 @@ export class DaemonKimiWebApi implements KimiWebApi {
},
pendingApprovals: data.pending_approvals.map(toAppApprovalRequest),
pendingQuestions: data.pending_questions.map(toAppQuestionRequest),
// Older servers omit the roster entirely; treat as an empty roster.
subagents: (data.subagents ?? []).map(toAppTask),
};
}

Expand Down
14 changes: 14 additions & 0 deletions apps/kimi-web/src/api/daemon/eventReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ const OPTIMISTIC_USER_MESSAGE_METADATA_KEY = 'kimiWeb.optimisticUserMessage';
* in full (small synthesized lines). */
const MAX_BACKGROUND_OUTPUT_LINES = 40;

/** Skeleton description used by `patchSubagent` in agentEventProjector.ts when
* a lifecycle event re-projects a subagent the projector never saw spawn
* (e.g. after a page refresh, where the snapshot roster — not the WS stream —
* carried the real description). */
const PLACEHOLDER_SUBAGENT_DESCRIPTION = 'Sub Agent';

// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -558,6 +564,14 @@ export function reduceAppEvent(
...event.task,
outputLines: previous.outputLines,
text: previous.text,
// A post-refresh lifecycle event re-projects the task with skeleton
// metadata; don't let its placeholder clobber the roster-seeded
// description.
description:
event.task.description === PLACEHOLDER_SUBAGENT_DESCRIPTION &&
previous.description !== PLACEHOLDER_SUBAGENT_DESCRIPTION
? previous.description
: event.task.description,
swarmIndex: event.task.swarmIndex ?? previous.swarmIndex,
parentToolCallId: event.task.parentToolCallId ?? previous.parentToolCallId,
subagentType: event.task.subagentType ?? previous.subagentType,
Expand Down
8 changes: 5 additions & 3 deletions apps/kimi-web/src/api/daemon/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,9 +382,11 @@ export function toAppTask(wire: WireTask): AppTask {
parentToolCallId: wire.parent_tool_call_id,
suspendedReason: wire.suspended_reason,
swarmIndex: wire.swarm_index,
// The background task store only holds detached tasks, so any subagent it
// returns is a background subagent (foreground ones never persist here).
runInBackground: wire.kind === 'subagent' ? true : undefined,
// The snapshot's subagent roster carries the explicit flag. REST `/tasks`
// does not, but its background-task store only holds detached tasks, so any
// subagent it returns is a background subagent (foreground ones never
// persist there) — hence the `?? true` fallback for that path.
runInBackground: wire.run_in_background ?? (wire.kind === 'subagent' ? true : undefined),
// outputLines starts undefined; populated by eventReducer via task.progress events
};
}
Expand Down
3 changes: 3 additions & 0 deletions apps/kimi-web/src/api/daemon/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ export interface WireTask {
parent_tool_call_id?: string;
suspended_reason?: string;
swarm_index?: number;
run_in_background?: boolean;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -561,6 +562,8 @@ export interface WireSessionSnapshot {
session: WireSession;
messages: { items: WireMessage[]; has_more: boolean };
in_flight_turn: WireInFlightTurn | null;
/** Live subagent roster at the watermark (absent on older servers). */
subagents?: WireTask[];
pending_approvals: WireApprovalRequest[];
pending_questions: WireQuestionRequest[];
}
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,8 @@ export interface AppSessionSnapshot {
messages: AppMessage[];
hasMoreMessages: boolean;
inFlightTurn: AppInFlightTurn | null;
/** Live subagent roster at the watermark — rebuilds swarm cards on refresh. */
subagents: AppTask[];
pendingApprovals: AppApprovalRequest[];
pendingQuestions: AppQuestionRequest[];
}
Expand Down
12 changes: 12 additions & 0 deletions apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '../lib/workspaceOrder';
import { mergeWorkspaces } from '../lib/mergeWorkspaces';
import { mergeSnapshotMessages } from '../lib/snapshotMessages';
import { mergeSnapshotSubagents } from '../lib/taskMerge';
import { createCoalescedAsyncRunner } from '../lib/snapshotSync';
import {
loadUnread,
Expand Down Expand Up @@ -1275,6 +1276,17 @@ async function syncSessionFromSnapshot(sessionId: string): Promise<SyncSessionRe
sessionId,
mergeSnapshotMessages(rawState.messagesBySession[sessionId] ?? [], snap.messages),
);
// Seed the live subagent roster so swarm cards survive a page refresh
// (their member rows otherwise only exist from non-replayed WS events).
// loadTasksForSession's keepLiveSubagents preserves these across REST
// reloads; the roster stays authoritative until then.
rawState.tasksBySession = {
...rawState.tasksBySession,
[sessionId]: mergeSnapshotSubagents(
snap.subagents,
rawState.tasksBySession[sessionId] ?? [],
),
};
rawState.messagesHasMoreBySession = {
...rawState.messagesHasMoreBySession,
[sessionId]: snap.hasMoreMessages,
Expand Down
19 changes: 19 additions & 0 deletions apps/kimi-web/src/lib/taskMerge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,22 @@ export function keepLiveSubagents(restBased: AppTask[], existing: AppTask[]): Ap
const liveSubagents = existing.filter((t) => t.kind === 'subagent' && !restIds.has(t.id));
return liveSubagents.length === 0 ? restBased : [...restBased, ...liveSubagents];
}

/**
* Seed the task store from the snapshot's subagent roster. The roster is
* authoritative for identity/status/phase; keep reducer-owned accumulated
* output (outputLines/text) from any already-live task, and keep tasks the
* roster does not know about (background bash tasks from REST).
*/
export function mergeSnapshotSubagents(roster: AppTask[], existing: AppTask[]): AppTask[] {
if (roster.length === 0) return existing;
const existingById = new Map(existing.map((t) => [t.id, t] as const));
const rosterIds = new Set(roster.map((t) => t.id));
const merged = roster.map((task) => {
const live = existingById.get(task.id);
if (!live) return task;
return { ...task, outputLines: live.outputLines, text: live.text };
});
const kept = existing.filter((t) => !rosterIds.has(t.id));
return kept.length === 0 ? merged : [...merged, ...kept];
}
41 changes: 41 additions & 0 deletions apps/kimi-web/test/event-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,47 @@ describe('reduceAppEvent taskProgress', () => {
text: 'partial',
});
});

it('keeps the roster-seeded description when a re-projected task carries the placeholder', () => {
// After a page refresh the snapshot roster seeds the real description; a
// later subagent.* lifecycle event re-projects the task with the
// projector's skeleton default ('Sub Agent') — it must not clobber it.
const state = {
...createInitialState(),
tasksBySession: {
's1': [{ ...makeSubagentTask('t1', 's1'), description: 'explore the auth flow' }],
},
};
const next = reduceAppEvent(
state,
{
type: 'taskCreated',
sessionId: 's1',
task: { ...makeSubagentTask('t1', 's1'), description: 'Sub Agent' },
},
{ sessionId: 's1', seq: 1 },
);
expect(next.tasksBySession['s1']?.[0]?.description).toBe('explore the auth flow');
});

it('takes the incoming description when it is a real one', () => {
const state = {
...createInitialState(),
tasksBySession: {
's1': [{ ...makeSubagentTask('t1', 's1'), description: 'Sub Agent' }],
},
};
const next = reduceAppEvent(
state,
{
type: 'taskCreated',
sessionId: 's1',
task: { ...makeSubagentTask('t1', 's1'), description: 'write the tests' },
},
{ sessionId: 's1', seq: 1 },
);
expect(next.tasksBySession['s1']?.[0]?.description).toBe('write the tests');
});
});

describe('reduceAppEvent sessions reference stability', () => {
Expand Down
59 changes: 58 additions & 1 deletion apps/kimi-web/test/lib-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { buildDiffLines } from '../src/lib/diffLines';
import { buildEditDiffLines } from '../src/lib/toolDiff';
import { createCoalescedAsyncRunner } from '../src/lib/snapshotSync';
import { mergeSnapshotMessages } from '../src/lib/snapshotMessages';
import { mergeSnapshotSubagents } from '../src/lib/taskMerge';
import { normalizeToolName, toolSummary } from '../src/lib/toolMeta';
import { collapsePrompt, humanizeCron } from '../src/lib/cronHumanize';
import {
Expand All @@ -25,7 +26,7 @@ import {
modelThinkingAvailability,
segmentsFor,
} from '../src/lib/modelThinking';
import type { AppMessage, AppModel } from '../src/api/types';
import type { AppMessage, AppModel, AppTask } from '../src/api/types';
import { resolveToolRenderer } from '../src/components/chat/tool-calls/toolRegistry';
import AgentTool from '../src/components/chat/tool-calls/AgentTool.vue';
import EditTool from '../src/components/chat/tool-calls/EditTool.vue';
Expand Down Expand Up @@ -534,3 +535,59 @@ describe('mergeSnapshotMessages', () => {
expect(mergeSnapshotMessages(snapshot, [])).toEqual([]);
});
});

describe('mergeSnapshotSubagents', () => {
function subagent(id: string, overrides: Partial<AppTask> = {}): AppTask {
return {
id,
sessionId: 's1',
kind: 'subagent',
description: `task ${id}`,
status: 'running',
createdAt: '2026-01-01T00:00:00.000Z',
...overrides,
};
}

it('seeds an empty store from the roster', () => {
const roster = [
subagent('a1', { subagentPhase: 'working', swarmIndex: 0, parentToolCallId: 'call-1' }),
subagent('a2', { subagentPhase: 'queued', swarmIndex: 1, parentToolCallId: 'call-1' }),
];
expect(mergeSnapshotSubagents(roster, [])).toEqual(roster);
});

it('keeps reducer-owned accumulated output from an already-live task', () => {
const live = subagent('a1', {
subagentPhase: 'queued',
outputLines: ['line 1'],
text: 'partial answer',
});
const roster = [subagent('a1', { subagentPhase: 'working' })];
const [merged] = mergeSnapshotSubagents(roster, [live]);
// Roster is authoritative for identity/status/phase…
expect(merged?.subagentPhase).toBe('working');
// …but the accumulated output survives the seed.
expect(merged?.outputLines).toEqual(['line 1']);
expect(merged?.text).toBe('partial answer');
});

it('keeps tasks the roster does not know about', () => {
const background: AppTask = {
id: 'bash-1',
sessionId: 's1',
kind: 'bash',
description: 'npm test',
status: 'running',
createdAt: '2026-01-01T00:00:00.000Z',
};
const roster = [subagent('a1')];
const merged = mergeSnapshotSubagents(roster, [background, subagent('a1')]);
expect(merged.map((t) => t.id)).toEqual(['a1', 'bash-1']);
});

it('returns the existing list untouched when the roster is empty', () => {
const existing = [subagent('a1')];
expect(mergeSnapshotSubagents([], existing)).toBe(existing);
});
});
46 changes: 46 additions & 0 deletions packages/protocol/src/__tests__/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,52 @@ describe('rest/snapshot — session snapshot', () => {
}
});

it('parses a snapshot with a subagent roster', () => {
const result = sessionSnapshotResponseSchema.safeParse({
as_of_seq: 12,
epoch: 'ep_01ABC',
session: SESSION,
messages: { items: [], has_more: false },
in_flight_turn: null,
subagents: [
{
id: 'agent_1',
session_id: 'sess_1',
kind: 'subagent',
description: 'explore the auth flow',
status: 'running',
created_at: TS,
started_at: TS,
subagent_phase: 'working',
subagent_type: 'explore',
parent_tool_call_id: 'call_1',
swarm_index: 0,
run_in_background: false,
},
{
id: 'agent_2',
session_id: 'sess_1',
kind: 'subagent',
description: 'write tests',
status: 'completed',
created_at: TS,
completed_at: TS,
output_preview: 'done',
subagent_phase: 'completed',
swarm_index: 1,
},
],
pending_approvals: [],
pending_questions: [],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.subagents).toHaveLength(2);
expect(result.data.subagents?.[0]?.parent_tool_call_id).toBe('call_1');
expect(result.data.subagents?.[1]?.subagent_phase).toBe('completed');
}
});

it('rejects a snapshot missing the watermark', () => {
const result = sessionSnapshotResponseSchema.safeParse({
epoch: 'ep_01ABC',
Expand Down
22 changes: 22 additions & 0 deletions packages/protocol/src/rest/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { approvalRequestSchema } from '../approval';
import { messageSchema } from '../message';
import { questionRequestSchema } from '../question';
import { sessionSchema } from '../session';
import { taskSchema } from '../task';

export const inFlightToolCallSchema = z.object({
tool_call_id: z.string().min(1),
Expand Down Expand Up @@ -59,6 +60,21 @@ export const inFlightTurnSchema = z.object({
});
export type InFlightTurn = z.infer<typeof inFlightTurnSchema>;

/**
* A live subagent task as of the snapshot watermark. Extends the base task
* wire shape with the swarm identity metadata that otherwise only rides the
* (non-replayed) `subagent.spawned` WS event.
*/
export const snapshotSubagentSchema = taskSchema.extend({
subagent_phase: z.enum(['queued', 'working', 'suspended', 'completed', 'failed']).optional(),
subagent_type: z.string().optional(),
parent_tool_call_id: z.string().optional(),
suspended_reason: z.string().optional(),
swarm_index: z.number().int().nonnegative().optional(),
run_in_background: z.boolean().optional(),
});
export type SnapshotSubagent = z.infer<typeof snapshotSubagentSchema>;

export const sessionSnapshotResponseSchema = z.object({
/** Durable event watermark this snapshot is consistent with. */
as_of_seq: z.number().int().nonnegative(),
Expand All @@ -71,6 +87,12 @@ export const sessionSnapshotResponseSchema = z.object({
has_more: z.boolean(),
}),
in_flight_turn: inFlightTurnSchema.nullable(),
/**
* Roster of live subagent tasks at the watermark, so a reconnecting client
* can rebuild swarm cards before the swarm's tool result lands. Optional
* for cross-version tolerance: older servers do not send it.
*/
subagents: z.array(snapshotSubagentSchema).optional(),
pending_approvals: z.array(approvalRequestSchema),
pending_questions: z.array(questionRequestSchema),
});
Expand Down
2 changes: 1 addition & 1 deletion packages/server/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The Kimi Code server. It hosts `agent-core` sessions and exposes them over REST

- Top level: `start.ts`, `index.ts`, `envelope.ts`, `error-handler.ts`, `lock.ts`, `request-id.ts`, `version.ts`.
- `routes/` — REST domain modules, the `registerApiV1Routes.ts` aggregator, `webAssets.ts`, and `action-suffix.ts`.
- `services/` — server-owned DI adapters: `approval/`, `question/`, `gateway/` (`rest`/`ws`/`broadcast`/`connectionRegistry`/`sessionClients`/`sessionEventJournal`/`inFlightTurnTracker`), `pinoLoggerService.ts`, `serviceCollection.ts`.
- `services/` — server-owned DI adapters: `approval/`, `question/`, `gateway/` (`rest`/`ws`/`broadcast`/`connectionRegistry`/`sessionClients`/`sessionEventJournal`/`inFlightTurnTracker`/`subagentRosterTracker`), `pinoLoggerService.ts`, `serviceCollection.ts`.
- `ws/` — `connection.ts` (`WsConnection`), `protocol.ts` (frame builders), `rawData.ts`.
- `middleware/` — `defineRoute.ts`, `schema.ts`, `validate.ts`. `openapi/transforms.ts`.
- `svc/` — OS service managers (launchd / systemd / schtasks) backing `kimi server install/start`.
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/routes/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ async function readViaLegacyAssembly(ix: IInstantiationService, sid: string) {
session: session!,
messages: { items, has_more: hasMore },
in_flight_turn: inFlightTurn,
subagents: snapState.subagents,
pending_approvals: approvals.listPending(sid),
pending_questions: questions.listPending(sid),
};
Expand Down
Loading
Loading