mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
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:
@@ -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'))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user