feat: 添加 Windows Terminal swarm 后端及 swarm 增强

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
unraid
2026-04-22 22:38:09 +08:00
parent c4775fff58
commit 59f8675fa3
17 changed files with 1298 additions and 86 deletions

View File

@@ -0,0 +1,279 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
let terminateCalls: string[] = []
mock.module('src/utils/swarm/backends/registry.js', () => {
const executor = {
type: 'in-process' as const,
setContext() {},
async isAvailable() {
return true
},
async spawn(config: { name: string; teamName: string; color?: string }) {
return {
success: true,
agentId: `${config.name}@${config.teamName}`,
taskId: `task-${config.name}`,
backendType: 'in-process',
color: config.color,
isSplitPane: false,
}
},
async sendMessage() {},
async terminate(agentId: string) {
terminateCalls.push(agentId)
return true
},
async kill() {
return true
},
async isActive() {
return true
},
}
return {
getTeammateExecutor: async () => executor,
getInProcessBackend: () => executor,
detectAndGetBackend: async () => ({
backend: { type: 'in-process' },
isNative: false,
needsIt2Setup: false,
}),
isInProcessEnabled: () => true,
markInProcessFallback: () => {},
resetBackendDetection: () => {},
getCachedBackend: () => null,
getCachedDetectionResult: () => null,
getResolvedTeammateMode: () => 'in-process',
ensureBackendsRegistered: async () => {},
getBackendByType: () => ({
type: 'tmux',
killPane: async () => true,
}),
}
})
let tempHome: string
let previousConfigDir: string | undefined
let previousAnthropicApiKey: string | undefined
let state: any
function setState(updater: (prev: any) => any): void {
state = updater(state)
}
function readTeamConfig(teamName: string): any {
return JSON.parse(
readFileSync(join(tempHome, 'teams', teamName, 'config.json'), 'utf-8'),
)
}
function writeTeamConfig(teamName: string, config: unknown): void {
const teamDir = join(tempHome, 'teams', teamName)
mkdirSync(teamDir, { recursive: true })
writeFileSync(join(teamDir, 'config.json'), JSON.stringify(config, null, 2))
}
beforeEach(() => {
terminateCalls = []
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY
tempHome = join(
tmpdir(),
`agent-teams-lifecycle-${Date.now()}-${Math.random().toString(16).slice(2)}`,
)
process.env.CLAUDE_CONFIG_DIR = tempHome
process.env.ANTHROPIC_API_KEY = 'test-key'
state = {
teamContext: undefined,
tasks: {},
inbox: { messages: [] },
toolPermissionContext: {
mode: 'default',
alwaysAllowRules: {},
alwaysDenyRules: {},
additionalWorkingDirectories: new Map(),
},
mainLoopModel: null,
mainLoopModelForSession: null,
agentNameRegistry: new Map(),
mcp: { tools: [] },
}
})
afterEach(() => {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
if (previousAnthropicApiKey === undefined) {
delete process.env.ANTHROPIC_API_KEY
} else {
process.env.ANTHROPIC_API_KEY = previousAnthropicApiKey
}
rmSync(tempHome, { recursive: true, force: true })
})
describe('Agent Teams lifecycle', () => {
test('runs TeamCreate -> spawn -> TaskUpdate -> SendMessage -> TeamDelete', async () => {
const { TeamCreateTool } = await import(
'@claude-code-best/builtin-tools/tools/TeamCreateTool/TeamCreateTool.js'
)
const { spawnTeammate } = await import(
'@claude-code-best/builtin-tools/tools/shared/spawnMultiAgent.js'
)
const { TaskCreateTool } = await import(
'@claude-code-best/builtin-tools/tools/TaskCreateTool/TaskCreateTool.js'
)
const { TaskUpdateTool } = await import(
'@claude-code-best/builtin-tools/tools/TaskUpdateTool/TaskUpdateTool.js'
)
const { SendMessageTool } = await import(
'@claude-code-best/builtin-tools/tools/SendMessageTool/SendMessageTool.js'
)
const { TeamDeleteTool } = await import(
'@claude-code-best/builtin-tools/tools/TeamDeleteTool/TeamDeleteTool.js'
)
const context = {
getAppState: () => state,
setAppState: setState,
options: {
agentDefinitions: { activeAgents: [] },
},
abortController: new AbortController(),
} as any
const created = await TeamCreateTool.call(
{ team_name: 'alpha', description: 'test team' },
context,
undefined as any,
undefined as any,
)
expect(created.data.team_name).toBe('alpha')
const spawned = await spawnTeammate(
{
name: 'worker',
prompt: 'handle assigned tasks',
team_name: 'alpha',
},
context,
)
expect(spawned.data.agent_id).toBe('worker@alpha')
const task = await TaskCreateTool.call(
{ subject: 'Check lifecycle', description: 'Verify team task flow' },
context,
)
await TaskUpdateTool.call(
{ taskId: task.data.task.id, owner: 'worker' },
context,
)
const message = await SendMessageTool.call(
{
to: 'worker',
summary: 'Status request',
message: 'Please report status.',
},
context,
async () => ({ behavior: 'allow' as const }),
undefined as any,
)
expect(message.data.success).toBe(true)
const blockedDelete = await TeamDeleteTool.call(
{},
context,
undefined as any,
undefined as any,
)
expect(blockedDelete.data.success).toBe(false)
expect(terminateCalls).toEqual(['worker@alpha'])
const config = readTeamConfig('alpha')
config.members = config.members.map((member: any) =>
member.name === 'worker' ? { ...member, isActive: false } : member,
)
writeTeamConfig('alpha', config)
const deleted = await TeamDeleteTool.call(
{},
context,
undefined as any,
undefined as any,
)
expect(deleted.data.success).toBe(true)
})
test('TeamDelete waits for active teammates to become inactive before cleanup', async () => {
const { TeamDeleteTool } = await import(
'@claude-code-best/builtin-tools/tools/TeamDeleteTool/TeamDeleteTool.js'
)
const now = Date.now()
writeTeamConfig('alpha', {
name: 'alpha',
createdAt: now,
leadAgentId: 'team-lead@alpha',
members: [
{
agentId: 'team-lead@alpha',
name: 'team-lead',
joinedAt: now,
tmuxPaneId: '',
cwd: tempHome,
subscriptions: [],
},
{
agentId: 'worker@alpha',
name: 'worker',
joinedAt: now,
tmuxPaneId: 'in-process',
cwd: tempHome,
subscriptions: [],
backendType: 'in-process',
},
],
})
state.teamContext = {
teamName: 'alpha',
teamFilePath: join(tempHome, 'teams', 'alpha', 'config.json'),
leadAgentId: 'team-lead@alpha',
teammates: {
'worker@alpha': {
name: 'worker',
tmuxSessionName: 'in-process',
tmuxPaneId: 'in-process',
cwd: tempHome,
spawnedAt: now,
},
},
}
setTimeout(() => {
const config = readTeamConfig('alpha')
config.members = config.members.map((member: any) =>
member.name === 'worker' ? { ...member, isActive: false } : member,
)
writeTeamConfig('alpha', config)
}, 25)
const result = await TeamDeleteTool.call(
{ wait_ms: 1000 },
{
getAppState: () => state,
setAppState: setState,
} as any,
undefined as any,
undefined as any,
)
expect(result.data.success).toBe(true)
})
})

