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.
169 changes: 125 additions & 44 deletions apps/kimi-web/src/api/daemon/agentEventProjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ interface SessionState {
// Subagent lifecycle deltas after spawned only carry subagentId. Keep the
// spawned metadata here so later updates can replace the full AppTask.
subagentMeta: Map<string, AppTask>;
subagentRosterAuthoritative: boolean;
}

function createSessionState(): SessionState {
Expand All @@ -146,6 +147,7 @@ function createSessionState(): SessionState {
model: '',
messages: [],
subagentMeta: new Map(),
subagentRosterAuthoritative: false,
};
}

Expand Down Expand Up @@ -203,18 +205,39 @@ function patchSubagent(
if (typeof subagentId !== 'string' || subagentId.length === 0) return null;
const prev = state.subagentMeta.get(subagentId) ?? {
id: subagentId,
agentId: subagentId,
sessionId,
kind: 'subagent',
description: 'Sub Agent',
status: 'running',
createdAt: new Date().toISOString(),
subagentPhase: 'queued',
} satisfies AppTask;
const next: AppTask = { ...prev, ...patch, id: subagentId, sessionId, kind: 'subagent' };
const next: AppTask = {
...prev,
...patch,
id: patch.id ?? prev.id,
agentId: subagentId,
sessionId,
kind: 'subagent',
};
state.subagentMeta.set(subagentId, next);
return next;
}

function patchRunningSubagent(
state: SessionState,
sessionId: string,
subagentId: unknown,
patch: Partial<AppTask>,
): AppTask | null {
if (typeof subagentId !== 'string' || subagentId.length === 0) return null;
const current = state.subagentMeta.get(subagentId);
if (current === undefined && state.subagentRosterAuthoritative) return null;
if (current !== undefined && current.status !== 'running') return null;
return patchSubagent(state, sessionId, subagentId, patch);
}

