mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix(teams): stabilize activity, history, and lifecycle
This commit is contained in:
@@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'ワークベンチ',
|
||||
|
||||
@@ -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': '워크벤치',
|
||||
|
||||
@@ -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': '工作台',
|
||||
|
||||
@@ -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': '工作台',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
+2040
-16
File diff suppressed because it is too large
Load Diff
+837
-139
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user