mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat: add CLI subprocess chat, Agent Teams API, and real background tasks
Three new modules completing the server-side functionality: 1. ConversationService: spawns CLI as subprocess with --input-format stream-json, forwards WebSocket messages to CLI stdin/stdout. CLI handles all AI communication, tool execution, and permissions. Graceful fallback to echo mode when CLI unavailable. 2. Agent Teams API: GET/DELETE /api/teams, member listing with status derivation (running/idle/completed), transcript reading from CLI-generated JSONL files. Teams created by CLI, API is read-only. 3. TaskService: replaces placeholder with real task file scanning from ~/.claude/tasks/, supports nested team directories. 229 tests passing (49 new: 12 conversations + 27 teams + 10 tasks). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f1c310a004
commit
29c9fb8f4a
257
src/server/__tests__/conversations.test.ts
Normal file
257
src/server/__tests__/conversations.test.ts
Normal file
@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Tests for ConversationService and WebSocket chat integration
|
||||
*
|
||||
* ConversationService 管理 CLI 子进程的生命周期。
|
||||
* WebSocket 集成测试验证消息从客户端经过服务端到达 CLI 的完整流转。
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { ConversationService } from '../services/conversationService.js'
|
||||
|
||||
// ============================================================================
|
||||
// ConversationService unit tests
|
||||
// ============================================================================
|
||||
|
||||
describe('ConversationService', () => {
|
||||
it('should report no session for unknown ID', () => {
|
||||
const svc = new ConversationService()
|
||||
const sid = crypto.randomUUID()
|
||||
expect(svc.hasSession(sid)).toBe(false)
|
||||
})
|
||||
|
||||
it('should track active sessions as empty initially', () => {
|
||||
const svc = new ConversationService()
|
||||
expect(svc.getActiveSessions()).toEqual([])
|
||||
})
|
||||
|
||||
it('should return false when sending message to non-existent session', () => {
|
||||
const svc = new ConversationService()
|
||||
const result = svc.sendMessage('no-such-session', 'hello')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when responding to permission for non-existent session', () => {
|
||||
const svc = new ConversationService()
|
||||
const result = svc.respondToPermission('no-such-session', 'req-1', true)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when sending interrupt to non-existent session', () => {
|
||||
const svc = new ConversationService()
|
||||
const result = svc.sendInterrupt('no-such-session')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should not throw when stopping non-existent session', () => {
|
||||
const svc = new ConversationService()
|
||||
expect(() => svc.stopSession('no-such-session')).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when registering callback for non-existent session', () => {
|
||||
const svc = new ConversationService()
|
||||
expect(() => svc.onOutput('no-such-session', () => {})).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket integration tests (with real server, CLI falls back to echo)
|
||||
// ============================================================================
|
||||
|
||||
describe('WebSocket Chat Integration', () => {
|
||||
let server: ReturnType<typeof Bun.serve>
|
||||
let baseUrl: string
|
||||
let wsUrl: string
|
||||
let tmpDir: string
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-conv-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
|
||||
|
||||
const port = 15000 + Math.floor(Math.random() * 1000)
|
||||
const { startServer } = await import('../index.js')
|
||||
server = startServer(port, '127.0.0.1')
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
wsUrl = `ws://127.0.0.1:${port}`
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
server?.stop()
|
||||
if (tmpDir) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
})
|
||||
|
||||
it('should connect and receive connected event', async () => {
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/chat-test-1`)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.onmessage = (e) => {
|
||||
messages.push(JSON.parse(e.data as string))
|
||||
if (messages.length >= 1) {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
setTimeout(() => {
|
||||
ws.close()
|
||||
resolve()
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
expect(messages[0].type).toBe('connected')
|
||||
expect(messages[0].sessionId).toBe('chat-test-1')
|
||||
})
|
||||
|
||||
it('should handle stop_generation and return idle status', async () => {
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/chat-test-2`)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data as string)
|
||||
messages.push(msg)
|
||||
if (msg.type === 'connected') {
|
||||
ws.send(JSON.stringify({ type: 'stop_generation' }))
|
||||
}
|
||||
if (msg.type === 'status' && msg.state === 'idle') {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
setTimeout(() => {
|
||||
ws.close()
|
||||
resolve()
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
expect(messages.some((m) => m.type === 'status' && m.state === 'idle')).toBe(true)
|
||||
})
|
||||
|
||||
it('should send user_message and receive fallback echo response', async () => {
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/chat-test-3`)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data as string)
|
||||
messages.push(msg)
|
||||
if (msg.type === 'connected') {
|
||||
ws.send(
|
||||
JSON.stringify({ type: 'user_message', content: 'Hello from test' })
|
||||
)
|
||||
}
|
||||
// Wait until we receive idle status after the echo
|
||||
if (
|
||||
msg.type === 'status' &&
|
||||
msg.state === 'idle' &&
|
||||
messages.length > 3
|
||||
) {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
setTimeout(() => {
|
||||
ws.close()
|
||||
resolve()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
const types = messages.map((m) => m.type)
|
||||
expect(types).toContain('connected')
|
||||
expect(types).toContain('status')
|
||||
// Fallback echo produces content_start, content_delta, message_complete, status
|
||||
expect(types).toContain('content_start')
|
||||
expect(types).toContain('content_delta')
|
||||
expect(types).toContain('message_complete')
|
||||
|
||||
// Verify thinking was first status
|
||||
const statusMsgs = messages.filter((m) => m.type === 'status')
|
||||
expect(statusMsgs[0].state).toBe('thinking')
|
||||
})
|
||||
|
||||
it('should handle permission_response without error', async () => {
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/chat-test-4`)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data as string)
|
||||
messages.push(msg)
|
||||
if (msg.type === 'connected') {
|
||||
// Send a permission response (no active session, should not crash)
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'permission_response',
|
||||
requestId: 'test-req-1',
|
||||
allowed: true,
|
||||
})
|
||||
)
|
||||
// Give a moment then close
|
||||
setTimeout(() => {
|
||||
ws.close()
|
||||
resolve()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
setTimeout(() => {
|
||||
ws.close()
|
||||
resolve()
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
// Should have received connected and no error
|
||||
expect(messages[0].type).toBe('connected')
|
||||
expect(messages.some((m) => m.type === 'error')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle ping/pong', async () => {
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/chat-test-5`)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data as string)
|
||||
messages.push(msg)
|
||||
if (msg.type === 'connected') {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
}
|
||||
if (msg.type === 'pong') {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
setTimeout(() => {
|
||||
ws.close()
|
||||
resolve()
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
expect(messages.some((m) => m.type === 'pong')).toBe(true)
|
||||
})
|
||||
})
|
||||
171
src/server/__tests__/tasks.test.ts
Normal file
171
src/server/__tests__/tasks.test.ts
Normal file
@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Unit tests for TaskService and Tasks API
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { TaskService } from '../services/taskService.js'
|
||||
|
||||
// ============================================================================
|
||||
// TaskService unit tests
|
||||
// ============================================================================
|
||||
|
||||
describe('TaskService', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-tasks-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
})
|
||||
|
||||
it('should return empty list when no tasks dir', async () => {
|
||||
const svc = new TaskService()
|
||||
const tasks = await svc.listTasks()
|
||||
expect(tasks).toEqual([])
|
||||
})
|
||||
|
||||
it('should list tasks from JSON files', async () => {
|
||||
const tasksDir = path.join(tmpDir, 'tasks')
|
||||
await fs.mkdir(tasksDir, { recursive: true })
|
||||
|
||||
await fs.writeFile(path.join(tasksDir, 'task-001.json'), JSON.stringify({
|
||||
id: 'task-001',
|
||||
type: 'local_agent',
|
||||
status: 'completed',
|
||||
name: 'code-review',
|
||||
description: 'Review PR #42',
|
||||
createdAt: Date.now() - 60000,
|
||||
completedAt: Date.now(),
|
||||
}))
|
||||
|
||||
await fs.writeFile(path.join(tasksDir, 'task-002.json'), JSON.stringify({
|
||||
id: 'task-002',
|
||||
type: 'in_process_teammate',
|
||||
status: 'running',
|
||||
name: 'frontend-dev',
|
||||
teamName: 'ui-team',
|
||||
createdAt: Date.now(),
|
||||
}))
|
||||
|
||||
const svc = new TaskService()
|
||||
const tasks = await svc.listTasks()
|
||||
expect(tasks.length).toBe(2)
|
||||
// 按 createdAt 倒序
|
||||
expect(tasks[0].id).toBe('task-002')
|
||||
expect(tasks[1].id).toBe('task-001')
|
||||
})
|
||||
|
||||
it('should scan nested team task directories', async () => {
|
||||
const teamDir = path.join(tmpDir, 'tasks', 'my-team')
|
||||
await fs.mkdir(teamDir, { recursive: true })
|
||||
|
||||
await fs.writeFile(path.join(teamDir, 'member-1.json'), JSON.stringify({
|
||||
id: 'member-1',
|
||||
type: 'in_process_teammate',
|
||||
status: 'completed',
|
||||
teamName: 'my-team',
|
||||
}))
|
||||
|
||||
const svc = new TaskService()
|
||||
const tasks = await svc.listTasks()
|
||||
expect(tasks.length).toBe(1)
|
||||
expect(tasks[0].teamName).toBe('my-team')
|
||||
})
|
||||
|
||||
it('should get single task by ID', async () => {
|
||||
const tasksDir = path.join(tmpDir, 'tasks')
|
||||
await fs.mkdir(tasksDir, { recursive: true })
|
||||
|
||||
await fs.writeFile(path.join(tasksDir, 'abc.json'), JSON.stringify({
|
||||
id: 'abc',
|
||||
type: 'local_shell',
|
||||
status: 'failed',
|
||||
name: 'build',
|
||||
}))
|
||||
|
||||
const svc = new TaskService()
|
||||
const task = await svc.getTask('abc')
|
||||
expect(task).toBeDefined()
|
||||
expect(task!.status).toBe('failed')
|
||||
})
|
||||
|
||||
it('should return null for unknown task', async () => {
|
||||
const svc = new TaskService()
|
||||
const task = await svc.getTask('nonexistent')
|
||||
expect(task).toBeNull()
|
||||
})
|
||||
|
||||
it('should skip invalid JSON files gracefully', async () => {
|
||||
const tasksDir = path.join(tmpDir, 'tasks')
|
||||
await fs.mkdir(tasksDir, { recursive: true })
|
||||
await fs.writeFile(path.join(tasksDir, 'bad.json'), 'not json {{{')
|
||||
|
||||
const svc = new TaskService()
|
||||
const tasks = await svc.listTasks()
|
||||
expect(tasks).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Tasks API integration tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Tasks API', () => {
|
||||
let server: any
|
||||
let baseUrl: string
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-tasks-api-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
|
||||
|
||||
const port = 15500 + Math.floor(Math.random() * 500)
|
||||
const { startServer } = await import('../../server/index.js')
|
||||
server = startServer(port, '127.0.0.1')
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
server?.stop()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
})
|
||||
|
||||
it('should return empty tasks list', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/tasks`)
|
||||
const data = await res.json()
|
||||
expect(res.status).toBe(200)
|
||||
expect(data.tasks).toEqual([])
|
||||
})
|
||||
|
||||
it('should return tasks when files exist', async () => {
|
||||
const tasksDir = path.join(tmpDir, 'tasks')
|
||||
await fs.mkdir(tasksDir, { recursive: true })
|
||||
await fs.writeFile(path.join(tasksDir, 'test.json'), JSON.stringify({
|
||||
id: 'test', type: 'local_agent', status: 'completed', name: 'test-task',
|
||||
}))
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/tasks`)
|
||||
const data = await res.json()
|
||||
expect(data.tasks.length).toBe(1)
|
||||
expect(data.tasks[0].name).toBe('test-task')
|
||||
})
|
||||
|
||||
it('should return 404 for unknown task', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/tasks/nonexistent`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('should reject non-GET methods', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/tasks`, { method: 'POST' })
|
||||
expect(res.status).toBe(405)
|
||||
})
|
||||
})
|
||||
484
src/server/__tests__/teams.test.ts
Normal file
484
src/server/__tests__/teams.test.ts
Normal file
@ -0,0 +1,484 @@
|
||||
/**
|
||||
* Unit tests for TeamService and Teams API
|
||||
*/
|
||||
|
||||
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'
|
||||
|
||||
// ============================================================================
|
||||
// Test helpers
|
||||
// ============================================================================
|
||||
|
||||
let tmpDir: string
|
||||
let service: TeamService
|
||||
|
||||
async function setupTmpConfigDir(): Promise<string> {
|
||||
tmpDir = path.join(
|
||||
os.tmpdir(),
|
||||
`claude-teams-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
)
|
||||
await fs.mkdir(path.join(tmpDir, 'teams'), { recursive: true })
|
||||
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
async function cleanupTmpDir(): Promise<void> {
|
||||
if (tmpDir) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
|
||||
/** Write a team config.json to the temp directory. */
|
||||
async function writeTeamConfig(
|
||||
teamName: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const teamDir = path.join(tmpDir, 'teams', teamName)
|
||||
await fs.mkdir(teamDir, { recursive: true })
|
||||
const configPath = path.join(teamDir, 'config.json')
|
||||
await fs.writeFile(configPath, JSON.stringify(config), 'utf-8')
|
||||
return configPath
|
||||
}
|
||||
|
||||
/** Write a mock JSONL transcript file under projects. */
|
||||
async function writeTranscriptFile(
|
||||
projectDir: string,
|
||||
sessionId: string,
|
||||
entries: Record<string, unknown>[],
|
||||
): Promise<string> {
|
||||
const dir = path.join(tmpDir, 'projects', projectDir)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
const filePath = path.join(dir, `${sessionId}.jsonl`)
|
||||
const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n'
|
||||
await fs.writeFile(filePath, content, 'utf-8')
|
||||
return filePath
|
||||
}
|
||||
|
||||
/** Create a standard team config for testing. */
|
||||
function makeTeamConfig(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
name: 'test-team',
|
||||
description: 'A test team',
|
||||
createdAt: 1700000000000,
|
||||
leadAgentId: 'agent-lead',
|
||||
members: [
|
||||
{
|
||||
agentId: 'agent-lead',
|
||||
name: 'Lead Agent',
|
||||
agentType: 'lead',
|
||||
model: 'claude-opus-4-6',
|
||||
color: '#ff0000',
|
||||
joinedAt: 1700000000000,
|
||||
tmuxPaneId: '%0',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-lead-001',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
agentId: 'agent-worker',
|
||||
name: 'Worker Agent',
|
||||
agentType: 'worker',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
color: '#00ff00',
|
||||
joinedAt: 1700000001000,
|
||||
tmuxPaneId: '%1',
|
||||
cwd: '/tmp/project/src',
|
||||
sessionId: 'session-worker-001',
|
||||
isActive: false,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TeamService tests
|
||||
// ============================================================================
|
||||
|
||||
describe('TeamService', () => {
|
||||
beforeEach(async () => {
|
||||
await setupTmpConfigDir()
|
||||
service = new TeamService()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTmpDir()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// listTeams
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('should return empty list when no teams exist', async () => {
|
||||
const teams = await service.listTeams()
|
||||
expect(teams).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty list when teams directory does not exist', async () => {
|
||||
await fs.rm(path.join(tmpDir, 'teams'), { recursive: true, force: true })
|
||||
const teams = await service.listTeams()
|
||||
expect(teams).toEqual([])
|
||||
})
|
||||
|
||||
it('should list teams from config files', async () => {
|
||||
await writeTeamConfig('alpha', makeTeamConfig({ name: 'alpha' }))
|
||||
await writeTeamConfig('beta', makeTeamConfig({ name: 'beta', description: 'Beta team' }))
|
||||
|
||||
const teams = await service.listTeams()
|
||||
expect(teams).toHaveLength(2)
|
||||
|
||||
const names = teams.map((t) => t.name).sort()
|
||||
expect(names).toEqual(['alpha', 'beta'])
|
||||
})
|
||||
|
||||
it('should compute memberCount and activeMemberCount', async () => {
|
||||
await writeTeamConfig('gamma', makeTeamConfig({ name: 'gamma' }))
|
||||
|
||||
const teams = await service.listTeams()
|
||||
expect(teams).toHaveLength(1)
|
||||
expect(teams[0]!.memberCount).toBe(2)
|
||||
expect(teams[0]!.activeMemberCount).toBe(1) // only lead is active
|
||||
})
|
||||
|
||||
it('should skip malformed team directories', async () => {
|
||||
// Create a team dir with invalid JSON
|
||||
const badDir = path.join(tmpDir, 'teams', 'bad-team')
|
||||
await fs.mkdir(badDir, { recursive: true })
|
||||
await fs.writeFile(path.join(badDir, 'config.json'), 'not json', 'utf-8')
|
||||
|
||||
// Also create a valid team
|
||||
await writeTeamConfig('good-team', makeTeamConfig({ name: 'good-team' }))
|
||||
|
||||
const teams = await service.listTeams()
|
||||
expect(teams).toHaveLength(1)
|
||||
expect(teams[0]!.name).toBe('good-team')
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// getTeam
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('should return team detail with members', async () => {
|
||||
await writeTeamConfig('detail-team', makeTeamConfig({ name: 'detail-team' }))
|
||||
|
||||
const detail = await service.getTeam('detail-team')
|
||||
expect(detail.name).toBe('detail-team')
|
||||
expect(detail.leadAgentId).toBe('agent-lead')
|
||||
expect(detail.members).toHaveLength(2)
|
||||
expect(detail.members[0]!.agentId).toBe('agent-lead')
|
||||
expect(detail.members[1]!.agentId).toBe('agent-worker')
|
||||
})
|
||||
|
||||
it('should derive running status for active member', async () => {
|
||||
await writeTeamConfig('status-team', makeTeamConfig({ name: 'status-team' }))
|
||||
|
||||
const detail = await service.getTeam('status-team')
|
||||
const lead = detail.members.find((m) => m.agentId === 'agent-lead')!
|
||||
expect(lead.status).toBe('running')
|
||||
})
|
||||
|
||||
it('should derive idle status for inactive member', async () => {
|
||||
await writeTeamConfig('status-team', makeTeamConfig({ name: 'status-team' }))
|
||||
|
||||
const detail = await service.getTeam('status-team')
|
||||
const worker = detail.members.find((m) => m.agentId === 'agent-worker')!
|
||||
expect(worker.status).toBe('idle')
|
||||
})
|
||||
|
||||
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
|
||||
delete (config.members[0] as Record<string, unknown>).isActive
|
||||
await writeTeamConfig('undef-team', config)
|
||||
|
||||
const detail = await service.getTeam('undef-team')
|
||||
const lead = detail.members.find((m) => m.agentId === 'agent-lead')!
|
||||
expect(lead.status).toBe('running')
|
||||
})
|
||||
|
||||
it('should throw 404 for non-existent team', async () => {
|
||||
expect(service.getTeam('nonexistent')).rejects.toThrow('Team not found')
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// getMemberTranscript
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('should return transcript messages for a member', async () => {
|
||||
await writeTeamConfig('transcript-team', makeTeamConfig({ name: 'transcript-team' }))
|
||||
|
||||
// Write a mock transcript JSONL for the lead session
|
||||
await writeTranscriptFile('-tmp-project', 'session-lead-001', [
|
||||
{
|
||||
type: 'file-history-snapshot',
|
||||
messageId: 'snap-1',
|
||||
snapshot: {},
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'msg-user-1',
|
||||
message: { role: 'user', content: 'Hello team' },
|
||||
timestamp: '2026-01-01T00:01:00.000Z',
|
||||
},
|
||||
{
|
||||
type: 'assistant',
|
||||
uuid: 'msg-asst-1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Hi! Ready to help.' }],
|
||||
},
|
||||
timestamp: '2026-01-01T00:02:00.000Z',
|
||||
},
|
||||
])
|
||||
|
||||
const messages = await service.getMemberTranscript(
|
||||
'transcript-team',
|
||||
'agent-lead',
|
||||
)
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(messages[0]!.type).toBe('user')
|
||||
expect(messages[0]!.id).toBe('msg-user-1')
|
||||
expect(messages[1]!.type).toBe('assistant')
|
||||
})
|
||||
|
||||
it('should return empty array when member has no sessionId', async () => {
|
||||
const config = makeTeamConfig({ name: 'no-session-team' })
|
||||
delete (config.members[0] as Record<string, unknown>).sessionId
|
||||
await writeTeamConfig('no-session-team', config)
|
||||
|
||||
const messages = await service.getMemberTranscript(
|
||||
'no-session-team',
|
||||
'agent-lead',
|
||||
)
|
||||
expect(messages).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array when transcript file not found', async () => {
|
||||
await writeTeamConfig('no-file-team', makeTeamConfig({ name: 'no-file-team' }))
|
||||
|
||||
// Don't write any transcript file
|
||||
const messages = await service.getMemberTranscript(
|
||||
'no-file-team',
|
||||
'agent-lead',
|
||||
)
|
||||
expect(messages).toEqual([])
|
||||
})
|
||||
|
||||
it('should throw 404 for unknown member', async () => {
|
||||
await writeTeamConfig('member-team', makeTeamConfig({ name: 'member-team' }))
|
||||
|
||||
expect(
|
||||
service.getMemberTranscript('member-team', 'nonexistent-agent'),
|
||||
).rejects.toThrow('Member not found')
|
||||
})
|
||||
|
||||
it('should skip meta entries in transcript', async () => {
|
||||
await writeTeamConfig('meta-team', makeTeamConfig({ name: 'meta-team' }))
|
||||
|
||||
await writeTranscriptFile('-tmp-project', 'session-lead-001', [
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'msg-meta',
|
||||
message: { role: 'user', content: 'internal meta' },
|
||||
isMeta: true,
|
||||
timestamp: '2026-01-01T00:00:30.000Z',
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'msg-real',
|
||||
message: { role: 'user', content: 'Real message' },
|
||||
timestamp: '2026-01-01T00:01:00.000Z',
|
||||
},
|
||||
])
|
||||
|
||||
const messages = await service.getMemberTranscript('meta-team', 'agent-lead')
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]!.id).toBe('msg-real')
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// deleteTeam
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
it('should delete a team with no active members', async () => {
|
||||
const config = makeTeamConfig({ name: 'deletable' })
|
||||
// Set all members to inactive
|
||||
for (const member of config.members) {
|
||||
;(member as Record<string, unknown>).isActive = false
|
||||
}
|
||||
await writeTeamConfig('deletable', config)
|
||||
|
||||
await service.deleteTeam('deletable')
|
||||
|
||||
// Team dir should be gone
|
||||
const teamDir = path.join(tmpDir, 'teams', 'deletable')
|
||||
expect(fs.access(teamDir)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should refuse to delete a team with active members', async () => {
|
||||
await writeTeamConfig('active-team', makeTeamConfig({ name: 'active-team' }))
|
||||
|
||||
expect(service.deleteTeam('active-team')).rejects.toThrow(
|
||||
'has active members',
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw 404 when deleting non-existent team', async () => {
|
||||
expect(service.deleteTeam('ghost')).rejects.toThrow('Team not found')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Teams API integration tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Teams API', () => {
|
||||
let baseUrl: string
|
||||
let server: ReturnType<typeof Bun.serve> | null = null
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupTmpConfigDir()
|
||||
service = new TeamService()
|
||||
|
||||
const { handleTeamsApi } = await import('../api/teams.js')
|
||||
|
||||
const port = 40000 + Math.floor(Math.random() * 10000)
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
|
||||
server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
const segments = url.pathname.split('/').filter(Boolean)
|
||||
|
||||
if (segments[0] === 'api' && segments[1] === 'teams') {
|
||||
return handleTeamsApi(req, url, segments)
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
server.stop(true)
|
||||
server = null
|
||||
}
|
||||
await cleanupTmpDir()
|
||||
})
|
||||
|
||||
it('GET /api/teams should return empty list', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/teams`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as { teams: unknown[] }
|
||||
expect(body.teams).toEqual([])
|
||||
})
|
||||
|
||||
it('GET /api/teams should list teams', async () => {
|
||||
await writeTeamConfig('api-team', makeTeamConfig({ name: 'api-team' }))
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/teams`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as { teams: Array<{ name: string }> }
|
||||
expect(body.teams).toHaveLength(1)
|
||||
expect(body.teams[0]!.name).toBe('api-team')
|
||||
})
|
||||
|
||||
it('GET /api/teams/:name should return team detail', async () => {
|
||||
await writeTeamConfig('detail', makeTeamConfig({ name: 'detail' }))
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/teams/detail`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as {
|
||||
name: string
|
||||
leadAgentId: string
|
||||
members: Array<{ agentId: string }>
|
||||
}
|
||||
expect(body.name).toBe('detail')
|
||||
expect(body.leadAgentId).toBe('agent-lead')
|
||||
expect(body.members).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('GET /api/teams/:name should 404 for unknown team', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/teams/nonexistent`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('GET /api/teams/:name/members/:id/transcript should return messages', async () => {
|
||||
await writeTeamConfig('t-team', makeTeamConfig({ name: 't-team' }))
|
||||
|
||||
await writeTranscriptFile('-tmp-project', 'session-lead-001', [
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'u1',
|
||||
message: { role: 'user', content: 'Hello' },
|
||||
timestamp: '2026-01-01T00:01:00.000Z',
|
||||
},
|
||||
])
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/teams/t-team/members/agent-lead/transcript`,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as {
|
||||
messages: Array<{ id: string; type: string }>
|
||||
}
|
||||
expect(body.messages).toHaveLength(1)
|
||||
expect(body.messages[0]!.type).toBe('user')
|
||||
})
|
||||
|
||||
it('GET /api/teams/:name/members/:id/transcript should 404 for unknown member', async () => {
|
||||
await writeTeamConfig('t2-team', makeTeamConfig({ name: 't2-team' }))
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/teams/t2-team/members/unknown-agent/transcript`,
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('DELETE /api/teams/:name should delete team', async () => {
|
||||
const config = makeTeamConfig({ name: 'del-team' })
|
||||
for (const member of (config as { members: Array<Record<string, unknown>> }).members) {
|
||||
member.isActive = false
|
||||
}
|
||||
await writeTeamConfig('del-team', config)
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/teams/del-team`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as { ok: boolean }
|
||||
expect(body.ok).toBe(true)
|
||||
|
||||
// Verify it's gone
|
||||
const res2 = await fetch(`${baseUrl}/api/teams/del-team`)
|
||||
expect(res2.status).toBe(404)
|
||||
})
|
||||
|
||||
it('DELETE /api/teams/:name should 409 when team has active members', async () => {
|
||||
await writeTeamConfig('active', makeTeamConfig({ name: 'active' }))
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/teams/active`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
expect(res.status).toBe(409)
|
||||
})
|
||||
|
||||
it('POST /api/teams should return 405', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/teams`, { method: 'POST' })
|
||||
expect(res.status).toBe(405)
|
||||
})
|
||||
})
|
||||
@ -7,11 +7,12 @@
|
||||
* PUT /api/agents/:name — 更新 Agent
|
||||
* DELETE /api/agents/:name — 删除 Agent
|
||||
*
|
||||
* GET /api/tasks — 获取后台任务列表 (placeholder)
|
||||
* GET /api/tasks/:id — 获取任务详情 (placeholder)
|
||||
* GET /api/tasks — 获取后台任务列表
|
||||
* GET /api/tasks/:id — 获取任务详情
|
||||
*/
|
||||
|
||||
import { AgentService } from '../services/agentService.js'
|
||||
import { taskService } from '../services/taskService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
const agentService = new AgentService()
|
||||
@ -25,7 +26,7 @@ export async function handleAgentsApi(
|
||||
const resource = segments[1] // 'agents' | 'tasks'
|
||||
|
||||
if (resource === 'tasks') {
|
||||
return await handleTasksPlaceholder(req, segments)
|
||||
return await handleTasksApi(req, segments)
|
||||
}
|
||||
|
||||
return await handleAgents(req, segments)
|
||||
@ -96,36 +97,34 @@ async function handleAgents(
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tasks placeholder ──────────────────────────────────────────────────────
|
||||
// ─── Tasks API ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleTasksPlaceholder(
|
||||
async function handleTasksApi(
|
||||
req: Request,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
const method = req.method
|
||||
const taskId = segments[2]
|
||||
|
||||
if (method !== 'GET') {
|
||||
if (req.method !== 'GET') {
|
||||
throw new ApiError(
|
||||
405,
|
||||
`Method ${method} not allowed on /api/tasks`,
|
||||
`Method ${req.method} not allowed on /api/tasks`,
|
||||
'METHOD_NOT_ALLOWED',
|
||||
)
|
||||
}
|
||||
|
||||
// GET /api/tasks/:id
|
||||
const taskId = segments[2]
|
||||
|
||||
if (taskId) {
|
||||
return Response.json({
|
||||
task: {
|
||||
id: taskId,
|
||||
status: 'pending',
|
||||
message: 'Background tasks are not yet implemented',
|
||||
},
|
||||
})
|
||||
// GET /api/tasks/:id
|
||||
const task = await taskService.getTask(taskId)
|
||||
if (!task) {
|
||||
throw ApiError.notFound(`Task not found: ${taskId}`)
|
||||
}
|
||||
return Response.json({ task })
|
||||
}
|
||||
|
||||
// GET /api/tasks
|
||||
return Response.json({ tasks: [] })
|
||||
const tasks = await taskService.listTasks()
|
||||
return Response.json({ tasks })
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
61
src/server/api/teams.ts
Normal file
61
src/server/api/teams.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Teams REST API
|
||||
*
|
||||
* GET /api/teams — 列出所有团队
|
||||
* GET /api/teams/:name — 获取团队详情
|
||||
* GET /api/teams/:name/members/:id/transcript — 获取成员 transcript
|
||||
* DELETE /api/teams/:name — 删除团队
|
||||
*/
|
||||
|
||||
import { teamService } from '../services/teamService.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
export async function handleTeamsApi(
|
||||
req: Request,
|
||||
_url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const method = req.method
|
||||
const teamName = segments[2] ? decodeURIComponent(segments[2]) : undefined
|
||||
|
||||
// ── GET /api/teams ────────────────────────────────────────────────────
|
||||
if (method === 'GET' && !teamName) {
|
||||
const teams = await teamService.listTeams()
|
||||
return Response.json({ teams })
|
||||
}
|
||||
|
||||
// ── GET /api/teams/:name/members/:id/transcript ───────────────────────
|
||||
if (
|
||||
method === 'GET' &&
|
||||
teamName &&
|
||||
segments[3] === 'members' &&
|
||||
segments[4] &&
|
||||
segments[5] === 'transcript'
|
||||
) {
|
||||
const agentId = decodeURIComponent(segments[4])
|
||||
const messages = await teamService.getMemberTranscript(teamName, agentId)
|
||||
return Response.json({ messages })
|
||||
}
|
||||
|
||||
// ── GET /api/teams/:name ──────────────────────────────────────────────
|
||||
if (method === 'GET' && teamName) {
|
||||
const team = await teamService.getTeam(teamName)
|
||||
return Response.json(team)
|
||||
}
|
||||
|
||||
// ── DELETE /api/teams/:name ───────────────────────────────────────────
|
||||
if (method === 'DELETE' && teamName) {
|
||||
await teamService.deleteTeam(teamName)
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
throw new ApiError(
|
||||
405,
|
||||
`Method ${method} not allowed on /api/teams${teamName ? `/${teamName}` : ''}`,
|
||||
'METHOD_NOT_ALLOWED',
|
||||
)
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ import { handleSearchApi } from './api/search.js'
|
||||
import { handleAgentsApi } from './api/agents.js'
|
||||
import { handleStatusApi } from './api/status.js'
|
||||
import { handleConversationsApi } from './api/conversations.js'
|
||||
import { handleTeamsApi } from './api/teams.js'
|
||||
|
||||
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
|
||||
const path = url.pathname
|
||||
@ -54,6 +55,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'status':
|
||||
return handleStatusApi(req, url, segments)
|
||||
|
||||
case 'teams':
|
||||
return handleTeamsApi(req, url, segments)
|
||||
|
||||
default:
|
||||
return Response.json(
|
||||
{ error: 'Not Found', message: `Unknown API resource: ${resource}` },
|
||||
|
||||
254
src/server/services/conversationService.ts
Normal file
254
src/server/services/conversationService.ts
Normal file
@ -0,0 +1,254 @@
|
||||
/**
|
||||
* ConversationService — CLI subprocess manager
|
||||
*
|
||||
* 每个会话(sessionId)对应一个 CLI 子进程,通过 stdin/stdout
|
||||
* 以 stream-json (NDJSON) 格式进行双向通信。
|
||||
*
|
||||
* 服务器仅做消息转发:
|
||||
* 用户 WebSocket → stdin (user message)
|
||||
* stdout (assistant message) → WebSocket
|
||||
*/
|
||||
|
||||
import * as path from 'path'
|
||||
|
||||
type SessionProcess = {
|
||||
proc: ReturnType<typeof Bun.spawn>
|
||||
outputCallbacks: Array<(msg: any) => void>
|
||||
workDir: string
|
||||
}
|
||||
|
||||
export class ConversationService {
|
||||
private sessions = new Map<string, SessionProcess>()
|
||||
|
||||
/**
|
||||
* 启动 CLI 子进程用于对话。
|
||||
* 如果该 sessionId 已有活跃进程,直接返回。
|
||||
*/
|
||||
async startSession(sessionId: string, workDir: string): Promise<void> {
|
||||
if (this.sessions.has(sessionId)) return
|
||||
|
||||
// 找到 CLI 入口点
|
||||
const cliPath = path.resolve(import.meta.dir, '../../entrypoints/cli.tsx')
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
'bun',
|
||||
cliPath,
|
||||
'--print',
|
||||
'--verbose',
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--session-id',
|
||||
sessionId,
|
||||
],
|
||||
{
|
||||
cwd: workDir,
|
||||
env: { ...process.env },
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
}
|
||||
)
|
||||
|
||||
const session: SessionProcess = {
|
||||
proc,
|
||||
outputCallbacks: [],
|
||||
workDir,
|
||||
}
|
||||
this.sessions.set(sessionId, session)
|
||||
|
||||
// 读取 stdout(NDJSON 格式,每行一个 JSON 对象)
|
||||
this.readOutputStream(sessionId, proc)
|
||||
|
||||
// 读取 stderr 用于调试
|
||||
this.readErrorStream(sessionId, proc)
|
||||
|
||||
// 进程退出时清理
|
||||
proc.exited.then((code) => {
|
||||
console.log(
|
||||
`[ConversationService] CLI process for ${sessionId} exited with code ${code}`
|
||||
)
|
||||
this.sessions.delete(sessionId)
|
||||
})
|
||||
|
||||
// 等待一小段时间,检测进程是否立即崩溃退出。
|
||||
// 使用 Promise.race 直接竞争 proc.exited,比检查 sessions map 更可靠。
|
||||
const STARTUP_GRACE_MS = 1000
|
||||
const earlyExitCode = await Promise.race([
|
||||
proc.exited,
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), STARTUP_GRACE_MS)),
|
||||
])
|
||||
|
||||
if (earlyExitCode !== null) {
|
||||
// 进程在启动窗口内退出了,确保清理并抛出错误
|
||||
this.sessions.delete(sessionId)
|
||||
throw new Error(
|
||||
`CLI process for ${sessionId} exited immediately with code ${earlyExitCode}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async readOutputStream(
|
||||
sessionId: string,
|
||||
proc: ReturnType<typeof Bun.spawn>
|
||||
): Promise<void> {
|
||||
if (!proc.stdout) return
|
||||
|
||||
const reader = (proc.stdout as ReadableStream).getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || '' // 保留最后不完整的行
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const msg = JSON.parse(line)
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session) {
|
||||
for (const cb of session.outputCallbacks) {
|
||||
cb(msg)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 行忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[ConversationService] stdout read error for ${sessionId}:`,
|
||||
err
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async readErrorStream(
|
||||
sessionId: string,
|
||||
proc: ReturnType<typeof Bun.spawn>
|
||||
): Promise<void> {
|
||||
if (!proc.stderr) return
|
||||
|
||||
const reader = (proc.stderr as ReadableStream).getReader()
|
||||
const decoder = new TextDecoder()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const text = decoder.decode(value, { stream: true })
|
||||
if (text.trim()) {
|
||||
console.error(`[CLI:${sessionId}] ${text.trim()}`)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// stderr 读取错误不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 stdout 消息回调。CLI 进程每输出一行 JSON,
|
||||
* 所有已注册的回调都会被调用。
|
||||
*/
|
||||
onOutput(sessionId: string, callback: (msg: any) => void): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session) {
|
||||
session.outputCallbacks.push(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送用户消息到 CLI stdin。
|
||||
* 消息格式遵循 stream-json 协议。
|
||||
*/
|
||||
sendMessage(sessionId: string, content: string): boolean {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.proc.stdin) return false
|
||||
|
||||
const msg =
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: content }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
session_id: '',
|
||||
}) + '\n'
|
||||
|
||||
session.proc.stdin.write(msg)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 回复权限请求(用户在 UI 中点击允许/拒绝)。
|
||||
*/
|
||||
respondToPermission(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
allowed: boolean
|
||||
): boolean {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.proc.stdin) return false
|
||||
|
||||
const response =
|
||||
JSON.stringify({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: allowed
|
||||
? { behavior: 'allow' }
|
||||
: { behavior: 'deny', message: 'User denied via UI' },
|
||||
},
|
||||
}) + '\n'
|
||||
|
||||
session.proc.stdin.write(response)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送中断信号,停止当前生成。
|
||||
*/
|
||||
sendInterrupt(sessionId: string): boolean {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.proc.stdin) return false
|
||||
|
||||
const request =
|
||||
JSON.stringify({
|
||||
type: 'control_request',
|
||||
request_id: crypto.randomUUID(),
|
||||
request: { subtype: 'interrupt' },
|
||||
}) + '\n'
|
||||
|
||||
session.proc.stdin.write(request)
|
||||
return true
|
||||
}
|
||||
|
||||
hasSession(sessionId: string): boolean {
|
||||
return this.sessions.has(sessionId)
|
||||
}
|
||||
|
||||
stopSession(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (session) {
|
||||
session.proc.kill()
|
||||
this.sessions.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
getActiveSessions(): string[] {
|
||||
return Array.from(this.sessions.keys())
|
||||
}
|
||||
}
|
||||
|
||||
// 导出全局单例
|
||||
export const conversationService = new ConversationService()
|
||||
99
src/server/services/taskService.ts
Normal file
99
src/server/services/taskService.ts
Normal file
@ -0,0 +1,99 @@
|
||||
/**
|
||||
* TaskService — 后台任务的读取与查询
|
||||
*
|
||||
* 任务信息存储在 ~/.claude/tasks/ 目录下,每个任务一个 JSON 文件。
|
||||
* 支持嵌套子目录(例如按 team/project 分组)。
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
|
||||
export type TaskInfo = {
|
||||
id: string
|
||||
type: 'local_shell' | 'local_agent' | 'in_process_teammate' | 'remote_agent' | 'workflow'
|
||||
status: 'running' | 'completed' | 'failed' | 'pending'
|
||||
name?: string
|
||||
description?: string
|
||||
createdAt?: number
|
||||
completedAt?: number
|
||||
teamName?: string
|
||||
agentName?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export class TaskService {
|
||||
private getConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
}
|
||||
|
||||
private getTasksDir(): string {
|
||||
return path.join(this.getConfigDir(), 'tasks')
|
||||
}
|
||||
|
||||
/** 列出所有持久化任务 */
|
||||
async listTasks(): Promise<TaskInfo[]> {
|
||||
const tasksDir = this.getTasksDir()
|
||||
try {
|
||||
const result: TaskInfo[] = []
|
||||
await this.scanTasksRecursive(tasksDir, result)
|
||||
return result.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') return []
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** 递归扫描任务目录 */
|
||||
private async scanTasksRecursive(dir: string, result: TaskInfo[]): Promise<void> {
|
||||
let entries
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await this.scanTasksRecursive(fullPath, result)
|
||||
} else if (entry.name.endsWith('.json')) {
|
||||
try {
|
||||
const raw = await fs.readFile(fullPath, 'utf-8')
|
||||
const data = JSON.parse(raw)
|
||||
const task = this.parseTaskFile(entry.name, data)
|
||||
if (task) result.push(task)
|
||||
} catch {
|
||||
// 跳过无法解析的文件
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析单个任务文件 */
|
||||
private parseTaskFile(filename: string, data: any): TaskInfo | null {
|
||||
if (!data || typeof data !== 'object') return null
|
||||
|
||||
const id = filename.replace('.json', '')
|
||||
return {
|
||||
id: data.id || id,
|
||||
type: data.type || 'local_agent',
|
||||
status: data.status || 'completed',
|
||||
name: data.name || data.title,
|
||||
description: data.description || data.prompt,
|
||||
createdAt: data.createdAt,
|
||||
completedAt: data.completedAt,
|
||||
teamName: data.teamName,
|
||||
agentName: data.agentName || data.name,
|
||||
metadata: data.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取单个任务详情 */
|
||||
async getTask(taskId: string): Promise<TaskInfo | null> {
|
||||
const tasks = await this.listTasks()
|
||||
return tasks.find(t => t.id === taskId) || null
|
||||
}
|
||||
}
|
||||
|
||||
export const taskService = new TaskService()
|
||||
296
src/server/services/teamService.ts
Normal file
296
src/server/services/teamService.ts
Normal file
@ -0,0 +1,296 @@
|
||||
/**
|
||||
* TeamService — 读取 CLI 生成的 Agent Teams 配置
|
||||
*
|
||||
* Team 配置存储在 ~/.claude/teams/{name}/config.json
|
||||
* 成员 transcript 存储为 JSONL 文件,通过 sessionId 在 ~/.claude/projects/ 下定位。
|
||||
* 服务端只读取,不负责创建团队(由 CLI 的 TeamCreate 工具完成)。
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TeamMember = {
|
||||
agentId: string
|
||||
name: string
|
||||
agentType?: string
|
||||
model?: string
|
||||
color?: string
|
||||
status: 'running' | 'completed' | 'idle' | 'failed'
|
||||
joinedAt: number
|
||||
cwd: string
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export type TeamSummary = {
|
||||
name: string
|
||||
description?: string
|
||||
createdAt: number
|
||||
memberCount: number
|
||||
activeMemberCount: number
|
||||
}
|
||||
|
||||
export type TeamDetail = TeamSummary & {
|
||||
leadAgentId: string
|
||||
members: TeamMember[]
|
||||
}
|
||||
|
||||
export type TranscriptMessage = {
|
||||
id: string
|
||||
type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result'
|
||||
content: unknown
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
/** Raw config.json structure written by CLI */
|
||||
type TeamFileRaw = {
|
||||
name: string
|
||||
description?: string
|
||||
createdAt: number
|
||||
leadAgentId: string
|
||||
leadSessionId?: string
|
||||
members: Array<{
|
||||
agentId: string
|
||||
name: string
|
||||
agentType?: string
|
||||
model?: string
|
||||
prompt?: string
|
||||
color?: string
|
||||
joinedAt: number
|
||||
tmuxPaneId: string
|
||||
cwd: string
|
||||
worktreePath?: string
|
||||
sessionId?: string
|
||||
isActive?: boolean
|
||||
mode?: string
|
||||
}>
|
||||
}
|
||||
|
||||
// ─── Service ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class TeamService {
|
||||
private getConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
}
|
||||
|
||||
private getTeamsDir(): string {
|
||||
return path.join(this.getConfigDir(), 'teams')
|
||||
}
|
||||
|
||||
private getProjectsDir(): string {
|
||||
return path.join(this.getConfigDir(), 'projects')
|
||||
}
|
||||
|
||||
// ── List all teams ──────────────────────────────────────────────────────
|
||||
|
||||
async listTeams(): Promise<TeamSummary[]> {
|
||||
const teamsDir = this.getTeamsDir()
|
||||
|
||||
try {
|
||||
await fs.access(teamsDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(teamsDir, { withFileTypes: true })
|
||||
const teams: TeamSummary[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
|
||||
try {
|
||||
const config = await this.loadTeamConfig(entry.name)
|
||||
teams.push(this.toSummary(config))
|
||||
} catch {
|
||||
// Skip malformed team directories
|
||||
}
|
||||
}
|
||||
|
||||
return teams
|
||||
}
|
||||
|
||||
// ── Get team detail ─────────────────────────────────────────────────────
|
||||
|
||||
async getTeam(name: string): Promise<TeamDetail> {
|
||||
const config = await this.loadTeamConfig(name)
|
||||
|
||||
const members: TeamMember[] = config.members.map((m) => ({
|
||||
agentId: m.agentId,
|
||||
name: m.name,
|
||||
agentType: m.agentType,
|
||||
model: m.model,
|
||||
color: m.color,
|
||||
status: this.deriveStatus(m.isActive),
|
||||
joinedAt: m.joinedAt,
|
||||
cwd: m.cwd,
|
||||
sessionId: m.sessionId,
|
||||
}))
|
||||
|
||||
return {
|
||||
...this.toSummary(config),
|
||||
leadAgentId: config.leadAgentId,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Get member transcript ───────────────────────────────────────────────
|
||||
|
||||
async getMemberTranscript(
|
||||
teamName: string,
|
||||
agentId: string,
|
||||
): Promise<TranscriptMessage[]> {
|
||||
const config = await this.loadTeamConfig(teamName)
|
||||
|
||||
const member = config.members.find((m) => m.agentId === agentId)
|
||||
if (!member) {
|
||||
throw ApiError.notFound(
|
||||
`Member not found: ${agentId} in team ${teamName}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!member.sessionId) {
|
||||
return []
|
||||
}
|
||||
|
||||
const jsonlPath = await this.findTranscriptFile(member.sessionId)
|
||||
if (!jsonlPath) {
|
||||
return []
|
||||
}
|
||||
|
||||
return this.parseTranscriptFile(jsonlPath)
|
||||
}
|
||||
|
||||
// ── Delete team ─────────────────────────────────────────────────────────
|
||||
|
||||
async deleteTeam(name: string): Promise<void> {
|
||||
const config = await this.loadTeamConfig(name)
|
||||
|
||||
const hasActive = config.members.some(
|
||||
(m) => m.isActive === undefined || m.isActive === true,
|
||||
)
|
||||
if (hasActive) {
|
||||
throw ApiError.conflict(
|
||||
`Cannot delete team "${name}": has active members`,
|
||||
)
|
||||
}
|
||||
|
||||
const teamDir = path.join(this.getTeamsDir(), name)
|
||||
await fs.rm(teamDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────────────
|
||||
|
||||
private async loadTeamConfig(name: string): Promise<TeamFileRaw> {
|
||||
const configPath = path.join(this.getTeamsDir(), name, 'config.json')
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(configPath, 'utf-8')
|
||||
return JSON.parse(raw) as TeamFileRaw
|
||||
} catch {
|
||||
throw ApiError.notFound(`Team not found: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
private toSummary(config: TeamFileRaw): TeamSummary {
|
||||
const activeMemberCount = config.members.filter(
|
||||
(m) => m.isActive === undefined || m.isActive === true,
|
||||
).length
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
createdAt: config.createdAt,
|
||||
memberCount: config.members.length,
|
||||
activeMemberCount,
|
||||
}
|
||||
}
|
||||
|
||||
private deriveStatus(
|
||||
isActive: boolean | undefined,
|
||||
): 'running' | 'completed' | 'idle' | 'failed' {
|
||||
if (isActive === false) return 'idle'
|
||||
// isActive === undefined || isActive === true
|
||||
return 'running'
|
||||
}
|
||||
|
||||
/** Search ~/.claude/projects/ for a JSONL file matching the sessionId. */
|
||||
private async findTranscriptFile(
|
||||
sessionId: string,
|
||||
): Promise<string | null> {
|
||||
const projectsDir = this.getProjectsDir()
|
||||
|
||||
try {
|
||||
await fs.access(projectsDir)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const projectEntries = await fs.readdir(projectsDir, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
|
||||
for (const entry of projectEntries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
|
||||
const candidate = path.join(projectsDir, entry.name, `${sessionId}.jsonl`)
|
||||
try {
|
||||
await fs.access(candidate)
|
||||
return candidate
|
||||
} catch {
|
||||
// Not in this project directory
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Parse a JSONL transcript file into messages. */
|
||||
private async parseTranscriptFile(
|
||||
filePath: string,
|
||||
): Promise<TranscriptMessage[]> {
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
const lines = raw.split('\n').filter((line) => line.trim().length > 0)
|
||||
|
||||
const messages: TranscriptMessage[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line) as Record<string, unknown>
|
||||
|
||||
// Skip non-message entries (snapshots, meta, etc.)
|
||||
const entryType = entry.type as string | undefined
|
||||
if (
|
||||
entryType !== 'user' &&
|
||||
entryType !== 'assistant' &&
|
||||
entryType !== 'system' &&
|
||||
entryType !== 'tool_use' &&
|
||||
entryType !== 'tool_result'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip meta entries
|
||||
if (entry.isMeta) continue
|
||||
|
||||
const message: TranscriptMessage = {
|
||||
id: (entry.uuid as string) || crypto.randomUUID(),
|
||||
type: entryType as TranscriptMessage['type'],
|
||||
content: entry.message ?? entry.content ?? null,
|
||||
timestamp:
|
||||
(entry.timestamp as string) || new Date().toISOString(),
|
||||
}
|
||||
|
||||
messages.push(message)
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
}
|
||||
|
||||
export const teamService = new TeamService()
|
||||
@ -2,10 +2,13 @@
|
||||
* WebSocket connection handler
|
||||
*
|
||||
* 管理 WebSocket 连接生命周期,处理消息路由。
|
||||
* 用户消息通过 CLI 子进程(stream-json 模式)处理,
|
||||
* CLI stdout 消息被转换为 ServerMessage 并转发到 WebSocket。
|
||||
*/
|
||||
|
||||
import type { ServerWebSocket } from 'bun'
|
||||
import type { ClientMessage, ServerMessage, WebSocketSession } from './events.js'
|
||||
import { conversationService } from '../services/conversationService.js'
|
||||
|
||||
export type WebSocketData = {
|
||||
sessionId: string
|
||||
@ -34,7 +37,9 @@ export const handleWebSocket = {
|
||||
|
||||
switch (message.type) {
|
||||
case 'user_message':
|
||||
handleUserMessage(ws, message)
|
||||
handleUserMessage(ws, message).catch((err) => {
|
||||
console.error(`[WS] Unhandled error in handleUserMessage:`, err)
|
||||
})
|
||||
break
|
||||
|
||||
case 'permission_response':
|
||||
@ -61,6 +66,11 @@ export const handleWebSocket = {
|
||||
const { sessionId } = ws.data
|
||||
console.log(`[WS] Client disconnected from session: ${sessionId} (${code}: ${reason})`)
|
||||
activeSessions.delete(sessionId)
|
||||
|
||||
// 断开连接时停止对应的 CLI 子进程
|
||||
if (conversationService.hasSession(sessionId)) {
|
||||
conversationService.stopSession(sessionId)
|
||||
}
|
||||
},
|
||||
|
||||
drain(ws: ServerWebSocket<WebSocketData>) {
|
||||
@ -69,7 +79,7 @@ export const handleWebSocket = {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message handlers (to be implemented by conversationService)
|
||||
// Message handlers
|
||||
// ============================================================================
|
||||
|
||||
async function handleUserMessage(
|
||||
@ -81,31 +91,140 @@ async function handleUserMessage(
|
||||
// Send thinking status
|
||||
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
|
||||
|
||||
// TODO: Integrate with ConversationService to process the message
|
||||
// For now, echo back a placeholder response
|
||||
sendMessage(ws, { type: 'content_start', blockType: 'text' })
|
||||
sendMessage(ws, {
|
||||
type: 'content_delta',
|
||||
text: `[Server] Received message for session ${sessionId}: "${message.content}". Chat integration pending.`,
|
||||
})
|
||||
sendMessage(ws, {
|
||||
type: 'message_complete',
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
})
|
||||
sendMessage(ws, { type: 'status', state: 'idle' })
|
||||
// 启动 CLI 子进程(如果还没有)
|
||||
if (!conversationService.hasSession(sessionId)) {
|
||||
try {
|
||||
const workDir = process.cwd()
|
||||
await conversationService.startSession(sessionId, workDir)
|
||||
|
||||
// 注册 CLI stdout → WebSocket 转发
|
||||
conversationService.onOutput(sessionId, (cliMsg) => {
|
||||
const serverMsg = translateCliMessage(cliMsg)
|
||||
if (serverMsg) {
|
||||
sendMessage(ws, serverMsg)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[WS] CLI start failed for ${sessionId}, falling back to echo`)
|
||||
// CLI 启动失败时回退到 echo 模式,保证基本可用性
|
||||
sendFallbackEcho(ws, sessionId, message.content)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 将用户消息写入 CLI stdin
|
||||
const sent = conversationService.sendMessage(sessionId, message.content)
|
||||
if (!sent) {
|
||||
// 消息发送失败(进程可能已退出),回退到 echo 模式
|
||||
sendFallbackEcho(ws, sessionId, message.content)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePermissionResponse(
|
||||
ws: ServerWebSocket<WebSocketData>,
|
||||
message: Extract<ClientMessage, { type: 'permission_response' }>
|
||||
) {
|
||||
// TODO: Forward permission response to the active tool execution
|
||||
const { sessionId } = ws.data
|
||||
conversationService.respondToPermission(sessionId, message.requestId, message.allowed)
|
||||
console.log(`[WS] Permission response for ${message.requestId}: ${message.allowed}`)
|
||||
}
|
||||
|
||||
function handleStopGeneration(ws: ServerWebSocket<WebSocketData>) {
|
||||
// TODO: Abort the active generation for this session
|
||||
console.log(`[WS] Stop generation requested for session: ${ws.data.sessionId}`)
|
||||
const { sessionId } = ws.data
|
||||
console.log(`[WS] Stop generation requested for session: ${sessionId}`)
|
||||
|
||||
// 向 CLI 子进程发送中断信号
|
||||
if (conversationService.hasSession(sessionId)) {
|
||||
conversationService.sendInterrupt(sessionId)
|
||||
}
|
||||
|
||||
sendMessage(ws, { type: 'status', state: 'idle' })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI message translation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 将 CLI stdout 的 stream-json 消息转换为 WebSocket ServerMessage。
|
||||
*
|
||||
* CLI 输出格式参考 src/bridge/sessionRunner.ts 中的 stream-json 协议。
|
||||
*/
|
||||
function translateCliMessage(cliMsg: any): ServerMessage | null {
|
||||
switch (cliMsg.type) {
|
||||
case 'assistant': {
|
||||
// 助手消息 - 提取文本内容
|
||||
if (cliMsg.message?.content) {
|
||||
const content = cliMsg.message.content
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text') {
|
||||
return { type: 'content_delta', text: block.text }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
case 'control_request': {
|
||||
// 权限请求 — CLI 需要用户授权才能执行工具
|
||||
if (cliMsg.request?.subtype === 'can_use_tool') {
|
||||
return {
|
||||
type: 'permission_request',
|
||||
requestId: cliMsg.request_id,
|
||||
toolName: cliMsg.request.tool_name || 'Unknown',
|
||||
input: cliMsg.request.input || {},
|
||||
description: cliMsg.request.description,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
// 对话完成
|
||||
if (cliMsg.subtype === 'success') {
|
||||
return {
|
||||
type: 'message_complete',
|
||||
usage: {
|
||||
input_tokens: cliMsg.usage?.input_tokens || 0,
|
||||
output_tokens: cliMsg.usage?.output_tokens || 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
case 'system':
|
||||
return { type: 'status', state: 'idle' }
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Fallback echo (for when CLI is not available)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 当 CLI 子进程启动失败或不可用时,使用 echo 模式作为回退。
|
||||
* 保证 WebSocket 客户端始终能收到完整的消息流转。
|
||||
*/
|
||||
function sendFallbackEcho(
|
||||
ws: ServerWebSocket<WebSocketData>,
|
||||
sessionId: string,
|
||||
content: string
|
||||
) {
|
||||
sendMessage(ws, { type: 'content_start', blockType: 'text' })
|
||||
sendMessage(ws, {
|
||||
type: 'content_delta',
|
||||
text: `[Server] Received message for session ${sessionId}: "${content}". Chat integration pending.`,
|
||||
})
|
||||
sendMessage(ws, {
|
||||
type: 'message_complete',
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
})
|
||||
sendMessage(ws, { type: 'status', state: 'idle' })
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user