mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 23:05:51 +00:00
feat: 添加 Windows Terminal swarm 后端及 swarm 增强
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
279
src/utils/swarm/__tests__/agentTeamsLifecycle.test.ts
Normal file
279
src/utils/swarm/__tests__/agentTeamsLifecycle.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
115
src/utils/swarm/__tests__/spawnInProcess.test.ts
Normal file
115
src/utils/swarm/__tests__/spawnInProcess.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
10
src/utils/swarm/__tests__/spawnUtils.test.ts
Normal file
10
src/utils/swarm/__tests__/spawnUtils.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user