Skip to content
Closed
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/kap-server-subagent-roster.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Fix swarm member lists disappearing after a page refresh on the v2 backend.
5 changes: 5 additions & 0 deletions .changeset/kap-server-task-foreground-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Keep foreground subagents out of the dock's background-task list on the v2 backend, where they appeared as unstoppable entries.
50 changes: 50 additions & 0 deletions apps/kimi-web/test/workspace-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DaemonApiError } from '../src/api/errors';
import { createInitialState } from '../src/api/daemon/eventReducer';
import { mergeWorkspaces } from '../src/lib/mergeWorkspaces';
import { loadWorkspaceNameOverrides, saveWorkspaceNameOverrides } from '../src/lib/storage';
import { useTaskPoller } from '../src/composables/client/useTaskPoller';
import { useWorkspaceState, type UseWorkspaceStateDeps } from '../src/composables/client/useWorkspaceState';
import type { ExtendedState } from '../src/composables/useKimiWebClient';

Expand All @@ -26,6 +27,7 @@ const apiMock = vi.hoisted(() => ({
getHealth: vi.fn(),
getMeta: vi.fn(),
listSessions: vi.fn(),
listTasks: vi.fn(),
listWorkspaces: vi.fn(),
}));

Expand Down Expand Up @@ -1270,3 +1272,51 @@ describe('useWorkspaceState — loadAllSessions usage preservation', () => {
expect(next[0].usage.contextTokens).toBe(0);
});
});

describe('useTaskPoller — foreground subagent identity', () => {
beforeEach(() => {
apiMock.listTasks.mockReset();
});

it('keeps the snapshot task when REST refreshes only background tasks', async () => {
const state = createState();
const foreground: AppTask = {
id: 'agent-1',
sessionId: 'sess_1',
kind: 'subagent',
description: 'Review files',
status: 'running',
createdAt: '2026-01-01T00:00:00.000Z',
subagentType: 'explore',
parentToolCallId: 'call-1',
swarmIndex: 0,
runInBackground: false,
};
const oldBackground: AppTask = {
id: 'bash-1',
sessionId: 'sess_1',
kind: 'bash',
description: 'Run tests',
status: 'running',
createdAt: '2026-01-01T00:00:00.000Z',
outputPreview: 'old output',
};
const refreshedBackground: AppTask = {
...oldBackground,
description: 'Fresh background task',
outputPreview: 'fresh output',
};
const poller = useTaskPoller(state, computed(() => []));
state.tasksBySession = { sess_1: [foreground, oldBackground] };
apiMock.listTasks.mockResolvedValue([refreshedBackground]);

await poller.loadTasksForSession('sess_1');

const tasks = state.tasksBySession.sess_1 ?? [];
expect(tasks.find((task) => task.id === foreground.id)).toEqual(foreground);
expect(tasks.find((task) => task.id === oldBackground.id)).toMatchObject({
description: 'Fresh background task',
outputPreview: 'fresh output',
});
});
});
1 change: 1 addition & 0 deletions packages/kap-server/src/routes/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ async function readViaLegacyAssembly(
session,
messages: { items, has_more: hasMore },
in_flight_turn: inFlightTurn,
subagents: snapState.subagents,
pending_approvals: pendingApprovals,
pending_questions: pendingQuestions,
};
Expand Down
16 changes: 11 additions & 5 deletions packages/kap-server/src/routes/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,11 +122,12 @@ export function registerTasksRoutes(app: TasksRouteHost, core: Scope): void {
return;
}

