diff --git a/.changeset/kap-server-subagent-roster.md b/.changeset/kap-server-subagent-roster.md new file mode 100644 index 0000000000..ea61c5e075 --- /dev/null +++ b/.changeset/kap-server-subagent-roster.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix swarm member lists disappearing after a page refresh on the v2 backend. diff --git a/.changeset/kap-server-task-foreground-flag.md b/.changeset/kap-server-task-foreground-flag.md new file mode 100644 index 0000000000..5d6f1e1008 --- /dev/null +++ b/.changeset/kap-server-task-foreground-flag.md @@ -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. diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 6e5c4c6821..ade7bf320d 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -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'; @@ -26,6 +27,7 @@ const apiMock = vi.hoisted(() => ({ getHealth: vi.fn(), getMeta: vi.fn(), listSessions: vi.fn(), + listTasks: vi.fn(), listWorkspaces: vi.fn(), })); @@ -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', + }); + }); +}); diff --git a/packages/kap-server/src/routes/snapshot.ts b/packages/kap-server/src/routes/snapshot.ts index 13ce2eecf1..a082b00a0a 100644 --- a/packages/kap-server/src/routes/snapshot.ts +++ b/packages/kap-server/src/routes/snapshot.ts @@ -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, }; diff --git a/packages/kap-server/src/routes/tasks.ts b/packages/kap-server/src/routes/tasks.ts index 922367a11c..ddc0f84aee 100644 --- a/packages/kap-server/src/routes/tasks.ts +++ b/packages/kap-server/src/routes/tasks.ts @@ -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) + .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; @@ -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, }; if (info.endedAt !== null && info.endedAt !== undefined) { base.completed_at = new Date(info.endedAt).toISOString(); diff --git a/packages/kap-server/src/services/snapshot/snapshotReader.ts b/packages/kap-server/src/services/snapshot/snapshotReader.ts index 91fa0a5e13..05ad26ba12 100644 --- a/packages/kap-server/src/services/snapshot/snapshotReader.ts +++ b/packages/kap-server/src/services/snapshot/snapshotReader.ts @@ -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, }; diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 6025c4d1f2..b64e36c3ba 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -61,6 +61,7 @@ import type { SessionCursor, SessionMetaUpdatedEvent, SessionStatus, + SnapshotSubagent, } from '@moonshot-ai/protocol'; import { isVolatileEventType } from '@moonshot-ai/protocol'; @@ -78,6 +79,7 @@ import { SessionEventJournal, sessionJournalPath, } from './sessionEventJournal'; +import { SubagentRosterTracker } from './subagentRosterTracker'; export type ResyncReason = 'buffer_overflow' | 'session_recreated' | 'epoch_changed'; @@ -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. */ @@ -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; @@ -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), }; } @@ -301,6 +307,7 @@ export class SessionEventBroadcaster { sessionId, journal, tracker: new InFlightTurnTracker(), + roster: new SubagentRosterTracker(), activity, lastStatus: activity.status(), tail: [], @@ -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(), @@ -639,8 +647,11 @@ export class SessionEventBroadcaster { } private async dispatch(state: SessionState, event: Event, volatile: boolean): Promise { - 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) { diff --git a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts new file mode 100644 index 0000000000..5098871ddc --- /dev/null +++ b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts @@ -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 `` 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>(); + + 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 `` 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); + } +} diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index e96e9c133f..3e1356413a 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -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'); diff --git a/packages/kap-server/test/snapshotReader.unit.test.ts b/packages/kap-server/test/snapshotReader.unit.test.ts index c357bb3f7a..dbd3481e5c 100644 --- a/packages/kap-server/test/snapshotReader.unit.test.ts +++ b/packages/kap-server/test/snapshotReader.unit.test.ts @@ -50,7 +50,7 @@ interface Fixture { sessionDir: (sid: string) => string; index: Map; reader: SnapshotReader; - broadcaster: { seq: number; epoch: string; inFlightTurn: unknown }; + broadcaster: { seq: number; epoch: string; inFlightTurn: unknown; subagents: unknown[] }; } const tmpDirs: string[] = []; @@ -70,7 +70,7 @@ async function makeFixtureAsync(opts?: { cacheLimit?: number }): Promise undefined }], ]), }; - const broadcaster = { seq: 0, epoch: 'ep_unit', inFlightTurn: null }; + const broadcaster = { seq: 0, epoch: 'ep_unit', inFlightTurn: null, subagents: [] as unknown[] }; const deps: SnapshotReaderDeps = { homeDir, core: core as never, @@ -79,6 +79,7 @@ async function makeFixtureAsync(opts?: { cacheLimit?: number }): Promise { expect(snap.messages.items).toHaveLength(1); expect((snap.messages.items[0]!.content[0] as { text: string }).text).toBe('only-one'); }); + + it('passes the broadcast subagent roster through to the response', async () => { + const f = await makeFixtureAsync(); + const sid = 'sess_roster'; + await seedSession(f, sid); + f.broadcaster.subagents = [ + { + id: 'agent_1', + session_id: sid, + kind: 'subagent', + description: 'explore the auth flow', + status: 'running', + created_at: new Date().toISOString(), + subagent_phase: 'working', + swarm_index: 0, + run_in_background: false, + }, + ]; + const snap = await f.reader.read(sid); + expect(snap.subagents).toMatchObject([ + { id: 'agent_1', subagent_phase: 'working', swarm_index: 0, run_in_background: false }, + ]); + }); }); describe('readWireRecords', () => { diff --git a/packages/kap-server/test/subagentRosterTracker.test.ts b/packages/kap-server/test/subagentRosterTracker.test.ts new file mode 100644 index 0000000000..e2683ee5d0 --- /dev/null +++ b/packages/kap-server/test/subagentRosterTracker.test.ts @@ -0,0 +1,137 @@ +/** + * `SubagentRosterTracker` — live subagent roster for snapshot rebuilds. + */ + +import type { Event } from '@moonshot-ai/protocol'; +import { describe, expect, it } from 'vitest'; + +import { SubagentRosterTracker } from '../src/transport/ws/v1/subagentRosterTracker'; + +const SID = 'sess_1'; + +function ev(partial: Record): Event { + return { agentId: 'main', sessionId: SID, ...partial } as unknown as Event; +} + +function spawned(overrides: Record = {}): Event { + return ev({ + type: 'subagent.spawned', + subagentId: 'agent_1', + subagentName: 'explore', + parentToolCallId: 'call_1', + description: 'explore the auth flow', + swarmIndex: 0, + runInBackground: false, + ...overrides, + }); +} + +describe('SubagentRosterTracker', () => { + it('records the full swarm identity on spawn', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + expect(t.get(SID)).toMatchObject([ + { + id: 'agent_1', + session_id: SID, + kind: 'subagent', + description: 'explore the auth flow', + status: 'running', + subagent_phase: 'queued', + subagent_type: 'explore', + parent_tool_call_id: 'call_1', + swarm_index: 0, + run_in_background: false, + }, + ]); + expect(t.get(SID)[0]?.created_at).toBeDefined(); + }); + + it('ignores lifecycle events for unknown subagent ids', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, ev({ type: 'subagent.completed', subagentId: 'ghost', resultSummary: 'x' })); + t.apply(SID, ev({ type: 'subagent.started', subagentId: 'ghost' })); + t.apply(SID, ev({ type: 'subagent.suspended', subagentId: 'ghost', reason: 'approval' })); + expect(t.get(SID)).toEqual([]); + }); + + it('tracks suspend and resume, keeping the original started_at', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply(SID, ev({ type: 'subagent.started', subagentId: 'agent_1' })); + const startedAt = t.get(SID)[0]?.started_at; + expect(startedAt).toBeDefined(); + + t.apply(SID, ev({ type: 'subagent.suspended', subagentId: 'agent_1', reason: 'awaiting approval' })); + expect(t.get(SID)[0]).toMatchObject({ + subagent_phase: 'suspended', + suspended_reason: 'awaiting approval', + }); + + t.apply(SID, ev({ type: 'subagent.started', subagentId: 'agent_1' })); + const resumed = t.get(SID)[0]!; + expect(resumed.subagent_phase).toBe('working'); + expect(resumed.started_at).toBe(startedAt); + expect(resumed.suspended_reason).toBeUndefined(); + }); + + it('records completion with the result summary as output preview', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply(SID, ev({ type: 'subagent.completed', subagentId: 'agent_1', resultSummary: 'done' })); + expect(t.get(SID)[0]).toMatchObject({ + subagent_phase: 'completed', + status: 'completed', + output_preview: 'done', + }); + expect(t.get(SID)[0]?.completed_at).toBeDefined(); + }); + + it('records failure with the error as output preview', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply(SID, ev({ type: 'subagent.failed', subagentId: 'agent_1', error: 'boom' })); + expect(t.get(SID)[0]).toMatchObject({ + subagent_phase: 'failed', + status: 'failed', + output_preview: 'boom', + }); + }); + + it('keeps the roster when a child agent turn ends', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply(SID, spawned({ subagentId: 'agent_2', swarmIndex: 1 })); + t.apply( + SID, + ev({ type: 'turn.ended', agentId: 'agent_1', turnId: 1, reason: 'completed' }), + ); + expect(t.get(SID).map((entry) => entry.id)).toEqual(['agent_1', 'agent_2']); + }); + + it('drops the roster when the main agent turn ends', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply(SID, ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + expect(t.get(SID)).toEqual([]); + }); + + it('returns fresh copies that do not alias the tracked entries', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + const first = t.get(SID); + first[0]!.status = 'failed'; + first.push({} as never); + const second = t.get(SID); + expect(second).toHaveLength(1); + expect(second[0]?.status).toBe('running'); + }); + + it('clear drops the roster', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + expect(t.get(SID)).toHaveLength(1); + t.clear(SID); + expect(t.get(SID)).toEqual([]); + }); +}); diff --git a/packages/kap-server/test/tasks.test.ts b/packages/kap-server/test/tasks.test.ts index e59f58f18d..5d21a560aa 100644 --- a/packages/kap-server/test/tasks.test.ts +++ b/packages/kap-server/test/tasks.test.ts @@ -34,6 +34,7 @@ interface TaskWire { completed_at?: string; output_preview?: string; output_bytes?: number; + run_in_background?: boolean; } interface ListWire { @@ -197,6 +198,47 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => { }); }); + it('lists only background tasks while GET preserves the foreground flag', async () => { + const id = await createSession(); + const tasks = await mainAgentTasks(id); + const backgroundId = tasks.registerTask(fakeTask('agent')); + const foregroundId = tasks.registerTask(fakeTask('agent'), { detached: false }); + await flush(); + + const { body } = await getJson(`/api/v1/sessions/${id}/tasks`); + const byId = new Map(body.data.items.map((t) => [t.id, t])); + expect(byId.get(backgroundId)?.run_in_background).toBe(true); + expect(byId.has(foregroundId)).toBe(false); + + const foreground = await getJson( + `/api/v1/sessions/${id}/tasks/${foregroundId}`, + ); + expect(foreground.body.data.run_in_background).toBe(false); + }); + + it('drops settled foreground tasks from the list but keeps settled background ones', async () => { + const id = await createSession(); + const tasks = await mainAgentTasks(id); + const settleNow = (): AgentTask => ({ + ...fakeTask('agent'), + start: (sink) => { + void sink.settle({ status: 'completed' }); + }, + }); + const foregroundId = tasks.registerTask(settleNow(), { detached: false }); + const backgroundId = tasks.registerTask(settleNow()); + await flush(); + + const { body } = await getJson(`/api/v1/sessions/${id}/tasks`); + // Terminal foreground tasks are deliberately hidden (shouldListTask); + // terminal background tasks stay as ghost records. + expect(body.data.items.some((t) => t.id === foregroundId)).toBe(false); + expect(body.data.items.find((t) => t.id === backgroundId)).toMatchObject({ + status: 'completed', + run_in_background: true, + }); + }); + it('filters the list by wire status', async () => { const id = await createSession(); const tasks = await mainAgentTasks(id); diff --git a/packages/protocol/src/__tests__/task.test.ts b/packages/protocol/src/__tests__/task.test.ts index 4bcffbdf01..c506440834 100644 --- a/packages/protocol/src/__tests__/task.test.ts +++ b/packages/protocol/src/__tests__/task.test.ts @@ -69,4 +69,9 @@ describe('taskSchema', () => { const bad = { ...full, created_at: '2026-06-04T10:00:00' }; expect(taskSchema.safeParse(bad).success).toBe(false); }); + + it('accepts the optional run_in_background flag', () => { + expect(taskSchema.parse({ ...full, run_in_background: false }).run_in_background).toBe(false); + expect(taskSchema.parse(full).run_in_background).toBeUndefined(); + }); }); diff --git a/packages/protocol/src/task.ts b/packages/protocol/src/task.ts index bc08eec71f..c18d18f043 100644 --- a/packages/protocol/src/task.ts +++ b/packages/protocol/src/task.ts @@ -25,6 +25,12 @@ export const taskSchema = z.object({ completed_at: isoDateTimeSchema.optional(), output_preview: z.string().optional(), output_bytes: z.number().int().nonnegative().optional(), + /** + * `false` = a tool call is still waiting on this task in the foreground + * (not detached background work). Optional for cross-version tolerance: + * older servers omit it and served only background tasks. + */ + run_in_background: z.boolean().optional(), }); export type Task = z.infer;