View File

@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { getDefaultAppState } from '../../../state/AppStateStore'
import { readMailbox, writeToMailbox } from '../../teammateMailbox'
import {
killInProcessTeammateByAgentId,
spawnInProcessTeammate,
} from '../spawnInProcess'
let tempHome: string
let previousConfigDir: string | undefined
beforeEach(() => {
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
tempHome = join(
tmpdir(),
`spawn-in-process-${Date.now()}-${Math.random().toString(16).slice(2)}`,
)
process.env.CLAUDE_CONFIG_DIR = tempHome
})
afterEach(() => {
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
rmSync(tempHome, { recursive: true, force: true })
})
describe('killInProcessTeammateByAgentId', () => {
test('registers a real in-process teammate task and mailbox', async () => {
let state = getDefaultAppState() as any
const result = await spawnInProcessTeammate(
{
name: 'worker',
teamName: 'alpha',
prompt: 'smoke test task',
color: 'blue',
planModeRequired: false,
},
{
setAppState(updater) {
state = updater(state)
},
toolUseId: 'toolu_smoke',
},
)
expect(result.success).toBe(true)
expect(result.agentId).toBe('worker@alpha')
expect(result.taskId).toBeString()
expect(state.tasks[result.taskId!].type).toBe('in_process_teammate')
expect(state.tasks[result.taskId!].identity.agentId).toBe('worker@alpha')
expect(state.tasks[result.taskId!].messages).toEqual([])
await writeToMailbox(
'worker',
{
from: 'team-lead',
text: 'mailbox smoke',
timestamp: new Date(0).toISOString(),
},
'alpha',
)
const messages = await readMailbox('worker', 'alpha')
expect(messages).toHaveLength(1)
expect(messages[0]!.text).toBe('mailbox smoke')
expect(messages[0]!.read).toBe(false)
})
test('aborts the running teammate and removes it from team context by agent id', () => {
const abortController = new AbortController()
let state: any = {
teamContext: {
teamName: 'alpha',
teammates: {
'worker@alpha': {
name: 'worker',
},
},
},
tasks: {
teammate_task_1: {
id: 'teammate_task_1',
type: 'in_process_teammate',
status: 'running',
identity: {
agentId: 'worker@alpha',
agentName: 'worker',
teamName: 'alpha',
planModeRequired: false,
parentSessionId: 'session',
},
abortController,
pendingUserMessages: [],
onIdleCallbacks: [],
messages: [],
},
},
}
const killed = killInProcessTeammateByAgentId('worker@alpha', updater => {
state = updater(state)
})
expect(killed).toBe(true)
expect(abortController.signal.aborted).toBe(true)
expect(state.tasks.teammate_task_1.status).toBe('killed')
expect(state.teamContext.teammates['worker@alpha']).toBeUndefined()
})
})

View File

@@ -0,0 +1,10 @@
import { describe, expect, test } from 'bun:test'
import { buildInheritedCliFlags } from '../spawnUtils'
describe('buildInheritedCliFlags', () => {
test('propagates auto permission mode to process-based teammates', () => {
const flags = buildInheritedCliFlags({ permissionMode: 'auto' })
expect(flags).toContain('--permission-mode auto')
})
})