diff --git a/src/tools/TaskCreateTool/TaskCreateTool.ts b/src/tools/TaskCreateTool/TaskCreateTool.ts index cb892656..6ed390a2 100644 --- a/src/tools/TaskCreateTool/TaskCreateTool.ts +++ b/src/tools/TaskCreateTool/TaskCreateTool.ts @@ -78,7 +78,7 @@ export const TaskCreateTool = buildTool({ return null }, async call({ subject, description, activeForm, metadata }, context) { - const taskId = await createTask(getTaskListId(), { + const taskId = await createTask(getTaskListId(context?.agentId), { subject, description, activeForm, @@ -108,7 +108,7 @@ export const TaskCreateTool = buildTool({ } if (blockingErrors.length > 0) { - await deleteTask(getTaskListId(), taskId) + await deleteTask(getTaskListId(context?.agentId), taskId) throw new Error(blockingErrors.join('\n')) } diff --git a/src/tools/TaskGetTool/TaskGetTool.ts b/src/tools/TaskGetTool/TaskGetTool.ts index ea9aac9f..478dc16a 100644 --- a/src/tools/TaskGetTool/TaskGetTool.ts +++ b/src/tools/TaskGetTool/TaskGetTool.ts @@ -70,8 +70,8 @@ export const TaskGetTool = buildTool({ renderToolUseMessage() { return null }, - async call({ taskId }) { - const taskListId = getTaskListId() + async call({ taskId }, context) { + const taskListId = getTaskListId(context?.agentId) const task = await getTask(taskListId, taskId) diff --git a/src/tools/TaskListTool/TaskListTool.ts b/src/tools/TaskListTool/TaskListTool.ts index a01ec63b..27cef8ad 100644 --- a/src/tools/TaskListTool/TaskListTool.ts +++ b/src/tools/TaskListTool/TaskListTool.ts @@ -62,8 +62,8 @@ export const TaskListTool = buildTool({ renderToolUseMessage() { return null }, - async call() { - const taskListId = getTaskListId() + async call(_input, context) { + const taskListId = getTaskListId(context?.agentId) const allTasks = (await listTasks(taskListId)).filter( t => !t.metadata?._internal, diff --git a/src/tools/TaskUpdateTool/TaskUpdateTool.ts b/src/tools/TaskUpdateTool/TaskUpdateTool.ts index 9be2d90f..92a4f13f 100644 --- a/src/tools/TaskUpdateTool/TaskUpdateTool.ts +++ b/src/tools/TaskUpdateTool/TaskUpdateTool.ts @@ -134,7 +134,7 @@ export const TaskUpdateTool = buildTool({ }, context, ) { - const taskListId = getTaskListId() + const taskListId = getTaskListId(context?.agentId) // Auto-expand task list when updating tasks context.setAppState(prev => { diff --git a/src/utils/taskListId.test.ts b/src/utils/taskListId.test.ts new file mode 100644 index 00000000..8ab2f114 --- /dev/null +++ b/src/utils/taskListId.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { runWithTeammateContext, type TeammateContext } from './teammateContext.js' +import { getTaskListId } from './tasks.js' + +// The real context API rather than a module stub: stubbing the module drops +// its other exports and breaks every importer of it. +const TEAMMATE: TeammateContext = { + agentId: 'researcher@review-crew', + agentName: 'researcher', + teamName: 'review-crew', + planModeRequired: false, + parentSessionId: 'leader-session', + isInProcessTeammate: true, +} + +describe('getTaskListId', () => { + const originalTaskList = process.env.CLAUDE_CODE_TASK_LIST_ID + + beforeEach(() => { + delete process.env.CLAUDE_CODE_TASK_LIST_ID + }) + + afterEach(() => { + if (originalTaskList) process.env.CLAUDE_CODE_TASK_LIST_ID = originalTaskList + }) + + test('a subagent keeps its own list instead of writing the session list', () => { + // The bug this guards: a subagent runs in-process, so without agent + // scoping every task it created landed in the parent session's list and + // rendered in the UI as if the assistant had planned it. The agent keeps + // its tracking — it just no longer shares the session's. + expect(getTaskListId('a7e7846c7975c3665')).toBe('a7e7846c7975c3665') + expect(getTaskListId('a7e7846c7975c3665')).not.toBe(getTaskListId()) + }) + + test('two subagents never share a list', () => { + // Twenty parallel workflow agents on one list is also a write race. + expect(getTaskListId('agent-one')).not.toBe(getTaskListId('agent-two')) + }) + + test('the session keeps a stable list of its own', () => { + expect(getTaskListId()).toBe(getTaskListId()) + expect(getTaskListId()).toBeTruthy() + }) + + test('an explicit task list id still wins over the agent', () => { + process.env.CLAUDE_CODE_TASK_LIST_ID = 'explicit-list' + expect(getTaskListId('some-agent')).toBe('explicit-list') + }) + + test('a teammate keeps sharing the leader list — that is the point of a team', () => { + runWithTeammateContext(TEAMMATE, () => { + expect(getTaskListId('teammate-agent')).toBe('review-crew') + }) + }) +}) diff --git a/src/utils/tasks.ts b/src/utils/tasks.ts index 90e50e72..0e595366 100644 --- a/src/utils/tasks.ts +++ b/src/utils/tasks.ts @@ -192,11 +192,20 @@ export async function resetTaskList(taskListId: string): Promise { * Priority: * 1. CLAUDE_CODE_TASK_LIST_ID - explicit task list ID * 2. In-process teammate: leader's team name (so teammates share the leader's task list) - * 3. CLAUDE_CODE_TEAM_NAME - set when running as a process-based teammate + * 3. Team name from the active team context * 4. Leader team name - set when the leader creates a team via TeamCreate - * 5. Session ID - fallback for standalone sessions + * 5. Agent ID - a subagent keeps its own list instead of writing the session's + * 6. Session ID - fallback for standalone sessions + * + * A subagent runs in-process, so without step 5 every task it created landed + * in its parent's list and surfaced in the UI as if the assistant had planned + * it. Scoping by agent keeps the agent's own tracking — which is what lets it + * hold a goal across a long run — while keeping it out of the session's list. + * `TodoWriteTool` has always keyed on `context.agentId ?? getSessionId()`; + * this brings the task tools in line with it. Teammates are checked first and + * so keep sharing the leader's list, which is the point of a team. */ -export function getTaskListId(): string { +export function getTaskListId(agentId?: string): string { if (process.env.CLAUDE_CODE_TASK_LIST_ID) { return process.env.CLAUDE_CODE_TASK_LIST_ID } @@ -206,7 +215,7 @@ export function getTaskListId(): string { if (teammateCtx) { return teammateCtx.teamName } - return getTeamName() || leaderTeamName || getSessionId() + return getTeamName() || leaderTeamName || agentId || getSessionId() } /**