Files
hermes-ui/tests/server/agent-runner-utils.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
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.
2026-10-16 10:34:35 +08:00

1677 lines
63 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import {
anthropicMessagesUrl,
chatCompletionsUrl,
providerEndpointUrl,
responsesUrl,
} from '../../packages/server/src/services/agent-runner/endpoint-resolver'
import { parseSseFrame, readSseFrames, readSseFrameTexts, sseEvent } from '../../packages/server/src/services/agent-runner/sse'
import { AgentTargetRegistry, type AgentTargetInput } from '../../packages/server/src/services/agent-runner/target-registry'
import { teeAsyncIterable } from '../../packages/server/src/services/agent-runner/stream-tee'
import { CodingAgentRunManager, codingAgentGatewayErrorMessage, sanitizeCodingAgentTerminalOutput } from '../../packages/server/src/services/agent-runner/coding-agent-run-manager'
import { mapCodingAgentResponseEvent } from '../../packages/server/src/services/agent-runner/coding-agent-event-mapper'
import { applyResponseStreamEvent } from '../../packages/server/src/services/hermes/run-chat/response-stream'
import { initAllHermesTables } from '../../packages/server/src/db/hermes/schemas'
import { addMessage, getSession, getSessionDetail, listSessions } from '../../packages/server/src/db/hermes/session-store'
import { getRecordedUsageTotals, getUsage } from '../../packages/server/src/db/hermes/usage-store'
describe('agent runner endpoint resolver', () => {
it('adds v1 for provider hosts without an API root path', () => {
expect(chatCompletionsUrl('https://api.deepseek.com')).toBe('https://api.deepseek.com/v1/chat/completions')
expect(responsesUrl('https://api.openai.com')).toBe('https://api.openai.com/v1/responses')
expect(anthropicMessagesUrl('https://api.anthropic.com')).toBe('https://api.anthropic.com/v1/messages')
})
it('does not duplicate existing OpenAI-compatible API roots', () => {
expect(chatCompletionsUrl('https://openrouter.ai/api/v1')).toBe('https://openrouter.ai/api/v1/chat/completions')
expect(chatCompletionsUrl('https://generativelanguage.googleapis.com/v1beta/openai')).toBe(
'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
)
expect(chatCompletionsUrl('https://api.z.ai/api/paas/v4')).toBe('https://api.z.ai/api/paas/v4/chat/completions')
expect(responsesUrl('https://api.apikey.fun/v1/')).toBe('https://api.apikey.fun/v1/responses')
})
it('does not duplicate existing endpoint paths', () => {
expect(chatCompletionsUrl('https://api.example.com/v1/chat/completions')).toBe(
'https://api.example.com/v1/chat/completions',
)
expect(responsesUrl('https://api.example.com/v1/responses')).toBe('https://api.example.com/v1/responses')
expect(anthropicMessagesUrl('https://api.example.com/v1/messages')).toBe('https://api.example.com/v1/messages')
})
it('handles Anthropic-compatible roots', () => {
expect(anthropicMessagesUrl('https://api.apikey.fun')).toBe('https://api.apikey.fun/v1/messages')
expect(anthropicMessagesUrl('https://api.z.ai/api/anthropic')).toBe('https://api.z.ai/api/anthropic/v1/messages')
expect(providerEndpointUrl('anthropic_messages', 'https://api.example.com/v1')).toBe('https://api.example.com/v1/messages')
})
})
describe('coding agent completion errors', () => {
it('treats gateway API error text as a failed coding-agent run', () => {
const error = 'API Error: 529 [1305][The service may be temporarily overloaded, please try again later]'
expect(codingAgentGatewayErrorMessage(error)).toBe(error)
expect(codingAgentGatewayErrorMessage(` ${error}\n`)).toBe(error)
expect(codingAgentGatewayErrorMessage('Provider returned HTTP 502')).toBe('Provider returned HTTP 502')
expect(codingAgentGatewayErrorMessage('Here is a normal answer mentioning API Error: 529 as an example')).toBeNull()
})
})
describe('agent runner SSE utilities', () => {
it('parses event and multi-line data fields', () => {
expect(parseSseFrame('event: response.output_text.delta\ndata: {"delta":"a"}\ndata: {"delta":"b"}')).toEqual({
event: 'response.output_text.delta',
data: '{"delta":"a"}\n{"delta":"b"}',
})
})
it('splits LF and CRLF SSE frame boundaries', () => {
expect(readSseFrameTexts('data: one\n\ndata: two\r\n\r\ndata: three')).toEqual({
frames: ['data: one', 'data: two'],
rest: 'data: three',
})
})
it('reads chunked SSE streams with CRLF boundaries', async () => {
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('event: first\r\ndata: {"a":'))
controller.enqueue(encoder.encode('1}\r\n\r\ndata: {"b":2}\n\n'))
controller.close()
},
})
const frames = []
for await (const frame of readSseFrames(stream)) frames.push(frame)
expect(frames).toEqual([
{ event: 'first', data: '{"a":1}' },
{ data: '{"b":2}' },
])
})
it('serializes named SSE events', () => {
expect(sseEvent('response.completed', { ok: true })).toBe('event: response.completed\ndata: {"ok":true}\n\n')
})
})
describe('agent runner target registry', () => {
it('reuses route credentials for the same normalized target', () => {
const registry = new AgentTargetRegistry<AgentTargetInput>(
input => [input.provider, input.model, input.apiMode, input.baseUrl],
)
const first = registry.register({
provider: ' deepseek ',
model: ' deepseek-chat ',
baseUrl: 'https://api.deepseek.com/',
apiKey: 'sk-first',
})
const second = registry.register({
provider: 'deepseek',
model: 'deepseek-chat',
baseUrl: 'https://api.deepseek.com',
apiKey: 'sk-second',
})
expect(second.routeKey).toBe(first.routeKey)
expect(second.token).toBe(first.token)
expect(second.apiKey).toBe('sk-second')
expect(registry.find(first.routeKey)?.apiKey).toBe('sk-second')
})
it('separates route credentials by API mode and upstream URL', () => {
const registry = new AgentTargetRegistry<AgentTargetInput>(
input => [input.provider, input.model, input.apiMode, input.baseUrl],
)
const chat = registry.register({
provider: 'same-provider',
model: 'same-model',
baseUrl: 'https://api-one.example.com/v1',
apiKey: 'sk-chat',
apiMode: 'chat_completions',
})
const responses = registry.register({
provider: 'same-provider',
model: 'same-model',
baseUrl: 'https://api-one.example.com/v1',
apiKey: 'sk-responses',
apiMode: 'codex_responses',
})
const secondUrl = registry.register({
provider: 'same-provider',
model: 'same-model',
baseUrl: 'https://api-two.example.com/v1',
apiKey: 'sk-second-url',
apiMode: 'chat_completions',
})
expect(chat.routeKey).not.toBe(responses.routeKey)
expect(chat.token).not.toBe(responses.token)
expect(chat.routeKey).not.toBe(secondUrl.routeKey)
expect(chat.token).not.toBe(secondUrl.token)
})
})
describe('agent runner stream tee', () => {
it('allows two consumers to receive the same chunks', async () => {
async function* source() {
yield 'a'
yield 'b'
}
const [left, right] = teeAsyncIterable(source())
const collect = async (iterable: AsyncIterable<string>) => {
const values: string[] = []
for await (const value of iterable) values.push(value)
return values
}
await expect(Promise.all([collect(left), collect(right)])).resolves.toEqual([
['a', 'b'],
['a', 'b'],
])
})
})
describe('coding agent terminal output sanitizer', () => {
it('strips terminal control codes and redacts provider credentials', () => {
const output = sanitizeCodingAgentTerminalOutput(
'\u001b[31mError\u001b[0m\r\nAuthorization: Bearer sk-test-secret-token\napi_key = sk-proj-secret-token',
)
expect(output).toContain('Error\nAuthorization: Bearer [redacted]')
expect(output).toContain('api_key = [redacted-api-key]')
expect(output).not.toContain('\u001b')
expect(output).not.toContain('sk-test-secret-token')
expect(output).not.toContain('sk-proj-secret-token')
})
})
describe('coding agent run state', () => {
it('marks existing scoped Codex runners incompatible when Hermes MCP config is missing', () => {
const codexHome = mkdtempSync(join(tmpdir(), 'hwui-codex-mcp-compat-'))
try {
writeFileSync(join(codexHome, 'config.toml'), 'model = "gpt-test"\n')
const manager = new CodingAgentRunManager()
;(manager as any).ensureDbSession = () => {}
;(manager as any).emitToChat = () => {}
manager.start({
agentSessionId: 'agent-session-1',
agentId: 'codex',
mode: 'scoped',
profile: 'default',
provider: 'test-provider',
model: 'gpt-test',
sessionId: 'chat-session-1',
command: 'codex',
args: [],
shellCommand: 'codex',
workspaceDir: process.cwd(),
env: { CODEX_HOME: codexHome },
state: { messages: [], isWorking: false, events: [], queue: [] },
})
expect(manager.isSessionLaunchCompatible('chat-session-1', {
agentId: 'codex',
mode: 'scoped',
provider: 'test-provider',
model: 'gpt-test',
})).toBe(false)
writeFileSync(join(codexHome, 'config.toml'), '[mcp_servers.hermes-studio]\ncommand = "node"\n')
expect(manager.isSessionLaunchCompatible('chat-session-1', {
agentId: 'codex',
mode: 'scoped',
provider: 'test-provider',
model: 'gpt-test',
})).toBe(true)
manager.shutdown()
} finally {
rmSync(codexHome, { recursive: true, force: true })
}
})
it('updates the shared chat session state during streaming', () => {
const manager = new CodingAgentRunManager()
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).ensureDbSession = () => {}
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
manager.start({
agentSessionId: 'agent-session-1',
agentId: 'claude-code',
profile: 'default',
provider: 'test-provider',
model: 'test-model',
sessionId: 'chat-session-1',
command: 'claude',
args: [],
shellCommand: 'claude',
workspaceDir: process.cwd(),
state,
})
manager.handleResponseEvent('agent-session-1', {
type: 'response.created',
data: { response: { id: 'resp-1', status: 'in_progress' } },
})
manager.handleResponseEvent('agent-session-1', {
type: 'response.output_text.delta',
data: { delta: 'hello' },
})
expect(state).toEqual(expect.objectContaining({
isWorking: true,
source: 'coding_agent',
profile: 'default',
runId: 'agent-session-1',
}))
expect(state.messages).toEqual([
expect.objectContaining({
session_id: 'chat-session-1',
role: 'assistant',
content: 'hello',
}),
])
expect(emitted.map(event => event.event)).toContain('message.delta')
manager.shutdown()
})
it('clears shared chat session run state when a print turn completes', async () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
;(manager as any).ensureDbSession = () => {}
;(manager as any).emitToChat = () => {}
;(manager as any).markChatRunCompleted = () => {}
manager.start({
agentSessionId: 'agent-session-1',
agentId: 'claude-code',
profile: 'default',
provider: 'test-provider',
model: 'test-model',
sessionId: 'chat-session-1',
command: 'claude',
args: [],
shellCommand: 'claude',
workspaceDir: process.cwd(),
state,
})
manager.handleResponseEvent('agent-session-1', {
type: 'response.created',
data: { response: { id: 'resp-1', status: 'in_progress' } },
})
state.events.push({ event: 'tool.started', data: { event: 'tool.started' } })
manager.handleResponseEvent('agent-session-1', {
type: 'response.completed',
data: {
response: {
id: 'resp-1',
status: 'completed',
output: [],
model: 'test-model',
usage: { input_tokens: 12, output_tokens: 5 },
},
},
})
await new Promise(resolve => setTimeout(resolve, 0))
expect(state).toEqual(expect.objectContaining({
isWorking: false,
runId: undefined,
activeRunMarker: undefined,
events: [],
}))
expect(getUsage('chat-session-1')).toEqual(expect.objectContaining({
input_tokens: 12,
output_tokens: 5,
model: 'test-model',
profile: 'default',
}))
manager.shutdown()
})
it('records scoped proxy usage once per model call and skips the scoped turn aggregate', async () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-proxy-usage-${suffix}`
const chatSessionId = `chat-session-proxy-usage-${suffix}`
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
;(manager as any).ensureDbSession = () => {}
;(manager as any).emitToChat = () => {}
;(manager as any).markChatRunCompleted = () => {}
manager.start({
agentSessionId,
agentId: 'claude-code',
mode: 'scoped',
profile: 'default',
provider: 'glm',
model: 'glm-5-turbo',
sessionId: chatSessionId,
command: 'claude',
args: [],
shellCommand: 'claude',
workspaceDir: process.cwd(),
state,
})
const proxyCompleted = {
type: 'response.completed',
data: {
response: {
id: `provider-call-${suffix}`,
status: 'completed',
model: 'glm-5-turbo',
output: [],
usage: {
prompt_tokens: 120,
completion_tokens: 7,
prompt_tokens_details: { cached_tokens: 30 },
},
},
},
}
manager.handleProxyUsageEvent(agentSessionId, proxyCompleted)
manager.handleProxyUsageEvent(agentSessionId, proxyCompleted)
manager.handleResponseEvent(agentSessionId, {
type: 'response.completed',
data: {
response: {
id: `turn-${suffix}`,
status: 'completed',
output: [],
usage: { input_tokens: 120, output_tokens: 7 },
},
},
})
expect(getRecordedUsageTotals(chatSessionId, 'coding_agent')).toEqual({
inputTokens: 90,
outputTokens: 7,
cacheReadTokens: 30,
cacheWriteTokens: 0,
reasoningTokens: 0,
apiCalls: 1,
})
manager.shutdown()
})
it('leaves coding agent session titles empty for the existing fallback title logic', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-empty-title-${suffix}`
const chatSessionId = `chat-session-empty-title-${suffix}`
const run: any = {
id: agentSessionId,
launch: {
agentSessionId,
agentId: 'claude-code',
profile: 'default',
provider: 'test-provider',
model: 'glm-5-turbo',
sessionId: chatSessionId,
command: 'claude',
args: [],
shellCommand: 'claude',
workspaceDir: process.cwd(),
},
state: { messages: [], isWorking: false, events: [], queue: [] },
lastActiveAt: Date.now(),
startedAt: Date.now(),
exited: false,
}
;(manager as any).ensureDbSession(run)
expect(getSession(chatSessionId)?.title).toBeNull()
manager.shutdown()
})
it('uses the first coding agent user message as the listed session title when title and preview are empty', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-title-fallback-${suffix}`
const chatSessionId = `chat-session-title-fallback-${suffix}`
const run: any = {
id: agentSessionId,
launch: {
agentSessionId,
agentId: 'codex',
mode: 'global',
profile: 'default',
provider: 'global',
model: '',
sessionId: chatSessionId,
command: 'codex',
args: [],
shellCommand: 'codex',
workspaceDir: process.cwd(),
},
state: { messages: [], isWorking: false, events: [], queue: [] },
lastActiveAt: Date.now(),
startedAt: Date.now(),
exited: false,
}
;(manager as any).ensureDbSession(run)
addMessage({
session_id: chatSessionId,
role: 'user',
content: 'Explain why global Codex should not show GLM',
timestamp: Math.floor(Date.now() / 1000),
})
const session = listSessions('default', 'coding_agent').find(item => item.id === chatSessionId)
expect(session?.preview).toBe('Explain why global Codex should not show GLM')
expect(session?.title).toBe('Explain why global Codex should not show...')
manager.shutdown()
})
it('starts Codex chat runner without a hidden PTY process', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const result = manager.start({
agentSessionId: `agent-session-codex-${suffix}`,
agentId: 'codex',
profile: 'default',
provider: 'test-provider',
model: 'gpt-5-codex',
sessionId: `chat-session-codex-${suffix}`,
command: 'codex',
args: ['--model', 'gpt-5-codex'],
shellCommand: 'codex --model gpt-5-codex',
workspaceDir: process.cwd(),
state: { messages: [], isWorking: false, events: [], queue: [] },
})
expect(result.pid).toBe(0)
expect(getSession(`chat-session-codex-${suffix}`)).toEqual(expect.objectContaining({
source: 'coding_agent',
agent: 'codex',
model: 'gpt-5-codex',
}))
manager.shutdown()
})
it('maps Codex exec JSONL assistant deltas into chat messages', async () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
;(manager as any).markChatRunCompleted = () => {}
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-codex-jsonl-${suffix}`
const chatSessionId = `chat-session-codex-jsonl-${suffix}`
manager.start({
agentSessionId,
agentId: 'codex',
profile: 'default',
provider: 'test-provider',
model: 'gpt-5-codex',
sessionId: chatSessionId,
command: 'codex',
args: ['--model', 'gpt-5-codex'],
shellCommand: 'codex --model gpt-5-codex',
workspaceDir: process.cwd(),
state,
})
const run = (manager as any).runs.get(agentSessionId)
run.printResponseId = 'resp_codex_1'
run.printMessageId = 'msg_resp_codex_1'
run.printTextStarted = false
run.printText = ''
run.printCompleted = false
run.responseStartEmitted = false
run.terminalEventHandled = false
run.codexToolBlocks = new Map()
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.created',
data: { response: { id: 'resp_codex_1', status: 'in_progress', model: 'gpt-5-codex', output: [] } },
})
;(manager as any).handleCodexExecLine(run, JSON.stringify({
method: 'item/agentMessage/delta',
params: { delta: 'I am GPT-5-Codex' },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({
type: 'turn.completed',
usage: { input_tokens: 12, output_tokens: 7, total_tokens: 19 },
}))
;(manager as any).completeCodexExecTurn(run, run.codexPendingUsage)
expect(state.messages).toContainEqual(expect.objectContaining({
session_id: chatSessionId,
role: 'assistant',
content: 'I am GPT-5-Codex',
finish_reason: 'stop',
}))
await new Promise(resolve => setTimeout(resolve, 0))
expect(state.isWorking).toBe(false)
expect(emitted.map(event => event.event)).toContain('message.delta')
expect(emitted.map(event => event.event)).toContain('usage.updated')
expect(emitted.find(event => event.event === 'usage.updated' && event.payload.contextTokens != null)?.payload).toEqual(expect.objectContaining({
contextTokens: expect.any(Number),
}))
expect(emitted.find(event => event.event === 'run.completed')?.payload).not.toHaveProperty('usage')
const eventNames = emitted.map(event => event.event)
expect(eventNames.lastIndexOf('usage.updated')).toBeLessThan(eventNames.indexOf('run.completed'))
manager.shutdown()
})
it('does not reset Codex context tokens when a usage refresh has no context estimate', async () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const state: any = {
messages: [],
isWorking: false,
events: [],
queue: [],
contextTokens: 15_000,
}
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-codex-empty-usage-${suffix}`
const chatSessionId = `chat-session-codex-empty-usage-${suffix}`
manager.start({
agentSessionId,
agentId: 'codex',
profile: 'default',
provider: 'test-provider',
model: 'gpt-5-codex',
sessionId: chatSessionId,
command: 'codex',
args: ['--model', 'gpt-5-codex'],
shellCommand: 'codex --model gpt-5-codex',
workspaceDir: process.cwd(),
state,
})
const run = (manager as any).runs.get(agentSessionId)
await (manager as any).refreshCodingAgentUsage(run)
expect(state.contextTokens).toBe(15_000)
expect(emitted).toContainEqual(expect.objectContaining({
event: 'usage.updated',
payload: expect.objectContaining({ inputTokens: 0, outputTokens: 0 }),
}))
expect(emitted).not.toContainEqual(expect.objectContaining({
event: 'usage.updated',
payload: expect.objectContaining({ contextTokens: 0 }),
}))
manager.shutdown()
})
it('does not duplicate replayed Codex assistant message text', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
;(manager as any).markChatRunCompleted = () => {}
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-codex-dedupe-${suffix}`
const chatSessionId = `chat-session-codex-dedupe-${suffix}`
manager.start({
agentSessionId,
agentId: 'codex',
profile: 'default',
provider: 'test-provider',
model: 'gpt-5-codex',
sessionId: chatSessionId,
command: 'codex',
args: ['--model', 'gpt-5-codex'],
shellCommand: 'codex --model gpt-5-codex',
workspaceDir: process.cwd(),
state,
})
const run = (manager as any).runs.get(agentSessionId)
run.printResponseId = 'resp_codex_dedupe'
run.printMessageId = 'msg_resp_codex_dedupe'
run.printTextStarted = false
run.printText = ''
run.printCompleted = false
run.responseStartEmitted = false
run.terminalEventHandled = false
run.codexToolBlocks = new Map()
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.created',
data: { response: { id: 'resp_codex_dedupe', status: 'in_progress', model: 'gpt-5-codex', output: [] } },
})
const text = '好的,你想查看 🐸 Meme币排行!让我先读取 Meme Rank API 的详细说明。'
;(manager as any).handleCodexExecLine(run, JSON.stringify({
method: 'item/agentMessage/delta',
params: { delta: text },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({
method: 'item/agentMessage/delta',
params: { delta: text },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({
msg: { content: text },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({
type: 'item.completed',
item: { type: 'assistant_message', text },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({ type: 'turn.completed' }))
;(manager as any).completeCodexExecTurn(run, run.codexPendingUsage)
expect(state.messages).toContainEqual(expect.objectContaining({
session_id: chatSessionId,
role: 'assistant',
content: text,
finish_reason: 'stop',
}))
expect(state.messages.find((message: any) => message.role === 'assistant')?.content).toBe(text)
expect(emitted.filter(event => event.event === 'message.delta').map(event => event.payload.delta)).toEqual([text])
manager.shutdown()
})
it('deduplicates repeated full Codex streaming deltas', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
;(manager as any).markChatRunCompleted = () => {}
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-codex-full-delta-dedupe-${suffix}`
const chatSessionId = `chat-session-codex-full-delta-dedupe-${suffix}`
manager.start({
agentSessionId,
agentId: 'codex',
profile: 'default',
provider: 'test-provider',
model: 'gpt-5-codex',
sessionId: chatSessionId,
command: 'codex',
args: ['--model', 'gpt-5-codex'],
shellCommand: 'codex --model gpt-5-codex',
workspaceDir: process.cwd(),
state,
})
const run = (manager as any).runs.get(agentSessionId)
run.printResponseId = 'resp_codex_full_delta_dedupe'
run.printMessageId = 'msg_resp_codex_full_delta_dedupe'
run.printTextStarted = false
run.printText = ''
run.printCompleted = false
run.responseStartEmitted = false
run.terminalEventHandled = false
run.codexToolBlocks = new Map()
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.created',
data: { response: { id: 'resp_codex_full_delta_dedupe', status: 'in_progress', model: 'gpt-5-codex', output: [] } },
})
const text = 'It seems like you are sending numbers without additional context. I am not sure what you are looking for.\n\nCould you describe what you need in a sentence or two?'
;(manager as any).handleCodexExecLine(run, JSON.stringify({
method: 'item/agentMessage/delta',
params: { delta: text },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({
method: 'item/agentMessage/delta',
params: { delta: text },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({ type: 'turn.completed' }))
;(manager as any).completeCodexExecTurn(run, run.codexPendingUsage)
expect(state.messages.find((message: any) => message.role === 'assistant')?.content).toBe(text)
expect(emitted.filter(event => event.event === 'message.delta').map(event => event.payload.delta)).toEqual([text])
manager.shutdown()
})
it('keeps repeated short Codex markdown chunks while deduplicating final replay', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
;(manager as any).markChatRunCompleted = () => {}
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-codex-markdown-${suffix}`
const chatSessionId = `chat-session-codex-markdown-${suffix}`
manager.start({
agentSessionId,
agentId: 'codex',
profile: 'default',
provider: 'test-provider',
model: 'gpt-5-codex',
sessionId: chatSessionId,
command: 'codex',
args: ['--model', 'gpt-5-codex'],
shellCommand: 'codex --model gpt-5-codex',
workspaceDir: process.cwd(),
state,
})
const run = (manager as any).runs.get(agentSessionId)
run.printResponseId = 'resp_codex_markdown'
run.printMessageId = 'msg_resp_codex_markdown'
run.printTextStarted = false
run.printText = ''
run.printCompleted = false
run.responseStartEmitted = false
run.terminalEventHandled = false
run.codexToolBlocks = new Map()
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.created',
data: { response: { id: 'resp_codex_markdown', status: 'in_progress', model: 'gpt-5-codex', output: [] } },
})
for (const delta of ['```', 'ts', '\n', 'const value = 1', '\n', '```']) {
;(manager as any).handleCodexExecLine(run, JSON.stringify({
method: 'item/agentMessage/delta',
params: { delta },
}))
}
const text = ['```ts', 'const value = 1', '```'].join('\n')
;(manager as any).handleCodexExecLine(run, JSON.stringify({
type: 'item.completed',
item: { type: 'assistant_message', text },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({ type: 'turn.completed' }))
;(manager as any).completeCodexExecTurn(run, run.codexPendingUsage)
expect(state.messages.find((message: any) => message.role === 'assistant')?.content).toBe(text)
expect(emitted.filter(event => event.event === 'message.delta').map(event => event.payload.delta)).toEqual([
'```',
'ts',
'\n',
'const value = 1',
'\n',
'```',
])
manager.shutdown()
})
it('normalizes Codex full-text snapshot deltas before emitting chat deltas', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
;(manager as any).markChatRunCompleted = () => {}
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-codex-snapshot-${suffix}`
const chatSessionId = `chat-session-codex-snapshot-${suffix}`
manager.start({
agentSessionId,
agentId: 'codex',
profile: 'default',
provider: 'test-provider',
model: 'gpt-5-codex',
sessionId: chatSessionId,
command: 'codex',
args: ['--model', 'gpt-5-codex'],
shellCommand: 'codex --model gpt-5-codex',
workspaceDir: process.cwd(),
state,
})
const run = (manager as any).runs.get(agentSessionId)
run.printResponseId = 'resp_codex_snapshot'
run.printMessageId = 'msg_resp_codex_snapshot'
run.printTextStarted = false
run.printText = ''
run.printCompleted = false
run.responseStartEmitted = false
run.terminalEventHandled = false
run.codexToolBlocks = new Map()
run.codexChatText = ''
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.created',
data: { response: { id: 'resp_codex_snapshot', status: 'in_progress', model: 'gpt-5-codex', output: [] } },
})
const first = 'Hello! How can I help you today?'
const snapshot = 'Hello! How can I help you today? Please describe what you need.'
;(manager as any).handleCodexExecLine(run, JSON.stringify({
method: 'item/agentMessage/delta',
params: { delta: first },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({
method: 'item/agentMessage/delta',
params: { delta: snapshot },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({ type: 'turn.completed' }))
;(manager as any).completeCodexExecTurn(run, run.codexPendingUsage)
expect(state.messages.find((message: any) => message.role === 'assistant')?.content).toBe(snapshot)
expect(emitted.filter(event => event.event === 'message.delta').map(event => event.payload.delta)).toEqual([
first,
' Please describe what you need.',
])
manager.shutdown()
})
it('stores Codex reasoning items in the response run state', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
;(manager as any).markChatRunCompleted = () => {}
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-codex-reasoning-${suffix}`
const chatSessionId = `chat-session-codex-reasoning-${suffix}`
manager.start({
agentSessionId,
agentId: 'codex',
profile: 'default',
provider: 'test-provider',
model: 'gpt-5-codex',
sessionId: chatSessionId,
command: 'codex',
args: ['--model', 'gpt-5-codex'],
shellCommand: 'codex --model gpt-5-codex',
workspaceDir: process.cwd(),
state,
})
const run = (manager as any).runs.get(agentSessionId)
run.printResponseId = 'resp_codex_reasoning'
run.printMessageId = 'msg_resp_codex_reasoning'
run.printTextStarted = false
run.printText = ''
run.printCompleted = false
run.responseStartEmitted = false
run.terminalEventHandled = false
run.codexToolBlocks = new Map()
run.codexChatText = ''
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.created',
data: { response: { id: 'resp_codex_reasoning', status: 'in_progress', model: 'gpt-5-codex', output: [] } },
})
;(manager as any).handleCodexExecLine(run, JSON.stringify({
type: 'item.completed',
item: { type: 'reasoning', summary: [{ text: 'Need inspect. ' }, { text: 'Then answer.' }] },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({
type: 'response_item',
payload: { type: 'reasoning', summary: [{ type: 'summary_text', text: ' From response item.' }] },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({
method: 'item/reasoning/delta',
params: { delta: ' Extra.' },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({
method: 'item/agentMessage/delta',
params: { delta: 'Done.' },
}))
expect(emitted.filter(event => event.event === 'reasoning.delta').map(event => event.payload.delta)).toEqual([
'Need inspect. Then answer.',
' From response item.',
' Extra.',
])
expect(state.messages.find((message: any) => message.role === 'assistant')).toMatchObject({
content: 'Done.',
reasoning: 'Need inspect. Then answer. From response item. Extra.',
reasoning_content: 'Need inspect. Then answer. From response item. Extra.',
})
manager.shutdown()
})
it('does not append unrelated Codex final text without a tool boundary', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
;(manager as any).markChatRunCompleted = () => {}
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-codex-no-tool-final-${suffix}`
const chatSessionId = `chat-session-codex-no-tool-final-${suffix}`
manager.start({
agentSessionId,
agentId: 'codex',
profile: 'default',
provider: 'test-provider',
model: 'gpt-5-codex',
sessionId: chatSessionId,
command: 'codex',
args: ['--model', 'gpt-5-codex'],
shellCommand: 'codex --model gpt-5-codex',
workspaceDir: process.cwd(),
state,
})
const run = (manager as any).runs.get(agentSessionId)
run.printResponseId = 'resp_codex_no_tool_final'
run.printMessageId = 'msg_resp_codex_no_tool_final'
run.printTextStarted = false
run.printText = ''
run.printCompleted = false
run.responseStartEmitted = false
run.terminalEventHandled = false
run.codexToolBlocks = new Map()
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.created',
data: { response: { id: 'resp_codex_no_tool_final', status: 'in_progress', model: 'gpt-5-codex', output: [] } },
})
;(manager as any).handleCodexExecLine(run, JSON.stringify({
type: 'item.completed',
item: { type: 'agent_message', text: 'Initial assistant text.' },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({
type: 'item.completed',
item: { type: 'agent_message', text: 'Different final text without tools.' },
}))
;(manager as any).completeCodexExecTurn(run)
const textMessages = state.messages.filter((message: any) => message.role === 'assistant' && !message.tool_calls?.length)
expect(textMessages.map((message: any) => message.content)).toEqual(['Initial assistant text.'])
expect(emitted.filter(event => event.event === 'message.delta').map(event => event.payload.delta)).toEqual(['Initial assistant text.'])
manager.shutdown()
})
it('keeps repeated short Codex streaming deltas', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
;(manager as any).markChatRunCompleted = () => {}
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-codex-short-deltas-${suffix}`
const chatSessionId = `chat-session-codex-short-deltas-${suffix}`
manager.start({
agentSessionId,
agentId: 'codex',
profile: 'default',
provider: 'test-provider',
model: 'gpt-5-codex',
sessionId: chatSessionId,
command: 'codex',
args: ['--model', 'gpt-5-codex'],
shellCommand: 'codex --model gpt-5-codex',
workspaceDir: process.cwd(),
state,
})
const run = (manager as any).runs.get(agentSessionId)
run.printResponseId = 'resp_codex_short_deltas'
run.printMessageId = 'msg_resp_codex_short_deltas'
run.printTextStarted = false
run.printText = ''
run.printCompleted = false
run.responseStartEmitted = false
run.terminalEventHandled = false
run.codexToolBlocks = new Map()
run.codexChatText = ''
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.created',
data: { response: { id: 'resp_codex_short_deltas', status: 'in_progress', model: 'gpt-5-codex', output: [] } },
})
;(manager as any).handleCodexExecLine(run, JSON.stringify({
method: 'item/agentMessage/delta',
params: { delta: '\n' },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({
method: 'item/agentMessage/delta',
params: { delta: '\n' },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({ type: 'turn.completed' }))
;(manager as any).completeCodexExecTurn(run, run.codexPendingUsage)
expect(state.messages.find((message: any) => message.role === 'assistant')?.content).toBe('\n\n')
expect(emitted.filter(event => event.event === 'message.delta').map(event => event.payload.delta)).toEqual(['\n', '\n'])
manager.shutdown()
})
it('waits for Codex process exit before flushing final text after tools', async () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
;(manager as any).markChatRunCompleted = () => {}
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-codex-tool-final-${suffix}`
const chatSessionId = `chat-session-codex-tool-final-${suffix}`
manager.start({
agentSessionId,
agentId: 'codex',
profile: 'default',
provider: 'test-provider',
model: 'gpt-5-codex',
sessionId: chatSessionId,
command: 'codex',
args: ['--model', 'gpt-5-codex'],
shellCommand: 'codex --model gpt-5-codex',
workspaceDir: process.cwd(),
state,
})
const run = (manager as any).runs.get(agentSessionId)
run.printResponseId = 'resp_codex_tool_final'
run.printMessageId = 'msg_resp_codex_tool_final'
run.printTextStarted = false
run.printText = ''
run.printCompleted = false
run.responseStartEmitted = false
run.terminalEventHandled = false
run.codexToolBlocks = new Map()
run.codexChatText = ''
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.created',
data: { response: { id: 'resp_codex_tool_final', status: 'in_progress', model: 'gpt-5-codex', output: [] } },
})
const openingText = '我会先查看你的桌面目录。'
;(manager as any).handleCodexExecLine(run, JSON.stringify({
type: 'item.completed',
item: { type: 'agent_message', text: openingText },
}))
expect(state.messages).toContainEqual(expect.objectContaining({
role: 'assistant',
content: openingText,
}))
manager.handleResponseEvent(agentSessionId, {
type: 'response.output_item.added',
data: {
item: {
type: 'function_call',
id: 'call-proxy',
call_id: 'call-proxy',
name: 'exec_command',
arguments: '{"cmd":"ls ~/Desktop"}',
},
},
})
manager.handleResponseEvent(agentSessionId, {
type: 'response.output_item.done',
data: {
item: {
type: 'function_call',
id: 'call-proxy',
call_id: 'call-proxy',
name: 'exec_command',
arguments: '{"cmd":"ls ~/Desktop"}',
},
},
})
;(manager as any).handleCodexExecLine(run, JSON.stringify({
type: 'item.started',
item: {
type: 'mcp_tool_call',
id: 'call-1',
name: 'exec_command',
arguments: { cmd: 'ls ~/Desktop' },
},
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({
type: 'item.completed',
item: {
type: 'mcp_tool_call',
id: 'call-1',
name: 'exec_command',
arguments: { cmd: 'ls ~/Desktop' },
output: 'ai素材\ncache\ngit',
},
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({
type: 'item.started',
item: { type: 'command_execution', id: 'cmd-1', command: 'ls ~/Desktop' },
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({
type: 'item.completed',
item: {
type: 'command_execution',
id: 'cmd-1',
command: 'ls ~/Desktop',
aggregated_output: 'ai素材\ncache\ngit',
},
}))
;(manager as any).handleCodexExecLine(run, JSON.stringify({ type: 'turn.completed' }))
run.currentChild = { exitCode: null, signalCode: null, killed: false } as any
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.completed',
data: {
response: {
id: 'resp_codex_tool_final',
status: 'completed',
model: 'gpt-5-codex',
output: [],
usage: { input_tokens: 1, output_tokens: 2 },
},
},
})
expect(emitted.map(event => event.event)).not.toContain('run.completed')
expect(state.isWorking).toBe(true)
expect(getSessionDetail(chatSessionId)?.messages || []).not.toContainEqual(expect.objectContaining({
role: 'assistant',
tool_calls: expect.any(Array),
}))
const finalText = '你的桌面上有以下 3 个目录:ai素材、cache、git。'
;(manager as any).handleCodexExecLine(run, JSON.stringify({
type: 'item.completed',
item: { type: 'agent_message', text: finalText },
}))
run.currentChild = undefined
;(manager as any).completeCodexExecTurn(run, run.codexPendingUsage)
await new Promise(resolve => setTimeout(resolve, 0))
const textMessages = state.messages.filter((message: any) => message.role === 'assistant' && !message.tool_calls?.length)
expect(textMessages.map((message: any) => message.content)).toEqual([openingText, finalText])
expect(textMessages.at(-1)).toEqual(expect.objectContaining({ finish_reason: 'stop' }))
const dbMessages = getSessionDetail(chatSessionId)?.messages || []
expect(dbMessages.filter(message => message.role === 'assistant' && message.tool_calls?.length)).toHaveLength(1)
expect(dbMessages).toContainEqual(expect.objectContaining({
role: 'tool',
content: 'ai素材\ncache\ngit',
tool_call_id: 'cmd-1',
}))
expect(dbMessages).toContainEqual(expect.objectContaining({
role: 'assistant',
content: finalText,
}))
expect(emitted.map(event => event.event)).toContain('run.completed')
manager.shutdown()
})
it('truncates large coding-agent tool outputs before emitting and flushing to SQLite', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
;(manager as any).markChatRunCompleted = () => {}
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-codex-large-tool-${suffix}`
const chatSessionId = `chat-session-codex-large-tool-${suffix}`
manager.start({
agentSessionId,
agentId: 'codex',
profile: 'default',
provider: 'test-provider',
model: 'gpt-5-codex',
sessionId: chatSessionId,
command: 'codex',
args: ['--model', 'gpt-5-codex'],
shellCommand: 'codex --model gpt-5-codex',
workspaceDir: process.cwd(),
state,
})
const run = (manager as any).runs.get(agentSessionId)
const largeOutput = `HEAD-${'a'.repeat(80 * 1024)}-TAIL`
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.created',
data: { response: { id: 'resp_large_tool', status: 'in_progress', model: 'gpt-5-codex', output: [] } },
})
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.output_item.added',
data: {
item: {
type: 'function_call',
id: 'call_large',
call_id: 'call_large',
name: 'read_file',
arguments: '{"path":"big.log"}',
},
},
})
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.output_item.done',
data: {
item: {
type: 'function_call',
id: 'call_large',
call_id: 'call_large',
name: 'read_file',
arguments: '{"path":"big.log"}',
},
},
})
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.output_item.done',
data: {
item: {
type: 'function_call_output',
id: 'call_large',
call_id: 'call_large',
output: largeOutput,
},
},
})
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.completed',
data: { response: { id: 'resp_large_tool', status: 'completed', model: 'gpt-5-codex', output: [] } },
})
const toolMessage = state.messages.find((message: any) => message.role === 'tool')
const completedPayload = emitted.find(event => event.event === 'tool.completed')?.payload as any
const dbToolMessage = (getSessionDetail(chatSessionId)?.messages || []).find(message => message.role === 'tool')
for (const output of [toolMessage?.content, completedPayload?.output, dbToolMessage?.content]) {
expect(output).toContain('HEAD-')
expect(output).toContain('-TAIL')
expect(output).toContain('coding-agent tool output truncated for storage')
expect(output.length).toBeLessThan(largeOutput.length)
expect(output.length).toBeLessThan(34 * 1024)
}
manager.shutdown()
})
it('records Codex thread id so follow-up turns can resume the native session', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-codex-thread-${suffix}`
const chatSessionId = `chat-session-codex-thread-${suffix}`
manager.start({
agentSessionId,
agentId: 'codex',
profile: 'default',
provider: 'test-provider',
model: 'gpt-5-codex',
sessionId: chatSessionId,
command: 'codex',
args: ['--model', 'gpt-5-codex'],
shellCommand: 'codex --model gpt-5-codex',
workspaceDir: process.cwd(),
state: { messages: [], isWorking: false, events: [], queue: [] },
})
const run = (manager as any).runs.get(agentSessionId)
;(manager as any).handleCodexExecLine(run, JSON.stringify({
type: 'thread.started',
thread_id: '0199a213-81c0-7800-8aa1-bbab2a035a53',
}))
expect(getSession(chatSessionId)?.agent_native_session_id).toBe('0199a213-81c0-7800-8aa1-bbab2a035a53')
manager.shutdown()
})
it('does not report a completed idle coding-agent session cleanup as a run failure', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
;(manager as any).markChatRunCompleted = (_sessionId: string, event: string) => {
emitted.push({ event, payload: { marked: true } })
}
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-idle-cleanup-${suffix}`
const chatSessionId = `chat-session-idle-cleanup-${suffix}`
manager.start({
agentSessionId,
agentId: 'codex',
profile: 'default',
provider: 'test-provider',
model: 'gpt-5-codex',
sessionId: chatSessionId,
command: 'codex',
args: ['--model', 'gpt-5-codex'],
shellCommand: 'codex --model gpt-5-codex',
workspaceDir: process.cwd(),
state: { messages: [], isWorking: false, events: [], queue: [] },
})
const run = (manager as any).runs.get(agentSessionId)
run.state.isWorking = false
run.currentChild = undefined
;(manager as any).cleanupRun(run, { kill: true })
expect(emitted).toEqual([])
})
it('does not emit run.failed when a print coding-agent session is stopped by abort', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
;(manager as any).markChatRunCompleted = (_sessionId: string, event: string) => {
emitted.push({ event, payload: { marked: true } })
}
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-user-stop-${suffix}`
const chatSessionId = `chat-session-user-stop-${suffix}`
manager.start({
agentSessionId,
agentId: 'claude-code',
profile: 'default',
provider: 'test-provider',
model: 'claude-test',
sessionId: chatSessionId,
command: 'claude',
args: [],
shellCommand: 'claude',
workspaceDir: process.cwd(),
state: { messages: [], isWorking: true, events: [], queue: [] },
})
expect(manager.stop(chatSessionId, { reportClosed: false })).toBe(true)
expect(emitted).toEqual([])
})
it('defers queued-run release until a print coding-agent child exits', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const state: any = { messages: [], isWorking: false, events: [], queue: [{ queue_id: 'queued-1', input: 'next' }] }
const completed = vi.fn()
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
;(manager as any).markChatRunCompleted = completed
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-defer-complete-${suffix}`
const chatSessionId = `chat-session-defer-complete-${suffix}`
manager.start({
agentSessionId,
agentId: 'claude-code',
profile: 'default',
provider: 'test-provider',
model: 'claude-test',
sessionId: chatSessionId,
command: 'claude',
args: [],
shellCommand: 'claude',
workspaceDir: process.cwd(),
state,
})
const run = (manager as any).runs.get(agentSessionId)
run.currentChild = { exitCode: null, signalCode: null, killed: false }
;(manager as any).handleClaudePrintResponseEvent(run, {
type: 'response.completed',
data: { response: { id: 'resp-defer', status: 'completed', output: [] } },
})
expect(completed).not.toHaveBeenCalled()
expect(state.isWorking).toBe(true)
expect(run.pendingChatCompletionEvent).toBe('run.completed')
expect(run.pendingChatCompletionPayload).toEqual(expect.objectContaining({
event: 'run.completed',
response_id: 'resp-defer',
}))
run.currentChild = undefined
;(manager as any).emitAndMarkPrintChatRunCompleted(run, run.pendingChatCompletionEvent, run.pendingChatCompletionPayload)
expect(completed).toHaveBeenCalledWith(chatSessionId, 'run.completed')
expect(emitted).toContainEqual(expect.objectContaining({
event: 'run.completed',
payload: expect.objectContaining({
response_id: 'resp-defer',
queue_remaining: 1,
}),
}))
expect(state.isWorking).toBe(false)
expect(run.pendingChatCompletionEvent).toBeUndefined()
expect(run.pendingChatCompletionPayload).toBeUndefined()
})
it('writes print coding-agent session end markers only after the final queued run completes', () => {
initAllHermesTables()
const manager = new CodingAgentRunManager()
const state: any = { messages: [], isWorking: true, events: [], queue: [{ queue_id: 'queued-1', input: 'next' }] }
const suffix = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const agentSessionId = `agent-session-ended-marker-${suffix}`
const chatSessionId = `chat-session-ended-marker-${suffix}`
manager.start({
agentSessionId,
agentId: 'claude-code',
profile: 'default',
provider: 'test-provider',
model: 'claude-test',
sessionId: chatSessionId,
command: 'claude',
args: [],
shellCommand: 'claude',
workspaceDir: process.cwd(),
state,
})
const run = (manager as any).runs.get(agentSessionId)
;(manager as any).emitAndMarkPrintChatRunCompleted(run, 'run.completed', { event: 'run.completed' })
expect(getSession(chatSessionId)?.ended_at).toBeNull()
expect(getSession(chatSessionId)?.end_reason).toBeNull()
state.queue = []
state.isWorking = true
;(manager as any).emitAndMarkPrintChatRunCompleted(run, 'run.failed', { event: 'run.failed' })
const session = getSession(chatSessionId)
expect(session?.ended_at).toEqual(expect.any(Number))
expect(session?.end_reason).toBe('error')
})
})
describe('coding agent chat event mapper', () => {
it('does not surface raw provider stream events as chat agent events', () => {
const mapped = mapCodingAgentResponseEvent({
type: 'response.output_text.delta',
data: { type: 'response.output_text.delta', delta: 'hello' },
})
expect(mapped).toEqual([])
})
it('maps reasoning deltas to chat reasoning deltas', () => {
expect(mapCodingAgentResponseEvent({
type: 'response.reasoning.delta',
data: { type: 'response.reasoning.delta', delta: 'thinking' },
})).toEqual([{
event: 'reasoning.delta',
payload: expect.objectContaining({
event: 'reasoning.delta',
delta: 'thinking',
}),
}])
})
})
describe('response stream tool detail events', () => {
it('emits updated tool.started payloads as function-call arguments stream in', () => {
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
applyResponseStreamEvent(state, 'session-1', 'run-1', 'response.created', {
response: { id: 'resp-1', status: 'in_progress' },
})
const started = applyResponseStreamEvent(state, 'session-1', 'run-1', 'response.output_item.added', {
item: { type: 'function_call', call_id: 'call-1', name: 'Bash', arguments: '' },
})
const withCommand = applyResponseStreamEvent(state, 'session-1', 'run-1', 'response.function_call_arguments.delta', {
item_id: 'call-1',
delta: '{"command":"pwd"',
})
const withFinalArgs = applyResponseStreamEvent(state, 'session-1', 'run-1', 'response.function_call_arguments.delta', {
item_id: 'call-1',
delta: '}',
})
expect(started).toEqual(expect.objectContaining({
event: 'tool.started',
payload: expect.objectContaining({
tool_call_id: 'call-1',
tool: 'Bash',
}),
}))
expect(withCommand).toEqual(expect.objectContaining({
event: 'tool.started',
payload: expect.objectContaining({
arguments: '{"command":"pwd"',
}),
}))
expect(withFinalArgs).toEqual(expect.objectContaining({
event: 'tool.started',
payload: expect.objectContaining({
arguments: '{"command":"pwd"}',
}),
}))
})
it('emits completed tool events with duration', () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
applyResponseStreamEvent(state, 'session-1', 'run-1', 'response.created', {
response: { id: 'resp-1', status: 'in_progress' },
})
applyResponseStreamEvent(state, 'session-1', 'run-1', 'response.output_item.added', {
item: { type: 'function_call', call_id: 'call-1', name: 'Bash', arguments: '{"command":"pwd"}' },
})
vi.setSystemTime(new Date('2026-01-01T00:00:01.230Z'))
const completed = applyResponseStreamEvent(state, 'session-1', 'run-1', 'response.output_item.done', {
item: { type: 'function_call_output', call_id: 'call-1', output: 'ok' },
})
expect(completed).toEqual(expect.objectContaining({
event: 'tool.completed',
payload: expect.objectContaining({
event: 'tool.completed',
tool_call_id: 'call-1',
duration: 1.23,
output: 'ok',
}),
}))
} finally {
vi.useRealTimers()
}
})
it('emits failed tool events with duration and persisted error state', () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
const state: any = { messages: [], isWorking: false, events: [], queue: [] }
applyResponseStreamEvent(state, 'session-1', 'run-1', 'response.created', {
response: { id: 'resp-1', status: 'in_progress' },
})
applyResponseStreamEvent(state, 'session-1', 'run-1', 'response.output_item.added', {
item: { type: 'function_call', call_id: 'call-1', name: 'Bash', arguments: '{"command":"pwd"}' },
})
vi.setSystemTime(new Date('2026-01-01T00:00:02.500Z'))
const failed = applyResponseStreamEvent(state, 'session-1', 'run-1', 'response.output_item.done', {
item: { type: 'function_call_output', call_id: 'call-1', output: { ok: false, error: 'permission denied' } },
})
expect(failed).toEqual(expect.objectContaining({
event: 'tool.failed',
payload: expect.objectContaining({
event: 'tool.failed',
tool_call_id: 'call-1',
duration: 2.5,
error: 'permission denied',
output: '{"ok":false,"error":"permission denied"}',
}),
}))
expect(state.messages.find((message: any) => message.role === 'tool')).toEqual(expect.objectContaining({
finish_reason: 'error',
tool_call_id: 'call-1',
}))
} finally {
vi.useRealTimers()
}
})
})
describe('Claude Code stream-json mapping', () => {
it('maps top-level tool_result messages to tool.completed', () => {
const manager = new CodingAgentRunManager()
const emitted: Array<{ event: string; payload: any }> = []
;(manager as any).emitToChat = (_sessionId: string, event: string, payload: any) => {
emitted.push({ event, payload })
}
;(manager as any).ensureDbSession = () => {}
const run = {
id: 'agent-session-1',
launch: {
agentSessionId: 'agent-session-1',
agentId: 'claude-code',
profile: 'default',
provider: 'test',
model: 'claude-test',
sessionId: 'chat-session-1',
command: 'claude',
args: [],
shellCommand: 'claude',
workspaceDir: process.cwd(),
},
state: { messages: [], isWorking: false, events: [], queue: [] },
lastActiveAt: Date.now(),
startedAt: Date.now(),
exited: false,
printResponseId: 'resp_1',
printMessageId: 'msg_resp_1',
printToolBlocks: new Map(),
}
;(manager as any).runs.set(run.id, run)
;(manager as any).handleClaudePrintLine(run, JSON.stringify({
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'pwd' } }],
},
}))
;(manager as any).handleClaudePrintLine(run, JSON.stringify({
type: 'user',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: '/tmp/project' }],
},
}))
expect(emitted.map(event => event.event)).toContain('tool.started')
expect(emitted).toContainEqual(expect.objectContaining({
event: 'tool.completed',
payload: expect.objectContaining({
tool_call_id: 'toolu_1',
output: '/tmp/project',
}),
}))
})
})