fix(tasks): scope a subagent's task list to the agent

`getTaskListId()` fell back to the session id, and a subagent runs
in-process, so every task one created landed in its parent session's list
and rendered in the UI as if the assistant had planned it. Observed with a
workflow whose scan agent filed three review tasks into the main session.

The reach was the bug, not the capability: an agent tracking its own work
is what lets it hold a goal across a long run. So the list is scoped by
agent rather than the tools being withheld. `TodoWriteTool` has always
keyed on `context.agentId ?? getSessionId()`; this brings the four task
tools in line with it.

Teammates are resolved before the agent branch and keep sharing the
leader's list, which is the point of a team. Also corrects the docstring's
claim that `CLAUDE_CODE_TEAM_NAME` is consulted — `getTeamName()` never
reads it.
This commit is contained in:
程序员阿江(Relakkes)
2026-08-10 00:17:16 +08:00
parent 3ce7c67822
commit 260759e133
6 changed files with 76 additions and 11 deletions
+2 -2
View File
@@ -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'))
}
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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 => {
+56
View File
@@ -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')
})
})
})
+13 -4
View File
@@ -192,11 +192,20 @@ export async function resetTaskList(taskListId: string): Promise<void> {
* 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()
}
/**