feat(agent-teams): add collaborative workbench

This commit is contained in:
程序员阿江(Relakkes)
2026-08-08 22:48:51 +08:00
parent 17e1cde278
commit 4fbbafa0b6
59 changed files with 5708 additions and 382 deletions
+8
View File
@@ -35,4 +35,12 @@ export const subagentsApi = {
`/api/sessions/${encodeURIComponent(sessionId)}/subagents/by-tool/${encodeURIComponent(toolUseId)}${query}`,
)
},
sendMessage(sessionId: string, toolUseId: string, content: string, taskId?: string) {
const query = taskId ? `?taskId=${encodeURIComponent(taskId)}` : ''
return api.post<{ ok: true; delivery?: 'queued' | 'resumed'; agent_id?: string }>(
`/api/sessions/${encodeURIComponent(sessionId)}/subagents/by-tool/${encodeURIComponent(toolUseId)}/messages${query}`,
{ content },
)
},
}
+20 -1
View File
@@ -1,5 +1,10 @@
import { api } from './client'
import type { TeamSummary, TeamDetail } from '../types/team'
import type {
TeamSummary,
TeamDetail,
TeamWorkbenchSnapshot,
TeamWorkbenchSessionTimeline,
} from '../types/team'
type TeamsResponse = { teams: TeamSummary[] }
@@ -24,6 +29,7 @@ type TranscriptOptions = {
signature?: string
cursor?: string
afterOrdinal?: number
leadSessionId?: string
}
export type { TranscriptMessage }
@@ -37,6 +43,18 @@ export const teamsApi = {
return api.get<TeamDetail>(`/api/teams/${encodeURIComponent(name)}`)
},
getWorkbench(name: string) {
return api.get<TeamWorkbenchSnapshot>(
`/api/teams/${encodeURIComponent(name)}/workbench`,
)
},
getWorkbenchForSession(sessionId: string) {
return api.get<TeamWorkbenchSessionTimeline>(
`/api/teams/session/${encodeURIComponent(sessionId)}/workbench`,
)
},
getMemberTranscript(
teamName: string,
agentId: string,
@@ -45,6 +63,7 @@ export const teamsApi = {
const params = new URLSearchParams()
if (options) {
params.set('incremental', 'true')
if (options.leadSessionId) params.set('leadSessionId', options.leadSessionId)
if (options.signature) params.set('signature', options.signature)
if (options.cursor) params.set('cursor', options.cursor)
if (options.afterOrdinal !== undefined) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@@ -0,0 +1,193 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTeamStore } from '../../stores/teamStore'
import { useTabStore } from '../../stores/tabStore'
import type { TeamWorkbenchSnapshot, TeamWorkbenchTask } from '../../types/team'
import { AgentTeamsWorkbench } from './AgentTeamsWorkbench'
const { getWorkbenchForSessionMock, getWorkbenchMock } = vi.hoisted(() => ({
getWorkbenchForSessionMock: vi.fn(),
getWorkbenchMock: vi.fn(),
}))
vi.mock('../../api/teams', () => ({
teamsApi: {
list: vi.fn(),
get: vi.fn(),
getWorkbenchForSession: getWorkbenchForSessionMock,
getWorkbench: getWorkbenchMock,
getMemberTranscript: vi.fn(),
sendMemberMessage: vi.fn(),
delete: vi.fn(),
},
}))
function task(
id: string,
status: TeamWorkbenchTask['status'],
blockedBy: string[] = [],
owner?: string,
): TeamWorkbenchTask {
return {
id,
subject: `Task ${id}`,
description: `Task ${id} detail`,
activeForm: status === 'in_progress' ? `Working on task ${id}` : undefined,
owner,
status,
blocks: [],
blockedBy,
taskListId: 'visual-team',
}
}
function workbench(
version: string,
statuses: [TeamWorkbenchTask['status'], TeamWorkbenchTask['status'], TeamWorkbenchTask['status']],
): TeamWorkbenchSnapshot {
return {
version,
generatedAt: `2026-08-08T00:00:0${version.slice(-1)}.000Z`,
team: {
name: 'visual-team',
leadAgentId: 'team-lead@visual-team',
leadSessionId: 'lead-session',
members: [
{ agentId: 'team-lead@visual-team', name: 'team-lead', role: 'lead', status: 'running' },
{ agentId: 'builder@visual-team', name: 'builder', role: 'frontend', status: 'running' },
{ agentId: 'reviewer@visual-team', name: 'reviewer', role: 'reviewer', status: 'idle' },
],
},
tasks: [
task('1', statuses[0], [], 'builder'),
task('2', statuses[1], ['1'], 'reviewer'),
task('3', statuses[2], ['1', '2']),
],
messages: [{
id: `message-${version}`,
from: 'builder',
to: 'reviewer',
recipients: ['reviewer'],
kind: 'direct',
text: `Snapshot ${version} ready`,
timestamp: `2026-08-08T00:00:0${version.slice(-1)}.000Z`,
}],
}
}
describe('AgentTeamsWorkbench', () => {
beforeEach(() => {
getWorkbenchMock.mockReset()
getWorkbenchForSessionMock.mockReset()
useTeamStore.getState().clearTeam()
useTabStore.setState({ tabs: [], activeTabId: null })
useSettingsStore.setState({ locale: 'en' })
})
it('renders a multi-dependency team and keeps history fixed while live updates continue', async () => {
getWorkbenchMock
.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
.mockResolvedValueOnce(workbench('v2', ['completed', 'completed', 'in_progress']))
.mockResolvedValueOnce(workbench('v3', ['completed', 'completed', 'completed']))
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
await useTeamStore.getState().fetchWorkbench('visual-team')
})
const { container } = render(<AgentTeamsWorkbench sessionId="lead-session" />)
expect(screen.getByTestId('agent-teams-task-2').getAttribute('data-state')).toBe('completed')
expect(screen.getByTestId('agent-teams-task-3').getAttribute('data-state')).toBe('running')
expect(screen.getByText('Snapshot v2 ready')).toBeTruthy()
expect(screen.getByTestId('agent-teams-member-team-lead@visual-team').getAttribute('data-avatar-key')).toBe('team-lead')
expect(screen.getByTestId('agent-teams-member-builder@visual-team').getAttribute('data-avatar-key')).toBe('ui-designer')
expect(screen.getByTestId('agent-teams-member-builder@visual-team').querySelector('img')).toBeTruthy()
expect(container.querySelector('[data-layout-role="leader-root"]')?.getAttribute('data-center-x')).toBe('302')
expect(container.querySelectorAll('[data-edge-kind="leader-root"]')).toHaveLength(1)
expect(container.querySelectorAll('[data-edge-kind="dependency-primary"]')).toHaveLength(2)
expect(container.querySelectorAll('[data-edge-kind="dependency-secondary"]')).toHaveLength(1)
expect(screen.getByTestId('agent-teams-task-2').querySelector('img')).toBeTruthy()
expect(screen.getByTestId('agent-teams-member-builder@visual-team').className).toContain('cursor-pointer')
fireEvent.mouseEnter(screen.getByTestId('agent-teams-task-3'))
expect(container.querySelectorAll('[data-edge-active="true"]')).toHaveLength(2)
fireEvent.mouseLeave(screen.getByTestId('agent-teams-task-3'))
expect(container.querySelectorAll('[data-edge-active="true"]')).toHaveLength(0)
fireEvent.click(screen.getByRole('button', { name: 'Review history' }))
expect(screen.getByTestId('agent-teams-task-2').getAttribute('data-state')).toBe('running')
expect(screen.getByTestId('agent-teams-task-3').getAttribute('data-state')).toBe('blocked')
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
expect(screen.getByTestId('agent-teams-task-2').getAttribute('data-state')).toBe('running')
expect(screen.getByText('Snapshot v1 ready')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Back to live' }))
await waitFor(() => {
expect(screen.getByTestId('agent-teams-task-3').getAttribute('data-state')).toBe('completed')
})
expect(screen.getByText('Snapshot v3 ready')).toBeTruthy()
})
it('opens teammate details from the moving figure without replacing the workbench', async () => {
getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'in_progress', 'pending']))
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
fireEvent.click(screen.getByTestId('agent-teams-member-reviewer@visual-team'))
expect(screen.getByRole('dialog', { name: 'reviewer details' })).toBeTruthy()
expect(screen.getByText('OWNED TASKS')).toBeTruthy()
expect(screen.getByText('Working on task 2')).toBeTruthy()
expect(screen.getByTestId('agent-teams-office')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Talk directly to reviewer' }))
expect(useTabStore.getState().activeTabId).toBe('team-member:reviewer@visual-team')
})
it('closes the workbench without discarding its archived timeline', async () => {
getWorkbenchMock.mockResolvedValueOnce(workbench('v1', ['completed', 'completed', 'completed']))
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
fireEvent.click(screen.getByRole('button', { name: /Close Agent Teams workbench/i }))
expect(useTeamStore.getState().workbenchOpenBySession['lead-session']).toBe(false)
expect(useTeamStore.getState().workbenchesBySession['lead-session']?.snapshots[0]?.version).toBe('v1')
})
it('keeps every archived teammate character visible inside the organization tree', 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',
}))
getWorkbenchMock.mockResolvedValueOnce(archived)
await act(async () => {
await useTeamStore.getState().fetchWorkbench('visual-team')
})
render(<AgentTeamsWorkbench sessionId="lead-session" />)
for (const member of archived.team.members) {
const figure = screen.getByTestId(`agent-teams-member-${member.agentId}`)
expect(figure.getAttribute('data-member-state')).toBe('exited')
expect(figure.querySelector('img')).toBeTruthy()
expect(figure.getAttribute('style') ?? '').not.toContain('opacity: 0')
}
fireEvent.click(screen.getByTestId('agent-teams-member-reviewer@visual-team'))
const executionButton = screen.getByRole('button', { name: 'View reviewer execution' })
expect((executionButton as HTMLButtonElement).disabled).toBe(false)
fireEvent.click(executionButton)
expect(useTabStore.getState().activeTabId).toBe('team-member:reviewer@visual-team')
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest'
import type { TeamMember, TeamWorkbenchSnapshot, TeamWorkbenchTask } from '../../types/team'
import {
getWorkbenchPhase,
getWorkbenchProgress,
getWorkbenchTaskState,
getMemberAvatarKey,
layoutWorkbenchTasks,
runningTaskForMember,
taskOwnedByMember,
WORKBENCH_TASK_WIDTH,
} from './agentTeamsModel'
function task(
id: string,
status: TeamWorkbenchTask['status'],
blockedBy: string[] = [],
owner?: string,
): TeamWorkbenchTask {
return {
id,
subject: `Task ${id}`,
description: `Description ${id}`,
status,
owner,
blocks: [],
blockedBy,
taskListId: 'team-a',
}
}
function snapshot(tasks: TeamWorkbenchTask[]): TeamWorkbenchSnapshot {
return {
version: 'v1',
generatedAt: '2026-08-08T00:00:00.000Z',
team: {
name: 'team-a',
leadAgentId: 'lead@team-a',
leadSessionId: 'session-a',
members: [],
},
tasks,
messages: [],
}
}
describe('Agent Teams workbench model', () => {
it('lays out a multi-parent DAG after every dependency and keeps edge states honest', () => {
const tasks = [
task('1', 'completed'),
task('2', 'in_progress'),
task('3', 'pending', ['1', '2']),
task('4', 'pending', ['missing']),
]
const layout = layoutWorkbenchTasks(tasks, 604)
const byId = new Map(tasks.map((entry) => [entry.id, entry]))
expect(layout.columns).toBe(2)
expect(layout.tasks.every((entry) => entry.x >= 0 && entry.x + WORKBENCH_TASK_WIDTH <= layout.width)).toBe(true)
expect(layout.byId.get('3')!.y).toBeGreaterThan(layout.byId.get('1')!.y)
expect(layout.byId.get('3')!.y).toBeGreaterThan(layout.byId.get('2')!.y)
expect(getWorkbenchTaskState(tasks[2]!, byId)).toBe('blocked')
expect(getWorkbenchTaskState(tasks[3]!, byId)).toBe('blocked')
tasks[1] = task('2', 'completed')
const completedParents = new Map(tasks.map((entry) => [entry.id, entry]))
expect(getWorkbenchTaskState(tasks[2]!, completedParents)).toBe('open')
})
it('falls back deterministically for cyclic dependencies instead of recursing forever', () => {
const tasks = [
task('a', 'pending', ['b']),
task('b', 'pending', ['a']),
]
const layout = layoutWorkbenchTasks(tasks, 440)
expect(layout.tasks.map((entry) => entry.task.id).sort()).toEqual(['a', 'b'])
expect(layout.columns).toBe(1)
expect(layout.tasks.every((entry) => entry.x >= 0 && entry.x + WORKBENCH_TASK_WIDTH <= layout.width)).toBe(true)
expect(layout.height).toBeGreaterThan(0)
})
it('uses a three-column organization tree only when complete person nodes fit', () => {
const tasks = [task('1', 'pending'), task('2', 'pending'), task('3', 'pending')]
const layout = layoutWorkbenchTasks(tasks, 760)
expect(layout.columns).toBe(3)
expect(layout.tasks.every((entry) => entry.x >= 0 && entry.x + WORKBENCH_TASK_WIDTH <= layout.width)).toBe(true)
})
it('normalizes bare owner names and full agent ids for active work', () => {
const member: TeamMember = {
agentId: 'reviewer@team-a',
name: 'reviewer',
role: 'security-reviewer',
status: 'running',
}
const bareOwner = task('1', 'in_progress', [], 'reviewer')
const fullOwner = task('2', 'pending', [], 'reviewer@team-a')
expect(taskOwnedByMember(bareOwner, member)).toBe(true)
expect(taskOwnedByMember(fullOwner, member)).toBe(true)
expect(runningTaskForMember([fullOwner, bareOwner], member)?.id).toBe('1')
})
it('assigns generated occupational characters and preserves unknown-member identity', () => {
const member = (agentId: string, role: string): TeamMember => ({
agentId,
name: agentId.split('@')[0],
role,
status: 'running',
})
expect(getMemberAvatarKey(member('lead@team-a', 'orchestrator'), true)).toBe('team-lead')
expect(getMemberAvatarKey(member('watcher-runtime@team-a', 'backend'))).toBe('server-engineer')
expect(getMemberAvatarKey(member('desktop-workbench@team-a', 'frontend'))).toBe('ui-designer')
expect(getMemberAvatarKey(member('test-engineer@team-a', 'quality'))).toBe('qa-engineer')
expect(getMemberAvatarKey(member('security-reviewer@team-a', 'reviewer'))).toBe('security-reviewer')
expect(getMemberAvatarKey(member('release-auditor@team-a', 'release'))).toBe('release-engineer')
expect(getMemberAvatarKey(member('unknown-specialist@team-a', 'general-purpose')))
.toBe(getMemberAvatarKey(member('unknown-specialist@team-a', 'general-purpose')))
})
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')
const finished = snapshot([task('1', 'completed'), task('2', 'completed')])
expect(getWorkbenchPhase(finished)).toBe('finishing')
expect(getWorkbenchProgress(finished)).toEqual({ completed: 2, total: 2, percent: 100 })
expect(getWorkbenchPhase({
...finished,
deletedAt: '2026-08-08T00:01:00.000Z',
})).toBe('completed')
})
})
@@ -0,0 +1,212 @@
import type {
TeamMember,
TeamWorkbenchSnapshot,
TeamWorkbenchTask,
} from '../../types/team'
export type WorkbenchTaskState = 'blocked' | 'open' | 'running' | 'completed'
export type WorkbenchPhase = 'forming' | 'running' | 'finishing' | 'completed'
export type MemberAvatarKey =
| 'team-lead'
| 'server-engineer'
| 'ui-designer'
| 'qa-engineer'
| 'security-reviewer'
| 'data-analyst'
| 'release-engineer'
| 'docs-coordinator'
export type PositionedWorkbenchTask = {
task: TeamWorkbenchTask
state: WorkbenchTaskState
x: number
y: number
}
export type WorkbenchLayout = {
tasks: PositionedWorkbenchTask[]
byId: Map<string, PositionedWorkbenchTask>
width: number
height: number
columns: number
}
const TASK_WIDTH = 216
const TASK_HEIGHT = 94
const HORIZONTAL_GAP = 32
const ROW_HEIGHT = 154
const DAG_TOP = 196
const WORKER_AVATARS: Array<{
key: Exclude<MemberAvatarKey, 'team-lead'>
matches: RegExp
}> = [
{ key: 'release-engineer', matches: /release|\bbuild\b|package|deploy|\bops\b|\bci\b|ship/ },
{ key: 'security-reviewer', matches: /security|secure|audit|risk|threat|review/ },
{ key: 'qa-engineer', matches: /\bqa\b|test|quality|verify|verification/ },
{ key: 'ui-designer', matches: /\bui\b|\bux\b|frontend|desktop|design|theme|accessib/ },
{ key: 'server-engineer', matches: /server|backend|\bapi\b|runtime|watcher|service|contract/ },
{ key: 'data-analyst', matches: /data|research|analyst|replay|state|investigat|explor/ },
{ key: 'docs-coordinator', matches: /docs?|product|spec|writer|coordinat/ },
]
function stableHash(value: string): number {
let hash = 0
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0
}
return Math.abs(hash)
}
/**
* Keeps a teammate's visual identity stable across snapshots while still
* assigning the purpose-built occupational art when its role is known.
*/
export function getMemberAvatarKey(member: TeamMember, isLead = false): MemberAvatarKey {
if (isLead) return 'team-lead'
const identity = [member.name, member.role, member.agentId, member.currentTask]
.filter(Boolean)
.join(' ')
.toLowerCase()
const matched = WORKER_AVATARS.find(({ matches }) => matches.test(identity))
if (matched) return matched.key
return WORKER_AVATARS[stableHash(member.agentId) % WORKER_AVATARS.length]!.key
}
export function getWorkbenchTaskState(
task: TeamWorkbenchTask,
tasksById: Map<string, TeamWorkbenchTask>,
): WorkbenchTaskState {
if (task.status === 'completed') return 'completed'
if (task.status === 'in_progress') return 'running'
const hasOpenDependency = task.blockedBy.some(
(dependencyId) => tasksById.get(dependencyId)?.status !== 'completed',
)
return hasOpenDependency ? 'blocked' : 'open'
}
/** Longest dependency path with deterministic fallbacks for missing nodes/cycles. */
function taskDepths(tasks: TeamWorkbenchTask[]): Map<string, number> {
const byId = new Map(tasks.map((task) => [task.id, task]))
const depths = new Map<string, number>()
const visiting = new Set<string>()
const depthOf = (taskId: string): number => {
const cached = depths.get(taskId)
if (cached !== undefined) return cached
if (visiting.has(taskId)) return 0
const task = byId.get(taskId)
if (!task) return 0
visiting.add(taskId)
const dependencies = task.blockedBy.filter((dependencyId) => byId.has(dependencyId))
const depth = dependencies.length === 0
? 0
: 1 + Math.max(...dependencies.map(depthOf))
visiting.delete(taskId)
depths.set(taskId, depth)
return depth
}
for (const task of tasks) depthOf(task.id)
return depths
}
export function layoutWorkbenchTasks(
tasks: TeamWorkbenchTask[],
requestedWidth: number,
): WorkbenchLayout {
const width = Math.max(360, Math.min(760, Math.round(requestedWidth || 604)))
const columns = width >= 704 ? 3 : width >= 480 ? 2 : 1
const depths = taskDepths(tasks)
const byLayer = new Map<number, TeamWorkbenchTask[]>()
for (const task of tasks) {
const depth = depths.get(task.id) ?? 0
const layer = byLayer.get(depth)
if (layer) layer.push(task)
else byLayer.set(depth, [task])
}
const tasksById = new Map(tasks.map((task) => [task.id, task]))
const positioned: PositionedWorkbenchTask[] = []
let row = 0
for (const depth of Array.from(byLayer.keys()).sort((left, right) => left - right)) {
const layer = byLayer.get(depth)!
for (let offset = 0; offset < layer.length; offset += columns) {
const chunk = layer.slice(offset, offset + columns)
const chunkWidth = chunk.length * TASK_WIDTH + (chunk.length - 1) * HORIZONTAL_GAP
const startX = Math.round((width - chunkWidth) / 2)
chunk.forEach((task, index) => {
positioned.push({
task,
state: getWorkbenchTaskState(task, tasksById),
x: startX + index * (TASK_WIDTH + HORIZONTAL_GAP),
y: DAG_TOP + row * ROW_HEIGHT,
})
})
row += 1
}
}
return {
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,
columns,
}
}
function memberNames(member: TeamMember): string[] {
return [
member.agentId,
member.agentId.split('@')[0] ?? '',
member.name ?? '',
member.role,
].filter(Boolean)
}
export function taskOwnedByMember(
task: TeamWorkbenchTask,
member: TeamMember,
): boolean {
if (!task.owner) return false
return memberNames(member).includes(task.owner)
}
export function runningTaskForMember(
tasks: TeamWorkbenchTask[],
member: TeamMember,
): TeamWorkbenchTask | undefined {
return tasks.find((task) => task.status === 'in_progress' && taskOwnedByMember(task, member))
}
export function getWorkbenchPhase(snapshot: TeamWorkbenchSnapshot): WorkbenchPhase {
if (snapshot.deletedAt) return 'completed'
if (snapshot.tasks.length === 0) return 'forming'
if (snapshot.tasks.every((task) => task.status === 'completed')) return 'finishing'
return 'running'
}
export function getWorkbenchProgress(snapshot: TeamWorkbenchSnapshot) {
const total = snapshot.tasks.length
const completed = snapshot.tasks.filter((task) => task.status === 'completed').length
return {
completed,
total,
percent: total === 0 ? 0 : Math.round((completed / total) * 100),
}
}
export function memberInitials(member: TeamMember): string {
const source = member.name || member.role || member.agentId.split('@')[0] || 'AG'
const words = source.split(/[^\p{L}\p{N}]+/u).filter(Boolean)
return (words.length > 1
? words.slice(0, 2).map((word) => word[0]).join('')
: source.slice(0, 2)
).toUpperCase()
}
export const WORKBENCH_TASK_WIDTH = TASK_WIDTH
export const WORKBENCH_TASK_HEIGHT = TASK_HEIGHT
+4 -1
View File
@@ -188,6 +188,9 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
setPreparingTurn,
} = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const activeTabType = useTabStore((s) =>
s.tabs.find((tab) => tab.sessionId === s.activeTabId)?.type,
)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const repositoryLaunchDraft = sessionState?.repositoryLaunchDraft
const launchWorkDir = repositoryLaunchDraft?.workDir ?? ''
@@ -254,7 +257,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
pasteGenerationRef.current += 1
}, [])
const isMemberSession = !!memberInfo
const isMemberSession = !!memberInfo || activeTabType === 'subagent'
const isActive = chatState !== 'idle'
const hasRunningSubagents = hasRunningSubagentTasks(sessionState?.backgroundAgentTasks)
const workspaceState = getSessionWorkspaceState(activeSession)
@@ -6059,6 +6059,42 @@ describe('MessageList nested tool calls', () => {
expect(screen.queryByText('first.ts')).toBeNull()
})
it('does not call parent-session checkpoint APIs for a completed SubAgent conversation', async () => {
const subagentTabId = '__subagent__parent-session__agent-tool-1'
const getTurnCheckpoints = vi.spyOn(sessionsApi, 'getTurnCheckpoints')
.mockRejectedValue(new Error(`Session not found: ${subagentTabId}`))
useTabStore.setState({
activeTabId: subagentTabId,
tabs: [{
sessionId: subagentTabId,
title: 'Completed reviewer',
type: 'subagent',
status: 'idle',
sourceSessionId: 'parent-session',
subagentToolUseId: 'agent-tool-1',
}],
})
useChatStore.setState({
sessions: {
[subagentTabId]: makeSessionState({
messages: [
{ id: 'user-1', type: 'user_text', content: 'Review the patch', timestamp: 1 },
{ id: 'assistant-1', type: 'assistant_text', content: 'Review complete', timestamp: 2 },
],
}),
},
})
render(<MessageList sessionId={subagentTabId} />)
await act(async () => {
await Promise.resolve()
})
expect(getTurnCheckpoints).not.toHaveBeenCalled()
expect(screen.queryByText(`Session not found: ${subagentTabId}`)).toBeNull()
})
it('confirms before rewinding to an earlier turn from a historical change card', async () => {
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockResolvedValue({
checkpoints: [
@@ -6749,3 +6785,81 @@ describe('workspace panel origin visibility', () => {
expect(isRenderItemFullyVisibleInChatScroller(item)).toBe(false)
})
})
describe('Agent Teams chat projection', () => {
function sendMessageRun(
id: string,
result: unknown,
isError = false,
input: unknown = { to: 'worker', message: 'Review task #2' },
): UIMessage[] {
return [
{
id: `tool-${id}`,
type: 'tool_use',
toolName: 'SendMessage',
toolUseId: id,
input,
timestamp: 1,
},
{
id: `result-${id}`,
type: 'tool_result',
toolUseId: id,
content: result,
isError,
timestamp: 2,
},
]
}
it('hides a successful routed teammate message only in a lead workbench session', () => {
const messages = sendMessageRun('team-message', {
routing: {
sender: 'team-lead',
target: 'worker',
content: 'Review task #2',
},
})
expect(buildRenderModel(messages).renderItems).toHaveLength(1)
expect(buildRenderModel(messages, null, {
hideTeamCoordinationTools: true,
teamMemberNames: new Set(['worker']),
}).renderItems).toHaveLength(0)
})
it('hides successful team messages from their real input shape when the transport omits routing', () => {
const direct = sendMessageRun('direct', 'Message sent to worker inbox')
const broadcast = sendMessageRun('broadcast', 'Message broadcast to 4 teammates', false, {
to: '*',
message: 'Start the dependency graph',
})
const messages = [...direct, ...broadcast]
expect(buildRenderModel(messages, null, {
hideTeamCoordinationTools: true,
teamMemberNames: new Set(['worker']),
}).renderItems).toHaveLength(0)
})
it('keeps ordinary agent continuation and failed team SendMessage calls visible', () => {
const ordinary = sendMessageRun(
'ordinary',
'Message queued to async agent a1',
false,
{ to: 'async-agent-a1', message: 'Continue' },
)
const failed = sendMessageRun('failed', {
routing: { sender: 'team-lead', target: 'missing-worker' },
}, true)
expect(buildRenderModel(ordinary, null, {
hideTeamCoordinationTools: true,
teamMemberNames: new Set(['worker']),
}).renderItems).toHaveLength(1)
expect(buildRenderModel(failed, null, {
hideTeamCoordinationTools: true,
}).renderItems).toHaveLength(1)
})
})
+82 -6
View File
@@ -629,7 +629,56 @@ function appendChildToolCall(
}
}
export function buildRenderModel(messages: UIMessage[], activeAskUserQuestionToolUseId?: string | null): RenderModel {
function hasTeamMessageRouting(value: unknown): boolean {
if (typeof value === 'string') {
try {
return hasTeamMessageRouting(JSON.parse(value))
} catch {
return false
}
}
if (Array.isArray(value)) return value.some(hasTeamMessageRouting)
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
const routing = record.routing
if (routing && typeof routing === 'object') {
const route = routing as Record<string, unknown>
if (typeof route.sender === 'string' && typeof route.target === 'string') return true
}
return 'content' in record && hasTeamMessageRouting(record.content)
}
function getSendMessageTarget(value: unknown): string | null {
if (typeof value === 'string') {
try {
return getSendMessageTarget(JSON.parse(value))
} catch {
return null
}
}
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const target = (value as Record<string, unknown>).to
return typeof target === 'string' ? target : null
}
function isTeamCoordinationSendMessage(
input: unknown,
result: unknown,
teamMemberNames: ReadonlySet<string> | undefined,
): boolean {
if (hasTeamMessageRouting(result)) return true
const target = getSendMessageTarget(input)
return target === '*' || Boolean(target && teamMemberNames?.has(target))
}
export function buildRenderModel(
messages: UIMessage[],
activeAskUserQuestionToolUseId?: string | null,
options: {
hideTeamCoordinationTools?: boolean
teamMemberNames?: ReadonlySet<string>
} = {},
): RenderModel {
const items: RenderItem[] = []
const toolResultMap = new Map<string, ToolResult>()
const childToolCallsByParent = new Map<string, ToolCall[]>()
@@ -726,6 +775,15 @@ export function buildRenderModel(messages: UIMessage[], activeAskUserQuestionToo
appendChildToolCall(childToolCallsByParent, msg.parentToolUseId, msg)
continue
}
const toolResult = toolResultMap.get(msg.toolUseId)
if (
options.hideTeamCoordinationTools &&
msg.toolName === 'SendMessage' &&
toolResult?.isError === false &&
isTeamCoordinationSendMessage(msg.input, toolResult.content, options.teamMemberNames)
) {
continue
}
if (msg.toolName === 'AskUserQuestion') {
const isResolved = toolResultMap.has(msg.toolUseId)
const lastUnresolvedIndex = lastUnresolvedAskUserQuestionIndexByToolUseId.get(msg.toolUseId)
@@ -1687,6 +1745,21 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
const isMemberSession = useTeamStore((s) =>
resolvedSessionId ? Boolean(s.getMemberBySessionId(resolvedSessionId)) : false,
)
const isSubagentSession = useTabStore((s) => s.tabs.some((tab) => (
tab.sessionId === resolvedSessionId && tab.type === 'subagent'
)))
const isDirectAgentSession = isMemberSession || isSubagentSession
const teamWorkbench = useTeamStore((s) =>
resolvedSessionId ? s.workbenchesBySession[resolvedSessionId] : undefined,
)
const isTeamLeadSession = Boolean(teamWorkbench?.snapshots.length)
const teamMemberNames = useMemo(() => {
const snapshots = teamWorkbench?.snapshots
if (!snapshots?.length) return undefined
return new Set(snapshots[snapshots.length - 1]!.team.members.flatMap((member) =>
member.name ? [member.name] : [],
))
}, [teamWorkbench?.snapshots])
const addToast = useUIStore((s) => s.addToast)
const messages = sessionState?.messages ?? EMPTY_MESSAGES
const chatState = sessionState?.chatState ?? 'idle'
@@ -1775,7 +1848,7 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
const conversationFindMatchesRef = useRef<ConversationFindMatch[]>([])
const [messageListWidth, setMessageListWidth] = useState<number | null>(null)
const branchActionsDisabled =
isMemberSession ||
isDirectAgentSession ||
isPreparingTurn ||
chatState !== 'idle' ||
hasRunningBackgroundTasks ||
@@ -2199,8 +2272,11 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
}, [requestLiveFollow])
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
() => buildRenderModel(messages, activeAskUserQuestionToolUseId),
[activeAskUserQuestionToolUseId, messages],
() => buildRenderModel(messages, activeAskUserQuestionToolUseId, {
hideTeamCoordinationTools: isTeamLeadSession,
teamMemberNames,
}),
[activeAskUserQuestionToolUseId, isTeamLeadSession, messages, teamMemberNames],
)
// Defer the per-message branchable / completed-turn computations so the first
// commit on tab switch can render the virtualization window without doing two
@@ -2362,7 +2438,7 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
}, [renderItemKeys])
useEffect(() => {
if (!resolvedSessionId || completedTurnTargets.length === 0 || isMemberSession) {
if (!resolvedSessionId || completedTurnTargets.length === 0 || isDirectAgentSession) {
setTurnChangeCards([])
setTurnChangeLoadError(null)
setIsLoadingTurnChangeCards(false)
@@ -2429,7 +2505,7 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
return () => {
cancelled = true
}
}, [chatState, completedTurnTargets, hasRunningBackgroundTasks, historyMutationEpoch, isMemberSession, latestCompletedTurnId, resolvedSessionId])
}, [chatState, completedTurnTargets, hasRunningBackgroundTasks, historyMutationEpoch, isDirectAgentSession, latestCompletedTurnId, resolvedSessionId])
const handleUndoCurrentTurn = useCallback(async (mode: SessionRewindMode = 'both') => {
if (!resolvedSessionId || !confirmTurnCard || rewindingTurnId || hasRunningBackgroundTasks) return
+52 -10
View File
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import type { PerSessionState } from '../../stores/chatStore'
import type { ChatState, UIMessage } from '../../types/chat'
import type { TeamWorkbenchTimeline } from '../../types/team'
import { browserHost } from '../../lib/desktopHost/browserHost'
type ToolUseMessage = Extract<UIMessage, { type: 'tool_use' }>
@@ -93,6 +94,29 @@ const completedTodoWriteMessage = (overrides: Partial<ToolUseMessage> = {}): UIM
...overrides,
})
function teamWorkbenchTimeline(sessionId: string): TeamWorkbenchTimeline {
return {
teamName: 'review-team',
loading: false,
error: null,
snapshots: [{
version: 'v1',
generatedAt: '2026-08-08T00:00:00.000Z',
team: {
name: 'review-team',
leadAgentId: 'lead',
leadSessionId: sessionId,
members: [
{ agentId: 'lead', role: 'Lead', status: 'running' },
{ agentId: 'security', role: 'Security reviewer', status: 'running' },
],
},
tasks: [],
messages: [],
}],
}
}
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: getCurrentWindowMock,
}))
@@ -129,6 +153,8 @@ vi.mock('../../i18n', () => ({
'tabs.hideWorkspace': 'Hide Workspace',
'tabs.showBrowser': 'Show Browser',
'tabs.hideBrowser': 'Hide Browser',
'agentTeams.showWorkbench': 'Show Agent Teams Workbench',
'agentTeams.hideWorkbench': 'Hide Agent Teams Workbench',
'tabs.scrollLeft': 'Scroll tabs left',
'tabs.scrollRight': 'Scroll tabs right',
'tabs.closeTab': 'Close {title}',
@@ -259,12 +285,7 @@ describe('TabBar', () => {
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
useActivityPanelStore.setState(useActivityPanelStore.getInitialState(), true)
useCLITaskStore.setState(useCLITaskStore.getInitialState(), true)
useTeamStore.setState({
teams: [],
activeTeam: null,
memberColors: new Map(),
error: null,
})
useTeamStore.setState(useTeamStore.getInitialState(), true)
Reflect.deleteProperty(window, 'desktopHost')
Reflect.deleteProperty(window, '__TAURI__')
@@ -437,7 +458,7 @@ describe('TabBar', () => {
expect(useActivityPanelStore.getState().isOpen(sessionId)).toBe(true)
})
it('shows the activity button for team members associated with the active session', async () => {
it('moves team activity out of the activity rail and reserves the workspace slot', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
@@ -468,14 +489,31 @@ describe('TabBar', () => {
{ agentId: 'security', role: 'Security reviewer', status: 'running' },
],
},
workbenchesBySession: {
[sessionId]: teamWorkbenchTimeline(sessionId),
},
} as Partial<ReturnType<typeof useTeamStore.getState>>)
await act(async () => {
render(<TabBar />)
})
expect(screen.getByRole('button', { name: /activity/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument()
expect(screen.queryByTestId('session-activity-badge')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument()
const workbenchButton = screen.getByRole('button', { name: 'Hide Agent Teams Workbench' })
expect(workbenchButton).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(workbenchButton)
expect(useTeamStore.getState().workbenchOpenBySession[sessionId]).toBe(false)
expect(screen.getByRole('button', { name: 'Show Agent Teams Workbench' })).toHaveAttribute(
'aria-pressed',
'false',
)
fireEvent.click(screen.getByRole('button', { name: 'Show Agent Teams Workbench' }))
expect(useTeamStore.getState().workbenchOpenBySession[sessionId]).toBe(true)
})
it('hides team-only activity when the active team belongs to another session', async () => {
@@ -518,7 +556,7 @@ describe('TabBar', () => {
expect(screen.queryByTestId('session-activity-badge')).not.toBeInTheDocument()
})
it('shows the activity button without a badge when team activity arrives after initial render', async () => {
it('keeps the activity rail absent when a workbench arrives after initial render', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
@@ -556,11 +594,15 @@ describe('TabBar', () => {
{ agentId: 'security', role: 'Security reviewer', status: 'error' },
],
},
workbenchesBySession: {
[sessionId]: teamWorkbenchTimeline(sessionId),
},
} as Partial<ReturnType<typeof useTeamStore.getState>>)
})
expect(screen.getByRole('button', { name: /activity/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /activity/i })).not.toBeInTheDocument()
expect(screen.queryByTestId('session-activity-badge')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Show Workspace' })).not.toBeInTheDocument()
})
it('does not show the activity button for settings tabs', async () => {
+25 -14
View File
@@ -28,7 +28,7 @@ import { getDesktopHost } from '../../lib/desktopHost'
import { hasRunningBackgroundTasks } from '../../lib/backgroundTasks'
import { WindowControls, showWindowControls } from './WindowControls'
import { OpenProjectMenu } from './OpenProjectMenu'
import { Folder, FolderOpen, SquareTerminal } from 'lucide-react'
import { Folder, FolderOpen, SquareTerminal, UsersRound } from 'lucide-react'
import { ActionDialog } from '@/components/ui/ActionDialog'
import { buildSessionActivityModel, hasVisibleSessionActivity } from '../activity/sessionActivityModel'
import { SessionActivityButton } from '../activity/SessionActivityButton'
@@ -131,7 +131,15 @@ export function TabBar() {
const workbenchMode = useWorkspacePanelStore((state) =>
activeTabId && isActiveSessionTab ? state.getMode(activeTabId) : 'workspace',
)
const isWorkspacePanelOpen = isWorkbenchOpen && workbenchMode === 'workspace'
const hasAgentTeamsWorkbench = useTeamStore((state) => Boolean(
activeTabId && state.workbenchesBySession[activeTabId]?.snapshots.length,
))
const isAgentTeamsWorkbenchOpen = useTeamStore((state) => Boolean(
activeTabId &&
state.workbenchesBySession[activeTabId]?.snapshots.length &&
(state.workbenchOpenBySession[activeTabId] ?? true),
))
const isWorkspacePanelOpen = !hasAgentTeamsWorkbench && isWorkbenchOpen && workbenchMode === 'workspace'
const isTerminalPanelOpen = useTerminalPanelStore((state) =>
activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false,
)
@@ -147,15 +155,6 @@ export function TabBar() {
() => new Set(dismissedBackgroundTaskKeyList),
[dismissedBackgroundTaskKeyList],
)
const activityTeamMembers = useTeamStore(useShallow((state) => {
const activeTeam = state.activeTeam
if (!activeTabId || !activeTeam || activeTeam.leadSessionId !== activeTabId) {
return []
}
return activeTeam.members.filter((member) =>
!activeTeam.leadAgentId || member.agentId !== activeTeam.leadAgentId
)
}))
const activityState = useChatStore(useShallow((state) => {
if (!activeTabId || !isActiveSessionTab) {
return { hasVisibleActivity: false }
@@ -172,13 +171,12 @@ export function TabBar() {
backgroundTasks: Object.values(sessionState?.backgroundAgentTasks ?? {}),
dismissedBackgroundTaskKeys,
agentNotifications: Object.values(sessionState?.agentTaskNotifications ?? {}),
teamMembers: activityTeamMembers,
})
return {
hasVisibleActivity: hasVisibleSessionActivity(model),
}
}))
const showActivityButton = activeTabId && activityState.hasVisibleActivity && !isWorkbenchOpen
const showActivityButton = activeTabId && activityState.hasVisibleActivity && !isWorkbenchOpen && !hasAgentTeamsWorkbench
const moveTab = useTabStore((s) => s.moveTab)
const scrollRef = useRef<HTMLDivElement>(null)
@@ -597,6 +595,19 @@ export function TabBar() {
{isDesktopRuntime && isActiveSessionTab && (
<OpenProjectMenu path={openProjectPath} />
)}
{isActiveSessionTab && activeTabId && hasAgentTeamsWorkbench && (
<IconButton
icon={<UsersRound size={17} strokeWidth={1.9} />}
label={t(isAgentTeamsWorkbenchOpen
? 'agentTeams.hideWorkbench'
: 'agentTeams.showWorkbench')}
onClick={() => useTeamStore.getState().toggleWorkbench(activeTabId)}
size="md"
tone={isAgentTeamsWorkbenchOpen ? 'default' : 'muted'}
pressed={isAgentTeamsWorkbenchOpen}
data-active={isAgentTeamsWorkbenchOpen ? 'true' : 'false'}
/>
)}
<IconButton
icon={<SquareTerminal size={17} strokeWidth={1.9} />}
label={t('tabs.openTerminal')}
@@ -612,7 +623,7 @@ export function TabBar() {
pressed={isTerminalPanelOpen}
data-active={isTerminalPanelOpen ? 'true' : 'false'}
/>
{isActiveSessionTab && activeTabId && (
{isActiveSessionTab && activeTabId && !hasAgentTeamsWorkbench && (
<IconButton
icon={isWorkspacePanelOpen ? <FolderOpen size={18} strokeWidth={1.9} /> : <Folder size={18} strokeWidth={1.9} />}
label={t(isWorkspacePanelOpen ? 'tabs.hideWorkspace' : 'tabs.showWorkspace')}
+56
View File
@@ -2222,6 +2222,62 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'teams.leader': 'team-lead',
'teams.memberPlaceholder': 'Message this teammate directly...',
'teams.memberSessionHint': 'Messages here go straight to this teammate while you watch its transcript update.',
'teams.archivedMemberSessionHint': 'This preserved execution shows the teammate’s original conversation and tool calls.',
'teams.archivedMemberReadOnly': 'This archived execution is read-only. Return to the leader to continue the work.',
'agentTeams.title': 'Agent Teams Workbench',
'agentTeams.showWorkbench': 'Open Agent Teams workbench',
'agentTeams.hideWorkbench': 'Hide Agent Teams workbench',
'agentTeams.closeWorkbench': 'Close Agent Teams workbench',
'agentTeams.loading': 'Waiting for Agent Teams activity…',
'agentTeams.phase.forming': 'Forming team',
'agentTeams.phase.running': 'Parallel execution',
'agentTeams.phase.finishing': 'Wrapping up',
'agentTeams.phase.completed': 'Completed',
'agentTeams.progressLabel': 'Team task progress',
'agentTeams.followingLive': 'Following live',
'agentTeams.reviewHistory': 'Review history',
'agentTeams.older': 'Older workbench state',
'agentTeams.newer': 'Newer workbench state',
'agentTeams.backToLive': 'Back to live',
'agentTeams.live': 'LIVE',
'agentTeams.leader': 'Lead',
'agentTeams.restArea': 'REST AREA',
'agentTeams.emptyTasks': 'The team is forming. The dependency graph appears after tasks are created.',
'agentTeams.resizePanel': 'Resize Agent Teams workbench',
'agentTeams.task.blocked': 'Blocked',
'agentTeams.task.open': 'Available',
'agentTeams.task.running': 'In progress',
'agentTeams.task.completed': 'Completed',
'agentTeams.task.dependencies': 'Depends on {dependencies}',
'agentTeams.task.unclaimed': 'Waiting for a teammate',
'agentTeams.member.working': 'Working',
'agentTeams.member.idle': 'Idle',
'agentTeams.member.stopped': 'Stopped',
'agentTeams.member.exited': 'Exited',
'agentTeams.member.error': 'Blocked',
'agentTeams.openMember': 'Open {name} details',
'agentTeams.communication.title': 'Communication',
'agentTeams.communication.count': '{count} messages · direct messages bypass the lead',
'agentTeams.communication.empty': 'Team messages will appear here.',
'agentTeams.communication.direct': 'DIRECT',
'agentTeams.communication.broadcast': 'BROADCAST',
'agentTeams.communication.system': 'SYSTEM',
'agentTeams.communication.everyone': 'everyone',
'agentTeams.communication.taskAssignment': 'Assigned {task} {subject}',
'agentTeams.communication.shutdownRequest': 'Shutdown requested: {reason}',
'agentTeams.communication.shutdownResponse': 'Shutdown response received',
'agentTeams.communication.idle': 'Teammate is now idle',
'agentTeams.disbanded': '✓ Team disbanded; results merged into the main conversation',
'agentTeams.memberDetails': '{name} details',
'agentTeams.memberTasks': 'OWNED TASKS',
'agentTeams.noMemberTasks': 'No tasks assigned yet.',
'agentTeams.memberMessages': 'RELATED MESSAGES',
'agentTeams.noMemberMessages': 'No related messages yet.',
'agentTeams.fullConversationHint': 'The full conversation, tool calls, and streaming output reuse the existing session view.',
'agentTeams.archivedConversationHint': 'Open the preserved execution session to review the full conversation and tool calls.',
'agentTeams.openConversation': 'Talk directly to {name}',
'agentTeams.openExecution': 'View {name} execution',
'agentTeams.dependencyLegend': 'Solid: primary dependency · dashed: secondary · hover to focus',
// ─── Session Activity ──────────────────────────────────────
'session.activity.title': 'Activity',
+56
View File
@@ -2224,6 +2224,62 @@ export const jp: Record<TranslationKey, string> = {
'teams.leader': 'team-lead',
'teams.memberPlaceholder': 'このメンバーに直接メッセージを送信...',
'teams.memberSessionHint': 'ここでのメッセージはこのメンバーに直接届き、記録の更新を見ながらやり取りできます。',
'teams.archivedMemberSessionHint': 'この保存済み実行には、当時の会話とツール呼び出しがすべて残っています。',
'teams.archivedMemberReadOnly': 'これは読み取り専用の実行履歴です。作業を続けるにはリーダーへ戻ってください。',
'agentTeams.title': 'Agent Teams ワークベンチ',
'agentTeams.showWorkbench': 'Agent Teams ワークベンチを開く',
'agentTeams.hideWorkbench': 'Agent Teams ワークベンチを隠す',
'agentTeams.closeWorkbench': 'Agent Teams ワークベンチを閉じる',
'agentTeams.loading': 'Agent Teams のアクティビティを待機中…',
'agentTeams.phase.forming': 'チーム編成中',
'agentTeams.phase.running': '並列実行',
'agentTeams.phase.finishing': '仕上げ中',
'agentTeams.phase.completed': '完了',
'agentTeams.progressLabel': 'チームタスクの進捗',
'agentTeams.followingLive': 'リアルタイム追従',
'agentTeams.reviewHistory': '履歴を見る',
'agentTeams.older': '以前のワークベンチ状態',
'agentTeams.newer': '新しいワークベンチ状態',
'agentTeams.backToLive': 'リアルタイムへ戻る',
'agentTeams.live': 'ライブ',
'agentTeams.leader': '長',
'agentTeams.restArea': '休憩エリア',
'agentTeams.emptyTasks': 'チームを編成中です。タスク作成後に依存グラフが表示されます。',
'agentTeams.resizePanel': 'Agent Teams ワークベンチの幅を変更',
'agentTeams.task.blocked': '依存待ち',
'agentTeams.task.open': '取得可能',
'agentTeams.task.running': '進行中',
'agentTeams.task.completed': '完了',
'agentTeams.task.dependencies': '依存 {dependencies}',
'agentTeams.task.unclaimed': 'メンバーの取得待ち',
'agentTeams.member.working': '作業中',
'agentTeams.member.idle': '待機中',
'agentTeams.member.stopped': '停止済み',
'agentTeams.member.exited': '退出済み',
'agentTeams.member.error': 'ブロック中',
'agentTeams.openMember': '{name} の詳細を開く',
'agentTeams.communication.title': '通信',
'agentTeams.communication.count': '{count} 件 · ダイレクトメッセージはリードを経由しません',
'agentTeams.communication.empty': 'チームメッセージはここに表示されます。',
'agentTeams.communication.direct': 'DM',
'agentTeams.communication.broadcast': 'ブロードキャスト',
'agentTeams.communication.system': 'システム',
'agentTeams.communication.everyone': '全員',
'agentTeams.communication.taskAssignment': '{task} {subject} を割り当てました',
'agentTeams.communication.shutdownRequest': '終了要求:{reason}',
'agentTeams.communication.shutdownResponse': '終了応答を受信しました',
'agentTeams.communication.idle': 'メンバーが待機状態になりました',
'agentTeams.disbanded': '✓ チームは解散し、成果はメイン会話に統合されました',
'agentTeams.memberDetails': '{name} の詳細',
'agentTeams.memberTasks': '担当タスク',
'agentTeams.noMemberTasks': '割り当てられたタスクはありません。',
'agentTeams.memberMessages': '関連メッセージ',
'agentTeams.noMemberMessages': '関連メッセージはありません。',
'agentTeams.fullConversationHint': '完全な会話、ツール呼び出し、ストリーミング出力は既存のセッション表示を再利用します。',
'agentTeams.archivedConversationHint': '保存された実行セッションを開き、会話全体とツール呼び出しを確認できます。',
'agentTeams.openConversation': '{name} と直接話す',
'agentTeams.openExecution': '{name} の実行を表示',
'agentTeams.dependencyLegend': '実線:主依存 · 破線:副依存 · ホバーでフォーカス',
// ─── Session Activity ──────────────────────────────────────
'session.activity.title': 'アクティビティ',
+56
View File
@@ -2224,6 +2224,62 @@ export const kr: Record<TranslationKey, string> = {
'teams.leader': 'team-lead',
'teams.memberPlaceholder': '이 팀원에게 직접 메시지 보내기...',
'teams.memberSessionHint': '여기서 보내는 메시지는 이 팀원에게 바로 전달되며, 기록이 업데이트되는 것을 보면서 주고받을 수 있습니다.',
'teams.archivedMemberSessionHint': '보존된 실행에는 당시의 전체 대화와 도구 호출이 남아 있습니다.',
'teams.archivedMemberReadOnly': '이 실행 기록은 읽기 전용입니다. 작업을 계속하려면 리더 세션으로 돌아가세요.',
'agentTeams.title': 'Agent Teams 워크벤치',
'agentTeams.showWorkbench': 'Agent Teams 워크벤치 열기',
'agentTeams.hideWorkbench': 'Agent Teams 워크벤치 숨기기',
'agentTeams.closeWorkbench': 'Agent Teams 워크벤치 닫기',
'agentTeams.loading': 'Agent Teams 활동을 기다리는 중…',
'agentTeams.phase.forming': '팀 구성 중',
'agentTeams.phase.running': '병렬 실행',
'agentTeams.phase.finishing': '마무리 중',
'agentTeams.phase.completed': '완료됨',
'agentTeams.progressLabel': '팀 작업 진행률',
'agentTeams.followingLive': '실시간 팔로우 중',
'agentTeams.reviewHistory': '기록 돌아보기',
'agentTeams.older': '이전 워크벤치 상태',
'agentTeams.newer': '다음 워크벤치 상태',
'agentTeams.backToLive': '실시간으로 돌아가기',
'agentTeams.live': 'LIVE',
'agentTeams.leader': '리드',
'agentTeams.restArea': '휴식 구역',
'agentTeams.emptyTasks': '팀을 구성하고 있습니다. 작업이 만들어지면 의존성 그래프가 표시됩니다.',
'agentTeams.resizePanel': 'Agent Teams 워크벤치 크기 조절',
'agentTeams.task.blocked': '차단됨',
'agentTeams.task.open': '할당 가능',
'agentTeams.task.running': '진행 중',
'agentTeams.task.completed': '완료됨',
'agentTeams.task.dependencies': '{dependencies}에 의존',
'agentTeams.task.unclaimed': '팀원 할당 대기 중',
'agentTeams.member.working': '작업 중',
'agentTeams.member.idle': '대기 중',
'agentTeams.member.stopped': '중지됨',
'agentTeams.member.exited': '종료됨',
'agentTeams.member.error': '차단됨',
'agentTeams.openMember': '{name} 세부 정보 열기',
'agentTeams.communication.title': '커뮤니케이션',
'agentTeams.communication.count': '{count}개 메시지 · 다이렉트 메시지는 리드를 거치지 않습니다',
'agentTeams.communication.empty': '팀 메시지가 여기에 표시됩니다.',
'agentTeams.communication.direct': 'DIRECT',
'agentTeams.communication.broadcast': 'BROADCAST',
'agentTeams.communication.system': 'SYSTEM',
'agentTeams.communication.everyone': '모두',
'agentTeams.communication.taskAssignment': '{task} {subject} 할당',
'agentTeams.communication.shutdownRequest': '종료 요청: {reason}',
'agentTeams.communication.shutdownResponse': '종료 응답을 받았습니다',
'agentTeams.communication.idle': '팀원이 대기 상태가 되었습니다',
'agentTeams.disbanded': '✓ 팀 해산 완료; 결과를 메인 대화에 병합했습니다',
'agentTeams.memberDetails': '{name} 세부 정보',
'agentTeams.memberTasks': '담당 작업',
'agentTeams.noMemberTasks': '아직 할당된 작업이 없습니다.',
'agentTeams.memberMessages': '관련 메시지',
'agentTeams.noMemberMessages': '아직 관련 메시지가 없습니다.',
'agentTeams.fullConversationHint': '전체 대화, 도구 호출, 스트리밍 출력은 기존 세션 화면을 재사용합니다.',
'agentTeams.archivedConversationHint': '보존된 실행 세션을 열어 전체 대화와 도구 호출을 검토할 수 있습니다.',
'agentTeams.openConversation': '{name}에게 직접 말하기',
'agentTeams.openExecution': '{name} 실행 보기',
'agentTeams.dependencyLegend': '실선: 주 의존성 · 점선: 보조 의존성 · 호버로 집중',
// ─── Session Activity ──────────────────────────────────────
'session.activity.title': '활동',
+56
View File
@@ -2223,6 +2223,62 @@ export const zh: Record<TranslationKey, string> = {
'teams.leader': '主控',
'teams.memberPlaceholder': '直接給這個 Agent 發訊息...',
'teams.memberSessionHint': '這裡傳送的訊息會直接投遞給該 Agent,同時繼續重新整理它的 transcript。',
'teams.archivedMemberSessionHint': '這裡保留了該成員當時的完整對話與工具呼叫。',
'teams.archivedMemberReadOnly': '這是唯讀的歷史執行記錄;如需繼續工作,請返回主控工作階段。',
'agentTeams.title': 'Agent Teams 協作工作台',
'agentTeams.showWorkbench': '開啟 Agent Teams 工作台',
'agentTeams.hideWorkbench': '隱藏 Agent Teams 工作台',
'agentTeams.closeWorkbench': '關閉 Agent Teams 工作台',
'agentTeams.loading': '正在等待 Agent Teams 活動…',
'agentTeams.phase.forming': '組建團隊',
'agentTeams.phase.running': '平行執行',
'agentTeams.phase.finishing': '收尾中',
'agentTeams.phase.completed': '已完成',
'agentTeams.progressLabel': '團隊任務進度',
'agentTeams.followingLive': '跟隨即時',
'agentTeams.reviewHistory': '回看歷史',
'agentTeams.older': '更早的工作台狀態',
'agentTeams.newer': '更新的工作台狀態',
'agentTeams.backToLive': '回到即時',
'agentTeams.live': '即時',
'agentTeams.leader': '長',
'agentTeams.restArea': '休息區',
'agentTeams.emptyTasks': '正在組建團隊;建立任務後,依賴圖會出現在這裡。',
'agentTeams.resizePanel': '調整 Agent Teams 工作台寬度',
'agentTeams.task.blocked': '等待依賴',
'agentTeams.task.open': '可領取',
'agentTeams.task.running': '進行中',
'agentTeams.task.completed': '已完成',
'agentTeams.task.dependencies': '依賴 {dependencies}',
'agentTeams.task.unclaimed': '等待成員領取',
'agentTeams.member.working': '工作中',
'agentTeams.member.idle': '待命',
'agentTeams.member.stopped': '已停止',
'agentTeams.member.exited': '已退出',
'agentTeams.member.error': '卡住',
'agentTeams.openMember': '開啟 {name} 的詳細資料',
'agentTeams.communication.title': '通訊',
'agentTeams.communication.count': '{count} 則 · 私訊不經過隊長',
'agentTeams.communication.empty': '團隊訊息會顯示在這裡。',
'agentTeams.communication.direct': '私訊',
'agentTeams.communication.broadcast': '廣播',
'agentTeams.communication.system': '系統',
'agentTeams.communication.everyone': '全體',
'agentTeams.communication.taskAssignment': '已分配 {task} {subject}',
'agentTeams.communication.shutdownRequest': '請求結束:{reason}',
'agentTeams.communication.shutdownResponse': '已收到退出確認',
'agentTeams.communication.idle': '成員已進入待命狀態',
'agentTeams.disbanded': '✓ 團隊已解散,成果併入主對話',
'agentTeams.memberDetails': '{name} 的詳細資料',
'agentTeams.memberTasks': '負責任務',
'agentTeams.noMemberTasks': '尚未分配任務。',
'agentTeams.memberMessages': '相關訊息',
'agentTeams.noMemberMessages': '暫無相關訊息。',
'agentTeams.fullConversationHint': '完整對話、工具呼叫與串流輸出會沿用既有的工作階段檢視。',
'agentTeams.archivedConversationHint': '開啟保留的執行工作階段,可回看完整對話與工具呼叫。',
'agentTeams.openConversation': '直接與 {name} 對話',
'agentTeams.openExecution': '查看 {name} 的執行過程',
'agentTeams.dependencyLegend': '實線為主要依賴 · 虛線為次要依賴 · 懸停聚焦',
// ─── Session Activity ──────────────────────────────────────
'session.activity.title': '活動',
+56
View File
@@ -2223,6 +2223,62 @@ export const zh: Record<TranslationKey, string> = {
'teams.leader': '主控',
'teams.memberPlaceholder': '直接给这个 Agent 发消息...',
'teams.memberSessionHint': '这里发送的消息会直接投递给该 Agent,同时继续刷新它的 transcript。',
'teams.archivedMemberSessionHint': '这里保留了该成员当时的完整对话与工具调用。',
'teams.archivedMemberReadOnly': '这是只读的历史执行记录;如需继续工作,请返回主控会话。',
'agentTeams.title': 'Agent Teams 协作工作台',
'agentTeams.showWorkbench': '打开 Agent Teams 工作台',
'agentTeams.hideWorkbench': '隐藏 Agent Teams 工作台',
'agentTeams.closeWorkbench': '关闭 Agent Teams 工作台',
'agentTeams.loading': '正在等待 Agent Teams 活动…',
'agentTeams.phase.forming': '组建团队',
'agentTeams.phase.running': '并行执行',
'agentTeams.phase.finishing': '收尾中',
'agentTeams.phase.completed': '已完成',
'agentTeams.progressLabel': '团队任务进度',
'agentTeams.followingLive': '跟随实时',
'agentTeams.reviewHistory': '回看历史',
'agentTeams.older': '更早的工作台状态',
'agentTeams.newer': '更新的工作台状态',
'agentTeams.backToLive': '回到实时',
'agentTeams.live': '实时',
'agentTeams.leader': '长',
'agentTeams.restArea': '休息区',
'agentTeams.emptyTasks': '正在组建团队;任务创建后,依赖图将出现在这里。',
'agentTeams.resizePanel': '调整 Agent Teams 工作台宽度',
'agentTeams.task.blocked': '等待依赖',
'agentTeams.task.open': '可领取',
'agentTeams.task.running': '进行中',
'agentTeams.task.completed': '已完成',
'agentTeams.task.dependencies': '依赖 {dependencies}',
'agentTeams.task.unclaimed': '等待成员领取',
'agentTeams.member.working': '工作中',
'agentTeams.member.idle': '待命',
'agentTeams.member.stopped': '已停止',
'agentTeams.member.exited': '已退出',
'agentTeams.member.error': '卡住',
'agentTeams.openMember': '打开 {name} 的详情',
'agentTeams.communication.title': '通讯',
'agentTeams.communication.count': '{count} 条 · 私信不经过队长',
'agentTeams.communication.empty': '团队消息会出现在这里。',
'agentTeams.communication.direct': '私信',
'agentTeams.communication.broadcast': '广播',
'agentTeams.communication.system': '系统',
'agentTeams.communication.everyone': '全体',
'agentTeams.communication.taskAssignment': '已分配 {task} {subject}',
'agentTeams.communication.shutdownRequest': '请求结束:{reason}',
'agentTeams.communication.shutdownResponse': '已收到退出确认',
'agentTeams.communication.idle': '成员已进入待命状态',
'agentTeams.disbanded': '✓ 团队已解散,成果并入主会话',
'agentTeams.memberDetails': '{name} 的详情',
'agentTeams.memberTasks': '负责任务',
'agentTeams.noMemberTasks': '暂未分配任务。',
'agentTeams.memberMessages': '相关消息',
'agentTeams.noMemberMessages': '暂无相关消息。',
'agentTeams.fullConversationHint': '完整会话、工具调用与流式输出复用既有会话视图。',
'agentTeams.archivedConversationHint': '打开保留的执行会话,可回看完整对话与工具调用。',
'agentTeams.openConversation': '直接与 {name} 对话',
'agentTeams.openExecution': '查看 {name} 的执行过程',
'agentTeams.dependencyLegend': '实线为主依赖 · 虚线为次依赖 · 悬停聚焦',
// ─── Session Activity ──────────────────────────────────────
'session.activity.title': '活动',
+69 -13
View File
@@ -43,11 +43,19 @@ vi.mock('../components/workbench/WorkbenchPanel', () => ({
),
}))
vi.mock('../components/agentTeams/AgentTeamsWorkbench', () => ({
AgentTeamsWorkbench: ({ sessionId }: { sessionId: string }) => (
<div data-testid="agent-teams-workbench">agent-teams:{sessionId}</div>
),
}))
vi.mock('../api/teams', () => ({
teamsApi: {
getMemberTranscript: vi.fn(() => Promise.resolve({ messages: [] })),
get: vi.fn(),
list: vi.fn(),
getWorkbenchForSession: vi.fn(() => Promise.reject(new Error('not a team session'))),
getWorkbench: vi.fn(),
sendMemberMessage: vi.fn(),
},
}))
@@ -120,7 +128,7 @@ afterEach(() => {
useChatStore.setState({ sessions: {} })
useSettingsStore.setState({ locale: 'en' })
useTeamStore.getState().stopMemberPolling()
useTeamStore.setState({ teams: [], activeTeam: null, memberColors: new Map(), error: null })
useTeamStore.setState(useTeamStore.getInitialState(), true)
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
useTerminalPanelStore.setState(useTerminalPanelStore.getInitialState(), true)
useActivityPanelStore.setState(useActivityPanelStore.getInitialState(), true)
@@ -1202,9 +1210,8 @@ describe('ActiveSession task polling', () => {
expect(useTabStore.getState().activeTabId).toBe('__subagent__activity-subagent-open-session__agent-tool-1')
})
it('opens a team member session from the activity panel', () => {
it('renders Agent Teams in its dedicated workbench instead of the activity panel', () => {
const sessionId = 'team-activity-panel-session'
const memberSessionId = 'team-member:security-reviewer@test-team'
useActivityPanelStore.getState().open(sessionId)
useTeamStore.setState({
@@ -1229,6 +1236,33 @@ describe('ActiveSession task polling', () => {
},
memberColors: new Map(),
error: null,
workbenchesBySession: {
[sessionId]: {
teamName: 'test-team',
loading: false,
error: null,
snapshots: [{
version: 'v1',
generatedAt: '2026-08-08T00:00:00.000Z',
team: {
name: 'test-team',
leadAgentId: 'team-lead@test-team',
leadSessionId: sessionId,
members: [
{ agentId: 'team-lead@test-team', role: 'team-lead', status: 'running' },
{
agentId: 'security-reviewer@test-team',
role: 'security-reviewer',
status: 'running',
currentTask: 'Auditing auth flow',
},
],
},
tasks: [],
messages: [],
}],
},
},
})
useSessionStore.setState({
sessions: [{
@@ -1276,17 +1310,20 @@ describe('ActiveSession task polling', () => {
render(<ActiveSession />)
const panel = screen.getByTestId('session-activity-panel')
expect(within(panel).queryByText('team-lead')).not.toBeInTheDocument()
expect(within(panel).getByText('security-reviewer')).toBeInTheDocument()
expect(within(panel).queryByText('Auditing auth flow')).not.toBeInTheDocument()
expect(screen.queryByTestId('session-activity-panel')).not.toBeInTheDocument()
expect(screen.getByTestId('agent-teams-workbench-panel')).toHaveClass('bg-[var(--color-surface)]')
expect(screen.getByTestId('agent-teams-resize-handle')).not.toHaveClass('border-x')
expect(screen.getByTestId('agent-teams-resize-handle').firstElementChild).toHaveClass('opacity-0')
expect(screen.getByTestId('agent-teams-workbench')).toHaveTextContent(`agent-teams:${sessionId}`)
expect(screen.getByTestId('message-list')).toHaveAttribute('data-compact', 'true')
expect(useActivityPanelStore.getState().isOpen(sessionId)).toBe(false)
fireEvent.click(within(panel).getByRole('button', { name: /open team member security-reviewer/i }))
act(() => {
useTeamStore.getState().setWorkbenchOpen(sessionId, false)
})
expect(useTabStore.getState().activeTabId).toBe(memberSessionId)
expect(useTabStore.getState().tabs).toEqual(expect.arrayContaining([
expect.objectContaining({ sessionId: memberSessionId, title: 'security-reviewer', type: 'session' }),
]))
expect(screen.queryByTestId('agent-teams-workbench-panel')).not.toBeInTheDocument()
expect(useTeamStore.getState().workbenchesBySession[sessionId]?.snapshots).toHaveLength(1)
})
it('clears the last visible background task by closing Activity while preserving later runs', async () => {
@@ -1571,7 +1608,7 @@ describe('ActiveSession task polling', () => {
useCLITaskStore.setState(originalCliTaskState)
})
it('keeps member sessions interactive and skips leader task polling', () => {
it('keeps live member sessions interactive, makes archived execution read-only, and skips leader task polling', () => {
const memberSessionId = 'team-member:security-reviewer@test-team'
const originalCliTaskState = useCLITaskStore.getState()
const fetchSessionTasks = vi.fn().mockResolvedValue(undefined)
@@ -1639,10 +1676,29 @@ describe('ActiveSession task polling', () => {
const { queryByTestId, unmount } = render(<ActiveSession />)
expect(queryByTestId('chat-input')).toBeInTheDocument()
expect(queryByTestId('member-session-readonly')).not.toBeInTheDocument()
expect(queryByTestId('session-task-bar')).not.toBeInTheDocument()
expect(queryByTestId('session-activity-panel')).not.toBeInTheDocument()
expect(fetchSessionTasks).not.toHaveBeenCalled()
act(() => {
useTeamStore.setState((state) => ({
activeTeam: state.activeTeam
? {
...state.activeTeam,
members: state.activeTeam.members.map((member) => (
member.agentId === 'security-reviewer@test-team'
? { ...member, status: 'completed' as const }
: member
)),
}
: null,
}))
})
expect(queryByTestId('chat-input')).not.toBeInTheDocument()
expect(queryByTestId('member-session-readonly')).toHaveTextContent('This archived execution is read-only')
unmount()
useCLITaskStore.setState(originalCliTaskState)
})
+131 -23
View File
@@ -13,7 +13,11 @@ import {
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
import { useCLITaskStore } from '../stores/cliTaskStore'
import { useTeamStore } from '../stores/teamStore'
import {
AGENT_TEAMS_WORKBENCH_MAX_WIDTH,
AGENT_TEAMS_WORKBENCH_MIN_WIDTH,
useTeamStore,
} from '../stores/teamStore'
import { useWorkspacePanelStore } from '../stores/workspacePanelStore'
import {
TERMINAL_PANEL_DEFAULT_HEIGHT,
@@ -31,6 +35,7 @@ import { ChatInput } from '../components/chat/ChatInput'
import { getWorktreeDisplayName, WorktreeDetails } from '../components/chat/WorktreeDetails'
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
import { WorkbenchPanel } from '../components/workbench/WorkbenchPanel'
import { AgentTeamsWorkbench } from '../components/agentTeams/AgentTeamsWorkbench'
import { SessionActivityPanel } from '../components/activity/SessionActivityPanel'
import { buildSessionActivityModel, hasVisibleSessionActivity } from '../components/activity/sessionActivityModel'
import { TerminalSettings } from './TerminalSettings'
@@ -222,6 +227,72 @@ function WorkspaceResizeHandle({ panelRef }: { panelRef: RefObject<HTMLElement>
)
}
function AgentTeamsResizeHandle({ panelRef }: { panelRef: RefObject<HTMLElement> }) {
const t = useTranslation()
const width = useTeamStore((state) => state.workbenchPanelWidth)
const setWidth = useTeamStore((state) => state.setWorkbenchPanelWidth)
const [dragState, setDragState] = useState<{ startX: number; startWidth: number } | null>(null)
const dragStateRef = useRef(dragState)
useEffect(() => {
dragStateRef.current = dragState
}, [dragState])
useEffect(() => {
if (!dragState) return
const handlePointerMove = (event: PointerEvent) => {
const current = dragStateRef.current
if (!current) return
setWidth(current.startWidth + current.startX - event.clientX)
}
const handlePointerUp = () => setDragState(null)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
window.addEventListener('pointercancel', handlePointerUp)
return () => {
document.body.style.cursor = ''
document.body.style.userSelect = ''
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerUp)
}
}, [dragState, setWidth])
return (
<div
role="separator"
aria-label={t('agentTeams.resizePanel')}
aria-orientation="vertical"
aria-valuemin={AGENT_TEAMS_WORKBENCH_MIN_WIDTH}
aria-valuemax={AGENT_TEAMS_WORKBENCH_MAX_WIDTH}
aria-valuenow={width}
tabIndex={0}
data-testid="agent-teams-resize-handle"
onPointerDown={(event) => {
if (event.button !== 0) return
event.preventDefault()
setDragState({ startX: event.clientX, startWidth: getRenderedWorkspacePanelWidth(panelRef, width) })
}}
onKeyDown={(event) => {
const renderedWidth = getRenderedWorkspacePanelWidth(panelRef, width)
if (event.key === 'ArrowLeft') {
event.preventDefault()
setWidth(renderedWidth + WORKSPACE_RESIZE_STEP)
}
if (event.key === 'ArrowRight') {
event.preventDefault()
setWidth(renderedWidth - WORKSPACE_RESIZE_STEP)
}
}}
className="group relative z-[var(--z-raised)] flex w-[7px] shrink-0 cursor-col-resize items-center justify-center bg-[var(--color-surface)] outline-none"
>
<span aria-hidden="true" className="h-[34px] w-px rounded-full bg-[var(--color-border-separator)] opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:bg-[var(--color-border-focus)] group-focus-visible:opacity-100" />
</div>
)
}
function TerminalResizeHandle() {
const t = useTranslation()
const height = useTerminalPanelStore((state) => state.height)
@@ -335,18 +406,35 @@ export function ActiveSession() {
const session = sessions.find((s) => s.id === activeTabId)
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
const activeTeam = useTeamStore((s) => s.activeTeam)
const agentTeamsTimeline = useTeamStore((s) => activeTabId ? s.workbenchesBySession[activeTabId] : undefined)
const agentTeamsWorkbenchOpen = useTeamStore((s) => activeTabId
? s.workbenchOpenBySession[activeTabId] ?? true
: false)
const agentTeamsPanelWidth = useTeamStore((s) => s.workbenchPanelWidth)
const fetchTeamForSession = useTeamStore((s) => s.fetchTeamForSession)
const isMemberSession = !!memberInfo
const isArchivedMemberSession = memberInfo?.status === 'completed'
const [sessionGitInfo, setSessionGitInfo] = useState<{
sessionId: string
info: SessionGitInfo
} | null>(null)
const showWorkbench = useWorkspacePanelStore((state) =>
const workspaceWorkbenchOpen = useWorkspacePanelStore((state) =>
activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout
? state.isPanelOpen(activeTabId)
: false,
)
const showRightPanel = showWorkbench
const rightPanelWidth = useWorkspacePanelStore((state) => state.width)
const showAgentTeamsWorkbench = Boolean(
agentTeamsTimeline?.snapshots.length &&
agentTeamsWorkbenchOpen &&
activeTabId &&
isSessionTabState(activeTabId, activeTabType) &&
!isMemberSession &&
!isMobileLayout,
)
const showWorkbench = workspaceWorkbenchOpen && !showAgentTeamsWorkbench
const showRightPanel = showWorkbench || showAgentTeamsWorkbench
const workspacePanelWidth = useWorkspacePanelStore((state) => state.width)
const rightPanelWidth = showAgentTeamsWorkbench ? agentTeamsPanelWidth : workspacePanelWidth
const showTerminalPanel = useTerminalPanelStore((state) =>
activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout
? state.isPanelOpen(activeTabId)
@@ -363,8 +451,9 @@ export function ActiveSession() {
useEffect(() => {
if (activeTabId && !isMemberSession) {
connectToSession(activeTabId)
void fetchTeamForSession(activeTabId)
}
}, [activeTabId, isMemberSession, connectToSession])
}, [activeTabId, isMemberSession, connectToSession, fetchTeamForSession])
useEffect(() => {
if (!activeTabId || isMemberSession || !isSessionTabState(activeTabId, activeTabType)) return
@@ -454,13 +543,6 @@ export function ActiveSession() {
const totalTokens = getTokenUsageTotal(tokenUsage)
const cachedTokens = (tokenUsage.cache_read_tokens ?? 0) +
(tokenUsage.cache_creation_tokens ?? 0)
const activityTeamMembers = useMemo(() => {
if (!activeTeam || activeTeam.leadSessionId !== activeTabId) return []
return activeTeam.members.filter((member) =>
!activeTeam.leadAgentId || member.agentId !== activeTeam.leadAgentId
)
}, [activeTabId, activeTeam])
useEffect(() => {
if (!activeTabId) return
pruneDismissedBackgroundTaskKeys(
@@ -482,11 +564,9 @@ export function ActiveSession() {
backgroundTasks,
dismissedBackgroundTaskKeys,
agentNotifications: Object.values(agentTaskNotifications),
teamMembers: activityTeamMembers,
})
}, [
activeTabId,
activityTeamMembers,
agentTaskNotifications,
backgroundTasks,
cliTasks,
@@ -540,9 +620,9 @@ export function ActiveSession() {
}, [activeTabId, closeActivityPanel, hasVisibleActivity, isActivityPanelOpen, sessionState?.historyStatus])
useEffect(() => {
if (!activeTabId || !showWorkbench || !isActivityPanelOpen) return
if (!activeTabId || !showRightPanel || !isActivityPanelOpen) return
closeActivityPanel(activeTabId)
}, [activeTabId, closeActivityPanel, isActivityPanelOpen, showWorkbench])
}, [activeTabId, closeActivityPanel, isActivityPanelOpen, showRightPanel])
const handleOpenSubagentRun = useCallback((payload: { sessionId: string; taskId?: string; toolUseId: string; title: string }) => {
useTabStore.getState().openSubagentTab(payload.sessionId, payload.toolUseId, payload.title, payload.taskId)
@@ -574,7 +654,7 @@ export function ActiveSession() {
// squeezes the column by itself — the column yields with padding instead.
const showActivityRail = Boolean(activityModel) &&
hasVisibleActivity &&
!showWorkbench &&
!showRightPanel &&
!isMobileLayout &&
!isMemberSession &&
isSessionTabState(activeTabId, activeTabType)
@@ -615,7 +695,9 @@ export function ActiveSession() {
)}
</div>
<p className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
{t('teams.memberSessionHint')}
{t(isArchivedMemberSession
? 'teams.archivedMemberSessionHint'
: 'teams.memberSessionHint')}
</p>
</div>
<Button
@@ -807,10 +889,20 @@ export function ActiveSession() {
/>
) : null}
<ChatInput
variant={isEmpty && !isMemberSession && !showRightPanel ? 'hero' : 'default'}
compact={showRightPanel}
/>
{isArchivedMemberSession ? (
<div
data-testid="member-session-readonly"
className="flex shrink-0 items-center justify-center gap-2 border-t border-[var(--color-border)] bg-[var(--color-surface-container)] px-6 py-3 text-[11px] text-[var(--color-text-secondary)]"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>history</span>
{t('teams.archivedMemberReadOnly')}
</div>
) : (
<ChatInput
variant={isEmpty && !isMemberSession && !showRightPanel ? 'hero' : 'default'}
compact={showRightPanel}
/>
)}
{terminalPanelRuntimeId && activeTabId ? (
<div
@@ -854,7 +946,23 @@ export function ActiveSession() {
/>
) : null}
{showWorkbench ? (
{showAgentTeamsWorkbench ? (
<>
<AgentTeamsResizeHandle panelRef={workbenchPanelRef} />
<aside
ref={workbenchPanelRef}
data-testid="agent-teams-workbench-panel"
className="flex h-full shrink-0 flex-col bg-[var(--color-surface)]"
style={{
width: rightPanelWidth,
maxWidth: 'min(940px, 72%)',
minWidth: 'min(440px, 54%)',
}}
>
<AgentTeamsWorkbench sessionId={activeTabId} />
</aside>
</>
) : showWorkbench ? (
<>
<WorkspaceResizeHandle panelRef={workbenchPanelRef} />
<aside
@@ -3,10 +3,12 @@ import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SubagentRunResponse } from '../api/subagents'
import { useSettingsStore } from '../stores/settingsStore'
import { setComposerText } from '../components/chat/composerTestUtils'
vi.mock('../api/subagents', () => ({
subagentsApi: {
getRunByTool: vi.fn(),
sendMessage: vi.fn(),
},
}))
@@ -68,6 +70,7 @@ describe('SubagentRunPage', () => {
cleanup()
vi.useRealTimers()
vi.mocked(subagentsApi.getRunByTool).mockReset()
vi.mocked(subagentsApi.sendMessage).mockReset()
})
it('returns to the parent session and closes its own tab via the back button', async () => {
@@ -106,6 +109,66 @@ describe('SubagentRunPage', () => {
expect(transcript).not.toHaveTextContent('assistant_text')
})
it('continues a completed SubAgent from the shared conversation composer', async () => {
vi.mocked(subagentsApi.getRunByTool).mockResolvedValue(subagentRun())
vi.mocked(subagentsApi.sendMessage).mockResolvedValue({
ok: true,
agent_id: 'abc123',
delivery: 'resumed',
})
useTabStore.getState().openTab('session-1', 'Parent session')
useTabStore.getState().openSubagentTab('session-1', 'tool-1', 'Kuhn', 'agent-1')
render(
<SubagentRunPage
sourceSessionId="session-1"
toolUseId="tool-1"
taskId="agent-1"
title="Kuhn"
/>,
)
await screen.findByTestId('subagent-conversation')
setComposerText('Review the new regression test.', 31)
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
await waitFor(() => {
expect(subagentsApi.sendMessage).toHaveBeenCalledWith(
'session-1',
'tool-1',
'Review the new regression test.',
'agent-1',
)
})
})
it('keeps a failed continuation visible after the transcript refreshes', async () => {
vi.mocked(subagentsApi.getRunByTool).mockResolvedValue(subagentRun())
vi.mocked(subagentsApi.sendMessage).mockRejectedValue(new Error('Agent transcript is unavailable'))
useTabStore.getState().openTab('session-1', 'Parent session')
useTabStore.getState().openSubagentTab('session-1', 'tool-1', 'Kuhn', 'agent-1')
render(
<SubagentRunPage
sourceSessionId="session-1"
toolUseId="tool-1"
taskId="agent-1"
title="Kuhn"
/>,
)
await screen.findByTestId('subagent-conversation')
setComposerText('Continue the review.', 20)
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
expect(await screen.findByText('Agent transcript is unavailable')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Refresh SubAgent run' }))
await waitFor(() => expect(subagentsApi.getRunByTool).toHaveBeenCalledTimes(2))
expect(screen.getByText('Continue the review.')).toBeInTheDocument()
expect(screen.getByText('Agent transcript is unavailable')).toBeInTheDocument()
expect(screen.queryByText('Thinking...')).not.toBeInTheDocument()
})
it('renders a loading state while the run is loading', () => {
vi.mocked(subagentsApi.getRunByTool).mockReturnValue(deferred<SubagentRunResponse>().promise)
+64 -146
View File
@@ -1,19 +1,23 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ArrowLeft, RefreshCw } from 'lucide-react'
import {
subagentsApi,
type SubagentRunResponse,
type SubagentRunStatus,
} from '../api/subagents'
import { buildRenderModel, MessageBlock } from '../components/chat/MessageList'
import { ToolCallGroup } from '../components/chat/ToolCallGroup'
import { MessageList } from '../components/chat/MessageList'
import { ChatInput } from '../components/chat/ChatInput'
import { Badge, type Tone as BadgeTone } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { useTranslation } from '../i18n'
import { mapHistoryMessagesToUiMessages, useChatStore } from '../stores/chatStore'
import {
createDefaultSessionState,
mapHistoryMessagesToUiMessages,
useChatStore,
} from '../stores/chatStore'
import { SUBAGENT_TAB_PREFIX, useTabStore } from '../stores/tabStore'
import type { AgentTaskNotification, UIMessage } from '../types/chat'
import type { UIMessage } from '../types/chat'
type TranslationFn = ReturnType<typeof useTranslation>
const LIVE_RUN_REFRESH_MS = 2000
@@ -34,6 +38,12 @@ export function SubagentRunPage({
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const requestIdRef = useRef(0)
const tabId = useTabStore((state) => {
const activeTab = state.tabs.find((tab) => tab.sessionId === state.activeTabId)
return activeTab?.type === 'subagent' && activeTab.subagentToolUseId === toolUseId
? activeTab.sessionId
: `${SUBAGENT_TAB_PREFIX}${sourceSessionId}__${toolUseId}`
})
const discoveredTaskId = useChatStore((state) => {
const session = state.sessions[sourceSessionId]
const liveTask = Object.values(session?.backgroundAgentTasks ?? {})
@@ -75,14 +85,42 @@ export function SubagentRunPage({
}, [load])
useEffect(() => {
if (data?.status !== 'running' || loading) return
const timer = window.setTimeout(() => {
void load()
}, LIVE_RUN_REFRESH_MS)
if (loading) return
const timer = window.setTimeout(() => void load(), LIVE_RUN_REFRESH_MS)
return () => window.clearTimeout(timer)
}, [data?.status, load, loading])
}, [data?.updatedAt, load, loading])
useEffect(() => {
if (!data) return
const transcriptMessages = buildSubagentConversationMessages(data)
useChatStore.setState((state) => {
const existing = state.sessions[tabId] ?? createDefaultSessionState()
const localMessages = existing.messages.filter((message) => {
if (message.type === 'error' && message.code === 'SUBAGENT_MESSAGE_FAILED') {
return true
}
return message.type === 'user_text' && !transcriptMessages.some((candidate) => (
candidate.type === 'user_text' && candidate.content === message.content
))
})
const hasPendingMessage = localMessages.some((message) => (
message.type === 'user_text' && message.pending === true
))
return {
sessions: {
...state.sessions,
[tabId]: {
...existing,
messages: [...transcriptMessages, ...localMessages],
connectionState: 'connected',
chatState: data.status === 'running' || hasPendingMessage
? 'thinking'
: 'idle',
},
},
}
})
}, [data, tabId])
return (
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)] text-[var(--color-text-primary)]">
@@ -110,6 +148,13 @@ export function SubagentRunPage({
<p className="mt-1 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
{sourceSessionId} / {toolUseId}
</p>
{data ? (
<p className="mt-1 flex min-w-0 flex-wrap gap-x-2 text-[11px] text-[var(--color-text-tertiary)]">
<span>{t('subagentRun.agent')}: {data.agentId ?? t('subagentRun.unknown')}</span>
{data.description ? <span>{data.description}</span> : null}
{data.outputFile ? <span>{t('subagentRun.output')}: {data.outputFile}</span> : null}
</p>
) : null}
</div>
</div>
{/* The icon spins in place while loading rather than using IconButton's
@@ -125,136 +170,26 @@ export function SubagentRunPage({
/>
</header>
<main className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<main className="flex min-h-0 flex-1 flex-col">
{loading && !data ? (
<div role="status" className="text-sm text-[var(--color-text-tertiary)]">{t('subagentRun.loading')}</div>
<div role="status" className="flex flex-1 items-center justify-center text-sm text-[var(--color-text-tertiary)]">{t('subagentRun.loading')}</div>
) : null}
{error ? (
<div role="alert" className="rounded-[var(--radius-md)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-3 py-2 text-sm text-[var(--color-on-error-container)]">
<div role="alert" className="mx-5 mt-4 rounded-[var(--radius-md)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-3 py-2 text-sm text-[var(--color-on-error-container)]">
{error}
</div>
) : null}
{data ? (
<SubagentRunDetails data={data} />
<div data-testid="subagent-conversation" className="flex min-h-0 flex-1 flex-col">
<MessageList sessionId={tabId} />
</div>
) : null}
</main>
<ChatInput />
</div>
)
}
function SubagentRunDetails({ data }: { data: SubagentRunResponse }) {
const t = useTranslation()
return (
<div className="mx-auto flex max-w-4xl flex-col gap-5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
<span>{t('subagentRun.source')}: {sourceLabel(data.source, t)}</span>
<span aria-hidden="true">/</span>
<span>{t('subagentRun.agent')}: {data.agentId ?? t('subagentRun.unknown')}</span>
{data.description ? (
<>
<span aria-hidden="true">/</span>
<span>{data.description}</span>
</>
) : null}
{data.taskId ? (
<>
<span aria-hidden="true">/</span>
<span>{t('subagentRun.task')}: <span className="font-mono">{data.taskId}</span></span>
</>
) : null}
<span aria-hidden="true">/</span>
<span>{t('subagentRun.updated')}: <span className="font-mono tabular-nums">{formatTimestamp(data.updatedAt)}</span></span>
{data.usage?.totalTokens ? (
<>
<span aria-hidden="true">/</span>
<span className="font-mono tabular-nums">{t('common.tokens', { count: formatNumber(data.usage.totalTokens) })}</span>
</>
) : null}
{data.outputFile ? (
<>
<span aria-hidden="true">/</span>
<span className="min-w-0 truncate font-mono" title={data.outputFile}>{t('subagentRun.output')}: {data.outputFile}</span>
</>
) : null}
</div>
<ConversationSection data={data} />
</div>
)
}
const EMPTY_AGENT_TASK_NOTIFICATIONS: Record<string, AgentTaskNotification> = {}
function ConversationSection({ data }: { data: SubagentRunResponse }) {
const t = useTranslation()
const conversationMessages = useMemo(() => buildSubagentConversationMessages(data), [data])
const renderModel = useMemo(() => buildRenderModel(conversationMessages), [conversationMessages])
if (renderModel.renderItems.length === 0) {
return (
<section>
<h2
className="mb-2 text-[13.5px] font-semibold text-[var(--color-text-secondary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('subagentRun.transcript')}
</h2>
<div className="rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-text-tertiary)]">
{t('subagentRun.noTranscript')}
</div>
</section>
)
}
return (
<section>
<div className="mb-2 flex items-center justify-between gap-3">
<h2
className="text-[13.5px] font-semibold text-[var(--color-text-secondary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('subagentRun.transcript')}
</h2>
{data.truncated ? (
<span className="text-[11px] text-[var(--color-text-tertiary)]">{t('subagentRun.truncated')}</span>
) : null}
</div>
<div data-testid="subagent-conversation" className="space-y-3">
{renderModel.renderItems.map((item) => {
if (item.kind === 'tool_group') {
return (
<ToolCallGroup
key={item.id}
toolCalls={item.toolCalls}
resultMap={renderModel.toolResultMap}
childToolCallsByParent={renderModel.childToolCallsByParent}
agentTaskNotifications={EMPTY_AGENT_TASK_NOTIFICATIONS}
showOpenRun={false}
isStreaming={false}
/>
)
}
const toolResult = item.message.type === 'tool_use'
? renderModel.toolResultMap.get(item.message.toolUseId)
: null
return (
<MessageBlock
key={item.message.id}
message={item.message}
activeThinkingId={null}
agentTaskNotifications={EMPTY_AGENT_TASK_NOTIFICATIONS}
toolResult={toolResult}
/>
)
})}
</div>
</section>
)
}
function StatusBadge({ status, t }: { status: SubagentRunStatus; t: TranslationFn }) {
return (
<Badge tone={statusTone(status)} size="xs" bordered>
@@ -270,13 +205,6 @@ function statusTone(status: SubagentRunStatus): BadgeTone {
return 'neutral'
}
function sourceLabel(source: SubagentRunResponse['source'], t: TranslationFn) {
if (source === 'subagent-jsonl') return t('subagentRun.source.transcript')
if (source === 'session-history') return t('subagentRun.source.sessionHistory')
if (source === 'live-task') return t('subagentRun.source.liveTask')
return t('subagentRun.source.none')
}
function getSubagentStatusLabel(status: SubagentRunStatus, t: TranslationFn) {
switch (status) {
case 'completed':
@@ -292,16 +220,6 @@ function getSubagentStatusLabel(status: SubagentRunStatus, t: TranslationFn) {
}
}
function formatNumber(value: number | undefined) {
return typeof value === 'number' && Number.isFinite(value) ? value.toLocaleString() : '-'
}
function formatTimestamp(value: string | undefined) {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
}
function timestampMs(value: string | undefined) {
if (!value) return Date.now()
const time = Date.parse(value)
+68
View File
@@ -8,6 +8,7 @@ const {
sendMessageToMemberMock,
handleTeamCreatedMock,
handleTeamUpdateMock,
handleTeamWorkbenchUpdatedMock,
handleTeamDeletedMock,
fetchSessionTasksMock,
clearTasksMock,
@@ -24,12 +25,15 @@ const {
sessionStoreSnapshot,
cliTaskStoreSnapshot,
connectionStateHandlers,
sendSubagentMessageMock,
tabStoreSnapshot,
} = vi.hoisted(() => ({
sendMock: vi.fn(),
getMemberBySessionIdMock: vi.fn<(sessionId: string) => any>(() => null),
sendMessageToMemberMock: vi.fn(async () => {}),
handleTeamCreatedMock: vi.fn(),
handleTeamUpdateMock: vi.fn(),
handleTeamWorkbenchUpdatedMock: vi.fn(),
handleTeamDeletedMock: vi.fn(),
fetchSessionTasksMock: vi.fn(),
clearTasksMock: vi.fn(),
@@ -60,6 +64,8 @@ const {
sessionId: null as string | null,
},
connectionStateHandlers: new Map<string, (state: 'connecting' | 'connected' | 'reconnecting' | 'disconnected') => void>(),
sendSubagentMessageMock: vi.fn(async () => ({ ok: true })),
tabStoreSnapshot: { tabs: [] as Array<Record<string, unknown>> },
}))
vi.mock('../lib/desktopNotifications', () => ({
@@ -88,6 +94,12 @@ vi.mock('../api/sessions', () => ({
},
}))
vi.mock('../api/subagents', () => ({
subagentsApi: {
sendMessage: sendSubagentMessageMock,
},
}))
vi.mock('./teamStore', () => ({
useTeamStore: {
getState: () => ({
@@ -95,6 +107,7 @@ vi.mock('./teamStore', () => ({
sendMessageToMember: sendMessageToMemberMock,
handleTeamCreated: handleTeamCreatedMock,
handleTeamUpdate: handleTeamUpdateMock,
handleTeamWorkbenchUpdated: handleTeamWorkbenchUpdatedMock,
handleTeamDeleted: handleTeamDeletedMock,
}),
},
@@ -103,6 +116,7 @@ vi.mock('./teamStore', () => ({
vi.mock('./tabStore', () => ({
useTabStore: {
getState: () => ({
tabs: tabStoreSnapshot.tabs,
updateTabStatus: updateTabStatusMock,
updateTabTitle: updateTabTitleMock,
}),
@@ -197,6 +211,20 @@ describe('stripGeneratedImageMetadataLines', () => {
})
})
describe('Agent Teams workbench invalidation', () => {
it('routes the server invalidation through the team store', () => {
handleTeamWorkbenchUpdatedMock.mockReset()
useChatStore.getState().handleServerMessage('lead-session', {
type: 'team_workbench_updated',
teamName: 'visual-team',
})
expect(handleTeamWorkbenchUpdatedMock).toHaveBeenCalledOnce()
expect(handleTeamWorkbenchUpdatedMock).toHaveBeenCalledWith('visual-team')
})
})
describe('chatStore tool settlement', () => {
beforeEach(() => {
sendMock.mockReset()
@@ -4040,6 +4068,8 @@ describe('chatStore history mapping', () => {
it('mirrors CLI permission-mode broadcasts locally without echoing back to the server', () => {
sendMock.mockReset()
updateSessionPermissionModeMock.mockReset()
sendSubagentMessageMock.mockReset()
sendSubagentMessageMock.mockResolvedValue({ ok: true })
// CLI 退出 plan 后恢复到 bypassPermissions,回传 permission_mode_changed。
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
@@ -8161,6 +8191,43 @@ describe('chatStore history mapping', () => {
})
})
it('routes subagent-session messages through resume control instead of the parent websocket', async () => {
const subagentSessionId = '__subagent__parent-session__tool-agent-1'
tabStoreSnapshot.tabs = [{
sessionId: subagentSessionId,
type: 'subagent',
sourceSessionId: 'parent-session',
subagentToolUseId: 'tool-agent-1',
subagentTaskId: 'agent-1',
}]
useChatStore.setState({
sessions: {
[subagentSessionId]: makeSession({ chatState: 'idle', messages: [] }),
},
})
useChatStore.getState().sendMessage(subagentSessionId, 'Continue the review')
await Promise.resolve()
expect(sendSubagentMessageMock).toHaveBeenCalledWith(
'parent-session',
'tool-agent-1',
'Continue the review',
'agent-1',
)
expect(sendMock).not.toHaveBeenCalledWith(
subagentSessionId,
expect.objectContaining({ type: 'user_message' }),
)
expect(
useChatStore.getState().sessions[subagentSessionId]?.messages.at(-1),
).toMatchObject({
type: 'user_text',
content: 'Continue the review',
pending: true,
})
})
it('refreshes CLI tasks when switching to an already-connected session', () => {
useChatStore.setState({
sessions: {
@@ -8517,6 +8584,7 @@ describe('chatStore activity state survival across reload paths', () => {
sessionStoreSnapshot.sessions = []
cliTaskStoreSnapshot.tasks = []
cliTaskStoreSnapshot.sessionId = null
tabStoreSnapshot.tabs = []
localStorage.clear()
useSettingsStore.setState({ locale: 'en' })
useChatStore.setState({
+61 -11
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand'
import { wsManager } from '../api/websocket'
import { sessionsApi } from '../api/sessions'
import { subagentsApi } from '../api/subagents'
import { useTeamStore } from './teamStore'
import { useSessionStore } from './sessionStore'
import { useCLITaskStore } from './cliTaskStore'
@@ -219,7 +220,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
queuedUserMessages: [],
}
function createDefaultSessionState(): PerSessionState {
export function createDefaultSessionState(): PerSessionState {
return {
...DEFAULT_SESSION_STATE,
messages: [],
@@ -1485,7 +1486,14 @@ export const useChatStore = create<ChatStore>((set, get) => ({
sendMessage: (sessionId, content, attachments, options) => {
const isMemberSession = !!useTeamStore.getState().getMemberBySessionId(sessionId)
const hideDisplayContent = !isMemberSession && options?.hideDisplayContent === true
const subagentTab = useTabStore.getState().tabs.find(
(tab) => tab.sessionId === sessionId && tab.type === 'subagent',
)
const isSubagentSession = Boolean(
subagentTab?.sourceSessionId && subagentTab.subagentToolUseId,
)
const isDirectAgentSession = isMemberSession || isSubagentSession
const hideDisplayContent = !isDirectAgentSession && options?.hideDisplayContent === true
const userFacingContent =
hideDisplayContent
? ''
@@ -1517,11 +1525,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
? sessionTasks.map((t) => ({ id: t.id, subject: t.subject, status: t.status, activeForm: t.activeForm }))
: []
if (!isMemberSession && allTasksDone) {
if (!isDirectAgentSession && allTasksDone) {
void taskStore.resetCompletedTasks(sessionId)
}
if (!isMemberSession) {
if (!isDirectAgentSession) {
updateOptimisticSessionTitle(sessionId, userFacingContent || content.trim())
}
@@ -1534,7 +1542,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
const newMessages = pendingAssistantText.trim()
? appendAssistantTextMessage(session.messages, pendingAssistantText, now)
: [...session.messages]
if (!isMemberSession && allTasksDone) {
if (!isDirectAgentSession && allTasksDone) {
newMessages.push({
id: nextId(),
type: 'task_summary',
@@ -1547,14 +1555,14 @@ export const useChatStore = create<ChatStore>((set, get) => ({
type: 'user_text',
content: userFacingContent,
...(userFacingContent !== modelFacingContent ? { modelContent: modelFacingContent } : {}),
attachments: isMemberSession ? undefined : uiAttachments,
attachments: isDirectAgentSession ? undefined : uiAttachments,
timestamp: now,
...(isMemberSession ? { pending: true } : {}),
...(isDirectAgentSession ? { pending: true } : {}),
})
if (!isMemberSession && session.elapsedTimer) clearInterval(session.elapsedTimer)
if (!isDirectAgentSession && session.elapsedTimer) clearInterval(session.elapsedTimer)
const timer = !isMemberSession
const timer = !isDirectAgentSession
? setInterval(() => {
set((st) => ({ sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({ elapsedSeconds: sess.elapsedSeconds + 1 })) }))
}, 1000)
@@ -1574,11 +1582,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
replaceHistoryOnCompletion: false,
streamingText: '',
streamingResponseChars: 0,
statusVerb: isMemberSession ? '' : randomSpinnerVerb(),
statusVerb: isDirectAgentSession ? '' : randomSpinnerVerb(),
apiRetry: null,
streamingFallback: null,
elapsedTimer: timer,
connectionState: isMemberSession ? 'connected' : session.connectionState,
connectionState: isDirectAgentSession ? 'connected' : session.connectionState,
},
},
}
@@ -1606,6 +1614,45 @@ export const useChatStore = create<ChatStore>((set, get) => ({
return
}
if (
isSubagentSession &&
subagentTab?.sourceSessionId &&
subagentTab.subagentToolUseId
) {
void subagentsApi.sendMessage(
subagentTab.sourceSessionId,
subagentTab.subagentToolUseId,
userFacingContent,
subagentTab.subagentTaskId,
).catch((err) => {
set((s) => ({
sessions: updateSessionIn(s.sessions, sessionId, (session) => {
const messages = [...session.messages]
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index]
if (
message?.type === 'user_text' &&
message.pending === true &&
message.content === userFacingContent
) {
messages[index] = { ...message, pending: false }
break
}
}
messages.push({
id: nextId(),
type: 'error',
message: err instanceof Error ? err.message : String(err),
code: 'SUBAGENT_MESSAGE_FAILED',
timestamp: Date.now(),
})
return { chatState: 'idle', messages }
}),
}))
})
return
}
wsManager.send(sessionId, { type: 'user_message', content, attachments })
},
@@ -3160,6 +3207,9 @@ export const useChatStore = create<ChatStore>((set, get) => ({
case 'team_update':
useTeamStore.getState().handleTeamUpdate(msg.teamName, msg.members)
break
case 'team_workbench_updated':
useTeamStore.getState().handleTeamWorkbenchUpdated(msg.teamName)
break
case 'team_deleted':
useTeamStore.getState().handleTeamDeleted(msg.teamName)
break
+263 -2
View File
@@ -1,17 +1,29 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mergeMemberTranscriptDelta, useTeamStore } from './teamStore'
import { useChatStore } from './chatStore'
import { useTabStore } from './tabStore'
import type { UIMessage } from '../types/chat'
import type { TeamWorkbenchSnapshot } from '../types/team'
const { getMemberTranscriptMock } = vi.hoisted(() => ({
const {
getMemberTranscriptMock,
getWorkbenchForSessionMock,
getWorkbenchMock,
getTeamMock,
} = vi.hoisted(() => ({
getMemberTranscriptMock: vi.fn(),
getWorkbenchForSessionMock: vi.fn(),
getWorkbenchMock: vi.fn(),
getTeamMock: vi.fn(),
}))
vi.mock('../api/teams', () => ({
teamsApi: {
getMemberTranscript: getMemberTranscriptMock,
getWorkbenchForSession: getWorkbenchForSessionMock,
getWorkbench: getWorkbenchMock,
list: vi.fn(),
get: vi.fn(),
get: getTeamMock,
sendMemberMessage: vi.fn(),
delete: vi.fn(),
},
@@ -27,9 +39,39 @@ function userMessage(id: string, content: string, timestamp: number, pending = f
}
}
function workbench(version: string, taskStatus: 'pending' | 'in_progress' | 'completed'): TeamWorkbenchSnapshot {
return {
version,
generatedAt: `2026-08-08T00:00:0${version.slice(-1)}.000Z`,
team: {
name: 'team-workbench',
leadAgentId: 'lead@team-workbench',
leadSessionId: 'lead-session',
members: [
{ agentId: 'lead@team-workbench', name: 'lead', role: 'lead', status: 'running' },
{ agentId: 'worker@team-workbench', name: 'worker', role: 'worker', status: 'running' },
],
},
tasks: [{
id: '1',
subject: 'Build workbench',
description: 'Exercise the state reducer',
status: taskStatus,
owner: 'worker',
blocks: [],
blockedBy: [],
taskListId: 'team-workbench',
}],
messages: [],
}
}
describe('teamStore incremental transcript polling', () => {
beforeEach(() => {
getMemberTranscriptMock.mockReset()
getWorkbenchForSessionMock.mockReset()
getWorkbenchMock.mockReset()
getTeamMock.mockReset()
useTeamStore.getState().clearTeam()
useChatStore.setState({ sessions: {} })
})
@@ -37,6 +79,8 @@ describe('teamStore incremental transcript polling', () => {
afterEach(() => {
useTeamStore.getState().stopMemberPolling()
useTeamStore.getState().clearTeam()
useTabStore.getState().closeTab('team-member:idle-worker@team-idle')
vi.useRealTimers()
})
it('appends unseen messages once and removes a matching pending echo', () => {
@@ -157,4 +201,221 @@ describe('teamStore incremental transcript polling', () => {
await useTeamStore.getState().refreshMemberSession(sessionId)
expect(getMemberTranscriptMock.mock.calls[2]?.[2]).toEqual({})
})
it('keeps polling an idle member tab so a resumed reply appears in the same conversation', async () => {
vi.useFakeTimers()
getMemberTranscriptMock
.mockResolvedValueOnce({
messages: [{
id: 'before-resume',
type: 'assistant',
content: [{ type: 'text', text: 'Initial review complete.' }],
timestamp: '2026-01-01T00:00:01.000Z',
}],
signature: 'signature-1',
cursor: 'cursor-1',
afterOrdinal: 0,
})
.mockResolvedValueOnce({
messages: [{
id: 'after-resume',
type: 'assistant',
content: [{ type: 'text', text: 'Follow-up review complete.' }],
timestamp: '2026-01-01T00:00:02.000Z',
}],
signature: 'signature-2',
cursor: 'cursor-2',
afterOrdinal: 1,
})
const member = {
agentId: 'idle-worker@team-idle',
name: 'idle-worker',
role: 'security-reviewer',
status: 'idle' as const,
}
getTeamMock.mockResolvedValue({
name: 'team-idle',
leadAgentId: 'lead@team-idle',
members: [member],
})
await useTeamStore.getState().fetchTeamDetail('team-idle')
useTeamStore.getState().openMemberSession(member)
await vi.advanceTimersByTimeAsync(0)
expect(getMemberTranscriptMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1_500)
expect(getMemberTranscriptMock).toHaveBeenCalledTimes(2)
expect(
useChatStore.getState().sessions['team-member:idle-worker@team-idle']?.messages
.filter(message => message.type === 'assistant_text')
.map(message => message.content),
).toEqual(['Initial review complete.', 'Follow-up review complete.'])
})
it('opens an archived member once with lead identity and does not start live polling', async () => {
vi.useFakeTimers()
getMemberTranscriptMock.mockResolvedValue({
messages: [{
id: 'archived-tool-call',
type: 'assistant',
content: [{ type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'bun test' } }],
timestamp: '2026-08-08T00:00:00.000Z',
}],
signature: 'archive-signature',
cursor: 'archive-cursor',
afterOrdinal: 0,
})
const member = {
agentId: 'reviewer@archived-team',
name: 'reviewer',
role: 'security-reviewer',
status: 'completed' as const,
}
useTeamStore.setState({
activeTeam: {
name: 'archived-team',
leadAgentId: 'team-lead@archived-team',
leadSessionId: 'archived-lead-session',
members: [member],
},
})
useTeamStore.getState().openMemberSession(member)
await vi.advanceTimersByTimeAsync(0)
expect(getMemberTranscriptMock).toHaveBeenCalledWith(
'archived-team',
'reviewer@archived-team',
{ leadSessionId: 'archived-lead-session' },
)
await vi.advanceTimersByTimeAsync(2_000)
expect(getMemberTranscriptMock).toHaveBeenCalledTimes(1)
useTabStore.getState().closeTab('team-member:reviewer@archived-team')
})
})
describe('teamStore workbench timeline', () => {
beforeEach(() => {
vi.useRealTimers()
getWorkbenchForSessionMock.mockReset()
getWorkbenchMock.mockReset()
useTeamStore.getState().clearTeam()
})
afterEach(() => {
useTeamStore.getState().clearTeam()
})
it('keeps collecting live snapshots while the user remains on a historical state', async () => {
getWorkbenchMock
.mockResolvedValueOnce(workbench('v1', 'pending'))
.mockResolvedValueOnce(workbench('v2', 'in_progress'))
.mockResolvedValueOnce(workbench('v3', 'completed'))
await useTeamStore.getState().fetchWorkbench('team-workbench')
await useTeamStore.getState().fetchWorkbench('team-workbench')
useTeamStore.getState().setWorkbenchHistoryIndex('lead-session', 0)
useTeamStore.getState().handleTeamWorkbenchUpdated('team-workbench')
await vi.waitFor(() => {
expect(useTeamStore.getState().workbenchesBySession['lead-session']?.snapshots).toHaveLength(3)
})
const state = useTeamStore.getState()
expect(state.workbenchHistoryIndexBySession['lead-session']).toBe(0)
expect(state.workbenchesBySession['lead-session']?.snapshots.map((entry) => entry.version)).toEqual([
'v1',
'v2',
'v3',
])
})
it('deduplicates unchanged snapshots and appends a durable disbanded tombstone', async () => {
getWorkbenchMock
.mockResolvedValueOnce(workbench('v1', 'completed'))
.mockResolvedValueOnce(workbench('v1', 'completed'))
await useTeamStore.getState().fetchWorkbench('team-workbench')
await useTeamStore.getState().fetchWorkbench('team-workbench')
expect(useTeamStore.getState().workbenchesBySession['lead-session']?.snapshots).toHaveLength(1)
useTeamStore.getState().handleTeamDeleted('team-workbench')
const snapshots = useTeamStore.getState().workbenchesBySession['lead-session']?.snapshots ?? []
expect(snapshots).toHaveLength(2)
expect(snapshots[1]).toMatchObject({
version: 'v1:deleted',
team: {
members: [
expect.objectContaining({ status: 'completed' }),
expect.objectContaining({ status: 'completed' }),
],
},
})
expect(snapshots[1]?.deletedAt).toBeTruthy()
})
it('restores an archived workbench by lead session and opens it by default', async () => {
const archived = {
...workbench('v9', 'completed'),
deletedAt: '2026-08-08T00:10:00.000Z',
}
getWorkbenchForSessionMock.mockResolvedValue({
sessionId: 'lead-session',
teamName: 'team-workbench',
source: 'archive',
snapshots: [archived],
})
await useTeamStore.getState().fetchTeamForSession('lead-session')
const state = useTeamStore.getState()
expect(getWorkbenchForSessionMock).toHaveBeenCalledWith('lead-session')
expect(state.workbenchesBySession['lead-session']).toMatchObject({
teamName: 'team-workbench',
snapshots: [expect.objectContaining({ version: 'v9', deletedAt: archived.deletedAt })],
})
expect(state.activeTeam?.name).toBe('team-workbench')
expect(state.workbenchOpenBySession['lead-session']).toBe(true)
})
it('keeps a restored workbench available after closing and reopening it', async () => {
getWorkbenchForSessionMock.mockResolvedValue({
sessionId: 'lead-session',
teamName: 'team-workbench',
source: 'transcript',
snapshots: [workbench('v8', 'completed')],
})
await useTeamStore.getState().fetchTeamForSession('lead-session')
useTeamStore.getState().setWorkbenchOpen('lead-session', false)
expect(useTeamStore.getState().workbenchOpenBySession['lead-session']).toBe(false)
expect(useTeamStore.getState().workbenchesBySession['lead-session']?.snapshots).toHaveLength(1)
useTeamStore.getState().toggleWorkbench('lead-session')
expect(useTeamStore.getState().workbenchOpenBySession['lead-session']).toBe(true)
expect(useTeamStore.getState().workbenchesBySession['lead-session']?.snapshots[0]?.version).toBe('v8')
})
it('does not mistake the lead session for the synthetic team-lead conversation', () => {
useTeamStore.setState({
activeTeam: {
name: 'team-workbench',
leadAgentId: 'lead@team-workbench',
leadSessionId: 'lead-session',
members: [{
agentId: 'lead@team-workbench',
name: 'lead',
role: 'lead',
status: 'completed',
sessionId: 'lead-session',
}],
},
})
expect(useTeamStore.getState().getMemberBySessionId('lead-session')).toBeNull()
expect(
useTeamStore.getState().getMemberBySessionId('team-member:lead@team-workbench'),
).toMatchObject({ agentId: 'lead@team-workbench' })
})
})
+278 -42
View File
@@ -1,6 +1,13 @@
import { create } from 'zustand'
import { teamsApi } from '../api/teams'
import type { TeamSummary, TeamDetail, TeamMember, AgentColor } from '../types/team'
import type {
TeamSummary,
TeamDetail,
TeamMember,
AgentColor,
TeamWorkbenchSnapshot,
TeamWorkbenchTimeline,
} from '../types/team'
import { AGENT_COLORS } from '../types/team'
import type { TeamMemberStatus, UIMessage } from '../types/chat'
import { useChatStore, mapHistoryMessagesToUiMessages } from './chatStore'
@@ -8,6 +15,10 @@ import { useTabStore } from './tabStore'
const MEMBER_POLL_INTERVAL_MS = 1500
const MEMBER_TRANSCRIPT_MATCH_WINDOW_MS = 120_000
const WORKBENCH_HISTORY_LIMIT = 200
export const AGENT_TEAMS_WORKBENCH_DEFAULT_WIDTH = 700
export const AGENT_TEAMS_WORKBENCH_MIN_WIDTH = 440
export const AGENT_TEAMS_WORKBENCH_MAX_WIDTH = 940
/** Generate a synthetic sessionId for team member tabs */
const memberSessionId = (agentId: string) => `team-member:${agentId}`
@@ -23,6 +34,15 @@ const memberTranscriptCursors = new Map<string, {
afterOrdinal: number
}>()
const memberRefreshGenerations = new Map<string, number>()
const workbenchRefreshGenerations = new Map<string, number>()
export function clampAgentTeamsWorkbenchWidth(width: number): number {
if (!Number.isFinite(width)) return AGENT_TEAMS_WORKBENCH_DEFAULT_WIDTH
return Math.min(
AGENT_TEAMS_WORKBENCH_MAX_WIDTH,
Math.max(AGENT_TEAMS_WORKBENCH_MIN_WIDTH, Math.round(width)),
)
}
function createMemberSessionState() {
return {
@@ -58,9 +78,9 @@ function toTeamMember(raw: Record<string, unknown>): TeamMember {
agentId: (raw.agentId as string) || '',
name: raw.name as string | undefined,
role:
(raw.name as string) ||
(raw.agentType as string) ||
(raw.role as string) ||
(raw.name as string) ||
(raw.agentId as string) ||
'',
status: normalizeMemberStatus(raw.status as string | undefined),
@@ -70,6 +90,41 @@ function toTeamMember(raw: Record<string, unknown>): TeamMember {
}
}
function toTeamDetail(raw: Record<string, unknown>): TeamDetail {
const rawMembers = Array.isArray(raw.members) ? raw.members : []
return {
name: typeof raw.name === 'string' ? raw.name : '',
leadAgentId: typeof raw.leadAgentId === 'string' ? raw.leadAgentId : undefined,
leadSessionId: typeof raw.leadSessionId === 'string' ? raw.leadSessionId : undefined,
members: rawMembers.map((member) => toTeamMember(member as Record<string, unknown>)),
createdAt: raw.createdAt != null ? String(raw.createdAt) : undefined,
}
}
function memberColorsForTeam(team: TeamDetail): Map<string, AgentColor> {
const colors = new Map<string, AgentColor>()
team.members.forEach((member, index) => {
colors.set(member.agentId, AGENT_COLORS[index % AGENT_COLORS.length]!)
})
return colors
}
function appendWorkbenchSnapshot(
timeline: TeamWorkbenchTimeline | undefined,
snapshot: TeamWorkbenchSnapshot,
): TeamWorkbenchTimeline {
if (timeline?.snapshots.at(-1)?.version === snapshot.version) {
return { ...timeline, loading: false, error: null }
}
const snapshots = [...(timeline?.snapshots ?? []), snapshot].slice(-WORKBENCH_HISTORY_LIMIT)
return {
teamName: snapshot.team.name,
snapshots,
loading: false,
error: null,
}
}
function isPendingMemberMessage(message: UIMessage): message is Extract<UIMessage, { type: 'user_text' }> & { pending: true } {
return message.type === 'user_text' && message.pending === true
}
@@ -147,12 +202,22 @@ type TeamStore = {
activeTeam: TeamDetail | null
memberColors: Map<string, AgentColor>
error: string | null
workbenchesBySession: Record<string, TeamWorkbenchTimeline | undefined>
workbenchHistoryIndexBySession: Record<string, number | null | undefined>
workbenchOpenBySession: Record<string, boolean | undefined>
workbenchPanelWidth: number
fetchTeams: () => Promise<void>
fetchTeamDetail: (name: string) => Promise<void>
fetchTeamForSession: (sessionId: string) => Promise<void>
fetchWorkbench: (teamName: string) => Promise<void>
setWorkbenchHistoryIndex: (sessionId: string, index: number | null) => void
setWorkbenchOpen: (sessionId: string, open: boolean) => void
toggleWorkbench: (sessionId: string) => void
setWorkbenchPanelWidth: (width: number) => void
getMemberBySessionId: (sessionId: string) => TeamMember | null
refreshMemberSession: (sessionId: string) => Promise<void>
openMemberSession: (member: TeamMember) => void
openMemberSession: (member: TeamMember, team?: TeamDetail) => void
sendMessageToMember: (sessionId: string, content: string) => Promise<void>
startMemberPolling: (sessionId: string, force?: boolean) => void
stopMemberPolling: () => void
@@ -161,6 +226,7 @@ type TeamStore = {
// WebSocket handlers
handleTeamCreated: (teamName: string) => void
handleTeamUpdate: (teamName: string, members: TeamMemberStatus[]) => void
handleTeamWorkbenchUpdated: (teamName: string) => void
handleTeamDeleted: (teamName: string) => void
}
@@ -169,6 +235,10 @@ export const useTeamStore = create<TeamStore>((set, get) => ({
activeTeam: null,
memberColors: new Map(),
error: null,
workbenchesBySession: {},
workbenchHistoryIndexBySession: {},
workbenchOpenBySession: {},
workbenchPanelWidth: AGENT_TEAMS_WORKBENCH_DEFAULT_WIDTH,
fetchTeams: async () => {
set({ error: null })
@@ -184,32 +254,141 @@ export const useTeamStore = create<TeamStore>((set, get) => ({
set({ error: null })
try {
const raw = await teamsApi.get(name) as Record<string, unknown>
const rawMembers = Array.isArray(raw.members) ? raw.members : []
const members: TeamMember[] = rawMembers.map((m: Record<string, unknown>) => toTeamMember(m))
const detail: TeamDetail = {
name: raw.name as string,
leadAgentId: raw.leadAgentId as string | undefined,
leadSessionId: raw.leadSessionId as string | undefined,
members,
createdAt: raw.createdAt != null ? String(raw.createdAt) : undefined,
}
// Assign colors to members
const colors = new Map<string, AgentColor>()
detail.members.forEach((m, i) => {
colors.set(m.agentId, AGENT_COLORS[i % AGENT_COLORS.length]!)
})
set({ activeTeam: detail, memberColors: colors })
const detail = toTeamDetail(raw)
set({ activeTeam: detail, memberColors: memberColorsForTeam(detail) })
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err) })
}
},
fetchTeamForSession: async (sessionId) => {
const known = get().workbenchesBySession[sessionId]
if (known?.snapshots.length) return
try {
const timeline = await teamsApi.getWorkbenchForSession(sessionId)
const snapshots = timeline.snapshots.slice(-WORKBENCH_HISTORY_LIMIT)
const latest = snapshots.at(-1)
if (!latest) return
const detail = toTeamDetail(latest.team as unknown as Record<string, unknown>)
set((state) => ({
activeTeam: detail,
memberColors: memberColorsForTeam(detail),
workbenchesBySession: {
...state.workbenchesBySession,
[sessionId]: {
teamName: timeline.teamName,
snapshots,
loading: false,
error: null,
},
},
workbenchOpenBySession: {
...state.workbenchOpenBySession,
[sessionId]: state.workbenchOpenBySession[sessionId] ?? true,
},
}))
} catch {
// Workbench discovery supplements the session; an ordinary conversation
// or a legacy sidecar must still open without an error surface.
}
},
fetchWorkbench: async (teamName) => {
const generation = (workbenchRefreshGenerations.get(teamName) ?? 0) + 1
workbenchRefreshGenerations.set(teamName, generation)
const knownSessionId = Object.entries(get().workbenchesBySession)
.find(([, timeline]) => timeline?.teamName === teamName)?.[0]
if (knownSessionId) {
set((state) => ({
workbenchesBySession: {
...state.workbenchesBySession,
[knownSessionId]: {
...state.workbenchesBySession[knownSessionId]!,
loading: true,
error: null,
},
},
}))
}
try {
const raw = await teamsApi.getWorkbench(teamName)
if (workbenchRefreshGenerations.get(teamName) !== generation) return
const team = toTeamDetail(raw.team as unknown as Record<string, unknown>)
const snapshot: TeamWorkbenchSnapshot = { ...raw, team }
const sessionId = team.leadSessionId
if (!sessionId) return
set((state) => ({
activeTeam: team,
memberColors: memberColorsForTeam(team),
workbenchesBySession: {
...state.workbenchesBySession,
[sessionId]: appendWorkbenchSnapshot(
state.workbenchesBySession[sessionId],
snapshot,
),
},
workbenchOpenBySession: {
...state.workbenchOpenBySession,
[sessionId]: state.workbenchOpenBySession[sessionId] ?? true,
},
}))
} catch (err) {
if (workbenchRefreshGenerations.get(teamName) !== generation) return
const message = err instanceof Error ? err.message : String(err)
const sessionId = Object.entries(get().workbenchesBySession)
.find(([, timeline]) => timeline?.teamName === teamName)?.[0]
if (!sessionId) return
set((state) => ({
workbenchesBySession: {
...state.workbenchesBySession,
[sessionId]: {
...state.workbenchesBySession[sessionId]!,
loading: false,
error: message,
},
},
}))
}
},
setWorkbenchHistoryIndex: (sessionId, index) => set((state) => {
const snapshots = state.workbenchesBySession[sessionId]?.snapshots ?? []
const nextIndex = index === null
? null
: Math.max(0, Math.min(snapshots.length - 1, Math.round(index)))
return {
workbenchHistoryIndexBySession: {
...state.workbenchHistoryIndexBySession,
[sessionId]: nextIndex,
},
}
}),
setWorkbenchOpen: (sessionId, open) => set((state) => ({
workbenchOpenBySession: {
...state.workbenchOpenBySession,
[sessionId]: open,
},
})),
toggleWorkbench: (sessionId) => set((state) => ({
workbenchOpenBySession: {
...state.workbenchOpenBySession,
[sessionId]: !(state.workbenchOpenBySession[sessionId] ?? true),
},
})),
setWorkbenchPanelWidth: (width) => set({
workbenchPanelWidth: clampAgentTeamsWorkbenchWidth(width),
}),
getMemberBySessionId: (sessionId: string) => {
const team = get().activeTeam
if (!team) return null
return team.members.find(
(m) => m.sessionId === sessionId || memberSessionId(m.agentId) === sessionId,
) ?? null
return team.members.find((member) => memberSessionId(member.agentId) === sessionId) ?? null
},
refreshMemberSession: async (sessionId) => {
@@ -227,7 +406,10 @@ export const useTeamStore = create<TeamStore>((set, get) => ({
const response = await teamsApi.getMemberTranscript(
team.name,
member.agentId,
cursorMatchesMember ? previousCursor : {},
{
...(cursorMatchesMember ? previousCursor : {}),
...(team.leadSessionId ? { leadSessionId: team.leadSessionId } : {}),
},
)
if (memberRefreshGenerations.get(sessionId) !== generation) return
const currentTeam = get().activeTeam
@@ -275,16 +457,22 @@ export const useTeamStore = create<TeamStore>((set, get) => ({
}
},
openMemberSession: (member: TeamMember) => {
const team = get().activeTeam
openMemberSession: (member: TeamMember, requestedTeam?: TeamDetail) => {
const team = requestedTeam ?? get().activeTeam
if (!team) return
if (get().activeTeam?.name !== team.name) {
set({ activeTeam: team, memberColors: memberColorsForTeam(team) })
}
get().stopMemberPolling()
const tabId = memberSessionId(member.agentId)
useTabStore.getState().openTab(tabId, member.role, 'session')
void get().refreshMemberSession(tabId)
get().startMemberPolling(tabId)
if (member.status === 'running' || member.status === 'idle') {
get().startMemberPolling(tabId)
}
},
sendMessageToMember: async (sessionId, content) => {
@@ -303,18 +491,10 @@ export const useTeamStore = create<TeamStore>((set, get) => ({
const member = get().getMemberBySessionId(sessionId)
if (!member) return
const hasPendingMessages =
useChatStore.getState().sessions[sessionId]?.messages.some(isPendingMemberMessage) ?? false
if (!force && polledMemberSessionId === sessionId && memberPollTimer) {
return
}
if (member.status !== 'running' && !hasPendingMessages) {
get().stopMemberPolling()
return
}
get().stopMemberPolling()
polledMemberSessionId = sessionId
memberPollTimer = setInterval(() => {
@@ -339,17 +519,34 @@ export const useTeamStore = create<TeamStore>((set, get) => ({
get().stopMemberPolling()
memberTranscriptCursors.clear()
memberRefreshGenerations.clear()
set({ activeTeam: null, memberColors: new Map() })
workbenchRefreshGenerations.clear()
set({
activeTeam: null,
memberColors: new Map(),
workbenchesBySession: {},
workbenchHistoryIndexBySession: {},
workbenchOpenBySession: {},
})
},
handleTeamCreated: (teamName: string) => {
set((s) => ({
teams: [...s.teams, { name: teamName, memberCount: 0 }],
}))
get().fetchTeamDetail(teamName)
setTimeout(() => get().fetchTeamDetail(teamName), 1500)
setTimeout(() => get().fetchTeamDetail(teamName), 4000)
setTimeout(() => get().fetchTeamDetail(teamName), 8000)
void get().fetchTeamDetail(teamName)
void get().fetchWorkbench(teamName)
setTimeout(() => {
void get().fetchTeamDetail(teamName)
void get().fetchWorkbench(teamName)
}, 1500)
setTimeout(() => {
void get().fetchTeamDetail(teamName)
void get().fetchWorkbench(teamName)
}, 4000)
setTimeout(() => {
void get().fetchTeamDetail(teamName)
void get().fetchWorkbench(teamName)
}, 8000)
},
handleTeamUpdate: (teamName: string, members: TeamMemberStatus[]) => {
@@ -394,13 +591,52 @@ export const useTeamStore = create<TeamStore>((set, get) => ({
}
},
handleTeamWorkbenchUpdated: (teamName: string) => {
void get().fetchTeamDetail(teamName)
void get().fetchWorkbench(teamName)
},
handleTeamDeleted: (teamName: string) => {
get().stopMemberPolling()
memberTranscriptCursors.clear()
memberRefreshGenerations.clear()
set((s) => ({
teams: s.teams.filter((t) => t.name !== teamName),
activeTeam: s.activeTeam?.name === teamName ? null : s.activeTeam,
}))
workbenchRefreshGenerations.delete(teamName)
set((state) => {
const deletedAt = new Date().toISOString()
const workbenchesBySession = { ...state.workbenchesBySession }
for (const [sessionId, timeline] of Object.entries(workbenchesBySession)) {
if (!timeline || timeline.teamName !== teamName) continue
const latest = timeline.snapshots.at(-1)
if (!latest || latest.deletedAt) continue
const tombstone: TeamWorkbenchSnapshot = {
...latest,
version: `${latest.version}:deleted`,
generatedAt: deletedAt,
deletedAt,
team: {
...latest.team,
members: latest.team.members.map((member) => ({
...member,
status: 'completed' as const,
})),
},
}
workbenchesBySession[sessionId] = appendWorkbenchSnapshot(timeline, tombstone)
}
return {
teams: state.teams.filter((team) => team.name !== teamName),
activeTeam: state.activeTeam?.name === teamName
? {
...state.activeTeam,
members: state.activeTeam.members.map((member) => ({
...member,
status: 'completed' as const,
})),
}
: state.activeTeam,
workbenchesBySession,
}
})
},
}))
+115
View File
@@ -401,6 +401,121 @@
}
}
/* ─── Agent Teams Workbench ──────────────────────────────────────
Position changes create the walking motion. React updates one layout state
and CSS interpolates it without scheduling per-frame JS work. */
.agent-teams-bot {
transition:
left 900ms cubic-bezier(0.55, 0, 0.25, 1),
top 900ms cubic-bezier(0.55, 0, 0.25, 1),
opacity 700ms ease;
}
.agent-teams-task {
animation: agent-teams-enter 350ms ease-out both;
transition:
transform 180ms ease,
box-shadow 180ms ease;
}
.agent-teams-person {
transform-origin: 50% 84%;
transition: transform 180ms ease;
}
.agent-teams-person:hover {
transform: translateY(-2px) scale(1.035);
}
.agent-teams-person:active {
transform: translateY(0) scale(0.98);
}
.agent-teams-drawer {
animation: agent-teams-drawer-enter 250ms ease-out both;
}
.agent-teams-character {
transform-origin: 50% 84%;
}
.agent-teams-character-working {
animation: agent-teams-focus 2.4s ease-in-out infinite;
}
.agent-teams-character-idle {
animation: agent-teams-breathe 4.2s ease-in-out infinite;
}
.agent-teams-character-archived {
filter: saturate(0.84);
opacity: 0.96;
}
.agent-teams-character-message {
animation: agent-teams-message 720ms cubic-bezier(0.2, 0.9, 0.3, 1) both;
}
.agent-teams-zz {
animation: agent-teams-zz 2.2s ease-out infinite;
}
.agent-teams-flow {
animation: agent-teams-flow 700ms linear infinite;
}
@keyframes agent-teams-enter {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: none; }
}
@keyframes agent-teams-drawer-enter {
from { opacity: 0; transform: translateX(18px); }
to { opacity: 1; transform: none; }
}
@keyframes agent-teams-focus {
0%, 100% { transform: translateY(0) rotate(0); }
45% { transform: translateY(-2px) rotate(-1deg); }
70% { transform: translateY(-1px) rotate(1deg); }
}
@keyframes agent-teams-breathe {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-1px) scale(1.015); }
}
@keyframes agent-teams-message {
0% { filter: drop-shadow(0 0 0 rgba(var(--cc-ac-rgb), 0)); transform: scale(1); }
45% { filter: drop-shadow(0 0 8px rgba(var(--cc-ac-rgb), 0.55)); transform: scale(1.1) translateY(-2px); }
100% { filter: drop-shadow(0 0 0 rgba(var(--cc-ac-rgb), 0)); transform: scale(1); }
}
@keyframes agent-teams-zz {
0% { opacity: 0; transform: translate(0, 2px); }
30% { opacity: 0.9; }
100% { opacity: 0; transform: translate(7px, -11px); }
}
@keyframes agent-teams-flow {
to { stroke-dashoffset: -18; }
}
@media (prefers-reduced-motion: reduce) {
.agent-teams-bot,
.agent-teams-person,
.agent-teams-task,
.agent-teams-drawer,
.agent-teams-character-working,
.agent-teams-character-idle,
.agent-teams-character-message,
.agent-teams-zz,
.agent-teams-flow {
animation: none !important;
transition: none !important;
}
}
/* ─── Terminal ANSI palette ───────────────────────────────────────
The embedded terminal stays dark under all three themes (that is what
the per-theme `--color-terminal-*` values encode — light/white/dark
+1
View File
@@ -144,6 +144,7 @@ export type ServerMessage =
| { type: 'pong' }
| { type: 'team_update'; teamName: string; members: TeamMemberStatus[] }
| { type: 'team_created'; teamName: string }
| { type: 'team_workbench_updated'; teamName: string }
| { type: 'team_deleted'; teamName: string }
| { type: 'task_update'; taskId: string; status: string; progress?: string }
| { type: 'session_title_updated'; sessionId: string; title: string }
+50
View File
@@ -24,6 +24,56 @@ export type TeamDetail = {
createdAt?: string
}
export type TeamWorkbenchTask = {
id: string
subject: string
description: string
activeForm?: string
owner?: string
status: 'pending' | 'in_progress' | 'completed'
blocks: string[]
blockedBy: string[]
metadata?: Record<string, unknown>
taskListId: string
}
export type TeamWorkbenchMessage = {
id: string
from: string
to: string | '*'
recipients: string[]
kind: 'direct' | 'broadcast' | 'system'
text: string
summary?: string
timestamp: string
color?: string
taskId?: string
protocolType?: string
}
export type TeamWorkbenchSnapshot = {
version: string
generatedAt: string
team: TeamDetail
tasks: TeamWorkbenchTask[]
messages: TeamWorkbenchMessage[]
deletedAt?: string
}
export type TeamWorkbenchTimeline = {
teamName: string
snapshots: TeamWorkbenchSnapshot[]
loading: boolean
error: string | null
}
export type TeamWorkbenchSessionTimeline = {
sessionId: string
teamName: string
snapshots: TeamWorkbenchSnapshot[]
source: 'live' | 'archive' | 'transcript'
}
export type AgentColor = 'red' | 'blue' | 'green' | 'yellow' | 'purple' | 'orange' | 'pink' | 'cyan'
export const AGENT_COLORS: AgentColor[] = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan']
+66 -1
View File
@@ -149,7 +149,10 @@ import {
import { createAbortController } from 'src/utils/abortController.js'
import { createCombinedAbortSignal } from 'src/utils/combinedAbortSignal.js'
import { generateSessionTitle } from 'src/utils/sessionTitle.js'
import { buildSideQuestionFallbackParams } from 'src/utils/queryContext.js'
import {
buildSideQuestionFallbackParams,
resolveAgentMessageToolUseContext,
} from 'src/utils/queryContext.js'
import { runSideQuestion } from 'src/utils/sideQuestion.js'
import {
processSessionStartHooks,
@@ -187,6 +190,12 @@ import {
type PromptVariant,
} from 'src/services/PromptSuggestion/promptSuggestion.js'
import { getLastCacheSafeParams } from 'src/utils/forkedAgent.js'
import {
isLocalAgentTask,
queuePendingMessage,
} from 'src/tasks/LocalAgentTask/LocalAgentTask.js'
import { isMainSessionTask } from 'src/tasks/LocalMainSessionTask.js'
import { resumeAgentBackground } from 'src/tools/AgentTool/resumeAgent.js'
import { getAccountInformation } from 'src/utils/auth.js'
import { OAuthService } from 'src/services/oauth/index.js'
import { installOAuthTokens } from 'src/cli/handlers/auth.js'
@@ -3756,6 +3765,62 @@ function runHeadlessStreaming(
} catch (error) {
sendControlResponseError(message, errorMessage(error))
}
} else if (message.request.subtype === 'send_agent_message') {
const agentId = message.request.agent_id.trim()
const content = message.request.content.trim()
if (!agentId || !content) {
sendControlResponseError(message, 'Agent id and message content are required')
continue
}
try {
const task = getAppState().tasks[agentId]
if (
task?.status === 'running' &&
isLocalAgentTask(task) &&
!isMainSessionTask(task)
) {
queuePendingMessage(agentId, content, setAppState)
sendControlResponseSuccess(message, {
agent_id: agentId,
delivery: 'queued',
})
continue
}
const toolUseContext = await resolveAgentMessageToolUseContext(
getLastCacheSafeParams(),
() => buildSideQuestionFallbackParams({
tools: buildAllTools(getAppState()),
commands: currentCommands,
mcpClients: [
...getAppState().mcp.clients,
...sdkClients,
...dynamicMcpState.clients,
],
messages: mutableMessages,
readFileState,
getAppState,
setAppState,
customSystemPrompt: options.systemPrompt,
appendSystemPrompt: options.appendSystemPrompt,
thinkingConfig: options.thinkingConfig,
agents: currentAgents,
}),
)
await resumeAgentBackground({
agentId,
prompt: content,
toolUseContext,
canUseTool,
})
sendControlResponseSuccess(message, {
agent_id: agentId,
delivery: 'resumed',
})
} catch (error) {
sendControlResponseError(message, errorMessage(error))
}
} else if (message.request.subtype === 'generate_session_title') {
// Fire-and-forget so the Haiku call does not block the stdin loop
// (which would delay processing of subsequent user messages /
@@ -1,5 +1,8 @@
import { describe, expect, test } from 'bun:test'
import { SDKControlGetSettingsResponseSchema } from './controlSchemas.js'
import {
SDKControlGetSettingsResponseSchema,
SDKControlRequestSchema,
} from './controlSchemas.js'
describe('SDKControlGetSettingsResponseSchema effort', () => {
test('accepts every runtime named effort level including xhigh', () => {
@@ -30,3 +33,29 @@ describe('SDKControlGetSettingsResponseSchema effort', () => {
).toBe(false)
})
})
describe('SDKControlRequestSchema agent continuation', () => {
test('accepts a typed follow-up message for an existing subagent', () => {
expect(SDKControlRequestSchema().safeParse({
type: 'control_request',
request_id: 'continue-1',
request: {
subtype: 'send_agent_message',
agent_id: 'agent-123',
content: 'Continue reviewing the patch.',
},
}).success).toBe(true)
})
test('rejects an empty follow-up message', () => {
expect(SDKControlRequestSchema().safeParse({
type: 'control_request',
request_id: 'continue-2',
request: {
subtype: 'send_agent_message',
agent_id: 'agent-123',
content: '',
},
}).success).toBe(false)
})
})
+11
View File
@@ -504,6 +504,16 @@ export const SDKControlStopTaskRequestSchema = lazySchema(() =>
.describe('Stops a running task.'),
)
export const SDKControlSendAgentMessageRequestSchema = lazySchema(() =>
z
.object({
subtype: z.literal('send_agent_message'),
agent_id: z.string().min(1),
content: z.string().min(1),
})
.describe('Queues a follow-up message for an existing subagent, resuming it when necessary.'),
)
export const SDKControlApplyFlagSettingsRequestSchema = lazySchema(() =>
z
.object({
@@ -613,6 +623,7 @@ export const SDKControlRequestInnerSchema = lazySchema(() =>
SDKControlMcpReconnectRequestSchema(),
SDKControlMcpToggleRequestSchema(),
SDKControlStopTaskRequestSchema(),
SDKControlSendAgentMessageRequestSchema(),
SDKControlApplyFlagSettingsRequestSchema(),
SDKControlGetSettingsRequestSchema(),
SDKControlElicitationRequestSchema(),
+99 -1
View File
@@ -2,7 +2,7 @@
* Unit tests for SessionService and Sessions API
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'
import * as fs from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import * as path from 'node:path'
@@ -3820,6 +3820,104 @@ describe('Sessions API', () => {
expect(body.messages).toHaveLength(2)
})
it('POST /api/sessions/:id/subagents/by-tool/:toolUseId/messages should resume the resolved agent', async () => {
const sessionId = 'fefefefe-bbbb-cccc-dddd-eeeeeeeeeeee'
const agentId = 'resumable-agent-1'
await writeSessionFile('-tmp-api-subagent-message', sessionId, [
makeSnapshotEntry(),
makeAssistantToolUseEntry([{
id: 'tool-1',
name: 'Agent',
input: { description: 'Review the route', prompt: 'Inspect the server API' },
}]),
makeToolResultUserEntry('tool-1', `review complete\nagentId: ${agentId}`),
])
const requestControl = spyOn(conversationService, 'requestControl').mockResolvedValue({
agent_id: agentId,
delivery: 'resumed',
})
const hasSession = spyOn(conversationService, 'hasSession').mockReturnValue(false)
const startSession = spyOn(conversationService, 'startSession').mockResolvedValue()
try {
const res = await fetch(
`${baseUrl}/api/sessions/${sessionId}/subagents/by-tool/tool-1/messages`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ content: 'Please review the new patch.' }),
},
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
ok: true,
agent_id: agentId,
delivery: 'resumed',
})
expect(requestControl).toHaveBeenCalledWith(sessionId, {
subtype: 'send_agent_message',
agent_id: agentId,
content: 'Please review the new patch.',
})
expect(startSession).toHaveBeenCalledWith(
sessionId,
expect.any(String),
expect.stringMatching(new RegExp(
`^ws://127\\.0\\.0\\.1:${new URL(baseUrl).port}/sdk/${sessionId}\\?token=`,
)),
expect.objectContaining({ resumeInterruptedTurn: false }),
)
} finally {
startSession.mockRestore()
hasSession.mockRestore()
requestControl.mockRestore()
}
})
it('POST /api/sessions/:id/subagents/by-tool/:toolUseId/messages should reuse a running parent CLI', async () => {
const sessionId = 'abababab-bbbb-cccc-dddd-eeeeeeeeeeee'
const agentId = 'running-parent-agent'
await writeSessionFile('-tmp-api-running-parent-subagent-message', sessionId, [
makeSnapshotEntry(),
makeAssistantToolUseEntry([{
id: 'tool-running',
name: 'Agent',
input: { description: 'Continue the agent', prompt: 'Stay available' },
}]),
makeToolResultUserEntry('tool-running', `ready\nagentId: ${agentId}`),
])
const hasSession = spyOn(conversationService, 'hasSession').mockReturnValue(true)
const startSession = spyOn(conversationService, 'startSession').mockResolvedValue()
const requestControl = spyOn(conversationService, 'requestControl').mockResolvedValue({
agent_id: agentId,
delivery: 'queued',
})
try {
const res = await fetch(
`${baseUrl}/api/sessions/${sessionId}/subagents/by-tool/tool-running/messages`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ content: 'Continue from the same context.' }),
},
)
expect(res.status).toBe(200)
expect(startSession).not.toHaveBeenCalled()
expect(requestControl).toHaveBeenCalledWith(sessionId, {
subtype: 'send_agent_message',
agent_id: agentId,
content: 'Continue from the same context.',
})
} finally {
requestControl.mockRestore()
startSession.mockRestore()
hasSession.mockRestore()
}
})
it('POST /api/sessions/:id/subagents/by-tool/:toolUseId should return 405', async () => {
const res = await fetch(
`${baseUrl}/api/sessions/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/subagents/by-tool/tool-1`,
+69 -18
View File
@@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import type { ServerMessage } from '../ws/events.js'
// ============================================================================
// Test helpers
@@ -42,6 +43,12 @@ async function writeTeamConfig(
return configPath
}
async function writeJsonFile(relativePath: string, value: unknown): Promise<void> {
const filePath = path.join(tmpDir, relativePath)
await fs.mkdir(path.dirname(filePath), { recursive: true })
await fs.writeFile(filePath, JSON.stringify(value), 'utf8')
}
/** Create a standard team config for testing. */
function makeTeamConfig(overrides?: Record<string, unknown>) {
return {
@@ -217,7 +224,7 @@ describe('TeamWatcher polling', () => {
it('should detect new team creation via checkNow()', async () => {
// First poll: no teams
watcher.checkNow()
await watcher.checkNow()
// Create a team
await writeTeamConfig('new-team', makeTeamConfig({ name: 'new-team' }))
@@ -226,13 +233,13 @@ describe('TeamWatcher polling', () => {
// Since sendToSession depends on active sessions, we test that the
// internal snapshot state is updated correctly.
// After checkNow, the watcher should have recorded the team.
watcher.checkNow()
await watcher.checkNow()
// Now modify the team config and check again -- this proves the previous
// checkNow() recorded the snapshot (otherwise it would emit team_created again)
const updatedConfig = makeTeamConfig({ name: 'new-team', description: 'updated' })
await writeTeamConfig('new-team', updatedConfig)
watcher.checkNow()
await watcher.checkNow()
// If we got here without errors, the snapshot logic is working
})
@@ -242,7 +249,7 @@ describe('TeamWatcher polling', () => {
await writeTeamConfig('change-team', makeTeamConfig({ name: 'change-team' }))
// First poll picks up the team
watcher.checkNow()
await watcher.checkNow()
// Modify the config
const updatedConfig = makeTeamConfig({
@@ -252,22 +259,66 @@ describe('TeamWatcher polling', () => {
await writeTeamConfig('change-team', updatedConfig)
// Second poll should detect the change
watcher.checkNow()
await watcher.checkNow()
// No error means the diff detection worked
})
it('invalidates the joined workbench when tasks or mailboxes change', async () => {
const deliveries: Array<{ message: ServerMessage; sessionId?: string }> = []
watcher = new TeamWatcher((message, sessionId) => deliveries.push({ message, sessionId }))
await writeTeamConfig('workbench-team', makeTeamConfig({
name: 'workbench-team',
leadSessionId: 'lead-session-workbench',
}))
await watcher.checkNow()
expect(deliveries).toEqual([{
message: { type: 'team_created', teamName: 'workbench-team' },
sessionId: 'lead-session-workbench',
}])
deliveries.length = 0
await writeJsonFile('tasks/workbench-team/1.json', {
id: '1',
subject: 'Map dependencies',
status: 'in_progress',
blocks: [],
blockedBy: [],
})
await watcher.checkNow()
expect(deliveries).toEqual([{
message: { type: 'team_workbench_updated', teamName: 'workbench-team' },
sessionId: 'lead-session-workbench',
}])
deliveries.length = 0
await writeJsonFile('teams/workbench-team/inboxes/worker.json', [{
from: 'team-lead',
text: 'Dependency map is ready',
timestamp: '2026-08-08T00:00:00.000Z',
}])
await watcher.checkNow()
expect(deliveries).toEqual([{
message: { type: 'team_workbench_updated', teamName: 'workbench-team' },
sessionId: 'lead-session-workbench',
}])
deliveries.length = 0
await watcher.checkNow()
expect(deliveries).toEqual([])
})
it('should detect team deletion', async () => {
// Create a team
await writeTeamConfig('doomed-team', makeTeamConfig({ name: 'doomed-team' }))
// First poll picks it up
watcher.checkNow()
await watcher.checkNow()
// Delete the team directory
await fs.rm(path.join(tmpDir, 'teams', 'doomed-team'), { recursive: true, force: true })
// Next poll should detect deletion
watcher.checkNow()
await watcher.checkNow()
// If no error, deletion detection worked
})
@@ -276,7 +327,7 @@ describe('TeamWatcher polling', () => {
await fs.rm(path.join(tmpDir, 'teams'), { recursive: true, force: true })
// Should not throw
watcher.checkNow()
await watcher.checkNow()
})
it('should handle malformed config.json gracefully', async () => {
@@ -286,7 +337,7 @@ describe('TeamWatcher polling', () => {
await fs.writeFile(path.join(teamDir, 'config.json'), 'not valid json', 'utf-8')
// Should not throw
watcher.checkNow()
await watcher.checkNow()
})
it('should skip directories without config.json', async () => {
@@ -295,7 +346,7 @@ describe('TeamWatcher polling', () => {
await fs.mkdir(teamDir, { recursive: true })
// Should not throw
watcher.checkNow()
await watcher.checkNow()
})
it('should track multiple teams independently', async () => {
@@ -303,18 +354,18 @@ describe('TeamWatcher polling', () => {
await writeTeamConfig('team-b', makeTeamConfig({ name: 'team-b' }))
// Pick up both teams
watcher.checkNow()
await watcher.checkNow()
// Modify only team-a
await writeTeamConfig('team-a', makeTeamConfig({ name: 'team-a', description: 'changed' }))
// Should detect change in team-a but not team-b
watcher.checkNow()
await watcher.checkNow()
// Delete only team-b
await fs.rm(path.join(tmpDir, 'teams', 'team-b'), { recursive: true, force: true })
watcher.checkNow()
await watcher.checkNow()
// No errors means independent tracking works
})
@@ -348,25 +399,25 @@ describe('TeamWatcher polling', () => {
await fs.rm(path.join(tmpDir, 'teams'), { recursive: true, force: true })
// First check -- no teams dir
watcher.checkNow()
await watcher.checkNow()
// Create teams dir and a team
await fs.mkdir(path.join(tmpDir, 'teams'), { recursive: true })
await writeTeamConfig('late-team', makeTeamConfig({ name: 'late-team' }))
// Second check should pick it up
watcher.checkNow()
await watcher.checkNow()
})
it('reset() should clear internal state', async () => {
await writeTeamConfig('reset-team', makeTeamConfig({ name: 'reset-team' }))
// Pick up the team
watcher.checkNow()
await watcher.checkNow()
// Reset and check again -- should treat it as new
watcher.reset()
watcher.checkNow()
await watcher.checkNow()
// No error means reset worked
})
})
@@ -389,7 +440,7 @@ describe('TeamWatcher broadcast', () => {
// With no active WebSocket sessions, checkNow should still succeed
// (broadcast sends to zero sessions)
watcher.checkNow()
await watcher.checkNow()
watcher.stop()
watcher.reset()
+541 -5
View File
@@ -6,7 +6,11 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
import { TeamService } from '../services/teamService.js'
import {
TeamService,
projectTeamWorkbenchesFromTranscript,
} from '../services/teamService.js'
import type { MessageEntry } from '../services/sessionService.js'
import {
captureSourceFingerprint,
serializeSourceFingerprint,
@@ -54,6 +58,25 @@ async function writeTeamConfig(
return configPath
}
async function writeTeamTask(
teamName: string,
task: Record<string, unknown>,
): Promise<void> {
const taskDir = path.join(tmpDir, 'tasks', teamName)
await fs.mkdir(taskDir, { recursive: true })
await fs.writeFile(path.join(taskDir, `${task.id}.json`), JSON.stringify(task), 'utf8')
}
async function writeTeamInbox(
teamName: string,
recipient: string,
messages: Record<string, unknown>[],
): Promise<void> {
const inboxDir = path.join(tmpDir, 'teams', teamName, 'inboxes')
await fs.mkdir(inboxDir, { recursive: true })
await fs.writeFile(path.join(inboxDir, `${recipient}.json`), JSON.stringify(messages), 'utf8')
}
/** Write a mock JSONL transcript file under projects. */
async function writeTranscriptFile(
projectDir: string,
@@ -119,6 +142,35 @@ function makeTeamConfig(overrides?: Record<string, unknown>) {
}
}
function transcriptToolUse(
id: string,
name: string,
input: Record<string, unknown>,
timestamp: string,
): MessageEntry {
return {
id: `message-${id}`,
type: 'tool_use',
content: [{ type: 'tool_use', id, name, input }],
timestamp,
cwd: '/tmp/project',
}
}
function transcriptToolResult(
id: string,
toolUseResult: Record<string, unknown>,
timestamp: string,
): MessageEntry {
return {
id: `result-${id}`,
type: 'tool_result',
content: [{ type: 'tool_result', tool_use_id: id, content: 'ok' }],
toolUseResult,
timestamp,
}
}
function disabledIndexGateway(): LocalIndexGateway {
return {
async start() {},
@@ -265,6 +317,126 @@ describe('TeamService', () => {
expect(detail.members.some((member) => member.name === 'security-reviewer')).toBe(true)
})
it('aggregates every resumed transcript fragment into one member conversation', async () => {
await writeTeamConfig('resumed-team', makeTeamConfig({
name: 'resumed-team',
leadSessionId: 'lead-session-resumed',
members: [{
agentId: 'security-reviewer@resumed-team',
name: 'security-reviewer',
agentType: 'security-reviewer',
joinedAt: 1700000000000,
cwd: '/tmp/project',
isActive: false,
}],
}))
const firstPath = await writeSubagentTranscriptFile(
'-tmp-project',
'lead-session-resumed',
'agent-a.jsonl',
[{
type: 'assistant',
agentName: 'security-reviewer',
uuid: 'first-fragment',
message: { role: 'assistant', content: 'first review' },
timestamp: '2026-01-01T00:00:01.000Z',
}],
)
const secondPath = await writeSubagentTranscriptFile(
'-tmp-project',
'lead-session-resumed',
'agent-b.jsonl',
[{
type: 'assistant',
agentName: 'security-reviewer',
uuid: 'second-fragment',
message: { role: 'assistant', content: 'second review' },
timestamp: '2026-01-01T00:00:02.000Z',
}],
)
await fs.utimes(firstPath, new Date(1_000), new Date(1_000))
await fs.utimes(secondPath, new Date(2_000), new Date(2_000))
const initial = await service.getMemberTranscriptPage(
'resumed-team',
'security-reviewer@resumed-team',
)
expect(initial.messages.map(message => message.id)).toEqual([
'first-fragment',
'second-fragment',
])
await fs.appendFile(secondPath, `${JSON.stringify({
type: 'assistant',
agentName: 'security-reviewer',
uuid: 'continued-fragment',
message: { role: 'assistant', content: 'continued review' },
timestamp: '2026-01-01T00:00:03.000Z',
})}\n`)
const continued = await service.getMemberTranscriptPage(
'resumed-team',
'security-reviewer@resumed-team',
{
signature: initial.signature,
cursor: initial.cursor,
afterOrdinal: initial.afterOrdinal,
},
)
expect(continued.reset).toBeUndefined()
expect(continued.messages.map(message => message.id)).toEqual(['continued-fragment'])
})
it('does not identify a teammate transcript from another member prompt mention', async () => {
await writeTeamConfig('identity-team', makeTeamConfig({
name: 'identity-team',
leadSessionId: 'lead-session-identity',
members: [{
agentId: 'security-reviewer@identity-team',
name: 'security-reviewer',
agentType: 'security-reviewer',
joinedAt: 1700000000000,
cwd: '/tmp/project',
isActive: false,
}],
}))
await writeSubagentTranscriptFile(
'-tmp-project',
'lead-session-identity',
'agent-security.jsonl',
[{
type: 'assistant',
agentName: 'security-reviewer',
uuid: 'security-message',
message: { role: 'assistant', content: 'security review complete' },
timestamp: '2026-01-01T00:00:01.000Z',
}],
)
await writeSubagentTranscriptFile(
'-tmp-project',
'lead-session-identity',
'agent-docs.jsonl',
[{
type: 'assistant',
agentName: 'docs-coordinator',
uuid: 'docs-message',
message: {
role: 'assistant',
content: 'Summarize findings from "security-reviewer" and **security-reviewer**.',
},
timestamp: '2026-01-01T00:00:02.000Z',
}],
)
const page = await service.getMemberTranscriptPage(
'identity-team',
'security-reviewer@identity-team',
)
expect(page.messages.map((message) => message.id)).toEqual(['security-message'])
})
it('should derive running status for active member', async () => {
await writeTeamConfig('status-team', makeTeamConfig({ name: 'status-team' }))
@@ -281,6 +453,279 @@ describe('TeamService', () => {
expect(worker.status).toBe('idle')
})
it('joins team, task DAG, and mailbox history without collapsing legitimate repeats', async () => {
await writeTeamConfig('workbench-team', makeTeamConfig({
name: 'workbench-team',
leadSessionId: 'lead-session-workbench',
members: [
...(makeTeamConfig().members as Array<Record<string, unknown>>),
{
agentId: 'reviewer@workbench-team',
name: 'reviewer',
agentType: 'reviewer',
joinedAt: 1700000002000,
tmuxPaneId: '%2',
cwd: '/tmp/project',
isActive: true,
},
],
}))
await writeTeamTask('workbench-team', {
id: '1',
subject: 'Build server contract',
description: 'Join the CLI data sources',
owner: 'Worker Agent',
status: 'completed',
blocks: ['2'],
blockedBy: [],
})
await writeTeamTask('workbench-team', {
id: '2',
subject: 'Verify the desktop',
description: 'Exercise the complete workbench',
owner: 'reviewer',
status: 'in_progress',
blocks: [],
blockedBy: ['1'],
})
const repeated = {
from: 'Lead Agent',
text: 'Please review the dependency join',
timestamp: '2026-08-08T00:00:00.000Z',
}
await writeTeamInbox('workbench-team', 'Worker Agent', [
{ ...repeated, id: 'broadcast-1' },
{ ...repeated, id: 'direct-repeat-a' },
{
from: 'Bash',
text: 'Tool activity must not become a teammate',
timestamp: '2026-08-08T00:00:02.000Z',
},
{
from: 'Lead Agent',
text: 'Legacy broadcast remains readable',
timestamp: '2026-08-08T00:00:03.000Z',
},
])
await writeTeamInbox('workbench-team', 'reviewer', [
{ ...repeated, id: 'broadcast-1', timestamp: '2026-08-08T00:00:00.042Z' },
{ ...repeated, id: 'direct-repeat-b', timestamp: '2026-08-08T00:00:00.043Z' },
{
from: 'Lead Agent',
text: JSON.stringify({ type: 'task_assignment', taskId: '2', subject: 'Verify the desktop' }),
timestamp: '2026-08-08T00:00:01.000Z',
},
{
from: 'Lead Agent',
text: 'Legacy broadcast remains readable',
timestamp: '2026-08-08T00:00:03.021Z',
},
])
const snapshot = await service.getWorkbench('workbench-team')
expect(snapshot.team.leadSessionId).toBe('lead-session-workbench')
expect(snapshot.team.members.map((member) => member.name)).toEqual([
'Lead Agent',
'Worker Agent',
'reviewer',
])
expect(snapshot.tasks.map((task) => ({ id: task.id, blockedBy: task.blockedBy }))).toEqual([
{ id: '1', blockedBy: [] },
{ id: '2', blockedBy: ['1'] },
])
expect(snapshot.messages).toHaveLength(6)
expect(snapshot.messages[0]).toMatchObject({
kind: 'broadcast',
recipients: ['Worker Agent', 'reviewer'],
})
expect(snapshot.messages[1]).toMatchObject({
kind: 'direct',
recipients: ['Worker Agent'],
})
expect(snapshot.messages[2]).toMatchObject({
kind: 'direct',
recipients: ['reviewer'],
})
expect(snapshot.messages[3]).toMatchObject({
kind: 'system',
protocolType: 'task_assignment',
taskId: '2',
text: 'Verify the desktop',
})
expect(snapshot.messages[4]).toMatchObject({
kind: 'direct',
from: 'Bash',
recipients: ['Worker Agent'],
})
expect(snapshot.messages[5]).toMatchObject({
kind: 'broadcast',
text: 'Legacy broadcast remains readable',
recipients: ['Worker Agent', 'reviewer'],
})
const unchanged = await service.getWorkbench('workbench-team')
expect(unchanged.version).toBe(snapshot.version)
})
it('reopens an archived workbench by lead session after CLI team cleanup', async () => {
await writeTeamConfig('archived-team', makeTeamConfig({
name: 'archived-team',
leadSessionId: 'archived-lead-session',
}))
await writeTeamTask('archived-team', {
id: '1',
subject: 'Persist the final DAG',
description: 'Survive deletion of the transient CLI directories',
owner: 'Worker Agent',
status: 'completed',
blocks: [],
blockedBy: [],
})
const live = await service.getWorkbench('archived-team')
await fs.rm(path.join(tmpDir, 'teams', 'archived-team'), { recursive: true, force: true })
await fs.rm(path.join(tmpDir, 'tasks', 'archived-team'), { recursive: true, force: true })
const reopened = await service.getWorkbenchForSession('archived-lead-session')
expect(reopened).toMatchObject({
sessionId: 'archived-lead-session',
teamName: 'archived-team',
source: 'archive',
})
expect(reopened?.snapshots.at(-1)).toEqual(live)
})
it('opens an archived out-of-process teammate execution transcript with tool calls', async () => {
await writeTeamConfig('archived-execution-team', makeTeamConfig({
name: 'archived-execution-team',
leadSessionId: 'archived-execution-lead',
members: [{
agentId: 'reviewer@archived-execution-team',
name: 'reviewer',
agentType: 'security-reviewer',
joinedAt: 1700000001000,
cwd: '/tmp/project',
isActive: false,
}],
}))
await writeTranscriptFile('-tmp-project', 'reviewer-root-session', [
{
type: 'assistant',
uuid: 'reviewer-tool-call',
teamName: 'archived-execution-team',
agentName: 'reviewer',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'bun test' } }],
},
timestamp: '2026-08-08T00:00:01.000Z',
},
{
type: 'user',
uuid: 'reviewer-tool-result',
teamName: 'archived-execution-team',
agentName: 'reviewer',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: '16 tests passed' }],
},
timestamp: '2026-08-08T00:00:02.000Z',
},
])
await service.getWorkbench('archived-execution-team')
await fs.rm(path.join(tmpDir, 'teams', 'archived-execution-team'), { recursive: true, force: true })
const page = await service.getMemberTranscriptPage(
'archived-execution-team',
'reviewer@archived-execution-team',
{ leadSessionId: 'archived-execution-lead' },
)
expect(page.messages.map((message) => message.id)).toEqual([
'reviewer-tool-call',
'reviewer-tool-result',
])
expect(page.messages[0]?.content).toEqual([
{ type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'bun test' } },
])
})
it('reconstructs a completed multi-member DAG from an old session transcript', () => {
const messages: MessageEntry[] = [
transcriptToolUse('team-create', 'TeamCreate', {
team_name: 'legacy-team',
description: 'A durable legacy workbench',
agent_type: 'team-lead',
}, '2026-08-08T00:00:00.000Z'),
transcriptToolResult('team-create', {
team_name: 'legacy-team',
lead_agent_id: 'team-lead@legacy-team',
}, '2026-08-08T00:00:00.100Z'),
transcriptToolUse('spawn-reviewer', 'Agent', {
team_name: 'legacy-team',
name: 'reviewer',
subagent_type: 'security-reviewer',
}, '2026-08-08T00:00:01.000Z'),
transcriptToolResult('spawn-reviewer', {
agent_id: 'reviewer@legacy-team',
agent_type: 'security-reviewer',
model: 'deepseek-v4-flash',
color: 'blue',
}, '2026-08-08T00:00:01.100Z'),
transcriptToolUse('task-one', 'TaskCreate', {
subject: 'Audit the server',
description: 'Trace the contract',
}, '2026-08-08T00:00:02.000Z'),
transcriptToolResult('task-one', {
task: { id: '1', subject: 'Audit the server' },
}, '2026-08-08T00:00:02.100Z'),
transcriptToolUse('task-two', 'TaskCreate', {
subject: 'Verify the desktop',
description: 'Exercise archive reopen',
}, '2026-08-08T00:00:03.000Z'),
transcriptToolResult('task-two', {
task: { id: '2', subject: 'Verify the desktop' },
}, '2026-08-08T00:00:03.100Z'),
transcriptToolUse('task-link', 'TaskUpdate', {
taskId: '1',
owner: 'reviewer',
addBlocks: ['2'],
}, '2026-08-08T00:00:04.000Z'),
transcriptToolUse('task-list', 'TaskList', {}, '2026-08-08T00:00:05.000Z'),
transcriptToolResult('task-list', {
tasks: [
{ id: '1', subject: 'Audit the server', owner: 'reviewer', status: 'completed' },
{ id: '2', subject: 'Verify the desktop', owner: 'team-lead', status: 'completed' },
],
}, '2026-08-08T00:00:05.100Z'),
transcriptToolUse('broadcast', 'SendMessage', {
to: '*',
type: 'broadcast',
content: 'Ship the archive',
}, '2026-08-08T00:00:06.000Z'),
]
const snapshots = projectTeamWorkbenchesFromTranscript('legacy-session', messages)
expect(snapshots).toHaveLength(1)
expect(snapshots[0]).toMatchObject({
deletedAt: '2026-08-08T00:00:06.000Z',
team: {
name: 'legacy-team',
leadSessionId: 'legacy-session',
memberCount: 2,
},
tasks: [
{ id: '1', status: 'completed', blocks: ['2'] },
{ id: '2', status: 'completed', blockedBy: ['1'] },
],
messages: [{ id: 'broadcast', kind: 'broadcast', text: 'Ship the archive' }],
})
})
it('should derive running status when isActive is undefined', async () => {
const config = makeTeamConfig({ name: 'undef-team' })
// Remove isActive from the first member to simulate undefined
@@ -502,10 +947,7 @@ describe('TeamService', () => {
'middle',
'last',
])
expect(raced.messages[1]?.content).toEqual({
role: 'user',
content: 'NEW-MIDDLE',
})
expect(raced.messages[1]?.content).toBe('NEW-MIDDLE')
})
it('keeps a stable indexed append incremental after post-read snapshot verification', async () => {
@@ -924,7 +1366,9 @@ describe('TeamService', () => {
expect(messages).toHaveLength(2)
expect(messages[0]!.type).toBe('user')
expect(messages[0]!.id).toBe('msg-user-1')
expect(messages[0]!.content).toBe('Hello team')
expect(messages[1]!.type).toBe('assistant')
expect(messages[1]!.content).toEqual([{ type: 'text', text: 'Hi! Ready to help.' }])
})
it('should return empty array when member has no sessionId', async () => {
@@ -1149,6 +1593,98 @@ describe('Teams API', () => {
expect(body.members).toHaveLength(2)
})
it('GET /api/teams/:name/workbench should return the joined DAG snapshot', async () => {
await writeTeamConfig(
'api-workbench',
makeTeamConfig({ name: 'api-workbench', leadSessionId: 'api-lead-session' }),
)
await writeTeamTask('api-workbench', {
id: '7',
subject: 'Verify workbench API',
description: 'Exercise the joined contract',
status: 'in_progress',
blocks: [],
blockedBy: [],
})
const res = await fetch(`${baseUrl}/api/teams/api-workbench/workbench`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
version: string
team: { leadSessionId?: string }
tasks: Array<{ id: string; subject: string }>
messages: unknown[]
}
expect(body.version).toHaveLength(64)
expect(body.team.leadSessionId).toBe('api-lead-session')
expect(body.tasks).toEqual([expect.objectContaining({
id: '7',
subject: 'Verify workbench API',
})])
expect(body.messages).toEqual([])
})
it('GET /api/teams/session/:id/workbench should reopen a completed archive', async () => {
await writeTeamConfig('api-archive', makeTeamConfig({
name: 'api-archive',
leadSessionId: 'api-archive-session',
}))
const live = await fetch(`${baseUrl}/api/teams/api-archive/workbench`)
expect(live.status).toBe(200)
await fs.rm(path.join(tmpDir, 'teams', 'api-archive'), { recursive: true, force: true })
const response = await fetch(
`${baseUrl}/api/teams/session/api-archive-session/workbench`,
)
expect(response.status).toBe(200)
const body = (await response.json()) as {
sessionId: string
teamName: string
source: string
snapshots: Array<{ team: { name: string } }>
}
expect(body).toMatchObject({
sessionId: 'api-archive-session',
teamName: 'api-archive',
source: 'archive',
})
expect(body.snapshots.at(-1)?.team.name).toBe('api-archive')
})
it('GET archived member transcript should use the lead session archive identity', async () => {
await writeTeamConfig('api-archive-member', makeTeamConfig({
name: 'api-archive-member',
leadSessionId: 'api-archive-member-lead',
members: [{
agentId: 'worker@api-archive-member',
name: 'worker',
agentType: 'worker',
joinedAt: 1700000001000,
cwd: '/tmp/project',
isActive: false,
}],
}))
await writeTranscriptFile('-tmp-project', 'worker-root-session', [{
type: 'assistant',
uuid: 'worker-execution',
teamName: 'api-archive-member',
agentName: 'worker',
message: { role: 'assistant', content: [{ type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: 'src/index.ts' } }] },
timestamp: '2026-08-08T00:00:00.000Z',
}])
expect((await fetch(`${baseUrl}/api/teams/api-archive-member/workbench`)).status).toBe(200)
await fs.rm(path.join(tmpDir, 'teams', 'api-archive-member'), { recursive: true, force: true })
const response = await fetch(
`${baseUrl}/api/teams/api-archive-member/members/worker%40api-archive-member/transcript?incremental=true&leadSessionId=api-archive-member-lead`,
)
const body = (await response.json()) as { messages: Array<{ id: string }> }
expect(response.status).toBe(200)
expect(body.messages.map((message) => message.id)).toEqual(['worker-execution'])
})
it('GET /api/teams/:name should 404 for unknown team', async () => {
const res = await fetch(`${baseUrl}/api/teams/nonexistent`)
expect(res.status).toBe(404)
+40 -7
View File
@@ -8,6 +8,7 @@
* GET /api/sessions/:id — 获取会话详情
* GET /api/sessions/:id/messages — 获取会话消息
* GET /api/sessions/:id/subagents/by-tool/:toolUseId — 获取 SubAgent 运行详情
* POST /api/sessions/:id/subagents/by-tool/:toolUseId/messages — 继续与 SubAgent 对话
* GET /api/sessions/:id/trace — 获取会话级模型调用 trace(body preview 裁剪后的列表视图)
* GET /api/sessions/:id/trace/calls/:callId — 获取单次调用的完整 trace 记录
* GET /api/sessions/:id/turn-checkpoints — 获取按轮次保留的 checkpoint 预览
@@ -22,7 +23,11 @@ import * as path from 'node:path'
import { sessionService } from '../services/sessionService.js'
import { conversationService } from '../services/conversationService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { closeSessionConnection, getSlashCommands } from '../ws/handler.js'
import {
closeSessionConnection,
ensureCliSessionStartedForControl,
getSlashCommands,
} from '../ws/handler.js'
import { listSkillSlashCommands, type SkillSlashCommand } from './skills.js'
import { WorkspaceService } from '../services/workspaceService.js'
import {
@@ -222,13 +227,21 @@ export async function handleSessionsApi(
}
if (subResource === 'subagents') {
if (req.method !== 'GET') {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 }
const isRunRoute = segments[4] === 'by-tool' && Boolean(segments[5])
const isRunRead = isRunRoute && segments.length === 6 && req.method === 'GET'
const isRunMessage = isRunRoute && segments.length === 7 &&
segments[6] === 'messages' && req.method === 'POST'
if (!isRunRead && !isRunMessage) {
const isKnownRunResource = isRunRoute && (
segments.length === 6 ||
(segments.length === 7 && segments[6] === 'messages')
)
}
if (segments[4] !== 'by-tool' || !segments[5] || segments.length !== 6) {
if (isKnownRunResource) {
return Response.json(
{ error: 'METHOD_NOT_ALLOWED', message: `Method ${req.method} not allowed` },
{ status: 405 },
)
}
return Response.json(
{ error: 'NOT_FOUND', message: 'SubAgent route not found' },
{ status: 404 }
@@ -252,6 +265,26 @@ export async function handleSessionsApi(
if (!result) {
throw ApiError.notFound(`SubAgent run not found: ${toolUseId}`)
}
if (isRunMessage) {
let body: { content?: unknown }
try {
body = await req.json() as { content?: unknown }
} catch {
throw ApiError.badRequest('Invalid JSON body')
}
const content = typeof body.content === 'string' ? body.content.trim() : ''
if (!content) throw ApiError.badRequest('content (string) is required in request body')
if (!result.agentId) {
throw ApiError.conflict(`SubAgent run has no resumable agent id: ${toolUseId}`)
}
await ensureCliSessionStartedForControl(sessionId, url)
const response = await conversationService.requestControl(sessionId, {
subtype: 'send_agent_message',
agent_id: result.agentId,
content,
})
return Response.json({ ok: true, ...response })
}
return Response.json(result)
}
+24
View File
@@ -2,7 +2,9 @@
* Teams REST API
*
* GET /api/teams — 列出所有团队
* GET /api/teams/session/:id/workbench — 获取主会话的实时或归档工作台
* GET /api/teams/:name — 获取团队详情
* GET /api/teams/:name/workbench — 获取协作工作台只读快照
* GET /api/teams/:name/members/:id/transcript — 获取成员 transcript
* POST /api/teams/:name/members/:id/messages — 给成员发送消息
* DELETE /api/teams/:name — 删除团队
@@ -26,6 +28,21 @@ export async function handleTeamsApi(
return Response.json({ teams })
}
// ── GET /api/teams/session/:id/workbench ──────────────────────────────
if (
method === 'GET' &&
teamName === 'session' &&
segments[3] &&
segments[4] === 'workbench'
) {
const sessionId = decodeURIComponent(segments[3])
const timeline = await teamService.getWorkbenchForSession(sessionId)
if (!timeline) {
throw ApiError.notFound(`No Agent Teams workbench for session: ${sessionId}`)
}
return Response.json(timeline)
}
// ── GET /api/teams/:name/members/:id/transcript ───────────────────────
if (
method === 'GET' &&
@@ -42,6 +59,7 @@ export async function handleTeamsApi(
? undefined
: Number.parseInt(rawAfterOrdinal, 10)
const page = await teamService.getMemberTranscriptPage(teamName, agentId, {
leadSessionId: url.searchParams.get('leadSessionId') || undefined,
signature: url.searchParams.get('signature') || undefined,
cursor: url.searchParams.get('cursor') || undefined,
afterOrdinal: parsedAfterOrdinal !== undefined && Number.isSafeInteger(parsedAfterOrdinal)
@@ -54,6 +72,12 @@ export async function handleTeamsApi(
return Response.json({ messages })
}
// ── GET /api/teams/:name/workbench ────────────────────────────────────
if (method === 'GET' && teamName && segments[3] === 'workbench') {
const snapshot = await teamService.getWorkbench(teamName)
return Response.json(snapshot)
}
// ── POST /api/teams/:name/members/:id/messages ─────────────────────────
if (
method === 'POST' &&
+52
View File
@@ -91,6 +91,12 @@ export type SessionListItem = {
effortLevel?: string
}
export type SubagentTranscriptFragment = {
agentId: string
messages: MessageEntry[]
modifiedAt: number
}
export type SessionWorkspaceState = 'available' | 'worktree_removed' | 'missing'
export type SessionListShadowComparison = {
@@ -3610,6 +3616,52 @@ export class SessionService {
return this.entriesToMessages(entries)
}
async getSubagentTranscriptFragmentsByAgentType(
sessionId: string,
agentType: string,
): Promise<SubagentTranscriptFragment[]> {
const found = await this.findSessionFile(sessionId)
if (!found) {
throw ApiError.notFound(`Session not found: ${sessionId}`)
}
const subagentsDir = path.join(
this.getProjectsDir(),
found.projectDir,
sessionId,
'subagents',
)
const files = await fs.readdir(subagentsDir).catch(() => [])
const fragments: SubagentTranscriptFragment[] = []
for (const metadataFile of files.filter((file) => file.endsWith('.meta.json'))) {
try {
const metadata = JSON.parse(
await fs.readFile(path.join(subagentsDir, metadataFile), 'utf8'),
) as Record<string, unknown>
if (metadata.agentType !== agentType) continue
const transcriptFile = metadataFile.replace(/\.meta\.json$/, '.jsonl')
const transcriptPath = path.join(subagentsDir, transcriptFile)
const [entries, stat] = await Promise.all([
this.readJsonlFile(transcriptPath),
fs.stat(transcriptPath),
])
fragments.push({
agentId: transcriptFile.replace(/^agent-/, '').replace(/\.jsonl$/, ''),
messages: this.entriesToMessages(entries),
modifiedAt: stat.mtimeMs,
})
} catch {
// A partially persisted fragment must not hide the other resumable runs.
}
}
return fragments.sort((left, right) => (
left.modifiedAt - right.modifiedAt || left.agentId.localeCompare(right.agentId)
))
}
async getSessionMessagesSignature(sessionId: string): Promise<string | null> {
const found = await this.findSessionFile(sessionId)
if (!found) return null
@@ -4,6 +4,7 @@ import * as os from 'node:os'
import * as path from 'node:path'
import {
getSubagentRunByTool,
mergeTeammateTranscriptFragments,
resolveSubagentRunFromMessages,
truncateSubagentMessages,
} from './subagentRunService.js'
@@ -50,6 +51,26 @@ async function writeSubagentTranscriptFile(
)
}
async function writeSubagentMetadata(
projectDir: string,
sessionId: string,
agentId: string,
agentType: string,
modifiedAt: number,
): Promise<void> {
if (!tmpDir) throw new Error('tmpDir not initialized')
const dir = path.join(tmpDir, 'projects', projectDir, sessionId, 'subagents')
const normalizedAgentId = agentId.startsWith('agent-') ? agentId : `agent-${agentId}`
const transcriptPath = path.join(dir, `${normalizedAgentId}.jsonl`)
await fs.writeFile(
path.join(dir, `${normalizedAgentId}.meta.json`),
JSON.stringify({ agentType }),
'utf-8',
)
const modifiedDate = new Date(modifiedAt)
await fs.utimes(transcriptPath, modifiedDate, modifiedDate)
}
function makeAgentToolUseEntry(toolUseId: string): Record<string, unknown> {
return {
type: 'assistant',
@@ -121,6 +142,25 @@ function makeTaskNotificationEntry(
}
describe('subagentRunService helpers', () => {
it('deduplicates copied transcript history by upstream id while retaining legitimate repeated messages', () => {
const repeated = (id: string): MessageEntry => ({
id,
type: 'assistant',
content: 'same reply',
timestamp: '2026-01-01T00:00:02.000Z',
})
const copied = repeated('shared-message-id')
expect(mergeTeammateTranscriptFragments([
{ messages: [copied, repeated('legitimate-repeat-1')] },
{ messages: [{ ...copied }, repeated('legitimate-repeat-2')] },
])).toEqual([
copied,
repeated('legitimate-repeat-1'),
repeated('legitimate-repeat-2'),
])
})
it('resolves agentId, description, and prompt from parent Agent messages by toolUseId', () => {
const messages = [
{
@@ -365,6 +405,58 @@ describe('getSubagentRunByTool', () => {
expect(result?.messages).toHaveLength(2)
})
it('aggregates resumed named teammate fragments and returns the latest resumable transcript id', async () => {
await setupTmpConfigDir()
const sessionId = '11111111-bbbb-cccc-dddd-eeeeeeeeeeee'
const projectDir = '-tmp-subagent-run'
const toolUseId = 'tool-team-1'
await writeSessionFile(projectDir, sessionId, [
makeAgentToolUseEntry(toolUseId),
{
type: 'user',
message: {
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: toolUseId,
content: [{
type: 'text',
text: 'Spawned successfully.\nagent_id: id-worker-a@workbench-id-0808\nname: id-worker-a\nteam_name: workbench-id-0808',
}],
}],
},
uuid: 'team-agent-result',
timestamp: '2026-01-01T00:00:02.000Z',
},
])
await writeSubagentTranscriptFile(projectDir, sessionId, 'older123', [{
type: 'assistant',
message: { role: 'assistant', content: 'First teammate turn' },
uuid: 'older-message',
timestamp: '2026-01-01T00:00:03.000Z',
}])
await writeSubagentMetadata(projectDir, sessionId, 'older123', 'id-worker-a', 1_000)
await writeSubagentTranscriptFile(projectDir, sessionId, 'latest456', [{
type: 'assistant',
message: { role: 'assistant', content: 'Resumed teammate turn' },
uuid: 'latest-message',
timestamp: '2026-01-01T00:00:04.000Z',
}])
await writeSubagentMetadata(projectDir, sessionId, 'latest456', 'id-worker-a', 2_000)
const result = await getSubagentRunByTool(sessionId, toolUseId)
expect(result).toMatchObject({
agentId: 'latest456',
status: 'completed',
source: 'subagent-jsonl',
})
expect(result?.messages.map((message) => message.content)).toEqual([
'First teammate turn',
'Resumed teammate turn',
])
})
it('keeps an async launch acknowledgement running until a terminal notification arrives', async () => {
await setupTmpConfigDir()
const sessionId = 'abababab-bbbb-cccc-dddd-eeeeeeeeeeee'
+39 -8
View File
@@ -72,6 +72,26 @@ type TruncateResult = {
truncated: boolean
}
export function mergeTeammateTranscriptFragments(
fragments: Array<{ messages: MessageEntry[] }>,
): MessageEntry[] {
const seenMessageIds = new Set<string>()
const messages: MessageEntry[] = []
for (const message of fragments.flatMap((fragment) => fragment.messages)) {
if (seenMessageIds.has(message.id)) continue
seenMessageIds.add(message.id)
messages.push(message)
}
return messages.sort((left, right) => {
const leftTime = Date.parse(left.timestamp)
const rightTime = Date.parse(right.timestamp)
return (Number.isFinite(leftTime) ? leftTime : 0) -
(Number.isFinite(rightTime) ? rightTime : 0)
})
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
@@ -102,7 +122,7 @@ function textFromContent(content: unknown): string {
}
function extractAgentId(text: string): string | null {
return text.match(/(?:^|\n)\s*agentId:\s*([A-Za-z0-9_-]+)/)?.[1] ?? null
return text.match(/(?:^|\n)\s*(?:agentId|agent_id):\s*([A-Za-z0-9_@.-]+)/)?.[1] ?? null
}
function normalizeAgentIdHint(value: string | undefined): string | undefined {
@@ -119,7 +139,7 @@ function cleanedAgentResultText(text: string): string | undefined {
const cleaned = text
.replace(/<usage>[\s\S]*?<\/usage>/gi, '')
.split('\n')
.filter((line) => !/^\s*agentId:\s*[A-Za-z0-9_-]+/.test(line))
.filter((line) => !/^\s*(?:agentId|agent_id):\s*[A-Za-z0-9_@.-]+/.test(line))
.join('\n')
.trim()
@@ -326,11 +346,22 @@ export async function getSubagentRunByTool(
const notification = taskNotifications.find((candidate) => candidate.toolUseId === toolUseId)
const safeLiveTaskId = normalizeAgentIdHint(liveTaskId)
const transcript = await resolveTranscript(sessionId, [
resolution.agentId,
safeLiveTaskId,
notification?.taskId,
])
const teammateName = resolution.agentId?.includes('@')
? resolution.agentId.split('@')[0]
: undefined
const teammateFragments = teammateName
? await sessionService.getSubagentTranscriptFragmentsByAgentType(sessionId, teammateName)
: []
const transcript = teammateFragments.length > 0
? {
agentId: teammateFragments[teammateFragments.length - 1]!.agentId,
messages: mergeTeammateTranscriptFragments(teammateFragments),
}
: await resolveTranscript(sessionId, [
resolution.agentId,
safeLiveTaskId,
notification?.taskId,
])
const transcriptMessages = transcript.messages
const truncated = truncateSubagentMessages(transcriptMessages)
const transcriptUsage = usageFromTranscriptMessages(transcriptMessages)
@@ -346,7 +377,7 @@ export async function getSubagentRunByTool(
return {
sessionId,
toolUseId,
agentId: resolution.agentId ?? transcript.agentId,
agentId: transcript.agentId ?? normalizeAgentIdHint(resolution.agentId ?? undefined) ?? null,
...(notification?.taskId || safeLiveTaskId
? { taskId: notification?.taskId || safeLiveTaskId }
: {}),
File diff suppressed because it is too large Load Diff
+135 -18
View File
@@ -1,6 +1,7 @@
/**
* TeamWatcher -- monitors ~/.claude/teams/ for changes and pushes
* real-time updates to all connected WebSocket clients.
* real-time updates to the team's lead desktop session. Legacy team files
* without a leadSessionId fall back to all connected clients.
*
* Uses polling (setInterval) rather than fs.watch for cross-platform reliability.
* Detects three kinds of events:
@@ -12,8 +13,10 @@
import * as fs from 'fs'
import * as path from 'path'
import * as os from 'os'
import * as crypto from 'node:crypto'
import { sendToSession, getActiveSessionIds } from '../ws/handler.js'
import type { ServerMessage, TeamMemberStatus } from '../ws/events.js'
import { teamService, type TeamService } from './teamService.js'
// ─── Helpers ──────────────────────────────────────────────────────────────
@@ -26,14 +29,31 @@ function getTeamsDir(): string {
export class TeamWatcher {
private intervalId: ReturnType<typeof setInterval> | null = null
private lastSnapshots = new Map<string, string>() // teamName -> raw JSON content
private checkPromise: Promise<void> | null = null
private lastSnapshots = new Map<string, string>() // teamName -> raw config JSON
private lastWorkbenchFingerprints = new Map<string, string>()
private lastLeadSessionIds = new Map<string, string>()
constructor(
private readonly emit: (message: ServerMessage, leadSessionId?: string) => void = (message, leadSessionId) => {
if (leadSessionId) {
sendToSession(leadSessionId, message)
return
}
for (const id of getActiveSessionIds()) sendToSession(id, message)
},
private readonly archive: Pick<
TeamService,
'getWorkbench' | 'markWorkbenchArchiveDeleted'
> = teamService,
) {}
/** Start polling for team changes. */
start(intervalMs = 3000): void {
if (this.intervalId) return // already running
// Run an initial check immediately, then start the interval
this.check()
this.intervalId = setInterval(() => this.check(), intervalMs)
void this.scheduleCheck()
this.intervalId = setInterval(() => void this.scheduleCheck(), intervalMs)
}
/** Stop polling. */
@@ -45,18 +65,29 @@ export class TeamWatcher {
}
/** Visible for testing -- force a single poll cycle. */
checkNow(): void {
this.check()
checkNow(): Promise<void> {
return this.scheduleCheck()
}
/** Clear internal snapshot state (useful in tests). */
reset(): void {
this.lastSnapshots.clear()
this.lastWorkbenchFingerprints.clear()
this.lastLeadSessionIds.clear()
}
// ── Core polling logic ─────────────────────────────────────────────────
private check(): void {
private scheduleCheck(): Promise<void> {
if (this.checkPromise) return this.checkPromise
const current = this.check().finally(() => {
if (this.checkPromise === current) this.checkPromise = null
})
this.checkPromise = current
return current
}
private async check(): Promise<void> {
const teamsDir = getTeamsDir()
let entries: fs.Dirent[]
@@ -66,9 +97,18 @@ export class TeamWatcher {
// teams directory doesn't exist yet -- nothing to watch
// If we previously knew about teams, they are now all "deleted"
for (const [name] of this.lastSnapshots) {
this.broadcast({ type: 'team_deleted', teamName: name })
await this.archive.markWorkbenchArchiveDeleted(
name,
this.lastLeadSessionIds.get(name),
).catch(() => {})
this.broadcast(
{ type: 'team_deleted', teamName: name },
this.lastLeadSessionIds.get(name),
)
}
this.lastSnapshots.clear()
this.lastWorkbenchFingerprints.clear()
this.lastLeadSessionIds.clear()
return
}
@@ -89,43 +129,123 @@ export class TeamWatcher {
}
const lastContent = this.lastSnapshots.get(teamName)
const leadSessionId = this.readLeadSessionId(content) ?? this.lastLeadSessionIds.get(teamName)
const workbenchFingerprint = this.buildWorkbenchFingerprint(
teamsDir,
teamName,
content,
)
if (lastContent === undefined) {
// New team detected
this.lastSnapshots.set(teamName, content)
this.broadcast({ type: 'team_created', teamName })
this.lastWorkbenchFingerprints.set(teamName, workbenchFingerprint)
if (leadSessionId) this.lastLeadSessionIds.set(teamName, leadSessionId)
this.broadcast({ type: 'team_created', teamName }, leadSessionId)
await this.archive.getWorkbench(teamName).catch(() => {})
} else if (content !== lastContent) {
// Team config changed -- extract member statuses and broadcast
this.lastSnapshots.set(teamName, content)
try {
const config = JSON.parse(content)
if (leadSessionId) this.lastLeadSessionIds.set(teamName, leadSessionId)
const members = this.extractMemberStatuses(config)
// Merge inbox-discovered members that are missing from config
const inboxMembers = this.discoverInboxMembers(teamsDir, teamName, config)
const subagentMembers = this.discoverSubagentMembers(teamsDir, config)
const allMembers = [...members, ...inboxMembers, ...subagentMembers]
this.broadcast({ type: 'team_update', teamName, members: allMembers })
this.broadcast({ type: 'team_update', teamName, members: allMembers }, leadSessionId)
} catch {
// JSON parse failed (likely truncated write) — try to recover partial members
const recovered = this.recoverPartialMembers(content)
if (recovered.length > 0) {
this.broadcast({ type: 'team_update', teamName, members: recovered })
this.broadcast({ type: 'team_update', teamName, members: recovered }, leadSessionId)
}
// If nothing recoverable, skip broadcast entirely — don't send empty members
}
}
// else: content unchanged, nothing to do
if (
lastContent !== undefined &&
this.lastWorkbenchFingerprints.get(teamName) !== workbenchFingerprint
) {
this.lastWorkbenchFingerprints.set(teamName, workbenchFingerprint)
await this.archive.getWorkbench(teamName).catch(() => {})
this.broadcast({ type: 'team_workbench_updated', teamName }, leadSessionId)
}
}
// Check for deleted teams (were in lastSnapshots but no longer on disk)
for (const [name] of this.lastSnapshots) {
if (!currentTeamNames.has(name)) {
const leadSessionId = this.lastLeadSessionIds.get(name)
await this.archive.markWorkbenchArchiveDeleted(name, leadSessionId).catch(() => {})
this.lastSnapshots.delete(name)
this.broadcast({ type: 'team_deleted', teamName: name })
this.lastWorkbenchFingerprints.delete(name)
this.lastLeadSessionIds.delete(name)
this.broadcast({ type: 'team_deleted', teamName: name }, leadSessionId)
}
}
}
/**
* Config writes are only one part of Agent Teams activity. Task ownership and
* dependencies live under tasks/, while direct messages live under inboxes/.
* Hash all three read-only surfaces so the desktop can invalidate its joined
* workbench snapshot without receiving file paths or partial file contents.
*/
private buildWorkbenchFingerprint(
teamsDir: string,
teamName: string,
configContent: string,
): string {
const hash = crypto.createHash('sha256')
hash.update(configContent)
const configDir = path.dirname(teamsDir)
this.updateHashFromJsonDirectory(
hash,
path.join(configDir, 'tasks', teamName),
)
this.updateHashFromJsonDirectory(
hash,
path.join(teamsDir, teamName, 'inboxes'),
)
return hash.digest('hex')
}
private updateHashFromJsonDirectory(
hash: crypto.Hash,
directory: string,
): void {
let files: string[]
try {
files = fs.readdirSync(directory).filter((file) => file.endsWith('.json')).sort()
} catch {
hash.update('\u0000missing')
return
}
for (const file of files) {
hash.update('\u0000' + file + '\u0000')
try {
hash.update(fs.readFileSync(path.join(directory, file)))
} catch {
hash.update('unreadable')
}
}
}
private readLeadSessionId(content: string): string | undefined {
try {
const config = JSON.parse(content) as Record<string, unknown>
return typeof config.leadSessionId === 'string' && config.leadSessionId
? config.leadSessionId
: undefined
} catch {
return undefined
}
}
// ── Member status extraction ───────────────────────────────────────────
/**
@@ -324,11 +444,8 @@ export class TeamWatcher {
// ── Broadcasting ───────────────────────────────────────────────────────
private broadcast(message: ServerMessage): void {
const sessionIds = getActiveSessionIds()
for (const id of sessionIds) {
sendToSession(id, message)
}
private broadcast(message: ServerMessage, leadSessionId?: string): void {
this.emit(message, leadSessionId)
}
}
+1
View File
@@ -125,6 +125,7 @@ export type ServerMessage =
| { type: 'pong' }
| { type: 'team_update'; teamName: string; members: TeamMemberStatus[] }
| { type: 'team_created'; teamName: string }
| { type: 'team_workbench_updated'; teamName: string }
| { type: 'team_deleted'; teamName: string }
| { type: 'task_update'; taskId: string; status: string; progress?: string }
| { type: 'session_title_updated'; sessionId: string; title: string }
+49
View File
@@ -2708,6 +2708,55 @@ async function ensureCliSessionStarted(
}
}
export async function ensureCliSessionStartedForControl(
sessionId: string,
requestUrl: URL,
): Promise<void> {
const pendingStartup = sessionStartupPromises.get(sessionId)
if (pendingStartup) {
await pendingStartup
return
}
if (conversationService.hasSession(sessionId)) return
const startupRuntimeVersion = runtimeOverrideVersions.get(sessionId) ?? 0
sessionStartupRuntimeVersions.set(sessionId, startupRuntimeVersion)
const startup = (async () => {
const workDir = await resolveSessionWorkDir(sessionId)
lastResolvedStartupWorkDirs.set(sessionId, workDir)
const runtimeSettings = await getRuntimeSettings(sessionId)
const protocol = requestUrl.protocol === 'https:' ? 'wss:' : 'ws:'
const authority = requestUrl.hostname === '0.0.0.0'
? `127.0.0.1${requestUrl.port ? `:${requestUrl.port}` : ''}`
: requestUrl.host
const sdkUrl = new URL(
`${protocol}//${authority}/sdk/${encodeURIComponent(sessionId)}`,
)
sdkUrl.searchParams.set('token', crypto.randomUUID())
console.log(`[WS] Starting CLI for ${sessionId} due to agent_message`)
await conversationService.startSession(
sessionId,
workDir,
sdkUrl.toString(),
{ ...runtimeSettings, resumeInterruptedTurn: false },
)
runtimeExitStoppedSessions.delete(sessionId)
})()
sessionStartupPromises.set(sessionId, startup)
try {
await startup
} finally {
if (sessionStartupPromises.get(sessionId) === startup) {
sessionStartupPromises.delete(sessionId)
sessionStartupRuntimeVersions.delete(sessionId)
}
}
}
export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
const streamState = getStreamState(sessionId)
switch (cliMsg.type) {
+9 -3
View File
@@ -36,7 +36,7 @@ import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'
import { FORK_AGENT, isForkSubagentEnabled } from './forkSubagent.js'
import type { AgentDefinition } from './loadAgentsDir.js'
import { isBuiltInAgent } from './loadAgentsDir.js'
import { runAgent } from './runAgent.js'
import { resolvePersistedAgentType, runAgent } from './runAgent.js'
export type ResumeAgentResult = {
agentId: string
@@ -183,6 +183,10 @@ export async function resumeAgentBackground({
const workerTools = isResumedFork
? toolUseContext.options.tools
: assembleToolPool(workerPermissionContext, appState.mcp.tools)
const resumedAgentType = resolvePersistedAgentType(
meta?.agentType,
selectedAgent.agentType,
)
const runAgentParams: Parameters<typeof runAgent>[0] = {
agentDefinition: selectedAgent,
@@ -215,6 +219,8 @@ export async function resumeAgentBackground({
worktreePath: resumedWorktreePath,
description: meta?.description,
spawningToolUseId,
persistedAgentType: resumedAgentType,
alreadyPersistedMessageCount: resumedMessages.length,
contentReplacementState: resumedReplacementState,
}
@@ -233,7 +239,7 @@ export async function resumeAgentBackground({
resolvedAgentModel,
isBuiltInAgent: isBuiltInAgent(selectedAgent),
startTime,
agentType: selectedAgent.agentType,
agentType: resumedAgentType,
isAsync: true,
}
@@ -241,7 +247,7 @@ export async function resumeAgentBackground({
agentId,
parentSessionId: getParentSessionId(),
agentType: 'subagent' as const,
subagentName: selectedAgent.agentType,
subagentName: resumedAgentType,
isBuiltIn: isBuiltInAgent(selectedAgent),
invokingRequestId,
invocationKind: 'resume' as const,
+33
View File
@@ -3,14 +3,47 @@ import { getDefaultAppState } from '../../state/AppStateStore.js'
import type { ToolUseContext } from '../../Tool.js'
import { createFileStateCacheWithSizeLimit } from '../../utils/fileStateCache.js'
import { asSystemPrompt } from '../../utils/systemPromptType.js'
import { createUserMessage } from '../../utils/messages.js'
import type { CustomAgentDefinition } from './loadAgentsDir.js'
import {
resolvePersistedAgentType,
runAgent,
selectInitialTranscriptMessages,
resolveSubagentEffortValue,
resolveSubagentThinkingConfig,
} from './runAgent.js'
describe('subagent runtime configuration', () => {
test('keeps a resumed teammate identity while allowing ordinary agents to use their runtime type', () => {
expect(
resolvePersistedAgentType('id-worker-a', 'general-purpose'),
).toBe('id-worker-a')
expect(resolvePersistedAgentType(undefined, 'Explore')).toBe('Explore')
expect(resolvePersistedAgentType(' ', 'general-purpose')).toBe(
'general-purpose',
)
})
test('records only new continuation messages while keeping the persisted parent chain', () => {
const persisted = createUserMessage({ content: 'old turn' })
const continuation = createUserMessage({ content: 'new turn' })
expect(selectInitialTranscriptMessages(
[persisted, continuation],
1,
)).toEqual({
messages: [continuation],
startingParentUuid: persisted.uuid,
})
expect(selectInitialTranscriptMessages(
[persisted, continuation],
undefined,
)).toEqual({
messages: [persisted, continuation],
startingParentUuid: undefined,
})
})
test('inherits the parent thinking configuration for regular and fork agents', () => {
const disabled = { type: 'disabled' } as const
const enabled = { type: 'enabled', budgetTokens: 4096 } as const
+54 -2
View File
@@ -95,6 +95,36 @@ export function resolveSubagentEffortValue(
return agentEffort ?? parentEffort
}
export function resolvePersistedAgentType(
persistedAgentType: string | undefined,
runtimeAgentType: string,
): string {
return persistedAgentType?.trim() || runtimeAgentType
}
export function selectInitialTranscriptMessages(
messages: Message[],
alreadyPersistedMessageCount: number | undefined,
): {
messages: Message[]
startingParentUuid: UUID | null | undefined
} {
if (alreadyPersistedMessageCount === undefined) {
return { messages, startingParentUuid: undefined }
}
const boundary = Math.min(
messages.length,
Math.max(0, Math.trunc(alreadyPersistedMessageCount)),
)
return {
messages: messages.slice(boundary),
startingParentUuid: boundary > 0
? messages[boundary - 1]?.uuid ?? null
: undefined,
}
}
/**
* Initialize agent-specific MCP servers
* Agents can define their own MCP servers in their frontmatter that are additive
@@ -279,6 +309,8 @@ export async function* runAgent({
worktreePath,
description,
spawningToolUseId,
persistedAgentType,
alreadyPersistedMessageCount,
transcriptSubdir,
onQueryProgress,
}: {
@@ -336,6 +368,15 @@ export async function* runAgent({
* metadata so resume can re-attach to the original Agent card instead of
* the resuming tool's own call. */
spawningToolUseId?: string
/** Stable upstream identity for a resumed agent. A named teammate may use
* the general-purpose runtime definition after its original definition is
* no longer active, but its transcript metadata must keep the teammate name
* so the desktop can continue joining the same conversation. */
persistedAgentType?: string
/** Number of leading prompt messages already present in this agent's
* transcript. Resume sends them to the model for context but must only
* append the new continuation messages to disk. */
alreadyPersistedMessageCount?: number
/** Optional subdirectory under subagents/ to group this agent's transcript
* with related ones (e.g. workflows/<runId> for workflow subagents). */
transcriptSubdir?: string
@@ -765,11 +806,22 @@ export async function* runAgent({
// Record initial messages before the query loop starts, plus the agentType
// so resume can route correctly when subagent_type is omitted. Both writes
// are fire-and-forget — persistence failure shouldn't block the agent.
void recordSidechainTranscript(initialMessages, agentId).catch(_err =>
const initialTranscriptWrite = selectInitialTranscriptMessages(
initialMessages,
alreadyPersistedMessageCount,
)
void recordSidechainTranscript(
initialTranscriptWrite.messages,
agentId,
initialTranscriptWrite.startingParentUuid,
).catch(_err =>
logForDebugging(`Failed to record sidechain transcript: ${_err}`),
)
void writeAgentMetadata(agentId, {
agentType: agentDefinition.agentType,
agentType: resolvePersistedAgentType(
persistedAgentType,
agentDefinition.agentType,
),
...(model && { model }),
...(worktreePath && { worktreePath }),
...(description && { description }),
+5 -1
View File
@@ -33,6 +33,7 @@ import {
isTeammate,
} from '../../utils/teammate.js'
import {
createMailboxMessageId,
createShutdownApprovedMessage,
createShutdownRejectedMessage,
createShutdownRequestMessage,
@@ -217,6 +218,8 @@ async function handleBroadcast(
}
const senderColor = getTeammateColor()
const messageId = createMailboxMessageId()
const timestamp = new Date().toISOString()
const recipients: string[] = []
for (const member of teamFile.members) {
@@ -240,10 +243,11 @@ async function handleBroadcast(
await writeToMailbox(
recipientName,
{
id: messageId,
from: senderName,
text: content,
summary,
timestamp: new Date().toISOString(),
timestamp,
color: senderColor,
},
teamName,
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'bun:test'
import type { ToolUseContext } from '../Tool.js'
import type { CacheSafeParams } from './forkedAgent.js'
import { resolveAgentMessageToolUseContext } from './queryContext.js'
function cacheParams(toolUseContext: ToolUseContext): CacheSafeParams {
return {
systemPrompt: [] as unknown as CacheSafeParams['systemPrompt'],
userContext: {},
systemContext: {},
toolUseContext,
forkContextMessages: [],
}
}
describe('resolveAgentMessageToolUseContext', () => {
it('reuses the live parent context without rebuilding it', async () => {
const liveContext = { marker: 'live' } as unknown as ToolUseContext
let fallbackCalls = 0
const resolved = await resolveAgentMessageToolUseContext(
cacheParams(liveContext),
async () => {
fallbackCalls += 1
return cacheParams({ marker: 'fallback' } as unknown as ToolUseContext)
},
)
expect(resolved).toBe(liveContext)
expect(fallbackCalls).toBe(0)
})
it('rebuilds the parent context after a completed session is restarted', async () => {
const rebuiltContext = { marker: 'rebuilt' } as unknown as ToolUseContext
const resolved = await resolveAgentMessageToolUseContext(
null,
async () => cacheParams(rebuiltContext),
)
expect(resolved).toBe(rebuiltContext)
})
})
+7
View File
@@ -27,6 +27,13 @@ import {
type ThinkingConfig,
} from './thinking.js'
export async function resolveAgentMessageToolUseContext(
saved: CacheSafeParams | null,
buildFallback: () => Promise<CacheSafeParams>,
): Promise<ToolUseContext> {
return (saved ?? await buildFallback()).toolUseContext
}
/**
* Fetch the three context pieces that form the API cache-key prefix:
* systemPrompt parts, userContext, systemContext.
+2
View File
@@ -257,6 +257,8 @@ export type TeammateSpawnResult = {
* Message to send to a teammate.
*/
export type TeammateMessage = {
/** Stable envelope identity shared by every copy of a broadcast. */
id?: string
/** Message content */
text: string
/** Sender agent ID */
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test'
import {
getTrustedShutdownApproval,
createMailboxMessage,
isPermissionResponse,
isSandboxPermissionResponse,
isTrustedTeamLeaderMessage,
@@ -9,6 +10,30 @@ import {
} from './teammateMailbox.js'
describe('mailbox protocol validation', () => {
test('creates a stable identity for new envelopes and preserves broadcast identities', () => {
const first = createMailboxMessage({
from: 'team-lead',
text: 'same content',
timestamp: '2026-08-08T00:00:00.000Z',
})
const second = createMailboxMessage({
from: 'team-lead',
text: 'same content',
timestamp: '2026-08-08T00:00:00.000Z',
})
const broadcastCopy = createMailboxMessage({
id: first.id,
from: 'team-lead',
text: 'same content',
timestamp: '2026-08-08T00:00:00.000Z',
})
expect(first.id).toBeTruthy()
expect(second.id).not.toBe(first.id)
expect(broadcastCopy.id).toBe(first.id)
expect(broadcastCopy.read).toBe(false)
})
test('uses the mailbox envelope as the team-leader identity', () => {
expect(
isTrustedTeamLeaderMessage({
+18 -4
View File
@@ -9,6 +9,7 @@
import { mkdir, readFile, writeFile } from 'fs/promises'
import { join } from 'path'
import { randomUUID } from 'node:crypto'
import { z } from 'zod/v4'
import { TEAMMATE_MESSAGE_TAG } from '../constants/xml.js'
import { PermissionModeSchema } from '../entrypoints/sdk/coreSchemas.js'
@@ -53,6 +54,8 @@ const LOCK_OPTIONS = {
}
export type TeammateMessage = {
/** Stable envelope identity. Older mailbox records may not have one. */
id?: string
from: string
text: string
timestamp: string
@@ -61,6 +64,20 @@ export type TeammateMessage = {
summary?: string // 5-10 word summary shown as preview in the UI
}
export function createMailboxMessageId(): string {
return `mailbox-${randomUUID()}`
}
export function createMailboxMessage(
message: Omit<TeammateMessage, 'read'>,
): TeammateMessage {
return {
...message,
id: message.id ?? createMailboxMessageId(),
read: false,
}
}
export function isTrustedTeamLeaderMessage(
message: TeammateMessage,
): boolean {
@@ -188,10 +205,7 @@ export async function writeToMailbox(
// Re-read messages after acquiring lock to get the latest state
const messages = await readMailbox(recipientName, teamName)
const newMessage: TeammateMessage = {
...message,
read: false,
}
const newMessage = createMailboxMessage(message)
messages.push(newMessage)