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 bc882d0bac..30ced16f43 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -10,6 +10,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'; import { clearTrace, traceKeyEvent } from '../src/debug/trace'; @@ -33,6 +34,7 @@ const apiMock = vi.hoisted(() => ({ getHealth: vi.fn(), getMeta: vi.fn(), listSessions: vi.fn(), + listTasks: vi.fn(), listWorkspaces: vi.fn(), })); @@ -1675,3 +1677,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/tasks.ts b/packages/kap-server/src/routes/tasks.ts index e2bf35888b..b5b1872129 100644 --- a/packages/kap-server/src/routes/tasks.ts +++ b/packages/kap-server/src/routes/tasks.ts @@ -123,11 +123,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; @@ -362,6 +363,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/test/tasks.test.ts b/packages/kap-server/test/tasks.test.ts index c617a4b45d..3ae29b026d 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;