export function subagentProgressText(rawType: string, payload: Record<string, unknown>): string | null {
// "Started a step" fires on every step and adds no information — the phase
// badge already shows the subagent is working, so skip it to cut the noise.
Expand Down Expand Up @@ -289,36 +312,44 @@ function projectSubagentProgress(
// taskProgress to existing tasks — without this, the deltas are dropped and
// the live detail stays blank until a non-text frame recreates the task.
const previous = state.subagentMeta.get(subagentId);
const task = patchSubagent(state, sessionId, subagentId, {
const task = patchRunningSubagent(state, sessionId, subagentId, {
status: 'running',
subagentPhase: 'working',
startedAt: previous?.startedAt ?? new Date().toISOString(),
});
const out: AppEvent[] = [];
if (task) out.push({ type: 'taskCreated', sessionId, task });
out.push({
type: 'taskProgress',
sessionId,
taskId: subagentId,
outputChunk: delta,
stream: 'stdout',
kind: 'text',
});
return out;
if (!task) return [];
return [
{ type: 'taskCreated', sessionId, task },
{
type: 'taskProgress',
sessionId,
taskId: task.id,
outputChunk: delta,
stream: 'stdout',
kind: 'text',
},
];
}

const text = subagentProgressText(rawType, payload);
if (text === null || text.length === 0) return [];
const previous = state.subagentMeta.get(subagentId);
const task = patchSubagent(state, sessionId, subagentId, {
const task = patchRunningSubagent(state, sessionId, subagentId, {
status: 'running',
subagentPhase: 'working',
startedAt: previous?.startedAt ?? new Date().toISOString(),
});
const out: AppEvent[] = [];
if (task) out.push({ type: 'taskCreated', sessionId, task });
out.push({ type: 'taskProgress', sessionId, taskId: subagentId, outputChunk: text, stream: 'stdout' });
return out;
if (!task) return [];
return [
{ type: 'taskCreated', sessionId, task },
{
type: 'taskProgress',
sessionId,
taskId: task.id,
outputChunk: text,
stream: 'stdout',
},
];
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -508,6 +539,7 @@ export interface AgentProjector {
* snapshot's `session.status` is the authoritative value.
*/
seedInFlight(sessionId: string, turn: AppInFlightTurn): AppEvent[];
seedSubagents(sessionId: string, tasks: AppTask[] | undefined): void;
/** Reset all per-session state (call on re-subscribe / resync). */
reset(sessionId: string): void;
/**
Expand Down Expand Up @@ -581,6 +613,17 @@ export function createAgentProjector(): AgentProjector {
return [{ type: 'messageCreated', message: cloneMessage(msg) }];
}

function seedSubagents(sessionId: string, tasks: AppTask[] | undefined): void {
const s = getOrCreate(sessionId);
s.subagentRosterAuthoritative = tasks !== undefined;
if (tasks === undefined) return;
s.subagentMeta.clear();
for (const task of tasks) {
if (task.kind !== 'subagent') continue;
s.subagentMeta.set(task.agentId ?? task.id, task);
}
Comment thread
wbxl2000 marked this conversation as resolved.
}

function project(
rawType: string,
payload: unknown,
Expand Down Expand Up @@ -1004,6 +1047,7 @@ export function createAgentProjector(): AgentProjector {
const taskId = typeof p?.subagentId === 'string' && p.subagentId.length > 0 ? p.subagentId : ulid('task_');
const task: AppTask = {
id: taskId,
agentId: taskId,
sessionId,
kind: 'subagent',
description: typeof p?.description === 'string' ? p.description : p?.subagentName ?? 'Sub Agent',
Expand All @@ -1025,7 +1069,7 @@ export function createAgentProjector(): AgentProjector {
}

case 'subagent.started': {
const task = patchSubagent(s, sessionId, p?.subagentId, {
const task = patchRunningSubagent(s, sessionId, p?.subagentId, {
subagentPhase: 'working',
status: 'running',
startedAt: new Date().toISOString(),
Expand All @@ -1035,7 +1079,7 @@ export function createAgentProjector(): AgentProjector {
}

case 'subagent.suspended': {
const task = patchSubagent(s, sessionId, p?.subagentId, {
const task = patchRunningSubagent(s, sessionId, p?.subagentId, {
subagentPhase: 'suspended',
status: 'running',
suspendedReason: typeof p?.reason === 'string' ? p.reason : undefined,
Expand All @@ -1046,39 +1090,25 @@ export function createAgentProjector(): AgentProjector {

case 'subagent.completed': {
const outputPreview = typeof p?.resultSummary === 'string' ? p.resultSummary : undefined;
const task = patchSubagent(s, sessionId, p?.subagentId, {
const task = patchRunningSubagent(s, sessionId, p?.subagentId, {
subagentPhase: 'completed',
status: 'completed',
completedAt: new Date().toISOString(),
outputPreview,
});
if (task) out.push({ type: 'taskCreated', sessionId, task });
out.push({
type: 'taskCompleted',
sessionId,
taskId: p?.subagentId ?? '',
status: 'completed',
outputPreview,
});
break;
}

case 'subagent.failed': {
const outputPreview = typeof p?.error === 'string' ? p.error : undefined;
const task = patchSubagent(s, sessionId, p?.subagentId, {
const task = patchRunningSubagent(s, sessionId, p?.subagentId, {
subagentPhase: 'failed',
status: 'failed',
completedAt: new Date().toISOString(),
outputPreview,
});
if (task) out.push({ type: 'taskCreated', sessionId, task });
out.push({
type: 'taskCompleted',
sessionId,
taskId: p?.subagentId ?? '',
status: 'failed',
outputPreview,
});
break;
}

Expand All @@ -1101,7 +1131,7 @@ export function createAgentProjector(): AgentProjector {
}

// -----------------------------------------------------------------------
// Tasks (e.g. a detached Bash command). Real daemon shape:
// Tasks (e.g. a detached Bash command or Agent run). Real daemon shape:
// payload.info = { taskId, description, status, startedAt(ms), endedAt,
// kind:'process', command, pid, exitCode }.
case 'task.started': {
Expand All @@ -1114,6 +1144,20 @@ export function createAgentProjector(): AgentProjector {
: typeof info.taskId === 'number'
? String(info.taskId)
: ulid('task_');
const agentId = typeof info.agentId === 'string' ? info.agentId : undefined;
if (info.kind === 'agent' && agentId !== undefined) {
const current = s.subagentMeta.get(agentId);
if (current === undefined ? s.subagentRosterAuthoritative : current.status !== 'running') break;
if (current !== undefined && current.id !== agentId && current.id !== taskId) break;
const task = patchSubagent(s, sessionId, agentId, {
id: taskId,
status: current?.status ?? 'running',
startedAt,
runInBackground: true,
});
if (task) out.push({ type: 'taskCreated', sessionId, task });
break;
}
const description =
typeof info.description === 'string'
? info.description
Expand All @@ -1140,18 +1184,55 @@ export function createAgentProjector(): AgentProjector {
}
case 'task.terminated': {
const info = (p?.info ?? {}) as Record<string, unknown>;
const agentId = typeof info.agentId === 'string' ? info.agentId : undefined;
const taskId =
typeof info.taskId === 'string'
? info.taskId
: typeof info.taskId === 'number'
? String(info.taskId)
: '';
if (info.kind === 'agent' && agentId !== undefined) {
const status =
info.status === 'completed'
? 'completed'
: info.status === 'killed'
? 'cancelled'
: 'failed';
const current = s.subagentMeta.get(agentId);
if (current !== undefined && taskId.length > 0 && current.id !== taskId) break;
const completedAt =
typeof info.endedAt === 'number'
? new Date(info.endedAt).toISOString()
: new Date().toISOString();
const task =
current === undefined && s.subagentRosterAuthoritative
? null
: patchSubagent(s, sessionId, agentId, {
id: taskId.length > 0 ? taskId : agentId,
status,
subagentPhase: status === 'completed' ? 'completed' : 'failed',
completedAt,
runInBackground: true,
});
out.push({
type: 'taskCompleted',
sessionId,
taskId: task?.id ?? (taskId || agentId),
agentId,
status,
subagentPhase: status === 'completed' ? 'completed' : 'failed',
completedAt: task?.completedAt ?? completedAt,
runInBackground: true,
});
break;
}
const failed =
info.status === 'failed' ||
(typeof info.exitCode === 'number' && info.exitCode !== 0);
out.push({
type: 'taskCompleted',
sessionId,
taskId:
typeof info.taskId === 'string'
? info.taskId
: typeof info.taskId === 'number'
? String(info.taskId)
: '',
taskId,
status: failed ? 'failed' : 'completed',
// Do NOT set outputPreview here. The command is already kept on the
// task as `command`; setting outputPreview to `$ <command>` would
Expand Down Expand Up @@ -1263,7 +1344,7 @@ export function createAgentProjector(): AgentProjector {
return out;
}

return { project, bindNextPromptId, seedInFlight, reset, markSideChannelAgent };
return { project, bindNextPromptId, seedInFlight, seedSubagents, reset, markSideChannelAgent };
}

// ---------------------------------------------------------------------------
Expand Down
15 changes: 8 additions & 7 deletions apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,8 +560,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),
// Preserve omission for older servers; a present empty roster is authoritative.
subagents: data.subagents?.map(toAppTask),
};
traceKeyEvent('session:snapshot:accepted', {
sessionId,
Expand Down Expand Up @@ -1492,12 +1492,13 @@ export class DaemonKimiWebApi implements KimiWebApi {
// message list.
if (snapshot.inFlightTurn === null) {
projector.reset(sessionId);
return;
}
const appEvents = projector.seedInFlight(sessionId, snapshot.inFlightTurn);
for (const appEvent of appEvents) {
handlers.onEvent(appEvent, { sessionId, seq: snapshot.asOfSeq });
} else {
const appEvents = projector.seedInFlight(sessionId, snapshot.inFlightTurn);
for (const appEvent of appEvents) {
handlers.onEvent(appEvent, { sessionId, seq: snapshot.asOfSeq });
}
}
projector.seedSubagents(sessionId, snapshot.subagents);
},
bindNextPromptId(sessionId: string, promptId: string): void {
// Wire the real daemon prompt_id into the projector so turn.started
Expand Down
Loading
Loading