Skip to content
Open
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-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 @@ -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';
Expand All @@ -33,6 +34,7 @@ const apiMock = vi.hoisted(() => ({
getHealth: vi.fn(),
getMeta: vi.fn(),
listSessions: vi.fn(),
listTasks: vi.fn(),
listWorkspaces: vi.fn(),
}));

Expand Down Expand Up @@ -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',
});
});
});
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 @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
42 changes: 42 additions & 0 deletions packages/kap-server/test/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface TaskWire {
completed_at?: string;
output_preview?: string;
output_bytes?: number;
run_in_background?: boolean;
}

interface ListWire {
Expand Down Expand Up @@ -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<ListWire>(`/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<TaskWire>(
`/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<ListWire>(`/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);
Expand Down
5 changes: 5 additions & 0 deletions packages/protocol/src/__tests__/task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
6 changes: 6 additions & 0 deletions packages/protocol/src/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof taskSchema>;

Expand Down