fix(teams): stabilize activity, history, and lifecycle

This commit is contained in:
程序员阿江(Relakkes)
2026-08-11 05:56:39 +08:00
parent bf695f21d7
commit b5a4d23af1
34 changed files with 6952 additions and 626 deletions
@@ -521,6 +521,249 @@ describe('buildSessionActivityModel', () => {
])
})
it('waits for a pending Agent input to finish before projecting an unknown owner', () => {
const buildMainModel = (message: UIMessage) => buildMainSessionActivityModel({
sessionId: 'session-1',
messages: [message],
tasks: [],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [],
})
const partialAgent: UIMessage = {
id: 'streaming-agent-tool',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'streaming-agent-tool',
input: { description: '正在流式解析任务' },
timestamp: 1000,
isPending: true,
partialInput: '{"description":"正在流式解析任务","name":',
}
expect(hasVisibleSessionActivity(buildMainModel(partialAgent))).toBe(false)
const completedDirectAgent = buildMainModel({
...partialAgent,
input: { description: '正在流式解析任务' },
isPending: false,
partialInput: undefined,
})
expect(completedDirectAgent.sections.subagents.rows).toEqual([
expect.objectContaining({
id: 'streaming-agent-tool',
label: '正在流式解析任务',
status: 'running',
}),
])
})
it('uses the durable Team window to classify name-only Agent launches in both directions', () => {
const messages: UIMessage[] = [
{
id: 'create-tool',
type: 'tool_use',
toolName: 'TeamCreate',
toolUseId: 'create-tool',
input: { team_name: 'durable-team' },
timestamp: 100,
},
{
id: 'create-result',
type: 'tool_result',
toolUseId: 'create-tool',
content: { success: true, team_name: 'durable-team' },
isError: false,
timestamp: 101,
},
{
id: 'window-member',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'window-member',
input: { name: 'reviewer', description: 'Team member from durable scope' },
timestamp: 150,
isPending: false,
},
{
id: 'window-direct',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'window-direct',
input: { description: 'Unnamed direct Agent inside Team scope' },
timestamp: 160,
isPending: false,
},
{
id: 'post-window-direct',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'post-window-direct',
input: { name: 'standalone-reviewer', description: 'Named direct Agent after Team scope' },
timestamp: 250,
isPending: false,
},
{
id: 'explicit-team-member',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'explicit-team-member',
input: {
name: 'explicit-reviewer',
team_name: 'archived-team',
description: 'Explicit Team member outside durable scope',
},
timestamp: 260,
isPending: false,
},
]
const model = buildMainSessionActivityModel({
sessionId: 'session-1',
messages,
teamTaskWindows: [{ startedAt: 100, endedAt: 200 }],
tasks: [],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.sections.subagents.rows).toEqual([
expect.objectContaining({
id: 'window-direct',
label: 'Unnamed direct Agent inside Team scope',
}),
expect.objectContaining({
id: 'post-window-direct',
label: 'Named direct Agent after Team scope',
}),
])
expect(model.sections.team.rows).toEqual([])
})
it('does not classify a named direct Agent as a teammate after TeamCreate fails', () => {
const createTool: UIMessage = {
id: 'create-tool',
type: 'tool_use',
toolName: 'TeamCreate',
toolUseId: 'create-tool',
input: { team_name: 'review-team' },
timestamp: 100,
}
const namedAgent: UIMessage = {
id: 'named-agent-tool',
type: 'tool_use',
toolName: 'Agent',
toolUseId: 'named-agent-tool',
input: { name: 'reviewer', description: 'Review independently' },
timestamp: 200,
}
const buildMainModel = (success: boolean, agentResult?: UIMessage) => buildMainSessionActivityModel({
sessionId: 'session-1',
messages: [
createTool,
{
id: 'create-result',
type: 'tool_result',
toolUseId: 'create-tool',
content: { success, team_name: 'review-team' },
isError: false,
timestamp: 101,
},
namedAgent,
...(agentResult ? [agentResult] : []),
],
tasks: [],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [],
})
expect(buildMainModel(false).sections.subagents.rows).toEqual([
expect.objectContaining({
id: 'named-agent-tool',
label: 'Review independently',
}),
])
expect(hasVisibleSessionActivity(buildMainModel(true))).toBe(false)
const completedTeammate = buildMainModel(true, {
id: 'named-agent-result',
type: 'tool_result',
toolUseId: 'named-agent-tool',
content: { status: 'teammate_spawned', name: 'reviewer', team_name: 'review-team' },
isError: false,
timestamp: 201,
})
expect(hasVisibleSessionActivity(completedTeammate)).toBe(false)
})
it('lets a durable Team end close task scope when TeamDelete is absent from history', () => {
const messages: UIMessage[] = [
{
id: 'create-tool', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create',
input: { team_name: 'audit-team' }, timestamp: 100,
},
{
id: 'create-result', type: 'tool_result', toolUseId: 'create',
content: { success: true }, isError: false, timestamp: 101,
},
{
id: 'team-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'team-task',
input: { subject: 'Shared Team task' }, timestamp: 150,
},
{
id: 'main-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'main-task',
input: { subject: 'Lead follow-up task' }, timestamp: 250,
},
]
const model = buildMainSessionActivityModel({
sessionId: 'session-1',
messages,
teamTaskWindows: [{ startedAt: 100, endedAt: 200 }],
tasks: [],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.sections.tasks.rows.map(row => row.label)).toEqual(['Lead follow-up task'])
})
it('uses a newer durable Team window after an older explicit TeamDelete', () => {
const messages: UIMessage[] = [
{
id: 'old-delete', type: 'tool_use', toolName: 'TeamDelete', toolUseId: 'old-delete',
input: { team_name: 'old-team' }, timestamp: 90,
},
{
id: 'old-delete-result', type: 'tool_result', toolUseId: 'old-delete',
content: { success: true }, isError: false, timestamp: 91,
},
{
id: 'team-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'team-task',
input: { subject: 'Compacted Team task' }, timestamp: 150,
},
{
id: 'main-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'main-task',
input: { subject: 'After compacted Team' }, timestamp: 250,
},
]
const model = buildMainSessionActivityModel({
sessionId: 'session-1',
messages,
teamTaskWindows: [{ startedAt: 100, endedAt: 200 }],
tasks: [],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [],
})
expect(model.sections.tasks.rows.map(row => row.label)).toEqual(['After compacted Team'])
})
it('uses successful Team lifecycle results to hide implicit member spawns without hiding ordinary Agents', () => {
const model = buildSessionActivityModel({
sessionId: 'session-1',
@@ -2,6 +2,11 @@ import type { BackgroundAgentTask, AgentTaskNotification, BackgroundAgentTaskUsa
import type { TaskSummaryItem, UIMessage } from '../../types/chat'
import type { CLITask, TaskStatus } from '../../types/cliTask'
import type { TeamMember } from '../../types/team'
import {
EMPTY_TEAM_LIFECYCLE_CURSOR,
isTeamLifecycleScopedAt,
updateTeamLifecycleCursor,
} from '../../lib/teamLifecycleScope'
import {
createBackgroundTaskDismissKey,
isVisibleSessionBackgroundTask,
@@ -359,16 +364,6 @@ function projectMessagesToRun(messages: UIMessage[], runScope: 'session' | 'agen
return runScope === 'agent' ? messages : messages.filter(keepSessionRunMessage)
}
function isWithinTeamTaskWindow(
timestamp: number,
windows: Array<{ startedAt: number; endedAt?: number }>,
): boolean {
return windows.some((window) => (
timestamp >= window.startedAt &&
(window.endedAt === undefined || timestamp <= window.endedAt)
))
}
function explicitSuccessFlag(value: unknown): boolean | undefined {
if (Array.isArray(value)) {
for (const item of value) {
@@ -409,14 +404,24 @@ function projectMessagesToTaskScope(
const sharedTaskToolUseIds = new Set<string>()
const resultsByToolUseId = collectToolResults(messages)
let transcriptTeamActive: boolean | undefined
let transcriptTeamCursor = EMPTY_TEAM_LIFECYCLE_CURSOR
for (const message of messages) {
if (taskScope === 'team-session' && message.type === 'tool_use') {
const lifecycleSucceeded = teamLifecycleSucceeded(
resultsByToolUseId.get(message.toolUseId),
)
if (message.toolName === 'TeamCreate' && lifecycleSucceeded) transcriptTeamActive = true
if (message.toolName === 'TeamDelete' && lifecycleSucceeded) transcriptTeamActive = false
if (message.toolName === 'TeamCreate' && lifecycleSucceeded) {
transcriptTeamCursor = updateTeamLifecycleCursor(
true,
message.timestamp,
)
}
if (message.toolName === 'TeamDelete' && lifecycleSucceeded) {
transcriptTeamCursor = updateTeamLifecycleCursor(
false,
message.timestamp,
)
}
}
if (
message.type === 'tool_use' &&
@@ -424,9 +429,11 @@ function projectMessagesToTaskScope(
(
taskScope === 'team' ||
(
transcriptTeamActive === undefined
? isWithinTeamTaskWindow(message.timestamp, teamTaskWindows)
: transcriptTeamActive
isTeamLifecycleScopedAt(
message.timestamp,
transcriptTeamCursor,
teamTaskWindows,
)
)
)
) {
@@ -635,6 +642,7 @@ function updatedActiveTeamName(
if (!output) return undefined
if (toolCall.toolName === 'TeamCreate') {
if (output.success === false) return undefined
return stringField(output, 'team_name') || undefined
}
if (toolCall.toolName === 'TeamDelete' && output.success === true) {
@@ -652,25 +660,30 @@ function teamSpawnIdentity(
toolCall: Extract<UIMessage, { type: 'tool_use' }>,
result: Extract<UIMessage, { type: 'tool_result' }> | undefined,
activeTeamName: string | undefined,
teamScoped: boolean,
): TeamSpawnIdentity | null {
const input = isRecordValue(toolCall.input) ? toolCall.input : {}
const inputMemberName = stringField(input, 'name')
if (!inputMemberName) return null
const inputTeamName = stringField(input, 'team_name')
if (inputTeamName || activeTeamName) {
if (inputMemberName && (inputTeamName || teamScoped)) {
return {
memberName: inputMemberName,
teamName: inputTeamName || activeTeamName,
...((inputTeamName || (teamScoped && activeTeamName))
? { teamName: inputTeamName || activeTeamName }
: {}),
}
}
const output = parseToolResultRecord(result)
if (stringField(output ?? {}, 'status') === 'teammate_spawned') {
const memberName = stringField(output ?? {}, 'name') || inputMemberName
if (!memberName) return null
const outputTeamName = stringField(output ?? {}, 'team_name')
return {
memberName: stringField(output ?? {}, 'name') || inputMemberName,
...(outputTeamName ? { teamName: outputTeamName } : {}),
memberName,
...((outputTeamName || activeTeamName)
? { teamName: outputTeamName || activeTeamName }
: {}),
}
}
@@ -689,7 +702,10 @@ function teamSpawnIdentity(
}
}
function buildAgentRowsFromMessages(messages: UIMessage[]): ActivityRow[] {
function buildAgentRowsFromMessages(
messages: UIMessage[],
teamTaskWindows: Array<{ startedAt: number; endedAt?: number }>,
): ActivityRow[] {
const resultsByToolUseId = new Map<string, Extract<UIMessage, { type: 'tool_result' }>>()
const toolCallsByToolUseId = new Map<string, Extract<UIMessage, { type: 'tool_use' }>>()
for (const message of messages) {
@@ -702,6 +718,7 @@ function buildAgentRowsFromMessages(messages: UIMessage[]): ActivityRow[] {
const rows: ActivityRow[] = []
let activeTeamName: string | undefined
let transcriptTeamCursor = EMPTY_TEAM_LIFECYCLE_CURSOR
for (const message of messages) {
if (message.type === 'tool_result') {
const toolCall = toolCallsByToolUseId.get(message.toolUseId)
@@ -711,11 +728,32 @@ function buildAgentRowsFromMessages(messages: UIMessage[]): ActivityRow[] {
}
continue
}
if (message.type !== 'tool_use' || message.toolName !== 'Agent') continue
if (message.type !== 'tool_use') continue
const result = resultsByToolUseId.get(message.toolUseId)
const lifecycleSucceeded = teamLifecycleSucceeded(result)
if (message.toolName === 'TeamCreate' && lifecycleSucceeded) {
transcriptTeamCursor = updateTeamLifecycleCursor(true, message.timestamp)
} else if (message.toolName === 'TeamDelete' && lifecycleSucceeded) {
transcriptTeamCursor = updateTeamLifecycleCursor(false, message.timestamp)
}
if (message.toolName !== 'Agent') continue
const resultText = result ? stripAgentMetadata(extractTextContent(result.content)) : ''
const teamIdentity = teamSpawnIdentity(message, result, activeTeamName)
const teamIdentity = teamSpawnIdentity(
message,
result,
activeTeamName,
isTeamLifecycleScopedAt(
message.timestamp,
transcriptTeamCursor,
teamTaskWindows,
),
)
// Agent inputs stream before their ownership fields. Until the input is
// complete, treating an unknown owner as direct makes Team members flash
// through the main session's SubAgents section.
if (message.isPending && !teamIdentity) continue
if (teamIdentity) {
rows.push({
id: message.toolUseId,
@@ -1169,7 +1207,7 @@ export function buildSessionActivityModel(input: BuildSessionActivityModelInput)
]).filter((name): name is string => Boolean(name)),
)
const teamLaunchRowsByMember = new Map<string, ActivityRow>()
for (const row of buildAgentRowsFromMessages(runMessages)) {
for (const row of buildAgentRowsFromMessages(runMessages, input.teamTaskWindows ?? [])) {
if (row.section === 'team') {
if (!includeTeamActivity) continue
if (row.teamMemberName && knownTeamMemberNames.has(row.teamMemberName)) continue
@@ -1,4 +1,5 @@
import { ChevronRight, UsersRound } from 'lucide-react'
import type { ReactNode } from 'react'
import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
import { useTranslation, type TranslationKey } from '../../i18n'
import type { TeamMember, TeamWorkbenchSnapshot } from '../../types/team'
@@ -126,15 +127,24 @@ export function AgentTeamsStrip({
*/
export function AgentTeamsInlineCard({
snapshot,
teamName,
fallbackPhase = 'forming',
phaseOverride,
onOpen,
children,
}: {
snapshot: TeamWorkbenchSnapshot
onOpen: () => void
snapshot?: TeamWorkbenchSnapshot
teamName: string
fallbackPhase?: WorkbenchPhase
phaseOverride?: WorkbenchPhase
onOpen?: () => void
children?: ReactNode
}) {
const t = useTranslation()
const phase = getWorkbenchPhase(snapshot)
const progress = getWorkbenchProgress(snapshot)
const members = snapshot.team.members
const phase = phaseOverride ?? (snapshot ? getWorkbenchPhase(snapshot) : fallbackPhase)
const progress = snapshot ? getWorkbenchProgress(snapshot) : { completed: 0, total: 0 }
const members = snapshot?.team.members ?? []
const canOpen = Boolean(snapshot && onOpen)
return (
<div className="mb-5 px-1">
@@ -142,35 +152,47 @@ export function AgentTeamsInlineCard({
type="button"
data-testid="agent-teams-inline-card"
onClick={onOpen}
disabled={!canOpen}
className="flex w-full items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3.5 py-2.5 text-left shadow-[var(--shadow-card)] transition-colors hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<AvatarStack members={members} leadAgentId={snapshot.team.leadAgentId} size={30} />
{members.length > 0 ? (
<AvatarStack members={members} leadAgentId={snapshot?.team.leadAgentId} size={30} />
) : (
<span className="inline-flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-container)] text-[var(--color-brand)] ring-1 ring-[var(--color-border)]">
<UsersRound size={16} strokeWidth={2.2} aria-hidden="true" />
</span>
)}
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate font-mono text-[12px] font-extrabold text-[var(--color-text-primary)]">
{snapshot.team.name}
{teamName}
</span>
<Badge tone={phaseTone(phase)} size="xs" bordered>
{t(`agentTeams.phase.${phase}` as TranslationKey)}
</Badge>
</span>
<span className="mt-0.5 flex items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<span>{t('agentTeams.inline.created', { count: members.length })}</span>
{progress.total > 0 ? (
<>
<span aria-hidden="true">·</span>
<span className="tabular-nums">
{t('agentTeams.inline.tasks', { completed: progress.completed, total: progress.total })}
</span>
</>
) : null}
{snapshot ? (
<span className="mt-0.5 flex items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<span>{t('agentTeams.inline.created', { count: members.length })}</span>
{progress.total > 0 ? (
<>
<span aria-hidden="true">·</span>
<span className="tabular-nums">
{t('agentTeams.inline.tasks', { completed: progress.completed, total: progress.total })}
</span>
</>
) : null}
</span>
) : null}
</span>
{canOpen ? (
<span className="flex shrink-0 items-center gap-0.5 text-[11.5px] font-medium text-[var(--color-brand)]">
{t('agentTeams.inline.open')}
<ChevronRight size={13} strokeWidth={2.4} aria-hidden="true" />
</span>
</span>
<span className="flex shrink-0 items-center gap-0.5 text-[11.5px] font-medium text-[var(--color-brand)]">
{t('agentTeams.inline.open')}
<ChevronRight size={13} strokeWidth={2.4} aria-hidden="true" />
</span>
) : null}
</button>
{children}
</div>
)
}
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useChatStore } from '../../stores/chatStore'
import { useSettingsStore } from '../../stores/settingsStore'
@@ -246,6 +246,57 @@ describe('AgentTeamsWorkbench', () => {
expect(useTabStore.getState().activeTabId).toBe('team-member:reviewer@visual-team')
})
it('replays a shrinking archived roster without orphaning owners or hanging exited observers under the lead', async () => {
const beforeRemoval = workbench('roster-1', ['in_progress', 'in_progress', 'pending'])
beforeRemoval.team.members.push({
agentId: 'observer@visual-team',
name: 'observer',
role: 'researcher',
status: 'idle',
})
const afterRemoval: TeamWorkbenchSnapshot = {
...workbench('roster-2', ['completed', 'completed', 'completed']),
deletedAt: '2026-08-08T00:10:00.000Z',
team: {
...beforeRemoval.team,
members: beforeRemoval.team.members
.filter((member) => member.name !== 'builder')
.map((member) => ({ ...member, status: 'completed' as const })),
},
tasks: workbench('roster-2', ['completed', 'completed', 'completed']).tasks.map((entry) => (
entry.id === '3' ? { ...entry, owner: 'legacy-owner' } : entry
)),
}
getWorkbenchForSessionMock.mockResolvedValueOnce({
sessionId: 'lead-session',
teamName: 'visual-team',
source: 'archive',
snapshots: [beforeRemoval, afterRemoval],
})
await act(async () => {
await useTeamStore.getState().fetchTeamForSession('lead-session', { force: true })
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
const restoredOwnerTask = screen.getByTestId('agent-teams-task-1')
expect(restoredOwnerTask.getAttribute('data-owner-agent-id')).toBe('builder@visual-team')
expect(within(restoredOwnerTask).getByText('builder')).toBeTruthy()
fireEvent.click(restoredOwnerTask)
expect(useTabStore.getState().activeTabId).toBe('team-member:builder@visual-team')
const rawOwnerTask = screen.getByTestId('agent-teams-task-3')
expect(within(rawOwnerTask).getByText('legacy-owner')).toBeTruthy()
expect(within(rawOwnerTask).queryByText('Waiting for a teammate')).toBeNull()
const archivedRoster = screen.getByTestId('agent-teams-archived-members')
const observer = screen.getByTestId('agent-teams-member-observer@visual-team')
expect(archivedRoster.contains(observer)).toBe(true)
expect(observer.getAttribute('data-member-state')).toBe('exited')
fireEvent.click(observer)
expect(useTabStore.getState().activeTabId).toBe('team-member:observer@visual-team')
})
it('carries no docked-panel chrome of its own', async () => {
getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
await act(async () => {
@@ -261,6 +312,29 @@ describe('AgentTeamsWorkbench', () => {
expect(feed.className).not.toContain('h-[210px]')
})
it('never describes an ownerless completed task as waiting to be claimed', async () => {
const snapshot = workbench('v1', ['completed', 'pending', 'completed'])
snapshot.tasks = snapshot.tasks.map((entry) => (
entry.id === '1' || entry.id === '2'
? { ...entry, owner: undefined }
: entry
))
getWorkbenchMock.mockResolvedValueOnce(snapshot)
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
const completed = screen.getByTestId('agent-teams-task-1')
expect(within(completed).getByText('Completed · owner not recorded')).toBeTruthy()
expect(within(completed).queryByText('Waiting for a teammate')).toBeNull()
const available = screen.getByTestId('agent-teams-task-2')
expect(within(available).getByText('Waiting for a teammate')).toBeTruthy()
expect(within(available).queryByText('Completed · owner not recorded')).toBeNull()
})
it('resizes the communication column by pointer and keyboard without collapsing the DAG', async () => {
getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
await act(async () => {
@@ -306,4 +380,45 @@ describe('AgentTeamsWorkbench', () => {
fireEvent.click(screen.getByTestId('agent-teams-member-reviewer@visual-team'))
expect(useTabStore.getState().activeTabId).toBe('team-member:reviewer@visual-team')
})
it('keeps a crowded archived roster in a scrollable narrow lane with touch-sized targets', async () => {
const archived = workbench('v1', ['completed', 'completed', 'completed'])
archived.deletedAt = '2026-08-08T00:10:00.000Z'
archived.team.members = [
...archived.team.members.map((member) => ({
...member,
status: 'completed' as const,
})),
...Array.from({ length: 12 }, (_, index) => ({
agentId: `observer-${index}@visual-team`,
name: `observer-${index}`,
role: 'observer',
status: 'completed' as const,
})),
]
getWorkbenchMock.mockResolvedValueOnce(archived)
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
const archivedRoster = screen.getByTestId('agent-teams-archived-members')
expect(archivedRoster.className).toContain('left-3')
expect(archivedRoster.className).toContain('right-3')
expect(archivedRoster.className).toContain('h-16')
expect(archivedRoster.className).toContain('overflow-x-auto')
expect(archivedRoster.className).toContain('overflow-y-hidden')
const observers = screen.getAllByTestId(/^agent-teams-member-observer-/)
expect(observers).toHaveLength(12)
for (const observer of observers) {
expect(observer.className).toContain('h-11')
expect(observer.className).toContain('w-11')
expect(observer.className).toContain('shrink-0')
}
fireEvent.click(observers.at(-1)!)
expect(useTabStore.getState().activeTabId).toBe('team-member:observer-11@visual-team')
})
})
@@ -27,6 +27,7 @@ import {
getMemberAvatarKey,
layoutWorkbenchTasks,
runningTaskForMember,
snapshotWithHistoricalMembers,
taskOwnedByMember,
WORKBENCH_TASK_HEIGHT,
WORKBENCH_TASK_WIDTH,
@@ -153,8 +154,14 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
const snapshots = timeline?.snapshots ?? []
const latestIndex = snapshots.length - 1
const selectedIndex = historyIndex === null ? latestIndex : Math.min(historyIndex, latestIndex)
const snapshot = selectedIndex >= 0 ? snapshots[selectedIndex] : undefined
const previousSnapshot = selectedIndex > 0 ? snapshots[selectedIndex - 1] : undefined
const snapshot = useMemo(
() => snapshotWithHistoricalMembers(snapshots, selectedIndex),
[selectedIndex, snapshots],
)
const previousSnapshot = useMemo(
() => snapshotWithHistoricalMembers(snapshots, selectedIndex - 1),
[selectedIndex, snapshots],
)
useEffect(() => {
const element = officeViewportRef.current
@@ -263,6 +270,12 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
if (firstOwnedTask) primaryTaskByMemberId.set(member.agentId, firstOwnedTask.id)
}
const unassignedMembers = workerMembers.filter((member) => !primaryTaskByMemberId.has(member.agentId))
const archivedUnassignedMembers = unassignedMembers.filter((member) => (
memberState(member, snapshot, false) === 'exited'
))
const activeUnassignedMembers = unassignedMembers.filter((member) => (
memberState(member, snapshot, false) !== 'exited'
))
const rootTasks = layout.tasks.filter(({ task }) =>
task.blockedBy.every((dependencyId) => !layout.byId.has(dependencyId)),
)
@@ -284,9 +297,10 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
state,
})
} else {
const unassignedIndex = unassignedMembers.findIndex((worker) => worker.agentId === member.agentId)
const pitch = Math.min(62, (layout.width - 48) / Math.max(unassignedMembers.length, 1))
const rowWidth = Math.max(0, (unassignedMembers.length - 1) * pitch)
const unassignedIndex = activeUnassignedMembers.findIndex((worker) => worker.agentId === member.agentId)
if (unassignedIndex < 0) return
const pitch = Math.min(62, (layout.width - 48) / Math.max(activeUnassignedMembers.length, 1))
const rowWidth = Math.max(0, (activeUnassignedMembers.length - 1) * pitch)
memberPositions.set(member.agentId, {
x: layout.width / 2 - rowWidth / 2 + Math.max(0, unassignedIndex) * pitch,
y: 147,
@@ -447,7 +461,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
/>
) : null}
{unassignedMembers.map((member) => {
{activeUnassignedMembers.map((member) => {
const position = memberPositions.get(member.agentId)
if (!position) return null
return (
@@ -464,6 +478,32 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
)
})}
{archivedUnassignedMembers.length > 0 ? (
<div
data-testid="agent-teams-archived-members"
data-layout-role="archived-participants"
aria-label={t('agentTeams.member.exited')}
className="absolute bottom-1 left-3 right-3 flex h-16 min-w-0 items-center gap-1.5 overflow-x-auto overflow-y-hidden overscroll-x-contain rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2 shadow-[var(--shadow-card)] [scrollbar-width:thin]"
>
<span className="mr-0.5 shrink-0 text-[9px] font-semibold text-[var(--color-text-tertiary)]">
{t('agentTeams.member.exited')}
</span>
{archivedUnassignedMembers.map((member) => (
<MemberFigure
key={member.agentId}
member={member}
state="exited"
accent={memberAccent(member, members.indexOf(member))}
isMessageSender={Boolean(hasNewMessage && latestMessage && memberMatchesIdentity(member, latestMessage.from))}
testId={`agent-teams-member-${member.agentId}`}
className="h-11 w-11 shrink-0"
onSelect={() => selectMember(member)}
t={t}
/>
))}
</div>
) : null}
{layout.tasks.length === 0 ? (
<div className="absolute inset-x-4 top-[196px] text-center text-[12px] leading-7 text-[var(--color-text-tertiary)]">
{t('agentTeams.emptyTasks')}
@@ -825,6 +865,7 @@ function TaskCard({
const owner = task.owner
? members.find((member) => taskOwnedByMember(task, member))
: undefined
const ownerLabel = owner ? memberName(owner) : task.owner?.trim()
const dependencyLabel = task.blockedBy.map((dependency) => `#${dependency}`).join(' ')
const ownerState = owner
? memberState(owner, snapshot, owner.agentId === snapshot.team.leadAgentId)
@@ -842,6 +883,7 @@ function TaskCard({
data-testid={`agent-teams-task-${task.id}`}
data-state={state}
data-owner-agent-id={owner?.agentId}
data-owner-identity={task.owner}
tabIndex={0}
role={owner ? 'button' : undefined}
aria-label={owner ? t('agentTeams.openMember', { name: memberName(owner) }) : undefined}
@@ -896,9 +938,13 @@ function TaskCard({
{task.subject}
</div>
<div className="mt-1 flex min-h-4 items-center gap-1.5">
{owner ? (
{ownerLabel ? (
<span className="truncate font-mono text-[10px] font-semibold text-[var(--color-text-secondary)]">
{memberName(owner)}
{ownerLabel}
</span>
) : state === 'completed' ? (
<span className="truncate text-[9.5px] text-[var(--color-text-tertiary)]">
{t('agentTeams.task.completedNoOwner')}
</span>
) : (
<span className="truncate text-[9.5px] text-[var(--color-text-tertiary)]">
@@ -10,6 +10,7 @@ import {
parseWorkbenchMessageBody,
resolveTeamMemberIdentity,
runningTaskForMember,
snapshotWithHistoricalMembers,
taskOwnedByMember,
WORKBENCH_TASK_WIDTH,
} from './agentTeamsModel'
@@ -165,6 +166,57 @@ describe('Agent Teams workbench model', () => {
expect(getMemberAvatarKey(reviewer.member, reviewer.isLead)).toBe('security-reviewer')
})
it('replays roster transitions so shutdown cannot orphan a completed task owner', () => {
const lead: TeamMember = {
agentId: 'team-lead@team-a',
name: 'team-lead',
role: 'orchestrator',
status: 'running',
}
const owner: TeamMember = {
agentId: 'builder@team-a',
name: 'builder',
role: 'frontend',
status: 'running',
}
const observer: TeamMember = {
agentId: 'observer@team-a',
name: 'observer',
role: 'reviewer',
status: 'idle',
}
const beforeRemoval = {
...snapshot([task('1', 'in_progress', [], 'builder')]),
version: 'roster-1',
team: { ...snapshot([]).team, members: [lead, owner, observer] },
}
const afterRemoval = {
...snapshot([task('1', 'completed', [], 'builder')]),
version: 'roster-2',
deletedAt: '2026-08-08T00:01:00.000Z',
team: {
...snapshot([]).team,
members: [
{ ...lead, status: 'completed' as const },
{ ...observer, status: 'completed' as const },
],
},
}
const repaired = snapshotWithHistoricalMembers([beforeRemoval, afterRemoval], 1)!
const repairedOwner = repaired.team.members.find(member => taskOwnedByMember(repaired.tasks[0]!, member))
expect(repaired.team.members.map(member => ({
name: member.name,
status: member.status,
}))).toEqual([
{ name: 'team-lead', status: 'completed' },
{ name: 'builder', status: 'completed' },
{ name: 'observer', status: 'completed' },
])
expect(repairedOwner?.agentId).toBe('builder@team-a')
})
it('derives forming, running, finishing, and completed phases from real transitions', () => {
expect(getWorkbenchPhase(snapshot([]))).toBe('forming')
expect(getWorkbenchPhase(snapshot([task('1', 'in_progress')]))).toBe('running')
@@ -81,6 +81,56 @@ function identityAliases(value: string): string[] {
return normalized === short ? [normalized] : [normalized, short]
}
function sameTeamMember(left: TeamMember, right: TeamMember): boolean {
const leftAliases = [
...identityAliases(left.agentId),
...(left.name ? identityAliases(left.name) : []),
]
const rightAliases = [
...identityAliases(right.agentId),
...(right.name ? identityAliases(right.name) : []),
]
return leftAliases.some(alias => rightAliases.includes(alias))
}
/**
* Team config is a mutable live roster, while the workbench is a run history.
* Rebuild the roster by replaying snapshots so a teammate removed during
* shutdown still owns its completed task and can reopen its transcript.
*/
export function snapshotWithHistoricalMembers(
snapshots: TeamWorkbenchSnapshot[],
selectedIndex: number,
): TeamWorkbenchSnapshot | undefined {
const selected = snapshots[selectedIndex]
if (!selected) return undefined
let members: TeamMember[] = []
for (const snapshot of snapshots.slice(0, selectedIndex + 1)) {
const remaining = [...snapshot.team.members]
members = members.map((historicalMember) => {
const currentIndex = remaining.findIndex(member => sameTeamMember(historicalMember, member))
if (currentIndex < 0) {
return {
...historicalMember,
status: historicalMember.status === 'error' ? 'error' : 'completed',
}
}
const [currentMember] = remaining.splice(currentIndex, 1)
return { ...historicalMember, ...currentMember! }
})
members.push(...remaining)
}
return {
...selected,
team: {
...selected.team,
members,
},
}
}
/**
* Resolves the same persisted teammate identity for the DAG, communication
* feed, and member transcript. A sender can be serialized as either its bare
@@ -194,7 +244,10 @@ export function layoutWorkbenchTasks(
tasks: positioned,
byId: new Map(positioned.map((task) => [task.task.id, task])),
width,
height: DAG_TOP + Math.max(0, row - 1) * ROW_HEIGHT + TASK_HEIGHT + 54,
// Leave enough room for a 44px historical-member target plus a classic
// horizontal scrollbar, which consumes layout height on Windows and on
// macOS when scrollbars are configured to stay visible.
height: DAG_TOP + Math.max(0, row - 1) * ROW_HEIGHT + TASK_HEIGHT + 70,
columns,
}
}
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, createEvent, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import {
MessageList,
@@ -17,6 +17,7 @@ import type { ConversationNavigationItem } from './ConversationNavigator'
import type { VirtualRenderItemMetric } from './virtualHeightCache'
import { relativizeWorkspacePath } from './CurrentTurnChangeCard'
import { sessionsApi } from '../../api/sessions'
import { teamsApi } from '../../api/teams'
import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
@@ -24,6 +25,7 @@ import { useSettingsStore } from '../../stores/settingsStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import { useTeamStore } from '../../stores/teamStore'
import { formatExactMessageTimestamp, formatMessageHoverTime } from '../../lib/formatMessageTimestamp'
import type { UIMessage } from '../../types/chat'
import type { PerSessionState } from '../../stores/chatStore'
@@ -302,6 +304,7 @@ describe('MessageList nested tool calls', () => {
useTabStore.setState({ activeTabId: ACTIVE_TAB, tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }] })
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
useTeamStore.getState().clearTeam()
useWorkspaceChatContextStore.setState(useWorkspaceChatContextStore.getInitialState(), true)
// The workspace panel store is a shared singleton; reset it so preview tabs opened by
// one test (clicking a change-card row) don't dedupe/leak into the next test.
@@ -319,6 +322,10 @@ describe('MessageList nested tool calls', () => {
})
})
afterEach(() => {
vi.useRealTimers()
})
it('windows long transcripts instead of mounting every historical message at once', () => {
useChatStore.setState({
sessions: {
@@ -7253,6 +7260,12 @@ describe('turn rail positions', () => {
})
describe('Agent Teams chat projection', () => {
function renderedToolUseIds(messages: UIMessage[], options?: Parameters<typeof buildRenderModel>[2]) {
return buildRenderModel(messages, null, options).renderItems.flatMap((item) => (
item.kind === 'tool_group' ? item.toolCalls.map((toolCall) => toolCall.toolUseId) : []
))
}
function sendMessageRun(
id: string,
result: unknown,
@@ -7289,10 +7302,14 @@ describe('Agent Teams chat projection', () => {
})
expect(buildRenderModel(messages).renderItems).toHaveLength(1)
expect(buildRenderModel(messages, null, {
const lead = buildRenderModel(messages, null, {
hideTeamCoordinationTools: true,
teamMemberNames: new Set(['worker']),
}).renderItems).toHaveLength(0)
teamName: 'audit-team',
})
expect(lead.renderItems).toEqual([
expect.objectContaining({ kind: 'team_card', coordinationToolCalls: [messages[0]] }),
])
})
it('hides successful team messages from their real input shape when the transport omits routing', () => {
@@ -7303,10 +7320,14 @@ describe('Agent Teams chat projection', () => {
})
const messages = [...direct, ...broadcast]
expect(buildRenderModel(messages, null, {
const lead = buildRenderModel(messages, null, {
hideTeamCoordinationTools: true,
teamMemberNames: new Set(['worker']),
}).renderItems).toHaveLength(0)
teamName: 'audit-team',
})
expect(lead.renderItems).toEqual([
expect.objectContaining({ kind: 'team_card', coordinationToolCalls: [direct[0], broadcast[0]] }),
])
})
it('keeps ordinary agent continuation and failed team SendMessage calls visible', () => {
@@ -7329,6 +7350,75 @@ describe('Agent Teams chat projection', () => {
}).renderItems).toHaveLength(1)
})
it('stops using a historical Team roster after delete when a same-name direct Agent is messaged', () => {
const messages: UIMessage[] = [
{
id: 'create-tool', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create',
input: { team_name: 'audit-team' }, timestamp: 1,
},
{
id: 'create-result', type: 'tool_result', toolUseId: 'create',
content: { success: true, team_name: 'audit-team' }, isError: false, timestamp: 2,
},
{
id: 'team-message-tool', type: 'tool_use', toolName: 'SendMessage', toolUseId: 'team-message',
input: { to: 'reviewer', message: 'Finish the Team review' }, timestamp: 3,
},
{
id: 'team-message-result', type: 'tool_result', toolUseId: 'team-message',
content: 'Message sent to reviewer inbox', isError: false, timestamp: 4,
},
{
id: 'delete-tool', type: 'tool_use', toolName: 'TeamDelete', toolUseId: 'delete',
input: {}, timestamp: 5,
},
{
id: 'delete-result', type: 'tool_result', toolUseId: 'delete',
content: { success: true, team_name: 'audit-team' }, isError: false, timestamp: 6,
},
{
id: 'direct-agent-tool', type: 'tool_use', toolName: 'Agent', toolUseId: 'direct-agent',
input: { name: 'reviewer', description: 'Continue as an ordinary SubAgent' }, timestamp: 7,
},
{
id: 'direct-agent-result', type: 'tool_result', toolUseId: 'direct-agent',
content: { status: 'completed', agentId: 'direct-reviewer' }, isError: false, timestamp: 8,
},
{
id: 'direct-message-tool', type: 'tool_use', toolName: 'SendMessage', toolUseId: 'direct-message',
input: { to: 'reviewer', message: 'Continue the direct review' }, timestamp: 9,
},
{
id: 'direct-message-result', type: 'tool_result', toolUseId: 'direct-message',
content: 'Message queued for delivery to reviewer at its next tool round.',
isError: false,
timestamp: 10,
},
]
const options = {
hideTeamCoordinationTools: true,
teamMemberNames: new Set(['reviewer']),
teamName: 'audit-team',
}
const model = buildRenderModel(messages, null, options)
const teamCards = model.renderItems.filter((item) => item.kind === 'team_card')
expect(teamCards).toHaveLength(1)
expect(teamCards[0]).toMatchObject({
teamName: 'audit-team',
endedAt: 5,
coordinationToolCalls: [
expect.objectContaining({ toolUseId: 'team-message' }),
expect.objectContaining({ toolUseId: 'delete' }),
],
})
expect(renderedToolUseIds(messages, options)).toEqual([
'direct-agent',
'direct-message',
])
})
it('replaces the TeamCreate call with a team card at the point the team was formed', () => {
const messages: UIMessage[] = [
{ id: 'user-1', type: 'user_text', content: 'Audit the queue', timestamp: 1 },
@@ -7362,4 +7452,560 @@ describe('Agent Teams chat projection', () => {
expect(buildRenderModel(messages).renderItems.map((item) => item.kind))
.toEqual(['message', 'tool_group', 'message'])
})
it('projects team orchestration into the team card without hiding ordinary tools', () => {
const tool = (
id: string,
toolName: string,
input: unknown,
timestamp: number,
): UIMessage => ({
id: `tool-${id}`,
type: 'tool_use',
toolName,
toolUseId: id,
input,
timestamp,
})
const result = (
id: string,
content: unknown,
timestamp: number,
isError = false,
): UIMessage => ({
id: `result-${id}`,
type: 'tool_result',
toolUseId: id,
content,
isError,
timestamp,
})
const messages: UIMessage[] = [
tool('pre-task', 'TaskCreate', { subject: 'Operator todo' }, 1),
result('pre-task', 'Task #1 created successfully', 2),
tool('create-team', 'TeamCreate', { team_name: 'audit-team' }, 3),
result('create-team', { team_name: 'audit-team' }, 4),
tool('team-task', 'TaskCreate', { subject: 'Review API' }, 5),
result('team-task', 'Task #1 created successfully', 6),
tool('team-update', 'TaskUpdate', { taskId: '1', owner: 'reviewer' }, 7),
result('team-update', 'Task #1 updated successfully', 8),
tool('team-agent', 'Agent', {
description: 'Review API',
name: 'reviewer',
team_name: 'audit-team',
}, 9),
result('team-agent', { status: 'teammate_spawned', name: 'reviewer', team_name: 'audit-team' }, 10),
tool('direct-agent', 'Agent', { description: 'Check an unrelated question' }, 11),
result('direct-agent', 'agentId: direct-1', 12),
tool('team-message', 'SendMessage', { to: 'reviewer', message: 'Start task #1' }, 13),
result('team-message', 'Message sent to reviewer inbox', 14),
tool('failed-update', 'TaskUpdate', { taskId: 'missing', status: 'completed' }, 15),
result('failed-update', 'Task missing not found', 16, true),
tool('failed-team-agent', 'Agent', {
description: 'Spawn missing teammate',
name: 'missing',
team_name: 'audit-team',
}, 17),
result('failed-team-agent', 'Agent spawn failed', 18, true),
tool('delete-team', 'TeamDelete', {}, 19),
result('delete-team', { success: true, team_name: 'audit-team' }, 20),
tool('post-task', 'TaskCreate', { subject: 'Operator follow-up' }, 21),
result('post-task', 'Task #2 created successfully', 22),
tool('post-agent', 'Agent', { description: 'Summarize the follow-up' }, 23),
result('post-agent', 'agentId: direct-2', 24),
]
const model = buildRenderModel(messages, null, {
hideTeamCoordinationTools: true,
teamMemberNames: new Set(['reviewer']),
})
expect(renderedToolUseIds(messages, {
hideTeamCoordinationTools: true,
teamMemberNames: new Set(['reviewer']),
})).toEqual([
'pre-task',
'direct-agent',
'failed-update',
'failed-team-agent',
'post-task',
'post-agent',
])
const teamCards = model.renderItems.filter((item) => item.kind === 'team_card')
expect(teamCards).toHaveLength(1)
expect(teamCards[0]).toMatchObject({
teamName: 'audit-team',
endedAt: 19,
coordinationToolCalls: [
expect.objectContaining({ toolUseId: 'team-task' }),
expect.objectContaining({ toolUseId: 'team-update' }),
expect.objectContaining({ toolUseId: 'team-agent' }),
expect.objectContaining({ toolUseId: 'team-message' }),
expect.objectContaining({ toolUseId: 'delete-team' }),
],
})
// Projection removes duplicate UI only. The raw audit result remains
// available to the transcript/store and the workbench owns its presentation.
expect(model.toolResultMap.get('team-task')?.content).toBe('Task #1 created successfully')
expect(messages).toHaveLength(24)
})
it('does not enter Team scope when TeamCreate returns success false', () => {
const messages: UIMessage[] = [
{
id: 'create-tool', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create',
input: { team_name: 'failed-team' }, timestamp: 1,
},
{
id: 'create-result', type: 'tool_result', toolUseId: 'create',
content: { success: false, error: 'already exists' }, isError: false, timestamp: 2,
},
{
id: 'task-tool', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'task',
input: { subject: 'Main-session task' }, timestamp: 3,
},
]
expect(renderedToolUseIds(messages, {
hideTeamCoordinationTools: true,
})).toEqual(['create', 'task'])
expect(buildRenderModel(messages, null, {
hideTeamCoordinationTools: true,
}).renderItems.some(item => item.kind === 'team_card')).toBe(false)
})
it('keeps Team scope active when TeamDelete returns success false', () => {
const messages: UIMessage[] = [
{
id: 'create-tool', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create',
input: { team_name: 'audit-team' }, timestamp: 1,
},
{
id: 'create-result', type: 'tool_result', toolUseId: 'create',
content: { success: true }, isError: false, timestamp: 2,
},
{
id: 'delete-tool', type: 'tool_use', toolName: 'TeamDelete', toolUseId: 'delete',
input: {}, timestamp: 3,
},
{
id: 'delete-result', type: 'tool_result', toolUseId: 'delete',
content: { success: false, team_name: 'audit-team', error: 'still running' }, isError: false, timestamp: 4,
},
{
id: 'task-tool', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'task',
input: { subject: 'Shared Team task' }, timestamp: 5,
},
]
expect(renderedToolUseIds(messages, {
hideTeamCoordinationTools: true,
})).toEqual(['delete'])
})
it('never renders an identity-unknown pending Agent as a SubAgent during a team lifecycle', () => {
vi.useFakeTimers()
const sessionId = 'team-transcript-stream'
useChatStore.setState({
sessions: { [sessionId]: makeSessionState() },
})
const store = useChatStore.getState()
const renderedIds = () => renderedToolUseIds(
useChatStore.getState().sessions[sessionId]?.messages ?? [],
{ hideTeamCoordinationTools: true },
)
store.handleServerMessage(sessionId, {
type: 'content_start', blockType: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create-team',
})
store.handleServerMessage(sessionId, {
type: 'tool_use_complete',
toolName: 'TeamCreate',
toolUseId: 'create-team',
input: { team_name: 'audit-team' },
})
store.handleServerMessage(sessionId, {
type: 'tool_result',
toolUseId: 'create-team',
content: { team_name: 'audit-team' },
isError: false,
})
store.handleServerMessage(sessionId, {
type: 'content_start', blockType: 'tool_use', toolName: 'Agent', toolUseId: 'team-agent',
})
store.handleServerMessage(sessionId, {
type: 'content_delta', toolInput: '{"description":"Review API","name":',
})
vi.advanceTimersByTime(60)
expect(useChatStore.getState().sessions[sessionId]?.messages).toContainEqual(
expect.objectContaining({
type: 'tool_use',
toolUseId: 'team-agent',
input: { description: 'Review API' },
isPending: true,
}),
)
expect(renderedIds()).toEqual([])
store.handleServerMessage(sessionId, {
type: 'tool_use_complete',
toolName: 'Agent',
toolUseId: 'team-agent',
input: { description: 'Review API', name: 'reviewer', team_name: 'audit-team' },
})
store.handleServerMessage(sessionId, {
type: 'tool_result',
toolUseId: 'team-agent',
content: { status: 'teammate_spawned', name: 'reviewer', team_name: 'audit-team' },
isError: false,
})
expect(renderedIds()).toEqual([])
store.handleServerMessage(sessionId, {
type: 'content_start', blockType: 'tool_use', toolName: 'Agent', toolUseId: 'direct-agent',
})
store.handleServerMessage(sessionId, {
type: 'content_delta', toolInput: '{"description":"Check an unrelated question"',
})
vi.advanceTimersByTime(60)
expect(renderedIds()).toEqual([])
store.handleServerMessage(sessionId, {
type: 'tool_use_complete',
toolName: 'Agent',
toolUseId: 'direct-agent',
input: { description: 'Check an unrelated question' },
})
expect(renderedIds()).toEqual(['direct-agent'])
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('uses a team lifecycle window only when compacted history omits TeamCreate', () => {
const messages: UIMessage[] = [
{
id: 'pre-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'pre-task',
input: { subject: 'Before team' }, timestamp: 90,
},
{
id: 'team-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'team-task',
input: { subject: 'Team task' }, timestamp: 150,
},
{
id: 'post-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'post-task',
input: { subject: 'After team' }, timestamp: 210,
},
]
expect(renderedToolUseIds(messages, {
hideTeamCoordinationTools: true,
teamTaskWindows: [{ startedAt: 100, endedAt: 200 }],
teamName: 'audit-team',
})).toEqual(['pre-task', 'post-task'])
expect(renderedToolUseIds(messages)).toEqual(['pre-task', 'team-task', 'post-task'])
})
it('lets a durable end close an explicit TeamCreate when TeamDelete was compacted', () => {
const messages: UIMessage[] = [
{
id: 'create-tool', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create',
input: { team_name: 'audit-team' }, timestamp: 100,
},
{
id: 'create-result', type: 'tool_result', toolUseId: 'create',
content: { success: true }, isError: false, timestamp: 101,
},
{
id: 'team-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'team-task',
input: { subject: 'Inside lifecycle' }, timestamp: 150,
},
{
id: 'post-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'post-task',
input: { subject: 'After lifecycle' }, timestamp: 250,
},
]
expect(renderedToolUseIds(messages, {
hideTeamCoordinationTools: true,
teamTaskWindows: [{ startedAt: 100, endedAt: 200 }],
teamName: 'audit-team',
})).toEqual(['post-task'])
})
it('uses a newer durable Team window after an older explicit TeamDelete', () => {
const messages: UIMessage[] = [
{
id: 'old-delete', type: 'tool_use', toolName: 'TeamDelete', toolUseId: 'old-delete',
input: {}, timestamp: 90,
},
{
id: 'old-delete-result', type: 'tool_result', toolUseId: 'old-delete',
content: { success: true, team_name: 'old-team' }, isError: false, timestamp: 91,
},
{
id: 'team-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'team-task',
input: { subject: 'Compacted new Team task' }, timestamp: 150,
},
{
id: 'post-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'post-task',
input: { subject: 'After compacted Team' }, timestamp: 250,
},
]
const options = {
hideTeamCoordinationTools: true,
teamTaskWindows: [{ startedAt: 100, endedAt: 200 }],
teamName: 'audit-team',
teamStartedAt: 100,
}
expect(renderedToolUseIds(messages, options)).toEqual(['post-task'])
expect(buildRenderModel(messages, null, options).renderItems.filter(
(item) => item.kind === 'team_card',
)).toEqual([
expect.objectContaining({
teamName: 'old-team',
endedAt: 90,
coordinationToolCalls: [expect.objectContaining({ toolUseId: 'old-delete' })],
}),
expect.objectContaining({
teamName: 'audit-team',
startedAt: 100,
coordinationToolCalls: [expect.objectContaining({ toolUseId: 'team-task' })],
}),
])
})
it('starts a new audit card when a compacted Team lifecycle follows an explicit delete', () => {
const messages: UIMessage[] = [
{
id: 'old-create', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'old-create',
input: { team_name: 'old-team' }, timestamp: 10,
},
{
id: 'old-create-result', type: 'tool_result', toolUseId: 'old-create',
content: { success: true, team_name: 'old-team' }, isError: false, timestamp: 11,
},
{
id: 'old-delete', type: 'tool_use', toolName: 'TeamDelete', toolUseId: 'old-delete',
input: {}, timestamp: 20,
},
{
id: 'old-delete-result', type: 'tool_result', toolUseId: 'old-delete',
content: { success: true, team_name: 'old-team' }, isError: false, timestamp: 21,
},
{
id: 'new-team-task', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'new-team-task',
input: { subject: 'Task from compacted new Team' }, timestamp: 150,
},
]
const model = buildRenderModel(messages, null, {
hideTeamCoordinationTools: true,
teamTaskWindows: [{ startedAt: 100, endedAt: 200 }],
teamName: 'new-team',
teamStartedAt: 100,
})
const cards = model.renderItems.filter((item) => item.kind === 'team_card')
expect(cards).toHaveLength(2)
expect(cards[0]).toMatchObject({
teamName: 'old-team',
startedAt: 10,
endedAt: 20,
coordinationToolCalls: [expect.objectContaining({ toolUseId: 'old-delete' })],
})
expect(cards[1]).toMatchObject({
teamName: 'new-team',
startedAt: 100,
coordinationToolCalls: [expect.objectContaining({ toolUseId: 'new-team-task' })],
})
})
it('uses the successful TeamCreate identity and never substitutes the current Team for an explicit old scope', () => {
const createMessages: UIMessage[] = [
{
id: 'create-tool', type: 'tool_use', toolName: 'TeamCreate', toolUseId: 'create',
input: { team_name: 'requested-name' }, timestamp: 10,
},
{
id: 'create-result', type: 'tool_result', toolUseId: 'create',
content: { success: true, team_name: 'requested-name-2' }, isError: false, timestamp: 11,
},
]
const createdCard = buildRenderModel(createMessages, null, {
hideTeamCoordinationTools: true,
}).renderItems.find((item) => item.kind === 'team_card')
expect(createdCard).toMatchObject({
teamName: 'requested-name-2',
startedAt: 10,
})
const explicitOldScope = buildRenderModel([{
id: 'old-agent', type: 'tool_use', toolName: 'Agent', toolUseId: 'old-agent',
input: { name: 'reviewer', team_name: 'old-team', description: 'Review the old run' },
timestamp: 150,
}], null, {
hideTeamCoordinationTools: true,
teamName: 'new-team',
teamStartedAt: 200,
}).renderItems.find((item) => item.kind === 'team_card')
expect(explicitOldScope).toMatchObject({
teamName: 'old-team',
startedAt: 150,
coordinationToolCalls: [expect.objectContaining({ toolUseId: 'old-agent' })],
})
})
it('uses synchronous Team lifecycle ownership before the first workbench snapshot resolves', async () => {
let resolveWorkbench!: (snapshot: Awaited<ReturnType<typeof teamsApi.getWorkbench>>) => void
vi.spyOn(teamsApi, 'get').mockImplementation(() => new Promise(() => {}))
vi.spyOn(teamsApi, 'getWorkbench').mockImplementation(() => new Promise((resolve) => {
resolveWorkbench = resolve
}))
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
{
id: 'task-tool', type: 'tool_use', toolName: 'TaskCreate', toolUseId: 'team-task',
input: { subject: 'Never flash this Team task' }, timestamp: 102,
},
{
id: 'agent-tool', type: 'tool_use', toolName: 'Agent', toolUseId: 'team-agent',
input: { description: 'Never flash this teammate', name: 'reviewer' },
timestamp: 103, isPending: true,
},
{
id: 'message-tool', type: 'tool_use', toolName: 'SendMessage', toolUseId: 'team-message',
input: { to: 'reviewer', message: 'Never flash this Team message' },
timestamp: 104, isPending: false,
},
],
}),
},
})
act(() => {
useTeamStore.getState().handleTeamCreated('audit-team', ACTIVE_TAB, { createdAt: 100 })
})
render(<MessageList sessionId={ACTIVE_TAB} />)
const pendingCard = screen.getByTestId('agent-teams-inline-card')
const audit = screen.getByTestId('agent-teams-coordination-audit')
expect((pendingCard as HTMLButtonElement).disabled).toBe(true)
expect(within(audit).getByText('Never flash this Team task')).toBeTruthy()
expect(within(audit).getByText(/Never flash this teammate/)).toBeTruthy()
expect(within(audit).getByText(/Never flash this Team message/)).toBeTruthy()
expect(screen.getAllByText('Never flash this Team task')).toHaveLength(1)
expect(screen.getAllByText(/Never flash this teammate/)).toHaveLength(1)
expect(screen.getAllByText(/Never flash this Team message/)).toHaveLength(1)
expect(useTeamStore.getState().workbenchesBySession[ACTIVE_TAB]).toBeUndefined()
await act(async () => {
resolveWorkbench({
version: 'snapshot-v1',
generatedAt: new Date(110).toISOString(),
team: {
name: 'audit-team',
leadSessionId: ACTIVE_TAB,
leadAgentId: 'team-lead@audit-team',
createdAt: '100',
members: [{
agentId: 'team-lead@audit-team', name: 'team-lead', role: 'lead', status: 'running',
}],
},
tasks: [],
messages: [],
})
await Promise.resolve()
})
await waitFor(() => expect(
(screen.getByTestId('agent-teams-inline-card') as HTMLButtonElement).disabled,
).toBe(false))
expect(screen.getAllByText('Never flash this Team task')).toHaveLength(1)
expect(screen.getAllByText(/Never flash this teammate/)).toHaveLength(1)
expect(screen.getAllByText(/Never flash this Team message/)).toHaveLength(1)
})
it('never binds an older TeamCreate card to a newer incarnation with the same name', async () => {
let resolveWorkbench!: (snapshot: Awaited<ReturnType<typeof teamsApi.getWorkbench>>) => void
vi.spyOn(teamsApi, 'get').mockImplementation(() => new Promise(() => {}))
vi.spyOn(teamsApi, 'getWorkbench').mockImplementation(() => new Promise((resolve) => {
resolveWorkbench = resolve
}))
const createRun = (id: string, timestamp: number): UIMessage[] => [
{
id: `${id}-tool`, type: 'tool_use', toolName: 'TeamCreate', toolUseId: id,
input: { team_name: 'reused-team' }, timestamp,
},
{
id: `${id}-result`, type: 'tool_result', toolUseId: id,
content: { success: true, team_name: 'reused-team' }, isError: false, timestamp: timestamp + 1,
},
]
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [
...createRun('old-create', 100_000),
{
id: 'old-delete-tool', type: 'tool_use', toolName: 'TeamDelete', toolUseId: 'old-delete',
input: {}, timestamp: 101_000,
},
{
id: 'old-delete-result', type: 'tool_result', toolUseId: 'old-delete',
content: { success: true, team_name: 'reused-team' }, isError: false, timestamp: 101_001,
},
// The next incarnation starts within the transport-tolerance
// window of the first. Its snapshot must still never revive the
// already-ended historical card.
...createRun('new-create', 102_000),
],
}),
},
})
act(() => {
useTeamStore.getState().handleTeamCreated('reused-team', ACTIVE_TAB, { createdAt: 102_000 })
})
render(<MessageList sessionId={ACTIVE_TAB} />)
await act(async () => {
resolveWorkbench({
version: 'new-incarnation',
generatedAt: new Date(102_010).toISOString(),
team: {
name: 'reused-team',
leadSessionId: ACTIVE_TAB,
leadAgentId: 'team-lead@reused-team',
incarnationId: 'new-incarnation',
createdAt: '102000',
members: [{
agentId: 'team-lead@reused-team', name: 'team-lead', role: 'lead', status: 'running',
}],
},
tasks: [],
messages: [],
})
await Promise.resolve()
})
const cards = await screen.findAllByTestId('agent-teams-inline-card')
expect(cards).toHaveLength(2)
expect((cards[0] as HTMLButtonElement).disabled).toBe(true)
expect((cards[1] as HTMLButtonElement).disabled).toBe(false)
expect(within(cards[0]!).getByText('Completed')).toBeTruthy()
expect(within(cards[1]!).getByText('Forming team')).toBeTruthy()
fireEvent.click(cards[1]!)
expect(useTabStore.getState().tabs).toContainEqual(expect.objectContaining({
type: 'team',
title: 'reused-team',
teamLeadSessionId: ACTIVE_TAB,
}))
})
})
+372 -31
View File
@@ -8,7 +8,7 @@ import { useSessionStore } from '../../stores/sessionStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useWorkspacePanelStore, type WorkspacePanelOrigin } from '../../stores/workspacePanelStore'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useTeamStore } from '../../stores/teamStore'
import { teamTaskWindowsForSnapshot, useTeamStore } from '../../stores/teamStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n/locales/en'
@@ -26,7 +26,11 @@ import { InlineTaskSummary } from './InlineTaskSummary'
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
import { AgentTeamsInlineCard } from '../agentTeams/AgentTeamsSummary'
import { MEMBER_AVATARS, memberAccentColor } from '../agentTeams/agentTeamsAvatars'
import { getMemberAvatarKey, resolveTeamMemberIdentity } from '../agentTeams/agentTeamsModel'
import {
getMemberAvatarKey,
resolveTeamMemberIdentity,
snapshotWithHistoricalMembers,
} from '../agentTeams/agentTeamsModel'
import {
buildConversationNavigationItems,
ConversationNavigator,
@@ -34,11 +38,16 @@ import {
type ConversationNavigationMode,
} from './ConversationNavigator'
import type { AgentTaskNotification, BackgroundAgentTask, UIMessage } from '../../types/chat'
import type { TeamDetail } from '../../types/team'
import type { TeamDetail, TeamWorkbenchSnapshot } from '../../types/team'
import { formatTokenCount } from '../../lib/formatTokenCount'
import { formatDurationMs, hasRunningBackgroundTasks as hasAnyRunningBackgroundTasks } from '../../lib/backgroundTasks'
import { buildTurnCompletionByMessageId, type TurnCompletion } from '../../lib/turnCompletion'
import { isTouchH5Document } from '../../lib/touchH5'
import {
EMPTY_TEAM_LIFECYCLE_CURSOR,
isTeamLifecycleScopedAt,
updateTeamLifecycleCursor,
} from '../../lib/teamLifecycleScope'
import { Button } from '@/components/ui/Button'
import { ActionDialog, type ActionDialogAction } from '@/components/ui/ActionDialog'
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
@@ -72,7 +81,14 @@ type RenderItem =
* Stands in for the TeamCreate call so the transcript records that this turn
* handed work to a team, without expanding into the workbench inline.
*/
| { kind: 'team_card'; id: string }
| {
kind: 'team_card'
id: string
teamName: string
startedAt: number
endedAt?: number
coordinationToolCalls: ToolCall[]
}
type RenderModel = {
renderItems: RenderItem[]
@@ -674,18 +690,105 @@ function isTeamCoordinationSendMessage(
input: unknown,
result: unknown,
teamMemberNames: ReadonlySet<string> | undefined,
allowRosterFallback: boolean,
): boolean {
if (hasTeamMessageRouting(result)) return true
if (!allowRosterFallback) return false
const target = getSendMessageTarget(input)
return target === '*' || Boolean(target && teamMemberNames?.has(target))
}
function parseRecordValue(value: unknown): Record<string, unknown> | null {
if (isRecordValue(value)) return value
if (typeof value !== 'string') return null
const text = value.trim()
if (!text.startsWith('{')) return null
try {
const parsed = JSON.parse(text) as unknown
return isRecordValue(parsed) ? parsed : null
} catch {
return null
}
}
function nestedStringField(value: unknown, field: string): string {
if (Array.isArray(value)) {
for (const item of value) {
const nested = nestedStringField(item, field)
if (nested) return nested
}
return ''
}
const record = parseRecordValue(value)
if (!record) return ''
const direct = record[field]
if (typeof direct === 'string' && direct.trim()) return direct.trim()
for (const child of [record.content, record.text]) {
const nested = nestedStringField(child, field)
if (nested) return nested
}
return ''
}
function explicitSuccessFlag(value: unknown): boolean | undefined {
if (Array.isArray(value)) {
for (const item of value) {
const nested = explicitSuccessFlag(item)
if (nested !== undefined) return nested
}
return undefined
}
const record = parseRecordValue(value)
if (!record) return undefined
if (typeof record.success === 'boolean') return record.success
if ('content' in record) return explicitSuccessFlag(record.content)
if ('text' in record) return explicitSuccessFlag(record.text)
return undefined
}
function canSummarizeCoordinationResult(result: ToolResult | undefined): boolean {
return !result || (!result.isError && explicitSuccessFlag(result.content) !== false)
}
function lifecycleToolSucceeded(result: ToolResult | undefined): boolean {
return Boolean(result) && canSummarizeCoordinationResult(result)
}
function teamNameFromCreate(toolCall: ToolCall, result: ToolResult | undefined): string {
const input = parseRecordValue(toolCall.input)
// TeamCreate can uniquify a requested name. The successful result owns the
// durable identity that the workbench will expose.
for (const value of [nestedStringField(result?.content, 'team_name'), input?.team_name]) {
if (typeof value === 'string' && value.trim()) return value.trim()
}
return 'Agent Teams'
}
function isExplicitTeamAgent(
input: unknown,
result: ToolResult | undefined,
teamActive: boolean,
): boolean {
const inputRecord = parseRecordValue(input)
const inputTeamName = inputRecord?.team_name
if (typeof inputTeamName === 'string' && inputTeamName.trim()) return true
const inputName = inputRecord?.name
if (teamActive && typeof inputName === 'string' && inputName.trim()) return true
if (nestedStringField(result?.content, 'status') === 'teammate_spawned') return true
if (typeof result?.content !== 'string') return false
return /^\s*team_name:\s*\S+/m.test(result.content) && /^\s*name:\s*\S+/m.test(result.content)
}
export function buildRenderModel(
messages: UIMessage[],
activeAskUserQuestionToolUseId?: string | null,
options: {
hideTeamCoordinationTools?: boolean
teamMemberNames?: ReadonlySet<string>
teamTaskWindows?: Array<{ startedAt: number; endedAt?: number }>
teamName?: string
teamStartedAt?: number
} = {},
): RenderModel {
const items: RenderItem[] = []
@@ -694,6 +797,8 @@ export function buildRenderModel(
const toolUseIds = new Set<string>()
const lastUnresolvedAskUserQuestionIndexByToolUseId = new Map<string, number>()
let lastUnresolvedAskUserQuestionIndex: number | null = null
let transcriptTeamCursor = EMPTY_TEAM_LIFECYCLE_CURSOR
let activeTeamCard: Extract<RenderItem, { kind: 'team_card' }> | undefined
let pendingSteps: ActivityStep[] = []
let pendingToolCount = 0
let pendingAgentCount = 0
@@ -743,6 +848,40 @@ export function buildRenderModel(
if (pendingToolCount > 0 && pendingAgentCount === pendingToolCount) flushGroup()
pendingSteps.push({ kind: 'thinking', message })
}
const ensureTeamCardForCoordination = (
message: ToolCall,
result: ToolResult | undefined,
) => {
const input = parseRecordValue(message.input)
const outputTeamName = nestedStringField(result?.content, 'team_name')
const inputTeamName = typeof input?.team_name === 'string'
? input.team_name.trim()
: ''
const explicitTeamName = outputTeamName || inputTeamName
if (
activeTeamCard &&
(!explicitTeamName || activeTeamCard.teamName === explicitTeamName)
) return activeTeamCard
if (activeTeamCard) activeTeamCard = undefined
const scopedTeamName = options.teamName?.trim() || ''
const teamName = explicitTeamName || scopedTeamName
if (!teamName) return undefined
const usesDurableScope = !explicitTeamName || explicitTeamName === scopedTeamName
flushGroup()
activeTeamCard = {
kind: 'team_card',
id: `team-card-scope-${message.id}`,
teamName,
startedAt: usesDurableScope
? options.teamStartedAt ?? message.timestamp
: message.timestamp,
coordinationToolCalls: [],
}
items.push(activeTeamCard)
return activeTeamCard
}
for (const msg of messages) {
if (msg.type === 'tool_use') {
@@ -752,6 +891,11 @@ export function buildRenderModel(
toolResultMap.set(msg.toolUseId, msg)
}
}
const hasTeamLifecycleEvidence = Boolean(options.teamTaskWindows?.length) || messages.some((msg) => (
msg.type === 'tool_use' &&
(msg.toolName === 'TeamCreate' || msg.toolName === 'TeamDelete') &&
lifecycleToolSucceeded(toolResultMap.get(msg.toolUseId))
))
messages.forEach((msg, index) => {
if (
msg.type === 'tool_use' &&
@@ -785,19 +929,96 @@ export function buildRenderModel(
continue
}
const toolResult = toolResultMap.get(msg.toolUseId)
if (
options.hideTeamCoordinationTools &&
msg.toolName === 'SendMessage' &&
toolResult?.isError === false &&
isTeamCoordinationSendMessage(msg.input, toolResult.content, options.teamMemberNames)
) {
continue
let summarizedTeamDelete = false
if (options.hideTeamCoordinationTools) {
if (msg.toolName === 'TeamCreate' && lifecycleToolSucceeded(toolResult)) {
transcriptTeamCursor = updateTeamLifecycleCursor(
true,
msg.timestamp,
)
} else if (msg.toolName === 'TeamDelete' && lifecycleToolSucceeded(toolResult)) {
transcriptTeamCursor = updateTeamLifecycleCursor(
false,
msg.timestamp,
)
const deletedTeamCard = ensureTeamCardForCoordination(msg, toolResult)
if (deletedTeamCard) {
deletedTeamCard.endedAt = msg.timestamp
deletedTeamCard.coordinationToolCalls.push(msg)
summarizedTeamDelete = true
}
// A later lifecycle can be present only in the durable workbench
// window after transcript compaction. Do not attach that run's
// coordination audit to the card from the lifecycle just deleted.
activeTeamCard = undefined
}
const isTeamScopedAtMessage = isTeamLifecycleScopedAt(
msg.timestamp,
transcriptTeamCursor,
options.teamTaskWindows,
)
const isTeamTask =
(msg.toolName === 'TaskCreate' || msg.toolName === 'TaskUpdate') &&
isTeamScopedAtMessage
const explicitTeamAgent = msg.toolName === 'Agent' &&
isExplicitTeamAgent(msg.input, toolResult, isTeamScopedAtMessage)
const ambiguousTeamAgent = msg.toolName === 'Agent' &&
isTeamScopedAtMessage &&
!explicitTeamAgent &&
msg.isPending === true
const isTeamAgent = msg.toolName === 'Agent' && (
explicitTeamAgent
)
const isTeamMessage = msg.toolName === 'SendMessage' && (
isTeamScopedAtMessage ||
isTeamCoordinationSendMessage(
msg.input,
toolResult?.content,
options.teamMemberNames,
!hasTeamLifecycleEvidence,
)
)
// During an active Team lifecycle, a streamed Agent without
// teammate identity is not yet classifiable: it can settle as a direct
// SubAgent if `name` stays absent, or as a teammate once that identity
// arrives. Keep it out of both projections during that partial state.
if (ambiguousTeamAgent) continue
// These records remain untouched in `messages` and `toolResultMap`.
// The lead transcript projects them through the inline team card while
// the workbench owns the roster, DAG, and communication presentation.
// A failed coordination call stays visible because it changes what the
// turn means and is not represented by successful workbench state.
if (
(isTeamTask || isTeamAgent || isTeamMessage) &&
canSummarizeCoordinationResult(toolResult)
) {
const teamCard = ensureTeamCardForCoordination(msg, toolResult)
if (teamCard) {
teamCard.coordinationToolCalls.push(msg)
continue
}
}
}
if (summarizedTeamDelete) continue
// The raw TeamCreate call and its JSON result say nothing a reader can
// use; the team card in its place links to the workbench that does.
if (options.hideTeamCoordinationTools && msg.toolName === 'TeamCreate') {
if (
options.hideTeamCoordinationTools &&
msg.toolName === 'TeamCreate' &&
canSummarizeCoordinationResult(toolResult)
) {
flushGroup()
items.push({ kind: 'team_card', id: `team-card-${msg.id}` })
activeTeamCard = {
kind: 'team_card',
id: `team-card-${msg.id}`,
teamName: teamNameFromCreate(msg, toolResult),
startedAt: msg.timestamp,
coordinationToolCalls: [],
}
items.push(activeTeamCard)
continue
}
if (msg.toolName === 'AskUserQuestion') {
@@ -846,6 +1067,77 @@ export function buildRenderModel(
return { renderItems: items, toolResultMap, childToolCallsByParent }
}
function coordinationToolSummary(toolCall: ToolCall): string | null {
const input = parseRecordValue(toolCall.input)
if (!input) return null
const values = toolCall.toolName === 'TaskCreate'
? [input.subject]
: toolCall.toolName === 'TaskUpdate'
? [input.taskId, input.status, input.owner]
: toolCall.toolName === 'Agent'
? [input.name, input.description]
: toolCall.toolName === 'SendMessage'
? [input.to, input.message]
: []
const summary = values
.filter((value): value is string => typeof value === 'string' && Boolean(value.trim()))
.join(' · ')
return summary || null
}
function TeamCoordinationAudit({ toolCalls }: { toolCalls: ToolCall[] }) {
const t = useTranslation()
if (toolCalls.length === 0) return null
return (
<details
data-testid="agent-teams-coordination-audit"
className="mx-2 border-x border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-1.5 text-[11px] text-[var(--color-text-secondary)]"
>
<summary className="cursor-pointer select-none font-medium text-[var(--color-text-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]">
{t('agentTeams.inline.coordination', { count: toolCalls.length })}
</summary>
<ol className="mt-2 space-y-1 border-t border-[var(--color-border)] pt-2">
{toolCalls.map((toolCall) => {
const summary = coordinationToolSummary(toolCall)
return (
<li key={toolCall.toolUseId} className="flex min-w-0 items-start gap-2">
<code className="shrink-0 font-mono text-[10px] font-semibold text-[var(--color-text-tertiary)]">
{toolCall.toolName}
</code>
{summary ? <span className="min-w-0 break-words">{summary}</span> : null}
</li>
)
})}
</ol>
</details>
)
}
function teamTimestamp(value: string | number | undefined): number | undefined {
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
if (!value) return undefined
const numeric = Number(value)
if (Number.isFinite(numeric)) return numeric
const parsed = Date.parse(value)
return Number.isFinite(parsed) ? parsed : undefined
}
function snapshotForTeamCard(
snapshot: TeamWorkbenchSnapshot | undefined,
item: Extract<RenderItem, { kind: 'team_card' }>,
): TeamWorkbenchSnapshot | undefined {
if (!snapshot || snapshot.team.name !== item.teamName) return undefined
const startedAt = teamTimestamp(snapshot.team.createdAt)
if (startedAt === undefined) return snapshot
if (item.endedAt !== undefined && item.endedAt < startedAt) return undefined
// The TeamCreate tool and server lifecycle event are emitted by separate
// transports, so allow a small clock/order gap while still refusing to bind
// an older card to a newer incarnation that reused the same team name.
return Math.abs(item.startedAt - startedAt) <= 5_000 ? snapshot : undefined
}
function isTurnResponseMessage(message: UIMessage) {
return (
message.type === 'assistant_text' ||
@@ -1606,10 +1898,16 @@ function estimateMessageHeight(message: UIMessage): number {
const ACTIVITY_GROUP_COLLAPSED_HEIGHT = 34
/** Avatar row plus two text lines, plus the turn's padding-bottom. */
const TEAM_CARD_HEIGHT = 86
/** Collapsed coordination disclosure below the card adds one compact row. */
const TEAM_CARD_WITH_AUDIT_HEIGHT = 116
export function estimateRenderItemHeight(item: RenderItem): number {
if (item.kind === 'message') return estimateMessageHeight(item.message)
if (item.kind === 'team_card') return TEAM_CARD_HEIGHT
if (item.kind === 'team_card') {
return item.coordinationToolCalls.length > 0
? TEAM_CARD_WITH_AUDIT_HEIGHT
: TEAM_CARD_HEIGHT
}
// Agent dispatch groups keep their taller per-agent cards; everything else
// collapses to the single-line activity header.
const isAgentGroup = item.toolCalls.length > 0 && item.toolCalls.every((toolCall) => toolCall.toolName === 'Agent')
@@ -1649,7 +1947,9 @@ function getMessageMetricSignature(message: UIMessage): string {
function getRenderItemMetricSignature(item: RenderItem): string {
if (item.kind === 'message') return getMessageMetricSignature(item.message)
if (item.kind === 'team_card') return `team_card:${item.id}`
if (item.kind === 'team_card') {
return `team_card:${item.id}:${item.coordinationToolCalls.length}`
}
return item.steps
.map((step) => getMessageMetricSignature(step.kind === 'tool' ? step.toolCall : step.message))
.join('|')
@@ -1892,25 +2192,39 @@ export function MessageList({
resolvedSessionId ? s.getTeamByMemberSessionId(resolvedSessionId) : null
))
const isMemberSession = Boolean(memberSessionTeam)
const isSubagentSession = useTabStore((s) => s.tabs.some((tab) => (
tab.sessionId === resolvedSessionId && tab.type === 'subagent'
const isAgentRunTab = useTabStore((s) => s.tabs.some((tab) => (
tab.sessionId === resolvedSessionId &&
(tab.type === 'subagent' || tab.type === 'team-member')
)))
const isDirectAgentSession = isMemberSession || isSubagentSession
const isDirectAgentSession = isMemberSession || isAgentRunTab
const teamWorkbench = useTeamStore((s) =>
resolvedSessionId ? s.workbenchesBySession[resolvedSessionId] : undefined,
)
const isTeamLeadSession = Boolean(teamWorkbench?.snapshots.length)
const teamSnapshot = teamWorkbench?.snapshots.at(-1)
const activeTeamName = useTeamStore((s) => (
resolvedSessionId ? s.teamNameBySession[resolvedSessionId] : undefined
))
const activeTeamStartedAt = useTeamStore((s) => (
resolvedSessionId ? s.activeTeamStartedAtBySession[resolvedSessionId] : undefined
))
const teamSnapshot = useMemo(() => {
const snapshots = teamWorkbench?.snapshots
return snapshots?.length
? snapshotWithHistoricalMembers(snapshots, snapshots.length - 1)
: undefined
}, [teamWorkbench?.snapshots])
const teamTaskWindows = useMemo(
() => teamTaskWindowsForSnapshot(teamSnapshot, activeTeamStartedAt),
[activeTeamStartedAt, teamSnapshot],
)
const openTeamWorkbench = useCallback((leadSessionId: string, teamName: string) => {
useTabStore.getState().openTeamWorkbenchTab(leadSessionId, teamName)
}, [])
const teamMemberNames = useMemo(() => {
const snapshots = teamWorkbench?.snapshots
if (!snapshots?.length) return undefined
return new Set(snapshots[snapshots.length - 1]!.team.members.flatMap((member) =>
if (!teamSnapshot) return undefined
return new Set(teamSnapshot.team.members.flatMap((member) =>
member.name ? [member.name] : [],
))
}, [teamWorkbench?.snapshots])
}, [teamSnapshot])
const addToast = useUIStore((s) => s.addToast)
const messages = sessionState?.messages ?? EMPTY_MESSAGES
const chatState = sessionState?.chatState ?? 'idle'
@@ -2424,10 +2738,22 @@ export function MessageList({
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
() => buildRenderModel(messages, activeAskUserQuestionToolUseId, {
hideTeamCoordinationTools: isTeamLeadSession,
hideTeamCoordinationTools: !isDirectAgentSession,
teamMemberNames,
teamTaskWindows,
teamName: activeTeamName ?? teamSnapshot?.team.name,
teamStartedAt: activeTeamStartedAt,
}),
[activeAskUserQuestionToolUseId, isTeamLeadSession, messages, teamMemberNames],
[
activeAskUserQuestionToolUseId,
activeTeamName,
activeTeamStartedAt,
isDirectAgentSession,
messages,
teamMemberNames,
teamSnapshot?.team.name,
teamTaskWindows,
],
)
// Defer the per-message branchable / completed-turn computations so the first
// commit on tab switch can render the virtualization window without doing two
@@ -3087,12 +3413,27 @@ export function MessageList({
isLive={chatState !== 'idle' && index === renderItems.length - 1 && !hasTrailingStreamingItem}
/>
) : item.kind === 'team_card' ? (
teamSnapshot && resolvedSessionId ? (
resolvedSessionId ? (() => {
const cardSnapshot = snapshotForTeamCard(teamSnapshot, item)
const fallbackPhase = item.endedAt !== undefined || teamTaskWindows.some((window) => (
item.startedAt >= window.startedAt &&
window.endedAt !== undefined &&
item.startedAt <= window.endedAt
)) ? 'completed' : 'forming'
return (
<AgentTeamsInlineCard
snapshot={teamSnapshot}
onOpen={() => openTeamWorkbench(resolvedSessionId, teamSnapshot.team.name)}
/>
) : null
snapshot={cardSnapshot}
teamName={item.teamName}
fallbackPhase={fallbackPhase}
phaseOverride={item.endedAt !== undefined ? 'completed' : undefined}
onOpen={cardSnapshot
? () => openTeamWorkbench(resolvedSessionId, cardSnapshot.team.name)
: undefined}
>
<TeamCoordinationAudit toolCalls={item.coordinationToolCalls} />
</AgentTeamsInlineCard>
)
})() : null
) : (
<MessageBlock
sessionId={resolvedSessionId}
+2
View File
@@ -2288,6 +2288,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'agentTeams.task.completed': 'Completed',
'agentTeams.task.dependencies': 'Depends on {dependencies}',
'agentTeams.task.unclaimed': 'Waiting for a teammate',
'agentTeams.task.completedNoOwner': 'Completed · owner not recorded',
'agentTeams.member.working': 'Working',
'agentTeams.member.idle': 'Idle',
'agentTeams.member.stopped': 'Stopped',
@@ -2338,6 +2339,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'agentTeams.communication.expand': 'Show more',
'agentTeams.communication.collapse': 'Show less',
'agentTeams.inline.created': 'Agent team · {count} teammates',
'agentTeams.inline.coordination': 'Team coordination · {count} steps',
'agentTeams.inline.open': 'Open run report',
'agentTeams.report.title': 'Agent Teams run report',
'agentTeams.report.openWorkbench': 'Workbench',
+2
View File
@@ -2290,6 +2290,7 @@ export const jp: Record<TranslationKey, string> = {
'agentTeams.task.completed': '完了',
'agentTeams.task.dependencies': '依存 {dependencies}',
'agentTeams.task.unclaimed': 'メンバーの取得待ち',
'agentTeams.task.completedNoOwner': '完了 · 担当者の記録なし',
'agentTeams.member.working': '作業中',
'agentTeams.member.idle': '待機中',
'agentTeams.member.stopped': '停止済み',
@@ -2340,6 +2341,7 @@ export const jp: Record<TranslationKey, string> = {
'agentTeams.communication.expand': 'もっと見る',
'agentTeams.communication.collapse': '折りたたむ',
'agentTeams.inline.created': 'エージェントチーム · メンバー {count} 名',
'agentTeams.inline.coordination': 'チーム編成ログ · {count} ステップ',
'agentTeams.inline.open': '実行レポートを開く',
'agentTeams.report.title': 'Agent Teams 実行レポート',
'agentTeams.report.openWorkbench': 'ワークベンチ',
+2
View File
@@ -2290,6 +2290,7 @@ export const kr: Record<TranslationKey, string> = {
'agentTeams.task.completed': '완료됨',
'agentTeams.task.dependencies': '{dependencies}에 의존',
'agentTeams.task.unclaimed': '팀원 할당 대기 중',
'agentTeams.task.completedNoOwner': '완료 · 담당자 기록 없음',
'agentTeams.member.working': '작업 중',
'agentTeams.member.idle': '대기 중',
'agentTeams.member.stopped': '중지됨',
@@ -2340,6 +2341,7 @@ export const kr: Record<TranslationKey, string> = {
'agentTeams.communication.expand': '더 보기',
'agentTeams.communication.collapse': '접기',
'agentTeams.inline.created': '에이전트 팀 · 팀원 {count}명',
'agentTeams.inline.coordination': '팀 조정 기록 · {count}단계',
'agentTeams.inline.open': '실행 보고서 열기',
'agentTeams.report.title': 'Agent Teams 실행 보고서',
'agentTeams.report.openWorkbench': '워크벤치',
+2
View File
@@ -2289,6 +2289,7 @@ export const zh: Record<TranslationKey, string> = {
'agentTeams.task.completed': '已完成',
'agentTeams.task.dependencies': '依賴 {dependencies}',
'agentTeams.task.unclaimed': '等待成員領取',
'agentTeams.task.completedNoOwner': '已完成 · 未記錄負責人',
'agentTeams.member.working': '工作中',
'agentTeams.member.idle': '待命',
'agentTeams.member.stopped': '已停止',
@@ -2339,6 +2340,7 @@ export const zh: Record<TranslationKey, string> = {
'agentTeams.communication.expand': '展開',
'agentTeams.communication.collapse': '收合',
'agentTeams.inline.created': 'Agent 團隊 · {count} 名成員',
'agentTeams.inline.coordination': '團隊編排記錄 · {count} 步',
'agentTeams.inline.open': '開啟執行報告',
'agentTeams.report.title': 'Agent Teams 執行報告',
'agentTeams.report.openWorkbench': '工作台',
+2
View File
@@ -2289,6 +2289,7 @@ export const zh: Record<TranslationKey, string> = {
'agentTeams.task.completed': '已完成',
'agentTeams.task.dependencies': '依赖 {dependencies}',
'agentTeams.task.unclaimed': '等待成员领取',
'agentTeams.task.completedNoOwner': '已完成 · 未记录负责人',
'agentTeams.member.working': '工作中',
'agentTeams.member.idle': '待命',
'agentTeams.member.stopped': '已停止',
@@ -2339,6 +2340,7 @@ export const zh: Record<TranslationKey, string> = {
'agentTeams.communication.expand': '展开',
'agentTeams.communication.collapse': '收起',
'agentTeams.inline.created': 'Agent 团队 · {count} 名成员',
'agentTeams.inline.coordination': '团队编排记录 · {count} 步',
'agentTeams.inline.open': '打开运行报告',
'agentTeams.report.title': 'Agent Teams 运行报告',
'agentTeams.report.openWorkbench': '工作台',
+64
View File
@@ -0,0 +1,64 @@
export type TeamLifecycleWindow = {
startedAt: number
endedAt?: number
}
export type TeamLifecycleCursor = {
active: boolean | undefined
changedAt: number | undefined
}
export const EMPTY_TEAM_LIFECYCLE_CURSOR: TeamLifecycleCursor = {
active: undefined,
changedAt: undefined,
}
export function updateTeamLifecycleCursor(
active: boolean,
changedAt: number,
): TeamLifecycleCursor {
return { active, changedAt }
}
/**
* Merge transcript lifecycle boundaries with durable workbench windows.
*
* Either source can be truncated: compacted transcripts may omit a TeamCreate,
* while a live transcript may not contain TeamDelete yet even though the
* archived workbench already has an end time. The newer applicable boundary
* wins, and a durable end closes the same lifecycle as an explicit create.
*/
export function isTeamLifecycleScopedAt(
timestamp: number,
cursor: TeamLifecycleCursor,
windows: readonly TeamLifecycleWindow[] = [],
): boolean {
const window = windows
.filter(candidate => candidate.startedAt <= timestamp)
.reduce<TeamLifecycleWindow | undefined>((latest, candidate) => (
!latest || candidate.startedAt >= latest.startedAt ? candidate : latest
), undefined)
const windowActive = Boolean(
window && (window.endedAt === undefined || timestamp <= window.endedAt),
)
if (cursor.active === undefined || cursor.changedAt === undefined) {
return windowActive
}
if (!window) return cursor.active
// A compacted newer lifecycle overrides an older explicit delete.
if (window.startedAt > cursor.changedAt) return windowActive
// A durable window that contains the explicit create describes that same
// lifecycle, so its end must still close the scope when TeamDelete is absent.
if (
cursor.active &&
cursor.changedAt >= window.startedAt &&
(window.endedAt === undefined || cursor.changedAt <= window.endedAt)
) {
return windowActive
}
return cursor.active
}
+8 -2
View File
@@ -37,6 +37,7 @@ import { getWorktreeDisplayName, WorktreeDetails } from '../components/chat/Work
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
import { WorkbenchPanel } from '../components/workbench/WorkbenchPanel'
import { AgentTeamsStrip } from '../components/agentTeams/AgentTeamsSummary'
import { snapshotWithHistoricalMembers } from '../components/agentTeams/agentTeamsModel'
import {
SessionActivityPanel,
type OpenSubagentPayload,
@@ -347,9 +348,14 @@ export function ActiveSession() {
session?.messageCount ?? 0,
)
const sessionSlashCommandCount = sessionState?.slashCommands.length ?? 0
const agentTeamsSnapshot = useTeamStore((s) => activeTabId
? s.workbenchesBySession[activeTabId]?.snapshots.at(-1)
const agentTeamsSnapshots = useTeamStore((s) => activeTabId
? s.workbenchesBySession[activeTabId]?.snapshots
: undefined)
const agentTeamsSnapshot = useMemo(() => (
agentTeamsSnapshots?.length
? snapshotWithHistoricalMembers(agentTeamsSnapshots, agentTeamsSnapshots.length - 1)
: undefined
), [agentTeamsSnapshots])
const activeTeamStartedAt = useTeamStore((s) => activeTabId
? s.activeTeamStartedAtBySession[activeTabId]
: undefined)
+96 -1
View File
@@ -1,6 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MessageEntry } from '../types/session'
import { buildSessionActivityModel } from '../components/activity/sessionActivityModel'
import {
buildMainSessionActivityModel,
buildSessionActivityModel,
hasVisibleSessionActivity,
} from '../components/activity/sessionActivityModel'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
const {
@@ -3209,6 +3213,97 @@ describe('chatStore history mapping', () => {
vi.useRealTimers()
})
it('never projects sequential streaming teammates into main Activity', () => {
vi.useFakeTimers()
useChatStore.setState({
sessions: {
[TEST_SESSION_ID]: makeSession(),
},
})
const buildMainActivity = () => buildMainSessionActivityModel({
sessionId: TEST_SESSION_ID,
messages: useChatStore.getState().sessions[TEST_SESSION_ID]?.messages ?? [],
tasks: [],
completedAndDismissed: false,
backgroundTasks: [],
agentNotifications: [],
})
for (const [index, member] of ['feature-analyst', 'bug-analyst', 'quality-analyst'].entries()) {
const toolUseId = `team-agent-${index + 1}`
const description = `分析第 ${index + 1} 组 commit`
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_start',
blockType: 'tool_use',
toolName: 'Agent',
toolUseId,
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
toolInput: `{"description":"${description}","name":`,
})
vi.advanceTimersByTime(60)
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toContainEqual(
expect.objectContaining({
type: 'tool_use',
toolUseId,
input: { description },
isPending: true,
}),
)
expect(hasVisibleSessionActivity(buildMainActivity())).toBe(false)
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'tool_use_complete',
toolName: 'Agent',
toolUseId,
input: {
description,
name: member,
team_name: 'commit-analysis',
},
})
expect(hasVisibleSessionActivity(buildMainActivity())).toBe(false)
}
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_start',
blockType: 'tool_use',
toolName: 'Agent',
toolUseId: 'direct-agent',
})
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'content_delta',
toolInput: '{"description":"审查普通 SubAgent 路径"',
})
vi.advanceTimersByTime(60)
expect(hasVisibleSessionActivity(buildMainActivity())).toBe(false)
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
type: 'tool_use_complete',
toolName: 'Agent',
toolUseId: 'direct-agent',
input: { description: '审查普通 SubAgent 路径' },
})
expect(buildMainActivity().sections.subagents.rows).toEqual([
expect.objectContaining({
id: 'direct-agent',
label: '审查普通 SubAgent 路径',
status: 'running',
}),
])
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
it('batches streaming tool input deltas before updating the pending card', () => {
vi.useFakeTimers()
+2
View File
@@ -56,6 +56,8 @@ export type TeamWorkbenchMessage = {
export type TeamWorkbenchSnapshot = {
version: string
generatedAt: string
taskListRevision?: number
terminalTaskFrameId?: string
team: TeamDetail
tasks: TeamWorkbenchTask[]
messages: TeamWorkbenchMessage[]
+12 -3
View File
@@ -146,7 +146,7 @@ type State = {
// persist on disk forever (gh-32730). TeamDelete removes entries to
// avoid double-cleanup. Lives here (not teamHelpers.ts) so
// resetStateForTests() clears it between tests.
sessionCreatedTeams: Set<string>
sessionCreatedTeams: Map<string, SessionCreatedTeamLifecycle>
// Session-only trust flag for home directory (not persisted to disk)
// When running from home dir, trust dialog is shown but not saved to disk.
// This flag allows features requiring trust to work during the session.
@@ -362,7 +362,7 @@ function getInitialState(): State {
// Scheduled tasks disabled until flag or dialog enables them
scheduledTasksEnabled: false,
sessionCronTasks: [],
sessionCreatedTeams: new Set(),
sessionCreatedTeams: new Map(),
// Session-only trust flag (not persisted to disk)
sessionTrustAccepted: false,
// Session-only flag to disable session persistence to disk
@@ -1315,6 +1315,15 @@ export type SessionCronTask = {
scheduledTime?: string
}
export type SessionCreatedTeamLifecycle = {
generation: number
identity: {
teamName: string
createdAt: number
leadSessionId?: string
}
}
export function getSessionCronTasks(): SessionCronTask[] {
return STATE.sessionCronTasks
}
@@ -1493,7 +1502,7 @@ export function getPlanSlugCache(): Map<string, string> {
return STATE.planSlugCache
}
export function getSessionCreatedTeams(): Set<string> {
export function getSessionCreatedTeams(): Map<string, SessionCreatedTeamLifecycle> {
return STATE.sessionCreatedTeams
}
+38
View File
@@ -367,6 +367,44 @@ describe('TeamWatcher polling', () => {
}])
})
it('uses the canonical config name when a sanitized Team directory is deleted', async () => {
const deliveries: Array<{ message: ServerMessage; sessionId?: string }> = []
const deleted: Array<[string, string | undefined, string | undefined]> = []
const workbenchNames: string[] = []
watcher = new TeamWatcher(
(message, sessionId) => deliveries.push({ message, sessionId }),
{
getWorkbench: async (name) => {
workbenchNames.push(name)
return {} as never
},
markWorkbenchArchiveDeleted: async (name, sessionId, incarnationId) => {
deleted.push([name, sessionId, incarnationId])
},
},
)
const config = makeTeamConfig({
name: 'My Team',
createdAt: 1900000000000,
leadSessionId: 'canonical-lead',
})
await writeTeamConfig('my-team', config)
await watcher.checkNow()
await fs.rm(path.join(tmpDir, 'teams', 'my-team'), { recursive: true, force: true })
await watcher.checkNow()
expect(workbenchNames).toEqual(['my-team'])
expect(deleted).toEqual([[
'My Team',
'canonical-lead',
teamIncarnationId(config),
]])
expect(deliveries.map(delivery => delivery.message)).toEqual([
expect.objectContaining({ type: 'team_created', teamName: 'My Team' }),
expect.objectContaining({ type: 'team_deleted', teamName: 'My Team' }),
])
})
it('should detect team deletion', async () => {
// Create a team
await writeTeamConfig('doomed-team', makeTeamConfig({ name: 'doomed-team' }))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -30,6 +30,7 @@ function getTeamsDir(): string {
}
type WatchedTeamIdentity = {
teamName: string
incarnationId: string
createdAt: number
leadSessionId?: string
@@ -111,7 +112,7 @@ export class TeamWatcher {
for (const [name] of this.lastSnapshots) {
const identity = this.lastTeamIdentities.get(name)
await this.archive.markWorkbenchArchiveDeleted(
name,
identity?.teamName ?? name,
identity?.leadSessionId ?? this.lastLeadSessionIds.get(name),
identity?.incarnationId,
).catch(() => {})
@@ -170,7 +171,7 @@ export class TeamWatcher {
previousIdentity.incarnationId !== currentIdentity.incarnationId
) {
await this.archive.markWorkbenchArchiveDeleted(
teamName,
previousIdentity.teamName,
previousIdentity.leadSessionId,
previousIdentity.incarnationId,
).catch(() => {})
@@ -245,7 +246,7 @@ export class TeamWatcher {
const identity = this.lastTeamIdentities.get(name)
const leadSessionId = identity?.leadSessionId ?? this.lastLeadSessionIds.get(name)
await this.archive.markWorkbenchArchiveDeleted(
name,
identity?.teamName ?? name,
leadSessionId,
identity?.incarnationId,
).catch(() => {})
@@ -318,14 +319,18 @@ export class TeamWatcher {
const leadSessionId = typeof config.leadSessionId === 'string' && config.leadSessionId
? config.leadSessionId
: undefined
const canonicalTeamName = typeof config.name === 'string' && config.name
? config.name
: teamName
const identity = {
teamName: canonicalTeamName,
createdAt,
...(leadSessionId ? { leadSessionId } : {}),
}
return {
...identity,
incarnationId: teamIncarnationId({
name: teamName,
name: canonicalTeamName,
createdAt,
leadSessionId,
}),
+7 -2
View File
@@ -6,7 +6,7 @@ import {
} from '../../utils/hooks.js'
import { lazySchema } from '../../utils/lazySchema.js'
import {
createTask,
createTaskWithCommit,
deleteTask,
getTaskListId,
isTodoV2Enabled,
@@ -39,6 +39,8 @@ const outputSchema = lazySchema(() =>
id: z.string(),
subject: z.string(),
}),
taskListMutationAt: z.string().optional(),
taskListMutationRevision: z.number().int().nonnegative().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
@@ -78,7 +80,7 @@ export const TaskCreateTool = buildTool({
return null
},
async call({ subject, description, activeForm, metadata }, context) {
const taskId = await createTask(getTaskListId(context?.agentId), {
const creation = await createTaskWithCommit(getTaskListId(context?.agentId), {
subject,
description,
activeForm,
@@ -88,6 +90,7 @@ export const TaskCreateTool = buildTool({
blockedBy: [],
metadata,
})
const taskId = creation.taskId
const blockingErrors: string[] = []
const generator = executeTaskCreatedHooks(
@@ -124,6 +127,8 @@ export const TaskCreateTool = buildTool({
id: taskId,
subject,
},
taskListMutationAt: creation.committedAt,
taskListMutationRevision: creation.revision,
},
}
},
+7 -2
View File
@@ -4,7 +4,7 @@ import { lazySchema } from '../../utils/lazySchema.js'
import {
getTaskListId,
isTodoV2Enabled,
listTasks,
readTaskListSnapshot,
TaskStatusSchema,
} from '../../utils/tasks.js'
import { TASK_LIST_TOOL_NAME } from './constants.js'
@@ -24,6 +24,8 @@ const outputSchema = lazySchema(() =>
blockedBy: z.array(z.string()),
}),
),
taskListSnapshotAt: z.string().optional(),
taskListSnapshotRevision: z.number().int().nonnegative().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
@@ -65,7 +67,8 @@ export const TaskListTool = buildTool({
async call(_input, context) {
const taskListId = getTaskListId(context?.agentId)
const allTasks = (await listTasks(taskListId)).filter(
const snapshot = await readTaskListSnapshot(taskListId)
const allTasks = snapshot.tasks.filter(
t => !t.metadata?._internal,
)
@@ -85,6 +88,8 @@ export const TaskListTool = buildTool({
return {
data: {
tasks,
taskListSnapshotAt: snapshot.capturedAt,
taskListSnapshotRevision: snapshot.revision,
},
}
},
+497
View File
@@ -1,8 +1,35 @@
import { describe, expect, it } from 'bun:test'
import { AsyncResource } from 'node:async_hooks'
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ToolUseContext } from '../Tool.js'
import { getSessionCreatedTeams } from '../bootstrap/state.js'
import {
mutateTeamFileAsync,
removeTeammateFromTeamFile,
} from '../utils/swarm/teamHelpers.js'
import {
blockTask,
beginTaskListLifecycle,
claimTask,
clearLeaderTeamName,
createTaskWithCommit,
deleteTaskWithCommit,
getCanonicalTeamTaskListId,
getTasksDir,
readTaskListLifecycleState,
readTaskListSnapshot,
resetTaskList,
updateTask,
withTaskListLifecycleLock,
} from '../utils/tasks.js'
import { TaskCreateTool } from './TaskCreateTool/TaskCreateTool.js'
import { TaskGetTool } from './TaskGetTool/TaskGetTool.js'
import { TaskListTool } from './TaskListTool/TaskListTool.js'
import { TaskUpdateTool } from './TaskUpdateTool/TaskUpdateTool.js'
import { TeamDeleteTool } from './TeamDeleteTool/TeamDeleteTool.js'
import { TeamCreateTool } from './TeamCreateTool/TeamCreateTool.js'
import { isDeferredTool } from './ToolSearchTool/prompt.js'
describe('Task tool discovery', () => {
@@ -24,4 +51,474 @@ describe('Task tool execution ordering', () => {
expect(TaskGetTool.isConcurrencySafe({ taskId: '1' })).toBe(false)
expect(TaskListTool.isConcurrencySafe({})).toBe(false)
})
it('joins TeamCreate, shared TaskCreate, and TeamDelete through one lifecycle token', async () => {
const configDir = await mkdtemp(join(tmpdir(), 'team-tool-lifecycle-'))
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
const previousTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID
process.env.CLAUDE_CONFIG_DIR = configDir
delete process.env.CLAUDE_CODE_TASK_LIST_ID
let appState: Record<string, unknown> = {
expandedView: undefined,
inbox: { messages: [] },
}
const context = {
abortController: new AbortController(),
getAppState: () => appState,
setAppState: (update: (prev: Record<string, unknown>) => Record<string, unknown>) => {
appState = update(appState)
},
} as unknown as ToolUseContext
try {
const createdTeam = await TeamCreateTool.call({
team_name: 'My Tool Team',
description: 'Exercise the real lifecycle join',
}, context)
expect(createdTeam.data.team_name).toBe('My Tool Team')
const active = await readTaskListLifecycleState('my-tool-team')
expect(active).toMatchObject({
generation: 1,
deleted: false,
activeIdentity: {
teamName: 'My Tool Team',
createdAt: expect.any(Number),
},
})
expect(getSessionCreatedTeams().get('My Tool Team')).toEqual({
generation: 1,
identity: active.activeIdentity,
})
await mutateTeamFileAsync('My Tool Team', teamFile => ({
...teamFile,
members: [...teamFile.members, {
agentId: 'idle-worker@My Tool Team',
name: 'idle-worker',
agentType: 'worker',
model: 'claude-sonnet-4-20250514',
joinedAt: Date.now(),
tmuxPaneId: '',
cwd: configDir,
subscriptions: [],
isActive: false,
}],
}))
const deniedWhileIdle = await TeamDeleteTool.call({}, context)
expect(deniedWhileIdle.data).toMatchObject({
success: false,
team_name: 'My Tool Team',
message: expect.stringContaining('registered teammate'),
})
expect((await readTaskListLifecycleState('my-tool-team')).deleted).toBe(false)
expect(removeTeammateFromTeamFile('My Tool Team', {
agentId: 'idle-worker@My Tool Team',
})).toBe(true)
const task = await TaskCreateTool.call({
subject: 'Shared lifecycle task',
description: 'Must land in the Team task list',
activeForm: 'Testing lifecycle join',
}, context)
expect(task.data.taskListMutationRevision).toBe(1)
const deleted = await TeamDeleteTool.call({}, context)
expect(deleted.data).toMatchObject({
success: true,
team_name: 'My Tool Team',
taskListSnapshotRevision: 1,
finalTasks: [{
id: task.data.task.id,
subject: 'Shared lifecycle task',
}],
})
const terminal = await readTaskListLifecycleState('my-tool-team')
expect(terminal).toMatchObject({
generation: 1,
deleted: true,
terminals: [{
generation: 1,
revision: 1,
identity: { teamName: 'My Tool Team' },
}],
})
expect(getSessionCreatedTeams().has('My Tool Team')).toBe(false)
await expect(stat(getTasksDir('my-tool-team'))).rejects.toThrow()
} finally {
getSessionCreatedTeams().delete('My Tool Team')
clearLeaderTeamName()
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir
if (previousTaskListId === undefined) delete process.env.CLAUDE_CODE_TASK_LIST_ID
else process.env.CLAUDE_CODE_TASK_LIST_ID = previousTaskListId
await rm(configDir, { recursive: true, force: true })
}
})
it('atomically reserves distinct names for concurrent TeamCreate calls', async () => {
const configDir = await mkdtemp(join(tmpdir(), 'team-create-race-'))
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
const previousTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID
process.env.CLAUDE_CONFIG_DIR = configDir
delete process.env.CLAUDE_CODE_TASK_LIST_ID
const states: Record<string, unknown>[] = [
{ expandedView: undefined, inbox: { messages: [] } },
{ expandedView: undefined, inbox: { messages: [] } },
]
const contextFor = (index: number) => ({
abortController: new AbortController(),
getAppState: () => states[index],
setAppState: (
update: (prev: Record<string, unknown>) => Record<string, unknown>,
) => {
states[index] = update(states[index]!)
},
}) as unknown as ToolUseContext
const createdNames: string[] = []
const firstResource = new AsyncResource('concurrent-team-create-1')
const secondResource = new AsyncResource('concurrent-team-create-2')
try {
let pending!: Array<ReturnType<typeof TeamCreateTool.call>>
await withTaskListLifecycleLock('concurrent-team', async () => {
// Both calls cross the old pre-lock existence check while the real
// candidate lock is held. Once released, only a lock-inside recheck
// can stop the second call from overwriting the first generation.
pending = [
firstResource.runInAsyncScope(() => TeamCreateTool.call({
team_name: 'Concurrent Team',
description: 'First independent leader',
}, contextFor(0))),
secondResource.runInAsyncScope(() => TeamCreateTool.call({
team_name: 'Concurrent Team',
description: 'Second independent leader',
}, contextFor(1))),
]
})
const results = await Promise.all(pending)
createdNames.push(...results.map(result => result.data.team_name))
expect(new Set(createdNames).size).toBe(2)
expect(createdNames).toContain('Concurrent Team')
for (const name of createdNames) {
const taskListId = getCanonicalTeamTaskListId(name)
const lifecycle = await readTaskListLifecycleState(taskListId)
expect(lifecycle).toMatchObject({
generation: 1,
deleted: false,
activeIdentity: { teamName: name },
})
expect(getSessionCreatedTeams().get(name)).toEqual({
generation: 1,
identity: lifecycle.activeIdentity,
})
await expect(
stat(join(configDir, 'teams', taskListId, 'config.json')),
).resolves.toBeDefined()
}
} finally {
for (const name of createdNames) getSessionCreatedTeams().delete(name)
clearLeaderTeamName()
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir
if (previousTaskListId === undefined) delete process.env.CLAUDE_CODE_TASK_LIST_ID
else process.env.CLAUDE_CODE_TASK_LIST_ID = previousTaskListId
await rm(configDir, { recursive: true, force: true })
}
})
it('keeps the real Team tool chain off a standalone task-list override', async () => {
const configDir = await mkdtemp(join(tmpdir(), 'team-tool-override-'))
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
const previousTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID
process.env.CLAUDE_CONFIG_DIR = configDir
process.env.CLAUDE_CODE_TASK_LIST_ID = 'standalone-explicit-list'
let appState: Record<string, unknown> = {
expandedView: undefined,
inbox: { messages: [] },
}
const context = {
abortController: new AbortController(),
getAppState: () => appState,
setAppState: (update: (prev: Record<string, unknown>) => Record<string, unknown>) => {
appState = update(appState)
},
} as unknown as ToolUseContext
try {
const standalone = await createTaskWithCommit('standalone-explicit-list', {
subject: 'Standalone task survives Team cleanup',
description: 'The Team lifecycle must never delete this list',
status: 'pending',
blocks: [],
blockedBy: [],
})
const created = await TeamCreateTool.call({
team_name: 'Override Safe Team',
description: 'Use the canonical Team DAG',
}, context)
const teamTask = await TaskCreateTool.call({
subject: 'Canonical Team task',
description: 'Must appear in the TeamDelete terminal frame',
}, context)
const deleted = await TeamDeleteTool.call({}, context)
expect(deleted.data).toMatchObject({
success: true,
team_name: created.data.team_name,
finalTasks: [{
id: teamTask.data.task.id,
subject: 'Canonical Team task',
}],
})
expect(await readTaskListSnapshot('standalone-explicit-list')).toMatchObject({
tasks: [{
id: standalone.taskId,
subject: 'Standalone task survives Team cleanup',
}],
})
} finally {
getSessionCreatedTeams().delete('Override Safe Team')
clearLeaderTeamName()
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir
if (previousTaskListId === undefined) delete process.env.CLAUDE_CODE_TASK_LIST_ID
else process.env.CLAUDE_CODE_TASK_LIST_ID = previousTaskListId
await rm(configDir, { recursive: true, force: true })
}
})
it('advances the persistent task revision only for real list mutations', async () => {
const configDir = await mkdtemp(join(tmpdir(), 'task-list-revision-'))
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = configDir
const taskListId = 'revision-contract'
try {
await resetTaskList(taskListId)
expect((await readTaskListSnapshot(taskListId)).revision).toBe(0)
const first = await createTaskWithCommit(taskListId, {
subject: 'First revision task',
description: 'Exercise mutation receipts',
status: 'pending',
blocks: [],
blockedBy: [],
})
const second = await createTaskWithCommit(taskListId, {
subject: 'Second revision task',
description: 'Exercise reciprocal edges',
status: 'pending',
blocks: [],
blockedBy: [],
})
expect([first.revision, second.revision]).toEqual([1, 2])
await updateTask(taskListId, first.taskId, {
subject: 'First revision task',
})
expect((await readTaskListSnapshot(taskListId)).revision).toBe(2)
expect(await blockTask(taskListId, first.taskId, second.taskId)).toBe(true)
expect((await readTaskListSnapshot(taskListId)).revision).toBe(3)
expect(await blockTask(taskListId, first.taskId, second.taskId)).toBe(true)
expect((await readTaskListSnapshot(taskListId)).revision).toBe(3)
expect((await claimTask(taskListId, first.taskId, 'worker')).success).toBe(true)
expect((await readTaskListSnapshot(taskListId)).revision).toBe(4)
expect((await claimTask(taskListId, first.taskId, 'worker')).success).toBe(true)
expect((await readTaskListSnapshot(taskListId)).revision).toBe(4)
const deleted = await deleteTaskWithCommit(taskListId, first.taskId)
expect(deleted).toMatchObject({ deleted: true, revision: 5 })
expect((await readTaskListSnapshot(taskListId))).toMatchObject({
revision: 5,
tasks: [{ id: second.taskId, blockedBy: [] }],
})
expect(await deleteTaskWithCommit(taskListId, first.taskId)).toEqual({
deleted: false,
})
expect((await readTaskListSnapshot(taskListId)).revision).toBe(5)
await resetTaskList(taskListId)
expect((await readTaskListSnapshot(taskListId)).revision).toBe(6)
await resetTaskList(taskListId)
expect((await readTaskListSnapshot(taskListId)).revision).toBe(6)
const busyTarget = await createTaskWithCommit(taskListId, {
subject: 'Busy claim target',
description: 'The busy claim is one transaction',
status: 'pending',
blocks: [],
blockedBy: [],
})
const blockedTarget = await createTaskWithCommit(taskListId, {
subject: 'Busy claim rejection',
description: 'A rejected claim is not a mutation',
status: 'pending',
blocks: [],
blockedBy: [],
})
expect((await claimTask(
taskListId,
busyTarget.taskId,
'busy-worker',
{ checkAgentBusy: true, markInProgress: true },
)).success).toBe(true)
expect((await readTaskListSnapshot(taskListId)).revision).toBe(9)
expect(await claimTask(
taskListId,
blockedTarget.taskId,
'busy-worker',
{ checkAgentBusy: true, markInProgress: true },
)).toMatchObject({ success: false, reason: 'agent_busy' })
expect((await readTaskListSnapshot(taskListId)).revision).toBe(9)
} finally {
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir
await rm(configDir, { recursive: true, force: true })
}
})
it('fails closed on malformed lifecycle state and preserves forward fields', async () => {
const configDir = await mkdtemp(join(tmpdir(), 'task-lifecycle-state-'))
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = configDir
const stateDir = join(configDir, 'tasks', '.lifecycle-locks')
try {
await mkdir(stateDir, { recursive: true })
await writeFile(
join(stateDir, 'malformed.state.json'),
JSON.stringify({ schemaVersion: 1, generation: 'bad' }),
)
await expect(createTaskWithCommit('malformed', {
subject: 'Must not write through malformed state',
description: 'Lifecycle corruption is fail-closed',
status: 'pending',
blocks: [],
blockedBy: [],
})).rejects.toThrow('Invalid task-list lifecycle state')
await expect(stat(getTasksDir('malformed'))).rejects.toThrow()
await writeFile(
join(stateDir, 'forward-state.state.json'),
JSON.stringify({
schemaVersion: 1,
generation: 4,
deleted: true,
terminals: [],
futureField: { preserved: true },
}),
)
await beginTaskListLifecycle('forward-state', {
teamName: 'Forward State',
createdAt: 42,
})
expect(await readTaskListLifecycleState('forward-state')).toMatchObject({
generation: 5,
deleted: false,
futureField: { preserved: true },
})
await resetTaskList('empty-revision')
await writeFile(join(getTasksDir('empty-revision'), '.revision'), ' ')
await expect(readTaskListSnapshot('empty-revision')).rejects.toThrow(
'Invalid empty task-list revision',
)
} finally {
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir
await rm(configDir, { recursive: true, force: true })
}
})
it('persists causal markers and the TeamDelete terminal task frame', async () => {
const configDir = await mkdtemp(join(tmpdir(), 'task-tool-markers-'))
const taskListId = 'task-tool-marker-team'
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
const previousTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID
process.env.CLAUDE_CONFIG_DIR = configDir
process.env.CLAUDE_CODE_TASK_LIST_ID = taskListId
let appState: Record<string, unknown> = {
expandedView: undefined,
inbox: { messages: [] },
}
const context = {
abortController: new AbortController(),
getAppState: () => appState,
setAppState: (update: (prev: Record<string, unknown>) => Record<string, unknown>) => {
appState = update(appState)
},
} as unknown as ToolUseContext
try {
const created = await TaskCreateTool.call({
subject: 'Persist causal identity',
description: 'Archive repair must order the real task-list commits',
activeForm: 'Persisting causal identity',
}, context)
const taskId = created.data.task.id
const dependent = await TaskCreateTool.call({
subject: 'Observe reciprocal dependency',
description: 'The same transaction must update both task files',
activeForm: 'Observing reciprocal dependency',
}, context)
const listed = await TaskListTool.call({}, context)
const updated = await TaskUpdateTool.call({
taskId,
status: 'in_progress',
addBlocks: [dependent.data.task.id],
}, context)
const afterUpdate = await TaskListTool.call({}, context)
expect(Number.isFinite(Date.parse(created.data.taskListMutationAt ?? ''))).toBe(true)
expect(Number.isFinite(Date.parse(listed.data.taskListSnapshotAt ?? ''))).toBe(true)
expect(Number.isFinite(Date.parse(updated.data.taskListMutationAt ?? ''))).toBe(true)
expect(created.data.taskListMutationRevision).toBe(1)
expect(dependent.data.taskListMutationRevision).toBe(2)
expect(listed.data.taskListSnapshotRevision).toBe(2)
expect(updated.data.taskListMutationRevision).toBe(3)
expect(afterUpdate.data.taskListSnapshotRevision).toBe(3)
expect(listed.data.tasks).toEqual([
expect.objectContaining({ id: taskId, status: 'pending' }),
expect.objectContaining({ id: dependent.data.task.id, status: 'pending' }),
])
expect(afterUpdate.data.tasks).toEqual([
expect.objectContaining({ id: taskId, status: 'in_progress' }),
expect.objectContaining({
id: dependent.data.task.id,
blockedBy: [taskId],
}),
])
appState = {
...appState,
teamContext: { teamName: taskListId },
}
const deleted = await TeamDeleteTool.call({}, context)
expect(deleted.data).toMatchObject({
success: true,
team_name: taskListId,
finalTasks: [
expect.objectContaining({
id: taskId,
status: 'in_progress',
blocks: [dependent.data.task.id],
}),
expect.objectContaining({
id: dependent.data.task.id,
blockedBy: [taskId],
}),
],
})
expect(Number.isFinite(Date.parse(deleted.data.taskListSnapshotAt ?? ''))).toBe(true)
expect(deleted.data.taskListSnapshotRevision).toBe(3)
await expect(stat(getTasksDir(taskListId))).rejects.toThrow()
} finally {
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir
if (previousTaskListId === undefined) delete process.env.CLAUDE_CODE_TASK_LIST_ID
else process.env.CLAUDE_CODE_TASK_LIST_ID = previousTaskListId
await rm(configDir, { recursive: true, force: true })
}
})
})
+88 -98
View File
@@ -9,15 +9,14 @@ import {
} from '../../utils/hooks.js'
import { lazySchema } from '../../utils/lazySchema.js'
import {
blockTask,
deleteTask,
deleteTaskWithCommit,
getTask,
getTaskListId,
isTodoV2Enabled,
listTasks,
type TaskStatus,
type Task,
TaskStatusSchema,
updateTask,
updateTaskAtomically,
} from '../../utils/tasks.js'
import {
getAgentId,
@@ -79,6 +78,8 @@ const outputSchema = lazySchema(() =>
})
.optional(),
verificationNudgeNeeded: z.boolean().optional(),
taskListMutationAt: z.string().optional(),
taskListMutationRevision: z.number().int().nonnegative().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
@@ -156,63 +157,11 @@ export const TaskUpdateTool = buildTool({
}
const updatedFields: string[] = []
// Update basic fields if provided and different from current value
const updates: {
subject?: string
description?: string
activeForm?: string
status?: TaskStatus
owner?: string
metadata?: Record<string, unknown>
} = {}
if (subject !== undefined && subject !== existingTask.subject) {
updates.subject = subject
updatedFields.push('subject')
}
if (description !== undefined && description !== existingTask.description) {
updates.description = description
updatedFields.push('description')
}
if (activeForm !== undefined && activeForm !== existingTask.activeForm) {
updates.activeForm = activeForm
updatedFields.push('activeForm')
}
if (owner !== undefined && owner !== existingTask.owner) {
updates.owner = owner
updatedFields.push('owner')
}
// Auto-set owner when a teammate marks a task as in_progress without
// explicitly providing an owner. This ensures the task list can match
// todo items to teammates for showing activity status.
if (
isAgentSwarmsEnabled() &&
status === 'in_progress' &&
owner === undefined &&
!existingTask.owner
) {
const agentName = getAgentName()
if (agentName) {
updates.owner = agentName
updatedFields.push('owner')
}
}
if (metadata !== undefined) {
const merged = { ...(existingTask.metadata ?? {}) }
for (const [key, value] of Object.entries(metadata)) {
if (value === null) {
delete merged[key]
} else {
merged[key] = value
}
}
updates.metadata = merged
updatedFields.push('metadata')
}
if (status !== undefined) {
// Handle deletion - delete the task file and return early
if (status === 'deleted') {
const deleted = await deleteTask(taskListId, taskId)
const deletion = await deleteTaskWithCommit(taskListId, taskId)
const deleted = deletion.deleted
return {
data: {
success: deleted,
@@ -222,11 +171,15 @@ export const TaskUpdateTool = buildTool({
statusChange: deleted
? { from: existingTask.status, to: 'deleted' }
: undefined,
taskListMutationAt: deletion.committedAt,
taskListMutationRevision: deletion.revision,
},
}
}
// For regular status updates, validate and apply if different
// For regular status updates, run completion hooks before the atomic
// commit. The commit re-reads the task so stale ownership decisions are
// never carried across this asynchronous hook boundary.
if (status !== existingTask.status) {
// Run TaskCompleted hooks when marking a task as completed
if (status === 'completed') {
@@ -264,29 +217,93 @@ export const TaskUpdateTool = buildTool({
}
}
updates.status = status
updatedFields.push('status')
}
}
if (Object.keys(updates).length > 0) {
await updateTask(taskListId, taskId, updates)
const automaticOwner = (
isAgentSwarmsEnabled() &&
status === 'in_progress' &&
owner === undefined
) ? getAgentName() : undefined
const mutation = await updateTaskAtomically(taskListId, taskId, (current) => {
const updates: Partial<Omit<Task, 'id'>> = {}
if (subject !== undefined && subject !== current.subject) updates.subject = subject
if (description !== undefined && description !== current.description) {
updates.description = description
}
if (activeForm !== undefined && activeForm !== current.activeForm) {
updates.activeForm = activeForm
}
if (owner !== undefined && owner !== current.owner) {
updates.owner = owner
} else if (owner === undefined && automaticOwner && !current.owner) {
updates.owner = automaticOwner
}
if (metadata !== undefined) {
const merged = { ...(current.metadata ?? {}) }
for (const [key, value] of Object.entries(metadata)) {
if (value === null) delete merged[key]
else merged[key] = value
}
updates.metadata = merged
}
if (status !== undefined && status !== 'deleted' && status !== current.status) {
updates.status = status
}
return updates
}, {
addBlocks,
addBlockedBy,
})
if (!mutation) {
return {
data: {
success: false,
taskId,
updatedFields: [],
error: 'Task not found',
},
}
}
const committedTask = mutation.task
const previousTask = mutation.previous
if (previousTask.subject !== committedTask.subject) updatedFields.push('subject')
if (previousTask.description !== committedTask.description) updatedFields.push('description')
if (previousTask.activeForm !== committedTask.activeForm) updatedFields.push('activeForm')
if (previousTask.owner !== committedTask.owner) updatedFields.push('owner')
if (JSON.stringify(previousTask.metadata) !== JSON.stringify(committedTask.metadata)) {
updatedFields.push('metadata')
}
if (previousTask.status !== committedTask.status) updatedFields.push('status')
if (JSON.stringify(previousTask.blocks) !== JSON.stringify(committedTask.blocks)) {
updatedFields.push('blocks')
}
if (
JSON.stringify(previousTask.blockedBy) !==
JSON.stringify(committedTask.blockedBy)
) updatedFields.push('blockedBy')
const statusChange = previousTask.status !== committedTask.status
? { from: previousTask.status, to: committedTask.status }
: undefined
// Notify new owner via mailbox when ownership changes
if (updates.owner && isAgentSwarmsEnabled()) {
if (
committedTask.owner &&
committedTask.owner !== previousTask.owner &&
isAgentSwarmsEnabled()
) {
const senderName = getAgentName() || 'team-lead'
const senderColor = getTeammateColor()
const assignmentMessage = JSON.stringify({
type: 'task_assignment',
taskId,
subject: existingTask.subject,
description: existingTask.description,
subject: committedTask.subject,
description: committedTask.description,
assignedBy: senderName,
timestamp: new Date().toISOString(),
})
await writeToMailbox(
updates.owner,
committedTask.owner,
{
from: senderName,
text: assignmentMessage,
@@ -297,32 +314,6 @@ export const TaskUpdateTool = buildTool({
)
}
// Add blocks if provided and not already present
if (addBlocks && addBlocks.length > 0) {
const newBlocks = addBlocks.filter(
id => !existingTask.blocks.includes(id),
)
for (const blockId of newBlocks) {
await blockTask(taskListId, taskId, blockId)
}
if (newBlocks.length > 0) {
updatedFields.push('blocks')
}
}
// Add blockedBy if provided and not already present (reverse: the blocker blocks this task)
if (addBlockedBy && addBlockedBy.length > 0) {
const newBlockedBy = addBlockedBy.filter(
id => !existingTask.blockedBy.includes(id),
)
for (const blockerId of newBlockedBy) {
await blockTask(taskListId, blockerId, taskId)
}
if (newBlockedBy.length > 0) {
updatedFields.push('blockedBy')
}
}
// Structural verification nudge: if the main-thread agent just closed
// out a 3+ task list and none of those tasks was a verification step,
// append a reminder to the tool result. Fires at the loop-exit moment
@@ -335,7 +326,7 @@ export const TaskUpdateTool = buildTool({
feature('VERIFICATION_AGENT') &&
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) &&
!context.agentId &&
updates.status === 'completed'
statusChange?.to === 'completed'
) {
const allTasks = await listTasks(taskListId)
const allDone = allTasks.every(t => t.status === 'completed')
@@ -353,11 +344,10 @@ export const TaskUpdateTool = buildTool({
success: true,
taskId,
updatedFields,
statusChange:
updates.status !== undefined
? { from: existingTask.status, to: updates.status }
: undefined,
statusChange,
verificationNudgeNeeded,
taskListMutationAt: mutation.committedAt,
taskListMutationRevision: mutation.revision,
},
}
},
+66 -48
View File
@@ -25,9 +25,12 @@ import {
} from '../../utils/swarm/teamHelpers.js'
import { assignTeammateColor } from '../../utils/swarm/teammateLayoutManager.js'
import {
beginTaskListLifecycle,
ensureTasksDir,
readTaskListLifecycleState,
resetTaskList,
setLeaderTeamName,
withTaskListLifecycleLock,
} from '../../utils/tasks.js'
import { generateWordSlug } from '../../utils/words.js'
import { TEAM_CREATE_TOOL_NAME } from './constants.js'
@@ -57,20 +60,6 @@ export type Output = {
export type Input = z.infer<InputSchema>
/**
* Generates a unique team name by checking if the provided name already exists.
* If the name already exists, generates a new word slug.
*/
function generateUniqueTeamName(providedName: string): string {
// If the team doesn't exist, use the provided name
if (!readTeamFile(providedName)) {
return providedName
}
// Team exists, generate a new unique name
return generateWordSlug()
}
export const TeamCreateTool: Tool<InputSchema, Output> = buildTool({
name: TEAM_CREATE_TOOL_NAME,
searchHint: 'create a multi-agent swarm team',
@@ -139,11 +128,6 @@ export const TeamCreateTool: Tool<InputSchema, Output> = buildTool({
)
}
// If team already exists, generate a unique name instead of failing
const finalTeamName = generateUniqueTeamName(team_name)
// Generate a deterministic agent ID for the team lead
const leadAgentId = formatAgentId(TEAM_LEAD_NAME, finalTeamName)
const leadAgentType = agent_type || TEAM_LEAD_NAME
// Get the team lead's current model from AppState (handles session model, settings, CLI override)
const leadModel = parseUserSpecifiedModel(
@@ -152,38 +136,72 @@ export const TeamCreateTool: Tool<InputSchema, Output> = buildTool({
getDefaultMainLoopModel(),
)
const teamFilePath = getTeamFilePath(finalTeamName)
let candidateName = team_name
let created: {
finalTeamName: string
leadAgentId: string
teamFile: TeamFile
} | undefined
while (!created) {
const finalTeamName = candidateName
const taskListId = sanitizeName(finalTeamName)
created = await withTaskListLifecycleLock(taskListId, async () => {
// Name reservation and lifecycle creation are one cross-process
// transaction. A pre-lock existence check allowed two leaders to both
// create the same absent Team and overwrite each other's generation.
const lifecycleState = await readTaskListLifecycleState(taskListId)
if (
readTeamFile(finalTeamName) ||
(!lifecycleState.deleted && lifecycleState.activeIdentity)
) return undefined
const teamFile: TeamFile = {
name: finalTeamName,
description: _description,
createdAt: Date.now(),
leadAgentId,
leadSessionId: getSessionId(), // Store actual session ID for team discovery
members: [
{
agentId: leadAgentId,
name: TEAM_LEAD_NAME,
agentType: leadAgentType,
model: leadModel,
joinedAt: Date.now(),
tmuxPaneId: '',
cwd: getCwd(),
subscriptions: [],
},
],
const leadAgentId = formatAgentId(TEAM_LEAD_NAME, finalTeamName)
const createdAt = Date.now()
const teamFile: TeamFile = {
name: finalTeamName,
description: _description,
createdAt,
leadAgentId,
leadSessionId: getSessionId(), // Store actual session ID for team discovery
members: [
{
agentId: leadAgentId,
name: TEAM_LEAD_NAME,
agentType: leadAgentType,
model: leadModel,
joinedAt: createdAt,
tmuxPaneId: '',
cwd: getCwd(),
subscriptions: [],
},
],
}
// The durable generation invalidates a writer or cleanup call queued
// by an older same-name Team before any new config/task state is exposed.
const lifecycle = await beginTaskListLifecycle(taskListId, {
teamName: finalTeamName,
createdAt: teamFile.createdAt,
...(teamFile.leadSessionId
? { leadSessionId: teamFile.leadSessionId }
: {}),
})
await writeTeamFileAsync(finalTeamName, teamFile)
// Track for session-end cleanup — teams were left on disk forever
// unless explicitly TeamDelete'd (gh-32730).
registerTeamForSessionCleanup(finalTeamName, lifecycle)
// Reset and create the corresponding task list directory (Team =
// Project = TaskList). This ensures task numbering starts fresh at 1.
await resetTaskList(taskListId)
await ensureTasksDir(taskListId)
return { finalTeamName, leadAgentId, teamFile }
})
if (!created) candidateName = generateWordSlug()
}
await writeTeamFileAsync(finalTeamName, teamFile)
// Track for session-end cleanup — teams were left on disk forever
// unless explicitly TeamDelete'd (gh-32730).
registerTeamForSessionCleanup(finalTeamName)
// Reset and create the corresponding task list directory (Team = Project = TaskList)
// This ensures task numbering starts fresh at 1 for each new swarm
const taskListId = sanitizeName(finalTeamName)
await resetTaskList(taskListId)
await ensureTasksDir(taskListId)
const { finalTeamName, leadAgentId, teamFile } = created
const teamFilePath = getTeamFilePath(finalTeamName)
// Register the team name so getTaskListId() returns it for the leader.
// Without this, the leader falls through to getSessionId() and writes tasks
+32 -13
View File
@@ -6,14 +6,14 @@ import { buildTool, type ToolDef } from '../../Tool.js'
import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
import { lazySchema } from '../../utils/lazySchema.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { TEAM_LEAD_NAME } from '../../utils/swarm/constants.js'
import {
cleanupTeamDirectories,
getRegisteredTeamLifecycle,
readTeamFile,
unregisterTeamForSessionCleanup,
} from '../../utils/swarm/teamHelpers.js'
import { clearTeammateColors } from '../../utils/swarm/teammateLayoutManager.js'
import { clearLeaderTeamName } from '../../utils/tasks.js'
import { clearLeaderTeamName, type Task } from '../../utils/tasks.js'
import { TEAM_DELETE_TOOL_NAME } from './constants.js'
import { getPrompt } from './prompt.js'
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
@@ -25,6 +25,9 @@ export type Output = {
success: boolean
message: string
team_name?: string
finalTasks?: Task[]
taskListSnapshotAt?: string
taskListSnapshotRevision?: number
}
export type Input = z.infer<InputSchema>
@@ -73,34 +76,47 @@ export const TeamDeleteTool: Tool<InputSchema, Output> = buildTool({
const appState = getAppState()
const teamName = appState.teamContext?.teamName
let finalTasks: Task[] | undefined
let taskListSnapshotAt: string | undefined
let taskListSnapshotRevision: number | undefined
if (teamName) {
// Read team config to check for active members
const teamFile = readTeamFile(teamName)
if (teamFile) {
// Filter out the team lead - only count non-lead members
// An idle teammate is still alive and polling its mailbox. Deleting
// the Team while it remains registered lets that old actor wake after
// a same-name recreation and mutate the new generation's task list.
// Shutdown approval removes teammates from the config; only that
// durable transition makes cleanup safe.
const nonLeadMembers = teamFile.members.filter(
m => m.name !== TEAM_LEAD_NAME,
m => m.agentId !== teamFile.leadAgentId,
)
// Separate truly active members from idle/dead ones
// Members with isActive === false are idle (finished their turn or crashed)
const activeMembers = nonLeadMembers.filter(m => m.isActive !== false)
if (activeMembers.length > 0) {
const memberNames = activeMembers.map(m => m.name).join(', ')
if (nonLeadMembers.length > 0) {
const memberNames = nonLeadMembers.map(m => m.name).join(', ')
return {
data: {
success: false,
message: `Cannot cleanup team with ${activeMembers.length} active member(s): ${memberNames}. Use requestShutdown to gracefully terminate teammates first.`,
message: `Cannot cleanup team with ${nonLeadMembers.length} registered teammate(s): ${memberNames}. Use requestShutdown and wait for shutdown approval first.`,
team_name: teamName,
},
}
}
}
await cleanupTeamDirectories(teamName)
const finalTaskSnapshot = await cleanupTeamDirectories(
teamName,
getRegisteredTeamLifecycle(teamName),
)
finalTasks = finalTaskSnapshot.tasks.filter(task => !task.metadata?._internal)
taskListSnapshotAt = finalTaskSnapshot.capturedAt
taskListSnapshotRevision = finalTaskSnapshot.revision
// Already cleaned — don't try again on gracefulShutdown.
unregisterTeamForSessionCleanup(teamName)
unregisterTeamForSessionCleanup(teamName, {
generation: finalTaskSnapshot.generation,
identity: finalTaskSnapshot.identity,
})
// Clear color assignments so new teams start fresh
clearTeammateColors()
@@ -130,6 +146,9 @@ export const TeamDeleteTool: Tool<InputSchema, Output> = buildTool({
? `Cleaned up directories and worktrees for team "${teamName}"`
: 'No team name found, nothing to clean up',
team_name: teamName,
finalTasks,
taskListSnapshotAt,
taskListSnapshotRevision,
},
}
},
+531 -28
View File
@@ -14,13 +14,28 @@ import type {
PluginAgentDefinition,
} from '../../tools/AgentTool/loadAgentsDir.js'
import * as runAgentModule from '../../tools/AgentTool/runAgent.js'
import { TaskUpdateTool } from '../../tools/TaskUpdateTool/TaskUpdateTool.js'
import * as lockfile from '../lockfile.js'
import { drainSdkEvents } from '../sdkEventQueue.js'
import { createTask, listTasks } from '../tasks.js'
import { createTeammateContext } from '../teammateContext.js'
import {
blockTask,
claimTask,
createTask,
deleteTask,
getTask,
getTaskPath,
getTasksDir,
listTasks,
unassignTeammateTasks,
updateTask,
} from '../tasks.js'
import {
createTeammateContext,
runWithTeammateContext,
} from '../teammateContext.js'
import {
buildInProcessTeammateAgentDefinition,
claimNextInProcessTask,
composeInitialTeammatePrompt,
runInProcessTeammate,
withInProcessTeammateActivity,
} from './inProcessRunner.js'
@@ -50,6 +65,20 @@ async function withTempConfig(
}
}
async function waitForFileLock(filePath: string): Promise<void> {
for (let attempt = 0; attempt < 200; attempt++) {
if (await lockfile.check(filePath)) return
await new Promise<void>(resolve => setImmediate(resolve))
}
throw new Error(`Timed out waiting for lock: ${filePath}`)
}
async function yieldEventLoop(turns = 1): Promise<void> {
for (let turn = 0; turn < turns; turn++) {
await new Promise<void>(resolve => setImmediate(resolve))
}
}
function createMember(
name: string,
teamName: string,
@@ -131,13 +160,415 @@ describe('buildInProcessTeammateAgentDefinition', () => {
})
describe('in-process teammate task claiming', () => {
test('claims from the team list instead of the parent session list', async () => {
test('serializes both sides of concurrent dependency edges under one list lock', async () => {
await withTempConfig(async () => {
const taskListId = 'concurrent-dependency-edges'
const sourceId = await createTask(taskListId, {
subject: 'Source', description: 'Blocks both targets', status: 'pending',
blocks: [], blockedBy: [],
})
const firstTargetId = await createTask(taskListId, {
subject: 'First target', description: 'First dependency', status: 'pending',
blocks: [], blockedBy: [],
})
const secondTargetId = await createTask(taskListId, {
subject: 'Second target', description: 'Second dependency', status: 'pending',
blocks: [], blockedBy: [],
})
const listLockPath = join(getTasksDir(taskListId), '.lock')
const releaseBarrier = await lockfile.lock(listLockPath)
const firstEdge = blockTask(taskListId, sourceId, firstTargetId)
const secondEdge = blockTask(taskListId, sourceId, secondTargetId)
await yieldEventLoop(10)
await releaseBarrier()
expect(await Promise.all([firstEdge, secondEdge])).toEqual([true, true])
expect((await getTask(taskListId, sourceId))?.blocks.sort()).toEqual([
firstTargetId,
secondTargetId,
].sort())
expect((await getTask(taskListId, firstTargetId))?.blockedBy).toEqual([sourceId])
expect((await getTask(taskListId, secondTargetId))?.blockedBy).toEqual([sourceId])
})
})
test('rechecks automatic TaskUpdate ownership after a concurrent leader assignment', async () => {
await withTempConfig(async () => {
const taskListId = 'task-update-owner-race'
const taskId = await createTask(taskListId, {
subject: 'Keep the leader assignment', description: 'Do not auto-own from stale state',
status: 'pending', blocks: [], blockedBy: [],
})
const taskPath = getTaskPath(taskListId, taskId)
const listLockPath = join(getTasksDir(taskListId), '.lock')
let releaseTaskBarrier: (() => Promise<void>) | undefined = await lockfile.lock(taskPath)
const originalUserType = process.env.USER_TYPE
process.env.USER_TYPE = 'ant'
try {
const leaderAssignment = updateTask(taskListId, taskId, {
owner: 'leader-assignee',
status: 'in_progress',
})
await waitForFileLock(listLockPath)
const teammateContext = createTeammateContext({
agentId: 'idle-agent@task-update-owner-race',
agentName: 'idle-agent',
teamName: taskListId,
planModeRequired: false,
parentSessionId: 'leader-session',
abortController: new AbortController(),
})
const toolContext = {
agentId: 'idle-agent@task-update-owner-race',
abortController: new AbortController(),
setAppState: () => {},
} as unknown as ToolUseContext
const teammateUpdate = runWithTeammateContext(
teammateContext,
() => TaskUpdateTool.call({ taskId, status: 'in_progress' }, toolContext),
)
await yieldEventLoop(4)
await releaseTaskBarrier()
releaseTaskBarrier = undefined
await leaderAssignment
expect((await teammateUpdate).data).toMatchObject({
success: true,
updatedFields: [],
})
expect(await getTask(taskListId, taskId)).toMatchObject({
owner: 'leader-assignee',
status: 'in_progress',
})
} finally {
await releaseTaskBarrier?.()
if (originalUserType === undefined) delete process.env.USER_TYPE
else process.env.USER_TYPE = originalUserType
}
})
})
test('automatically owns an unassigned task when a teammate starts it', async () => {
await withTempConfig(async () => {
const taskListId = 'task-update-auto-owner'
const taskId = await createTask(taskListId, {
subject: 'Start assigned work',
description: 'The first in-progress transition should record its teammate',
status: 'pending',
blocks: [],
blockedBy: [],
})
const originalUserType = process.env.USER_TYPE
process.env.USER_TYPE = 'ant'
const teammateContext = createTeammateContext({
agentId: 'worker@task-update-auto-owner',
agentName: 'worker',
teamName: taskListId,
planModeRequired: false,
parentSessionId: 'leader-session',
abortController: new AbortController(),
})
const toolContext = {
agentId: 'worker@task-update-auto-owner',
abortController: new AbortController(),
setAppState: () => {},
} as unknown as ToolUseContext
try {
const result = await runWithTeammateContext(
teammateContext,
() => TaskUpdateTool.call({ taskId, status: 'in_progress' }, toolContext),
)
expect(result.data).toMatchObject({
success: true,
updatedFields: ['owner', 'status'],
statusChange: { from: 'pending', to: 'in_progress' },
})
expect(await getTask(taskListId, taskId)).toMatchObject({
owner: 'worker',
status: 'in_progress',
})
} finally {
if (originalUserType === undefined) delete process.env.USER_TYPE
else process.env.USER_TYPE = originalUserType
}
})
})
test('does not unassign a task after another actor reassigns it', async () => {
await withTempConfig(async () => {
const taskListId = 'shutdown-reassignment-race'
const taskId = await createTask(taskListId, {
subject: 'Preserve reassignment', description: 'Shutdown cleanup must use owner CAS',
owner: 'exiting-agent', status: 'in_progress', blocks: [], blockedBy: [],
})
const taskPath = getTaskPath(taskListId, taskId)
const listLockPath = join(getTasksDir(taskListId), '.lock')
let releaseTaskBarrier: (() => Promise<void>) | undefined = await lockfile.lock(taskPath)
try {
const reassignment = updateTask(taskListId, taskId, {
owner: 'replacement-agent',
status: 'in_progress',
})
await waitForFileLock(listLockPath)
const cleanup = unassignTeammateTasks(
taskListId,
'exiting-agent@shutdown-reassignment-race',
'exiting-agent',
'shutdown',
)
await yieldEventLoop(4)
await releaseTaskBarrier()
releaseTaskBarrier = undefined
await reassignment
expect(await cleanup).toMatchObject({ unassignedTasks: [] })
expect(await getTask(taskListId, taskId)).toMatchObject({
owner: 'replacement-agent',
status: 'in_progress',
})
} finally {
await releaseTaskBarrier?.()
}
})
})
test('returns genuinely unassigned teammate work to the pending pool', async () => {
await withTempConfig(async () => {
const taskListId = 'shutdown-positive-unassign'
const taskId = await createTask(taskListId, {
subject: 'Hand work back to the team',
description: 'Shutdown cleanup should release work still owned by the exiting member',
owner: 'exiting-agent',
status: 'in_progress',
blocks: [],
blockedBy: [],
})
const result = await unassignTeammateTasks(
taskListId,
'exiting-agent@shutdown-positive-unassign',
'exiting-agent',
'shutdown',
)
expect(result.unassignedTasks).toEqual([{
id: taskId,
subject: 'Hand work back to the team',
}])
expect(result.notificationMessage).toContain('1 task(s) were unassigned')
expect(await getTask(taskListId, taskId)).toMatchObject({ status: 'pending' })
expect((await getTask(taskListId, taskId))?.owner).toBeUndefined()
})
})
test('marks a claim in progress only when the caller explicitly requests it', async () => {
await withTempConfig(async () => {
const taskListId = 'claim-status-contract'
const pendingClaim = await createTask(taskListId, {
subject: 'Keep pending',
description: 'Legacy claim callers only assign an owner',
status: 'pending',
blocks: [],
blockedBy: [],
})
const activeClaim = await createTask(taskListId, {
subject: 'Start immediately',
description: 'In-process idle claiming activates atomically',
status: 'pending',
blocks: [],
blockedBy: [],
})
expect(await claimTask(taskListId, pendingClaim, 'legacy-agent'))
.toMatchObject({ success: true, task: { owner: 'legacy-agent', status: 'pending' } })
expect(await claimTask(taskListId, activeClaim, 'idle-agent', {
markInProgress: true,
})).toMatchObject({
success: true,
task: { owner: 'idle-agent', status: 'in_progress' },
})
})
})
test('does not overwrite a leader owner/status update committed before a busy claim', async () => {
await withTempConfig(async () => {
const taskListId = 'claim-after-owner-update'
const taskId = await createTask(taskListId, {
subject: 'Leader-owned task',
description: 'The idle claimant must observe the serialized assignment',
status: 'pending',
blocks: [],
blockedBy: [],
})
const taskPath = getTaskPath(taskListId, taskId)
const listLockPath = join(getTasksDir(taskListId), '.lock')
let releaseTaskBarrier: (() => Promise<void>) | undefined = await lockfile.lock(taskPath)
try {
const leaderUpdate = updateTask(taskListId, taskId, {
owner: 'leader-assignee',
status: 'in_progress',
})
await waitForFileLock(listLockPath)
let claimSettled = false
const idleClaim = claimTask(taskListId, taskId, 'idle-claimant', {
checkAgentBusy: true,
markInProgress: true,
}).finally(() => {
claimSettled = true
})
await yieldEventLoop(4)
expect(claimSettled).toBe(false)
await releaseTaskBarrier()
releaseTaskBarrier = undefined
expect(await leaderUpdate).toMatchObject({
owner: 'leader-assignee',
status: 'in_progress',
})
expect(await idleClaim).toMatchObject({
success: false,
reason: 'already_claimed',
task: {
owner: 'leader-assignee',
status: 'in_progress',
},
})
expect(await getTask(taskListId, taskId)).toMatchObject({
owner: 'leader-assignee',
status: 'in_progress',
})
} finally {
await releaseTaskBarrier?.()
}
})
})
test('serializes a new busy assignment before claiming different follow-up work', async () => {
await withTempConfig(async () => {
const taskListId = 'claim-after-busy-update'
const busyTaskId = await createTask(taskListId, {
subject: 'Fresh leader assignment',
description: 'This update must participate in the claim transaction',
status: 'pending',
blocks: [],
blockedBy: [],
})
const followUpTaskId = await createTask(taskListId, {
subject: 'Unowned follow-up',
description: 'Must remain available while the member is busy',
status: 'pending',
blocks: [],
blockedBy: [],
})
const busyTaskPath = getTaskPath(taskListId, busyTaskId)
const listLockPath = join(getTasksDir(taskListId), '.lock')
let releaseTaskBarrier: (() => Promise<void>) | undefined = await lockfile.lock(busyTaskPath)
try {
const leaderUpdate = updateTask(taskListId, busyTaskId, {
owner: 'idle-claimant',
status: 'in_progress',
})
await waitForFileLock(listLockPath)
const idleClaim = claimTask(
taskListId,
followUpTaskId,
'idle-claimant',
{ checkAgentBusy: true, markInProgress: true },
)
await releaseTaskBarrier()
releaseTaskBarrier = undefined
await leaderUpdate
expect(await idleClaim).toMatchObject({
success: false,
reason: 'agent_busy',
busyWithTasks: [busyTaskId],
})
expect(await getTask(taskListId, followUpTaskId)).toMatchObject({
status: 'pending',
})
expect((await getTask(taskListId, followUpTaskId))?.owner).toBeUndefined()
} finally {
await releaseTaskBarrier?.()
}
})
})
test('does not let delete turn a serialized busy claim into success without a task', async () => {
await withTempConfig(async () => {
const taskListId = 'delete-during-claim'
const taskId = await createTask(taskListId, {
subject: 'Claim or delete atomically',
description: 'A successful claim must always carry the committed task',
status: 'pending',
blocks: [],
blockedBy: [],
})
const taskPath = getTaskPath(taskListId, taskId)
const listLockPath = join(getTasksDir(taskListId), '.lock')
let releaseTaskBarrier: (() => Promise<void>) | undefined = await lockfile.lock(taskPath)
try {
const idleClaim = claimTask(taskListId, taskId, 'idle-claimant', {
checkAgentBusy: true,
markInProgress: true,
})
await waitForFileLock(listLockPath)
let deleteSettled = false
const deletion = deleteTask(taskListId, taskId).finally(() => {
deleteSettled = true
})
await yieldEventLoop(20)
expect(deleteSettled).toBe(false)
expect(await getTask(taskListId, taskId)).not.toBeNull()
await releaseTaskBarrier()
releaseTaskBarrier = undefined
expect(await idleClaim).toMatchObject({
success: true,
task: {
id: taskId,
owner: 'idle-claimant',
status: 'in_progress',
},
})
expect(await deletion).toBe(true)
expect(await getTask(taskListId, taskId)).toBeNull()
} finally {
await releaseTaskBarrier?.()
}
})
})
test('claims follow-up work from the team list instead of the parent session list', async () => {
await withTempConfig(async () => {
const teamName = 'Release_Audit'
const taskListId = 'release-audit'
const parentSessionId = 'leader-session'
const agentName = 'workflow-analyzer'
const explicitAssignment = await createTask(taskListId, {
subject: 'Completed explicit assignment',
description: 'Establish that the lead assigned this teammate',
status: 'pending',
blocks: [],
blockedBy: [],
})
await updateTask(taskListId, explicitAssignment, {
owner: agentName,
status: 'in_progress',
})
await updateTask(taskListId, explicitAssignment, { status: 'completed' })
await createTask(taskListId, {
subject: 'Audit workflow',
description: 'Audit workflow changes',
@@ -157,10 +588,8 @@ describe('in-process teammate task claiming', () => {
expect(prompt).toContain('Audit workflow')
const claimedTasks = await listTasks(taskListId)
expect(claimedTasks[0]?.owner).toBe(agentName)
expect(claimedTasks.every(task => task.status === 'in_progress')).toBe(
true,
)
expect(claimedTasks[1]?.owner).toBe(agentName)
expect(claimedTasks[1]?.status).toBe('in_progress')
const unrelatedTasks = await listTasks(parentSessionId)
expect(unrelatedTasks).toHaveLength(1)
@@ -175,6 +604,16 @@ describe('in-process teammate task claiming', () => {
await withTempConfig(async () => {
const teamName = 'parallel-audit'
const agentNames = ['workflow-analyzer', 'desktop-analyzer', 'provider-analyzer']
for (const agentName of agentNames) {
await createTask(teamName, {
subject: `Completed assignment for ${agentName}`,
description: 'Establish prior explicit ownership',
status: 'completed',
owner: agentName,
blocks: [],
blockedBy: [],
})
}
for (const subject of ['Audit workflow', 'Audit desktop', 'Audit providers']) {
await createTask(teamName, {
subject,
@@ -191,19 +630,27 @@ describe('in-process teammate task claiming', () => {
expect(prompts.every(Boolean)).toBe(true)
const claimedTasks = await listTasks(teamName)
expect(claimedTasks.map(task => task.owner).sort()).toEqual([...agentNames].sort())
expect(claimedTasks.every(task => task.status === 'in_progress')).toBe(true)
const followUpTasks = claimedTasks.filter(task => task.status === 'in_progress')
expect(followUpTasks.map(task => task.owner).sort()).toEqual([...agentNames].sort())
})
})
test('honors the explicit task list override used by the leader', async () => {
test('keeps teammate claims on the canonical Team list despite a standalone override', async () => {
await withTempConfig(async () => {
const previousTaskListId = process.env.CLAUDE_CODE_TASK_LIST_ID
process.env.CLAUDE_CODE_TASK_LIST_ID = 'explicit-team-list'
try {
await createTask('explicit-team-list', {
subject: 'Audit explicit list',
description: 'Use the configured list',
await createTask('ignored-team-name', {
subject: 'Completed canonical assignment',
description: 'Establish prior explicit ownership',
status: 'completed',
owner: 'auditor',
blocks: [],
blockedBy: [],
})
await createTask('ignored-team-name', {
subject: 'Audit canonical Team list',
description: 'Do not split Team work into the standalone override',
status: 'pending',
blocks: [],
blockedBy: [],
@@ -212,8 +659,14 @@ describe('in-process teammate task claiming', () => {
expect(await claimNextInProcessTask({
agentName: 'auditor',
teamName: 'Ignored_Team_Name',
})).toContain('Audit explicit list')
expect((await listTasks('explicit-team-list'))[0]?.owner).toBe('auditor')
})).toContain('Audit canonical Team list')
const canonicalTask = (await listTasks('ignored-team-name'))
.find(task => task.subject === 'Audit canonical Team list')
expect(canonicalTask).toMatchObject({
owner: 'auditor',
status: 'in_progress',
})
expect(await listTasks('explicit-team-list')).toEqual([])
} finally {
if (previousTaskListId === undefined) {
delete process.env.CLAUDE_CODE_TASK_LIST_ID
@@ -224,12 +677,54 @@ describe('in-process teammate task claiming', () => {
})
})
test('delivers the claimed task instructions in the first teammate turn', () => {
expect(composeInitialTeammatePrompt(
'<teammate-message>Review the release</teammate-message>',
'Complete task #2: Audit desktop changes',
)).toContain('Complete task #2: Audit desktop changes')
expect(composeInitialTeammatePrompt('Review the release', undefined)).toBe('Review the release')
test('waits for a lead owner assignment before opportunistically claiming work', async () => {
await withTempConfig(async () => {
const teamName = 'assignment-barrier'
await createTask(teamName, {
subject: 'Feature analysis',
description: 'Must not be attached to an arbitrary spawn prompt',
status: 'pending',
blocks: [],
blockedBy: [],
})
expect(await claimNextInProcessTask({
agentName: 'bug-analyst',
teamName,
})).toBeUndefined()
expect((await listTasks(teamName))[0]).toMatchObject({
status: 'pending',
})
expect((await listTasks(teamName))[0]?.owner).toBeUndefined()
})
})
test('does not claim another task while the teammate owns unfinished work', async () => {
await withTempConfig(async () => {
const teamName = 'busy-claim-guard'
const agentName = 'reviewer'
await createTask(teamName, {
subject: 'Current review',
description: 'The teammate is still working on this assignment',
status: 'in_progress',
owner: agentName,
blocks: [],
blockedBy: [],
})
await createTask(teamName, {
subject: 'Unclaimed follow-up',
description: 'Must stay available for another idle teammate',
status: 'pending',
blocks: [],
blockedBy: [],
})
expect(await claimNextInProcessTask({ agentName, teamName })).toBeUndefined()
const followUp = (await listTasks(teamName))
.find(task => task.subject === 'Unclaimed follow-up')
expect(followUp?.owner).toBeUndefined()
expect(followUp?.status).toBe('pending')
})
})
})
@@ -275,7 +770,7 @@ describe('in-process teammate activity synchronization', () => {
}
})
test('delivers the claimed task during the first active turn and scopes its terminal event', async () => {
test('keeps the leader-assigned first turn separate from opportunistic task claiming', async () => {
await withTempConfig(async () => {
resetStateForTests()
setIsInteractive(false)
@@ -299,6 +794,14 @@ describe('in-process teammate activity synchronization', () => {
leadAgentId: `team-lead@${teamName}`,
members: [member],
})
await createTask(teamName, {
subject: 'Earlier explicit assignment',
description: 'Makes an accidental first-turn claim observable',
status: 'completed',
owner: agentName,
blocks: [],
blockedBy: [],
})
await createTask(teamName, {
subject: 'Audit first-turn delivery',
description: 'Verify the claimed task reaches runAgent',
@@ -370,12 +873,12 @@ describe('in-process teammate activity synchronization', () => {
expect(result.success).toBe(true)
expect(firstPrompt).toContain('Review the release')
expect(firstPrompt).toContain('Audit first-turn delivery')
expect(firstPrompt).not.toContain('Audit first-turn delivery')
expect(readTeamFile(teamName)?.members[0]?.isActive).toBe(false)
expect((await listTasks(teamName))[0]).toMatchObject({
owner: agentName,
status: 'in_progress',
})
const unclaimedTask = (await listTasks(teamName))
.find(task => task.subject === 'Audit first-turn delivery')
expect(unclaimedTask?.owner).toBeUndefined()
expect(unclaimedTask?.status).toBe('pending')
expect(drainSdkEvents()).toContainEqual(expect.objectContaining({
subtype: 'task_notification',
task_id: taskId,
+17 -25
View File
@@ -89,10 +89,9 @@ import { jsonStringify } from '../slowOperations.js'
import { asSystemPrompt } from '../systemPromptType.js'
import {
claimTask,
getTeamTaskListId,
getCanonicalTeamTaskListId,
listTasks,
type Task,
updateTask,
} from '../tasks.js'
import type { TeammateContext } from '../teammateContext.js'
import { runWithTeammateContext } from '../teammateContext.js'
@@ -689,19 +688,31 @@ function formatTaskAsPrompt(task: Task): string {
}
/**
* Try to claim an available task from the team's task list.
* Returns the formatted prompt if a task was claimed, or undefined if none available.
* Try to claim follow-up work from the team's task list.
*
* A newly spawned teammate must first receive an explicit owner assignment from
* the lead. Otherwise concurrent spawns race through the same unowned list and
* can attach another member's task to the teammate's first prompt. Once the
* member has appeared as an owner, completed ownership is the durable signal
* that it may autonomously continue with the next available task.
*/
export async function claimNextInProcessTask(
identity: Pick<TeammateIdentity, 'agentName' | 'teamName'>,
): Promise<string | undefined> {
const { agentName } = identity
const taskListId = getTeamTaskListId(identity.teamName)
const taskListId = getCanonicalTeamTaskListId(identity.teamName)
try {
const tasks = await listTasks(taskListId)
if (!tasks.some(task => task.owner === agentName)) return undefined
for (const availableTask of findAvailableTasks(tasks)) {
const result = await claimTask(taskListId, availableTask.id, agentName)
const result = await claimTask(
taskListId,
availableTask.id,
agentName,
{ checkAgentBusy: true, markInProgress: true },
)
if (!result.success) {
logForDebugging(
`[inProcessRunner] Failed to claim task #${availableTask.id}: ${result.reason}`,
@@ -709,9 +720,6 @@ export async function claimNextInProcessTask(
continue
}
// Also set status to in_progress so the UI reflects it immediately
await updateTask(taskListId, availableTask.id, { status: 'in_progress' })
logForDebugging(
`[inProcessRunner] Claimed task #${availableTask.id}: ${availableTask.subject}`,
)
@@ -725,15 +733,6 @@ export async function claimNextInProcessTask(
}
}
export function composeInitialTeammatePrompt(
initialPrompt: string,
claimedTaskPrompt: string | undefined,
): string {
return claimedTaskPrompt
? `${initialPrompt}\n\n${claimedTaskPrompt}`
: initialPrompt
}
/**
* Result of waiting for messages.
*/
@@ -1069,13 +1068,6 @@ export async function runInProcessTeammate(
let currentPrompt = wrappedInitialPrompt
let shouldExit = false
// Try to claim an available task immediately so the UI can show activity
// from the very start. The idle loop handles claiming for subsequent tasks.
currentPrompt = composeInitialTeammatePrompt(
wrappedInitialPrompt,
await claimNextInProcessTask(identity),
)
try {
// Add initial prompt to task.messages for display (wrapped with XML)
updateTaskState(
+202 -52
View File
@@ -12,7 +12,17 @@ import { lazySchema } from '../lazySchema.js'
import * as lockfile from '../lockfile.js'
import type { PermissionMode } from '../permissions/PermissionMode.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import { getTasksDir, notifyTasksUpdated } from '../tasks.js'
import {
completeTaskListLifecycle,
getCanonicalTeamTaskListId,
getTasksDir,
notifyTasksUpdated,
readTaskListLifecycleState,
readTaskListSnapshot,
type TaskListLifecycleToken,
type TaskListTerminalReceipt,
withTaskListLifecycleLock,
} from '../tasks.js'
import { getAgentName, getTeamName, isTeammate } from '../teammate.js'
import { type BackendType, isPaneBackend } from './backends/types.js'
import { TEAM_LEAD_NAME } from './constants.js'
@@ -615,16 +625,39 @@ async function destroyWorktree(worktreePath: string): Promise<void> {
* Backing Set lives in bootstrap/state.ts so resetStateForTests()
* clears it between tests (avoids the PR #17615 cross-shard leak class).
*/
export function registerTeamForSessionCleanup(teamName: string): void {
getSessionCreatedTeams().add(teamName)
export function registerTeamForSessionCleanup(
teamName: string,
lifecycle: TaskListLifecycleToken,
): void {
getSessionCreatedTeams().set(teamName, lifecycle)
}
export function getRegisteredTeamLifecycle(
teamName: string,
): TaskListLifecycleToken | undefined {
return getSessionCreatedTeams().get(teamName)
}
/**
* Remove a team from session cleanup tracking (e.g., after explicit
* TeamDelete — already cleaned, don't try again on shutdown).
*/
export function unregisterTeamForSessionCleanup(teamName: string): void {
getSessionCreatedTeams().delete(teamName)
export function unregisterTeamForSessionCleanup(
teamName: string,
expected?: TaskListLifecycleToken,
): void {
const teams = getSessionCreatedTeams()
const current = teams.get(teamName)
if (
expected &&
(
current?.generation !== expected.generation ||
current.identity.teamName !== expected.identity.teamName ||
current.identity.createdAt !== expected.identity.createdAt ||
current.identity.leadSessionId !== expected.identity.leadSessionId
)
) return
teams.delete(teamName)
}
/**
@@ -634,17 +667,22 @@ export function unregisterTeamForSessionCleanup(teamName: string): void {
export async function cleanupSessionTeams(): Promise<void> {
const sessionCreatedTeams = getSessionCreatedTeams()
if (sessionCreatedTeams.size === 0) return
const teams = Array.from(sessionCreatedTeams)
const teams = Array.from(sessionCreatedTeams.entries())
logForDebugging(
`cleanupSessionTeams: removing ${teams.length} orphan team dir(s): ${teams.join(', ')}`,
`cleanupSessionTeams: removing ${teams.length} orphan team dir(s): ${teams.map(([name]) => name).join(', ')}`,
)
// Kill panes first — on SIGINT the teammate processes are still running;
// deleting directories alone would orphan them in open tmux/iTerm2 panes.
// (TeamDeleteTool's path doesn't need this — by then teammates have
// gracefully exited and useInboxPoller has already closed their panes.)
await Promise.allSettled(teams.map(name => killOrphanedTeammatePanes(name)))
await Promise.allSettled(teams.map(name => cleanupTeamDirectories(name)))
sessionCreatedTeams.clear()
const results = await Promise.allSettled(
teams.map(([name, lifecycle]) => cleanupTeamDirectories(
name,
lifecycle,
{ killOrphanedPanes: true },
)),
)
for (let index = 0; index < teams.length; index++) {
if (results[index]?.status !== 'fulfilled') continue
const [name, lifecycle] = teams[index]!
unregisterTeamForSessionCleanup(name, lifecycle)
}
}
/**
@@ -691,44 +729,14 @@ async function killOrphanedTeammatePanes(teamName: string): Promise<void> {
)
}
/**
* Cleans up team and task directories for a given team name.
* Also cleans up git worktrees created for teammates.
* Called when a swarm session is terminated.
*/
export async function cleanupTeamDirectories(teamName: string): Promise<void> {
const sanitizedName = sanitizeName(teamName)
// Read team file to get worktree paths BEFORE deleting the team directory
const teamFile = readTeamFile(teamName)
const worktreePaths: string[] = []
if (teamFile) {
for (const member of teamFile.members) {
if (member.worktreePath) {
worktreePaths.push(member.worktreePath)
}
}
}
// Clean up worktrees first
for (const worktreePath of worktreePaths) {
await destroyWorktree(worktreePath)
}
// Clean up team directory (~/.claude/teams/{team-name}/)
const teamDir = getTeamDir(teamName)
try {
await rm(teamDir, { recursive: true, force: true })
logForDebugging(`[TeammateTool] Cleaned up team directory: ${teamDir}`)
} catch (error) {
logForDebugging(
`[TeammateTool] Failed to clean up team directory ${teamDir}: ${errorMessage(error)}`,
)
}
// Clean up tasks directory (~/.claude/tasks/{taskListId}/)
// The leader and teammates all store tasks under the sanitized team name.
const tasksDir = getTasksDir(sanitizedName)
async function removeTeamLifecycleDirectories(
teamName: string,
taskListId: string,
): Promise<void> {
// Remove the task directory first. If that fails, retaining the Team config
// leaves the public cleanup entry retryable instead of stranding an
// invisible, lifecycle-fenced task directory.
const tasksDir = getTasksDir(taskListId)
try {
await rm(tasksDir, { recursive: true, force: true })
logForDebugging(`[TeammateTool] Cleaned up tasks directory: ${tasksDir}`)
@@ -737,5 +745,147 @@ export async function cleanupTeamDirectories(teamName: string): Promise<void> {
logForDebugging(
`[TeammateTool] Failed to clean up tasks directory ${tasksDir}: ${errorMessage(error)}`,
)
throw new Error(
`Failed to clean up Team task directory ${tasksDir}: ${errorMessage(error)}`,
)
}
const teamDir = getTeamDir(teamName)
try {
await rm(teamDir, { recursive: true, force: true })
logForDebugging(`[TeammateTool] Cleaned up team directory: ${teamDir}`)
} catch (error) {
logForDebugging(
`[TeammateTool] Failed to clean up team directory ${teamDir}: ${errorMessage(error)}`,
)
throw new Error(
`Failed to clean up Team directory ${teamDir}: ${errorMessage(error)}`,
)
}
}
async function cleanupTeamRuntimeResources(
teamName: string,
members: TeamFile['members'],
options: { killOrphanedPanes?: boolean },
): Promise<void> {
if (options.killOrphanedPanes) {
await killOrphanedTeammatePanes(teamName)
}
for (const member of members) {
if (member.worktreePath) await destroyWorktree(member.worktreePath)
}
}
/**
* Cleans up team and task directories for a given team name.
* Also cleans up git worktrees created for teammates.
* Called when a swarm session is terminated.
*/
export async function cleanupTeamDirectories(
teamName: string,
expectedLifecycle?: TaskListLifecycleToken,
options: { killOrphanedPanes?: boolean } = {},
): Promise<TaskListTerminalReceipt> {
const sanitizedName = getCanonicalTeamTaskListId(teamName)
const teamFile = readTeamFile(teamName)
const fallbackIdentity = {
teamName: teamFile?.name ?? teamName,
createdAt: teamFile?.createdAt ?? 0,
...(teamFile?.leadSessionId ? { leadSessionId: teamFile.leadSessionId } : {}),
}
const observedLifecycle = await readTaskListLifecycleState(sanitizedName)
if (!expectedLifecycle && !teamFile && observedLifecycle.activeIdentity) {
throw new Error(
`Cannot infer which incarnation owns missing Team config ${teamName}`,
)
}
const lifecycle = expectedLifecycle ?? {
generation: observedLifecycle.generation,
identity: observedLifecycle.activeIdentity ?? fallbackIdentity,
}
if (
teamFile &&
(
lifecycle.identity.teamName !== teamFile.name ||
lifecycle.identity.createdAt !== teamFile.createdAt ||
lifecycle.identity.leadSessionId !== teamFile.leadSessionId
)
) {
throw new Error(`Refusing cleanup after Team ${teamName} changed incarnation`)
}
return withTaskListLifecycleLock(sanitizedName, async () => {
const currentLifecycle = await readTaskListLifecycleState(sanitizedName)
const sameIdentity = currentLifecycle.activeIdentity?.teamName ===
lifecycle.identity.teamName &&
currentLifecycle.activeIdentity?.createdAt === lifecycle.identity.createdAt &&
currentLifecycle.activeIdentity?.leadSessionId ===
lifecycle.identity.leadSessionId
if (
currentLifecycle.generation !== lifecycle.generation ||
(currentLifecycle.activeIdentity && !sameIdentity)
) {
throw new Error(
`Refusing stale cleanup for Team ${teamName}: task-list generation changed`,
)
}
const currentTeamFile = readTeamFile(teamName)
if (
currentTeamFile &&
(
currentTeamFile.name !== lifecycle.identity.teamName ||
currentTeamFile.createdAt !== lifecycle.identity.createdAt ||
currentTeamFile.leadSessionId !== lifecycle.identity.leadSessionId
)
) {
throw new Error(`Refusing stale cleanup for a newer Team ${teamName}`)
}
if (currentLifecycle.deleted) {
const terminal = currentLifecycle.terminals.find(receipt => (
receipt.generation === lifecycle.generation &&
receipt.identity.teamName === lifecycle.identity.teamName &&
receipt.identity.createdAt === lifecycle.identity.createdAt &&
receipt.identity.leadSessionId === lifecycle.identity.leadSessionId
))
if (terminal) {
await cleanupTeamRuntimeResources(
teamName,
currentTeamFile?.members ?? teamFile?.members ?? [],
options,
)
await removeTeamLifecycleDirectories(teamName, sanitizedName)
return terminal
}
throw new Error(`Team ${teamName} was deleted without a terminal task frame`)
}
// Capture the terminal DAG under the same durable lock that excludes
// watcher reads and whole-directory removal. TeamDelete persists this
// frame in its result so archive repair never has to infer mutations that
// happened after the watcher's last poll.
const finalTaskSnapshot = await readTaskListSnapshot(sanitizedName)
const terminalReceipt = await completeTaskListLifecycle(
sanitizedName,
finalTaskSnapshot,
lifecycle,
)
// Persist the terminal frame before potentially slow pane/worktree
// teardown. Graceful shutdown has a hard time budget; once the deleted
// receipt exists, retries can finish external and physical cleanup without
// losing the final DAG or allowing new task writers.
await cleanupTeamRuntimeResources(
teamName,
currentTeamFile?.members ?? teamFile?.members ?? [],
options,
)
// The terminal receipt is durable before physical deletion. A failed rm
// therefore rejects and remains retryable; a later call reuses the receipt
// without recreating or rereading the deleted task directory.
await removeTeamLifecycleDirectories(teamName, sanitizedName)
return terminalReceipt
})
}
+2 -2
View File
@@ -58,11 +58,11 @@ describe('getTaskListId', () => {
})
})
test('canonicalizes teammate names and still honors an explicit list', () => {
test('keeps a teammate on the canonical Team list despite a standalone override', () => {
runWithTeammateContext({ ...TEAMMATE, teamName: 'My_Team' }, () => {
expect(getTaskListId('teammate-agent')).toBe('my-team')
process.env.CLAUDE_CODE_TASK_LIST_ID = 'explicit-team-list'
expect(getTaskListId('teammate-agent')).toBe('explicit-team-list')
expect(getTaskListId('teammate-agent')).toBe('my-team')
})
})
})
+737 -97
View File
File diff suppressed because it is too large Load Diff