Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const testState = vi.hoisted(() => {
|
|
class TestEmitter {
|
|
private readonly handlers = new Map<string, Array<(...args: any[]) => void>>()
|
|
|
|
on(event: string, handler: (...args: any[]) => void) {
|
|
const handlers = this.handlers.get(event) || []
|
|
handlers.push(handler)
|
|
this.handlers.set(event, handlers)
|
|
return this
|
|
}
|
|
|
|
emit(event: string, ...args: any[]) {
|
|
for (const handler of this.handlers.get(event) || []) handler(...args)
|
|
return true
|
|
}
|
|
}
|
|
|
|
return {
|
|
spawnCalls: [] as Array<{ command: string; args: string[]; options: any; child: any }>,
|
|
TestEmitter,
|
|
}
|
|
})
|
|
|
|
vi.mock('child_process', () => ({
|
|
spawn: vi.fn((command: string, args: string[], options: any) => {
|
|
const child = new testState.TestEmitter() as any
|
|
child.stdout = new testState.TestEmitter()
|
|
child.stderr = new testState.TestEmitter()
|
|
child.pid = 1234
|
|
child.exitCode = null
|
|
child.signalCode = null
|
|
child.killed = false
|
|
child.kill = vi.fn(() => {
|
|
child.killed = true
|
|
})
|
|
testState.spawnCalls.push({ command, args, options, child })
|
|
return child
|
|
}),
|
|
}))
|
|
|
|
import { CodingAgentRunManager } from '../../packages/server/src/services/agent-runner/coding-agent-run-manager'
|
|
|
|
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
|
|
|
function setPlatform(platform: NodeJS.Platform) {
|
|
Object.defineProperty(process, 'platform', { value: platform })
|
|
}
|
|
|
|
beforeEach(() => {
|
|
testState.spawnCalls.length = 0
|
|
setPlatform('win32')
|
|
})
|
|
|
|
afterEach(() => {
|
|
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
|
|
})
|
|
|
|
describe('coding agent Windows process launch', () => {
|
|
it('runs npm .cmd shims through cmd.exe for hidden Claude Code chat turns', () => {
|
|
const manager = new CodingAgentRunManager()
|
|
;(manager as any).ensureDbSession = () => {}
|
|
;(manager as any).addUserMessage = () => {}
|
|
;(manager as any).emitToChat = () => {}
|
|
;(manager as any).markChatRunCompleted = () => {}
|
|
|
|
manager.start({
|
|
agentSessionId: 'agent-session-1',
|
|
agentId: 'claude-code',
|
|
mode: 'scoped',
|
|
profile: 'default',
|
|
provider: 'test-provider',
|
|
model: 'claude-test',
|
|
sessionId: 'chat-session-1',
|
|
command: 'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\claude.cmd',
|
|
args: ['--settings', 'C:\\Users\\Administrator\\.hermes-web-ui\\settings.json'],
|
|
shellCommand: 'claude',
|
|
workspaceDir: process.cwd(),
|
|
state: { messages: [], isWorking: false, events: [], queue: [] },
|
|
})
|
|
|
|
manager.send('chat-session-1', 'test', { systemPrompt: 'system prompt\nsecond line' })
|
|
|
|
expect(testState.spawnCalls[0]).toMatchObject({
|
|
command: 'cmd.exe',
|
|
args: expect.arrayContaining(['/d', '/s', '/c']),
|
|
})
|
|
expect(testState.spawnCalls[0].args[3]).toContain('C:\\Users\\Administrator\\AppData\\Roaming\\npm\\claude.cmd')
|
|
expect(testState.spawnCalls[0].args[3]).toContain('^"--settings^"')
|
|
expect(testState.spawnCalls[0].args[3]).toContain('^"--append-system-prompt^"')
|
|
expect(testState.spawnCalls[0].args[3]).toContain('^"system^ prompt^ /^ second^ line^"')
|
|
expect(testState.spawnCalls[0].args[3]).not.toContain('\n')
|
|
expect(testState.spawnCalls[0].args[3]).not.toContain('\r')
|
|
expect(testState.spawnCalls[0].args[3]).toContain('^"test^"')
|
|
expect(testState.spawnCalls[0].options).toMatchObject({
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsVerbatimArguments: true,
|
|
windowsHide: true,
|
|
})
|
|
|
|
const run = (manager as any).runs.get('agent-session-1')
|
|
if (run?.idleTimer) clearTimeout(run.idleTimer)
|
|
;(manager as any).runs.clear()
|
|
;(manager as any).sessionIndex.clear()
|
|
})
|
|
|
|
it('runs npm .cmd shims through cmd.exe for hidden Codex chat turns', () => {
|
|
const manager = new CodingAgentRunManager()
|
|
;(manager as any).ensureDbSession = () => {}
|
|
;(manager as any).addUserMessage = () => {}
|
|
;(manager as any).emitToChat = () => {}
|
|
;(manager as any).markChatRunCompleted = () => {}
|
|
|
|
manager.start({
|
|
agentSessionId: 'agent-session-codex-1',
|
|
agentId: 'codex',
|
|
mode: 'scoped',
|
|
profile: 'default',
|
|
provider: 'test-provider',
|
|
model: 'gpt-test',
|
|
sessionId: 'chat-session-codex-1',
|
|
command: 'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\codex.cmd',
|
|
args: ['--model', 'gpt-test'],
|
|
shellCommand: 'codex',
|
|
workspaceDir: process.cwd(),
|
|
state: { messages: [], isWorking: false, events: [], queue: [] },
|
|
})
|
|
|
|
manager.send('chat-session-codex-1', 'test', { systemPrompt: 'system prompt\nsecond line' })
|
|
|
|
expect(testState.spawnCalls[0]).toMatchObject({
|
|
command: 'cmd.exe',
|
|
args: expect.arrayContaining(['/d', '/s', '/c']),
|
|
})
|
|
expect(testState.spawnCalls[0].args[3]).toContain('C:\\Users\\Administrator\\AppData\\Roaming\\npm\\codex.cmd')
|
|
expect(testState.spawnCalls[0].args[3]).toContain('^"exec^"')
|
|
expect(testState.spawnCalls[0].args[3]).toContain('^"-c^"')
|
|
expect(testState.spawnCalls[0].args[3]).toContain('model_reasoning_summary=\\^"auto\\^"')
|
|
expect(testState.spawnCalls[0].args[3]).not.toContain('developer_instructions=')
|
|
expect(testState.spawnCalls[0].args[3]).not.toContain('system^ prompt^ /^ second^ line')
|
|
expect(testState.spawnCalls[0].args[3]).not.toContain('\n')
|
|
expect(testState.spawnCalls[0].args[3]).not.toContain('\r')
|
|
expect(testState.spawnCalls[0].args[3]).toContain('^"--model^"')
|
|
expect(testState.spawnCalls[0].args[3]).toContain('^"test^"')
|
|
expect(testState.spawnCalls[0].args[3]).not.toContain('system^ prompt\r\n\r\ntest')
|
|
expect(testState.spawnCalls[0].options).toMatchObject({
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsVerbatimArguments: true,
|
|
windowsHide: true,
|
|
})
|
|
|
|
const run = (manager as any).runs.get('agent-session-codex-1')
|
|
if (run?.idleTimer) clearTimeout(run.idleTimer)
|
|
;(manager as any).runs.clear()
|
|
;(manager as any).sessionIndex.clear()
|
|
})
|
|
|
|
it('preserves non-ASCII Windows .cmd paths when launching hidden chat turns', () => {
|
|
const manager = new CodingAgentRunManager()
|
|
;(manager as any).ensureDbSession = () => {}
|
|
;(manager as any).addUserMessage = () => {}
|
|
;(manager as any).emitToChat = () => {}
|
|
;(manager as any).markChatRunCompleted = () => {}
|
|
|
|
manager.start({
|
|
agentSessionId: 'agent-session-codex-unicode-1',
|
|
agentId: 'codex',
|
|
mode: 'scoped',
|
|
profile: 'default',
|
|
provider: 'test-provider',
|
|
model: 'gpt-test',
|
|
sessionId: 'chat-session-codex-unicode-1',
|
|
command: 'C:\\用户\\管理员\\AppData\\Roaming\\npm\\codex.cmd',
|
|
args: ['--model', 'gpt-test'],
|
|
shellCommand: 'codex',
|
|
workspaceDir: process.cwd(),
|
|
state: { messages: [], isWorking: false, events: [], queue: [] },
|
|
})
|
|
|
|
manager.send('chat-session-codex-unicode-1', 'test')
|
|
|
|
expect(testState.spawnCalls[0]).toMatchObject({
|
|
command: 'cmd.exe',
|
|
args: expect.arrayContaining(['/d', '/s', '/c']),
|
|
})
|
|
expect(testState.spawnCalls[0].args[3]).toContain('C:\\用户\\管理员\\AppData\\Roaming\\npm\\codex.cmd')
|
|
|
|
const run = (manager as any).runs.get('agent-session-codex-unicode-1')
|
|
if (run?.idleTimer) clearTimeout(run.idleTimer)
|
|
;(manager as any).runs.clear()
|
|
;(manager as any).sessionIndex.clear()
|
|
})
|
|
|
|
it('normalizes already quoted Windows .cmd paths before launching hidden chat turns', () => {
|
|
const manager = new CodingAgentRunManager()
|
|
;(manager as any).ensureDbSession = () => {}
|
|
;(manager as any).addUserMessage = () => {}
|
|
;(manager as any).emitToChat = () => {}
|
|
;(manager as any).markChatRunCompleted = () => {}
|
|
|
|
manager.start({
|
|
agentSessionId: 'agent-session-codex-quoted-1',
|
|
agentId: 'codex',
|
|
mode: 'scoped',
|
|
profile: 'default',
|
|
provider: 'test-provider',
|
|
model: 'gpt-test',
|
|
sessionId: 'chat-session-codex-quoted-1',
|
|
command: '"C:\\nvm4w\\nodejs\\codex.cmd"',
|
|
args: ['--model', 'gpt-test'],
|
|
shellCommand: 'codex',
|
|
workspaceDir: process.cwd(),
|
|
state: { messages: [], isWorking: false, events: [], queue: [] },
|
|
})
|
|
|
|
manager.send('chat-session-codex-quoted-1', 'test')
|
|
|
|
expect(testState.spawnCalls[0]).toMatchObject({
|
|
command: 'cmd.exe',
|
|
args: expect.arrayContaining(['/d', '/s', '/c']),
|
|
})
|
|
expect(testState.spawnCalls[0].args[3]).toContain('C:\\nvm4w\\nodejs\\codex.cmd')
|
|
expect(testState.spawnCalls[0].args[3]).not.toContain('"C:\\nvm4w\\nodejs\\codex.cmd"')
|
|
|
|
const run = (manager as any).runs.get('agent-session-codex-quoted-1')
|
|
if (run?.idleTimer) clearTimeout(run.idleTimer)
|
|
;(manager as any).runs.clear()
|
|
;(manager as any).sessionIndex.clear()
|
|
})
|
|
|
|
it('emits a readable failed run when a hidden Claude Code process cannot start', async () => {
|
|
const manager = new CodingAgentRunManager()
|
|
const emitted: Array<{ event: string; payload: any }> = []
|
|
;(manager as any).ensureDbSession = () => {}
|
|
;(manager as any).addUserMessage = () => {}
|
|
;(manager as any).markChatRunCompleted = (_sessionId: string, event: string) => {
|
|
emitted.push({ event: 'marked', payload: { event } })
|
|
}
|
|
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
|
|
emitted.push({ event, payload })
|
|
}
|
|
|
|
manager.start({
|
|
agentSessionId: 'agent-session-error-1',
|
|
agentId: 'claude-code',
|
|
mode: 'scoped',
|
|
profile: 'default',
|
|
provider: 'test-provider',
|
|
model: 'claude-test',
|
|
sessionId: 'chat-session-error-1',
|
|
command: 'claude',
|
|
args: [],
|
|
shellCommand: 'claude',
|
|
workspaceDir: process.cwd(),
|
|
state: { messages: [], isWorking: false, events: [], queue: [] },
|
|
})
|
|
|
|
manager.send('chat-session-error-1', 'test')
|
|
testState.spawnCalls[0].child.emit('error', Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }))
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
|
expect(emitted).toContainEqual(expect.objectContaining({
|
|
event: 'run.failed',
|
|
payload: expect.objectContaining({
|
|
error: 'spawn claude ENOENT',
|
|
}),
|
|
}))
|
|
|
|
const run = (manager as any).runs.get('agent-session-error-1')
|
|
if (run?.idleTimer) clearTimeout(run.idleTimer)
|
|
;(manager as any).runs.clear()
|
|
;(manager as any).sessionIndex.clear()
|
|
})
|
|
|
|
it('includes decoded stderr detail when a hidden Codex process exits non-zero', async () => {
|
|
const manager = new CodingAgentRunManager()
|
|
const emitted: Array<{ event: string; payload: any }> = []
|
|
;(manager as any).ensureDbSession = () => {}
|
|
;(manager as any).addUserMessage = () => {}
|
|
;(manager as any).markChatRunCompleted = (_sessionId: string, event: string) => {
|
|
emitted.push({ event: 'marked', payload: { event } })
|
|
}
|
|
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
|
|
emitted.push({ event, payload })
|
|
}
|
|
|
|
manager.start({
|
|
agentSessionId: 'agent-session-codex-error-1',
|
|
agentId: 'codex',
|
|
mode: 'scoped',
|
|
profile: 'default',
|
|
provider: 'test-provider',
|
|
model: 'gpt-test',
|
|
sessionId: 'chat-session-codex-error-1',
|
|
command: 'C:\\Users\\Administrator\\AppData\\Roaming\\npm\\codex.cmd',
|
|
args: ['--model', 'gpt-test'],
|
|
shellCommand: 'codex',
|
|
workspaceDir: process.cwd(),
|
|
state: { messages: [], isWorking: false, events: [], queue: [] },
|
|
})
|
|
|
|
manager.send('chat-session-codex-error-1', 'test')
|
|
testState.spawnCalls[0].child.stderr.emit('data', Buffer.from([0xb2, 0xbb, 0xca, 0xc7]))
|
|
testState.spawnCalls[0].child.emit('exit', 1)
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
|
|
expect(emitted).toContainEqual(expect.objectContaining({
|
|
event: 'run.failed',
|
|
payload: expect.objectContaining({
|
|
error: 'Codex exited with code 1: 不是',
|
|
}),
|
|
}))
|
|
|
|
const run = (manager as any).runs.get('agent-session-codex-error-1')
|
|
if (run?.idleTimer) clearTimeout(run.idleTimer)
|
|
;(manager as any).runs.clear()
|
|
;(manager as any).sessionIndex.clear()
|
|
})
|
|
})
|