feat(agent-teams): add collaborative workbench
@@ -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 },
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'アクティビティ',
|
||||
|
||||
@@ -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': '활동',
|
||||
|
||||
@@ -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': '活動',
|
||||
|
||||
@@ -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': '活动',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||