// `list(false)` = include terminal (ghost) tasks, matching v1 which
// lists everything and filters by wire status in-memory.
const all = (resolved.tasks?.list(false) ?? []).map((info) =>
toWireTask(session_id, info),
);
// `list(false)` includes terminal ghost records. The v1 endpoint lists
// only background tasks, while v2 also tracks foreground work internally,
// so keep foreground ownership on the snapshot / WS path.
const all = (resolved.tasks?.list(false) ?? [])
.filter((info) => info.detached !== false)
Comment thread
wbxl2000 marked this conversation as resolved.
.map((info) => toWireTask(session_id, info));
const query = req.query as { status?: TaskStatus };
const items =
query.status !== undefined ? all.filter((t) => t.status === query.status) : all;
Expand Down Expand Up @@ -360,6 +361,11 @@ function toWireTask(
// running tasks usually start immediately after creation.
created_at: createdIso,
started_at: createdIso,
// `detached === false` marks a task a tool call is still waiting on in the
// foreground (v2 registers those too, e.g. foreground Agent runs) — the web
// dock must not list them as background work. Legacy records without the
// flag count as detached.
run_in_background: info.detached !== false,
Comment thread
wbxl2000 marked this conversation as resolved.
};
if (info.endedAt !== null && info.endedAt !== undefined) {
base.completed_at = new Date(info.endedAt).toISOString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export class SnapshotReader implements ISnapshotReader {
session,
messages: { items, has_more: hasMore },
in_flight_turn: inFlightTurn,
subagents: snapState.subagents,
pending_approvals: approvals,
pending_questions: questions,
};
Expand Down
17 changes: 14 additions & 3 deletions packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import type {
SessionCursor,
SessionMetaUpdatedEvent,
SessionStatus,
SnapshotSubagent,
} from '@moonshot-ai/protocol';
import { isVolatileEventType } from '@moonshot-ai/protocol';

Expand All @@ -78,6 +79,7 @@ import {
SessionEventJournal,
sessionJournalPath,
} from './sessionEventJournal';
import { SubagentRosterTracker } from './subagentRosterTracker';

export type ResyncReason = 'buffer_overflow' | 'session_recreated' | 'epoch_changed';

Expand All @@ -93,6 +95,8 @@ export interface SessionSnapshotState {
seq: number;
epoch: string;
inFlightTurn: InFlightTurn | null;
/** Live subagent roster at the watermark (see `SubagentRosterTracker`). */
subagents: SnapshotSubagent[];
}

/** A connection (or test double) that receives sequenced envelopes. */
Expand All @@ -112,6 +116,7 @@ interface SessionState {
readonly sessionId: string;
readonly journal: SessionEventJournal;
readonly tracker: InFlightTurnTracker;
readonly roster: SubagentRosterTracker;
readonly activity?: ISessionActivity;
/** Last status emitted (initialized from live session activity). */
lastStatus?: SessionStatus;
Expand Down Expand Up @@ -238,14 +243,15 @@ export class SessionEventBroadcaster {
if (state === undefined) {
const cold = await this.readColdWatermark(sessionId);
return cold !== undefined
? { ...cold, inFlightTurn: null }
: { seq: 0, epoch: '', inFlightTurn: null };
? { ...cold, inFlightTurn: null, subagents: [] }
: { seq: 0, epoch: '', inFlightTurn: null, subagents: [] };
}
await state.queue;
return {
seq: state.journal.seq,
epoch: state.journal.epoch,
inFlightTurn: state.tracker.get(sessionId),
subagents: state.roster.get(sessionId),
};
}

Expand Down Expand Up @@ -301,6 +307,7 @@ export class SessionEventBroadcaster {
sessionId,
journal,
tracker: new InFlightTurnTracker(),
roster: new SubagentRosterTracker(),
activity,
lastStatus: activity.status(),
tail: [],
Expand Down Expand Up @@ -335,6 +342,7 @@ export class SessionEventBroadcaster {
sessionId: GLOBAL_SESSION_ID,
journal,
tracker: new InFlightTurnTracker(),
roster: new SubagentRosterTracker(),
tail: [],
targets: new Map(),
queue: Promise.resolve(),
Expand Down Expand Up @@ -639,8 +647,11 @@ export class SessionEventBroadcaster {
}

private async dispatch(state: SessionState, event: Event, volatile: boolean): Promise<void> {
const { journal, tracker, tail, targets, sessionId } = state;
const { journal, tracker, roster, tail, targets, sessionId } = state;
const annotation = tracker.apply(sessionId, event);
// Same queue-discipline for the subagent roster: snapshot rebuilds must
// see exactly the roster as of the durable watermark.
roster.apply(sessionId, event);

let envelope: EventEnvelope;
if (volatile) {
Expand Down
106 changes: 106 additions & 0 deletions packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* `SubagentRosterTracker` — accumulates the per-session roster of live
* subagent tasks so a reconnecting client can rebuild swarm cards from the
* session snapshot. The refresh flow subscribes at the snapshot watermark, so
* earlier `subagent.spawned` events — the only carriers of the swarm identity
* metadata — are never replayed to it.
*
* Owned by the `SessionEventBroadcaster` and updated inside its per-session
* dispatch queue — same pattern as `InFlightTurnTracker`, keeping the roster,
* the journal watermark, and fan-out order mutually consistent.
*
* Lifetime: the roster is dropped on the main agent's `turn.ended`. After the
* main turn ends, the swarm's `<agent_swarm_result>` tool output is in the wire
* transcript and becomes the restore source; this also bounds the roster's
* lifetime (background subagents that outlive a turn are a known, pre-existing
* bound — same trade-off as `InFlightTurnTracker`).
*/

import { MAIN_AGENT_ID } from '@moonshot-ai/agent-core-v2';
import type { Event, SnapshotSubagent } from '@moonshot-ai/protocol';

export class SubagentRosterTracker {
private readonly bySession = new Map<string, Map<string, SnapshotSubagent>>();

apply(sessionId: string, event: Event): void {
switch (event.type) {
case 'subagent.spawned': {
let roster = this.bySession.get(sessionId);
if (!roster) {
roster = new Map();
this.bySession.set(sessionId, roster);
}
roster.set(event.subagentId, {
id: event.subagentId,
session_id: sessionId,
kind: 'subagent',
description: event.description ?? event.subagentName ?? 'Sub Agent',
status: 'running',
subagent_phase: 'queued',
subagent_type: event.subagentName,
parent_tool_call_id: event.parentToolCallId,
swarm_index: event.swarmIndex,
run_in_background: event.runInBackground,
created_at: new Date().toISOString(),
});
return;
}
case 'subagent.started': {
const entry = this.bySession.get(sessionId)?.get(event.subagentId);
if (!entry) return;
entry.subagent_phase = 'working';
entry.suspended_reason = undefined;
// Keep an existing started_at: a resumed (previously suspended)
// subagent re-fires `subagent.started`.
entry.started_at ??= new Date().toISOString();
return;
}
case 'subagent.suspended': {
const entry = this.bySession.get(sessionId)?.get(event.subagentId);
if (!entry) return;
entry.subagent_phase = 'suspended';
entry.suspended_reason = event.reason;
return;
}
case 'subagent.completed': {
const entry = this.bySession.get(sessionId)?.get(event.subagentId);
if (!entry) return;
entry.subagent_phase = 'completed';
entry.status = 'completed';
entry.completed_at = new Date().toISOString();
entry.output_preview = event.resultSummary;
return;
}
case 'subagent.failed': {
const entry = this.bySession.get(sessionId)?.get(event.subagentId);
if (!entry) return;
entry.subagent_phase = 'failed';
entry.status = 'failed';
entry.completed_at = new Date().toISOString();
entry.output_preview = event.error;
return;
}
case 'turn.ended': {
if (event.agentId !== MAIN_AGENT_ID) return;
// After turn end the swarm's `<agent_swarm_result>` tool output is in
// the wire transcript and becomes the restore source; dropping the
// roster here also bounds its lifetime.
this.bySession.delete(sessionId);
return;
}
default:
return;
}
}

/** Fresh copies — callers must not mutate the tracked entries. */
get(sessionId: string): SnapshotSubagent[] {
const roster = this.bySession.get(sessionId);
if (!roster) return [];
return Array.from(roster.values(), (entry) => ({ ...entry }));
}

clear(sessionId: string): void {
this.bySession.delete(sessionId);
}
}
79 changes: 79 additions & 0 deletions packages/kap-server/test/sessionEventBroadcaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,85 @@ describe('SessionEventBroadcaster', () => {
expect(snap.inFlightTurn).toMatchObject({ turn_id: 1, assistant_text: 'Hello' });
});

it('getSnapshotState keeps the multi-subagent roster when one child turn ends', async () => {
const lc = new FakeLifecycle();
const main = lc.addAgent('main');
const firstChild = lc.addAgent('agent_1');
sessions.set('s1', lc);
await bc.subscribe('s1', collectingTarget().target);

main.bus.emit(
agentEvent('subagent.spawned', {
subagentId: 'agent_1',
subagentName: 'explore',
parentToolCallId: 'call_1',
description: 'explore the auth flow',
swarmIndex: 0,
runInBackground: false,
}),
);
main.bus.emit(
agentEvent('subagent.spawned', {
subagentId: 'agent_2',
subagentName: 'explore',
parentToolCallId: 'call_1',
description: 'inspect the cancellation path',
swarmIndex: 1,
runInBackground: false,
}),
);
main.bus.emit(agentEvent('subagent.started', { subagentId: 'agent_1' }));
main.bus.emit(agentEvent('subagent.started', { subagentId: 'agent_2' }));
firstChild.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' }));
main.bus.emit(agentEvent('subagent.completed', { subagentId: 'agent_1', resultSummary: 'done' }));
const mid = await bc.getSnapshotState('s1');
expect(mid.subagents).toMatchObject([
{
id: 'agent_1',
session_id: 's1',
kind: 'subagent',
description: 'explore the auth flow',
status: 'completed',
subagent_phase: 'completed',
subagent_type: 'explore',
parent_tool_call_id: 'call_1',
swarm_index: 0,
run_in_background: false,
},
{
id: 'agent_2',
session_id: 's1',
kind: 'subagent',
description: 'inspect the cancellation path',
status: 'running',
subagent_phase: 'working',
subagent_type: 'explore',
parent_tool_call_id: 'call_1',
swarm_index: 1,
run_in_background: false,
},
]);
});

it('getSnapshotState drops the live subagent roster when the main turn ends', async () => {
const lc = new FakeLifecycle();
const main = lc.addAgent('main');
sessions.set('s1', lc);
await bc.subscribe('s1', collectingTarget().target);

main.bus.emit(
agentEvent('subagent.spawned', {
subagentId: 'agent_1',
description: 'inspect the auth flow',
runInBackground: false,
}),
);
main.bus.emit(agentEvent('turn.started', { turnId: 1 }));
main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' }));
const after = await bc.getSnapshotState('s1');
expect(after.subagents).toEqual([]);
});

it('fans core model-catalog changes out to every session subscriber', async () => {
const lc = new FakeLifecycle();
lc.addAgent('main');
Expand Down
Loading
Loading