From 8c1ddf415f1408b5fcdf514966f08cd005bcd36a Mon Sep 17 00:00:00 2001 From: Jack Date: Fri, 16 Oct 2026 13:27:50 +0800 Subject: [PATCH] Phase 003: remove the coding-agent and ekko-agent runtimes Every chat session now runs through the Hermes bridge, so the three chat chains become feature-equivalent instead of three partially-maintained dispatch paths. Keeping the coding-agent and in-house agent runtimes alive meant every bridge change had to be mirrored into two more runtimes that had already drifted. Historical rows are read, not migrated: sessions stored with the retired source normalize to cli on load, workflow graphs naming the retired agents keep loading and run on the bridge, and the visible-source check plus SOURCE_LABELS still recognize the old value so those sessions stay listed. The public surface follows the code: the OpenAPI generator and the MCP chat-run tool schema drop the six retired entry fields so external callers are not told about runtimes that no longer exist. harness-check gains a RETIRED_RUNTIME scan so the residual check is a mechanical gate instead of a manual grep, with the two surviving read-side compat sites recorded with reasons. Refs: docs/phases/phase_003.md --- AGENTS.md | 4 +- ARCHITECTURE.md | 1 - bin/hermes-ui-mcp.mjs | 42 +- ...026-10-16-phase003-remove-coding-agents.md | 107 + docs/openapi.json | 552 +---- docs/phases/phase_003.md | 9 +- packages/client/src/api/coding-agents.ts | 209 -- packages/client/src/api/hermes/chat.ts | 7 +- .../src/components/hermes/chat/ChatInput.vue | 2 +- .../src/components/hermes/chat/ChatPanel.vue | 273 +-- .../components/hermes/chat/MessageList.vue | 37 +- .../hermes/chat/SessionListItem.vue | 13 +- .../hermes/usage/AgentBreakdown.vue | 3 - .../hermes/workflow/WorkflowAgentNode.vue | 31 +- .../src/components/hermes/workflow/types.ts | 4 +- .../src/components/layout/AppSidebar.vue | 8 - packages/client/src/i18n/locales/en.ts | 83 +- packages/client/src/i18n/locales/zh.ts | 83 +- packages/client/src/router/index.ts | 5 - packages/client/src/stores/hermes/chat.ts | 155 +- .../src/views/hermes/CodingAgentsView.vue | 1026 --------- .../client/src/views/hermes/HistoryView.vue | 13 +- .../client/src/views/hermes/WorkflowView.vue | 22 +- packages/ekko-agent/README.md | 133 -- packages/ekko-agent/docs/memory-chain-plan.md | 1274 ----------- packages/ekko-agent/package.json | 17 - packages/ekko-agent/src/index.ts | 57 - packages/ekko-agent/src/model/errors.ts | 27 - packages/ekko-agent/src/model/http.ts | 151 -- packages/ekko-agent/src/model/messages.ts | 155 -- .../ekko-agent/src/model/provider-config.ts | 98 - .../src/model/providers/anthropic.ts | 293 --- .../src/model/providers/custom-runtime.ts | 70 - .../ekko-agent/src/model/providers/gemini.ts | 205 -- .../src/model/providers/openai-compatible.ts | 354 --- .../src/model/providers/openai-responses.ts | 228 -- .../src/model/providers/prompt-completion.ts | 125 -- packages/ekko-agent/src/model/registry.ts | 64 - packages/ekko-agent/src/model/types.ts | 117 - packages/ekko-agent/src/runtime/events.ts | 23 - packages/ekko-agent/src/runtime/runtime.ts | 483 ---- .../ekko-agent/src/runtime/system-prompt.ts | 49 - packages/ekko-agent/src/runtime/types.ts | 67 - packages/ekko-agent/src/skills/types.ts | 9 - packages/ekko-agent/src/tools/browser.ts | 795 ------- packages/ekko-agent/src/tools/files.ts | 86 - packages/ekko-agent/src/tools/mcp.ts | 221 -- packages/ekko-agent/src/tools/path-safety.ts | 17 - packages/ekko-agent/src/tools/registry.ts | 76 - packages/ekko-agent/src/tools/terminal.ts | 162 -- packages/ekko-agent/src/tools/types.ts | 38 - packages/ekko-agent/tsconfig.json | 12 - .../server/src/controllers/coding-agents.ts | 169 -- .../server/src/controllers/hermes/sessions.ts | 45 +- .../server/src/routes/claude-code-proxy.ts | 7 - packages/server/src/routes/codex-proxy.ts | 7 - packages/server/src/routes/coding-agents.ts | 15 - packages/server/src/routes/index.ts | 6 - .../agent-runner/adapters/anthropic-stream.ts | 429 ---- .../agent-runner/adapters/anthropic.ts | 292 --- .../agent-runner/adapters/responses-stream.ts | 535 ----- .../agent-runner/adapters/responses.ts | 584 ----- .../agent-runner/coding-agent-event-mapper.ts | 45 - .../agent-runner/coding-agent-run-manager.ts | 1974 ----------------- .../agent-runner/endpoint-resolver.ts | 71 - .../src/services/agent-runner/gateway.ts | 75 - .../agent-runner/proxies/claude-code-proxy.ts | 273 --- .../agent-runner/proxies/codex-proxy.ts | 269 --- .../server/src/services/agent-runner/sse.ts | 63 - .../src/services/agent-runner/stream-tee.ts | 52 - .../services/agent-runner/target-registry.ts | 66 - .../server/src/services/agent-runner/types.ts | 8 - packages/server/src/services/coding-agents.ts | 1952 ---------------- .../server/src/services/ekko-agent/manager.ts | 32 - .../server/src/services/ekko-agent/mcp.ts | 116 - .../src/services/hermes/run-chat/abort.ts | 105 +- .../run-chat/handle-coding-agent-run.ts | 138 -- .../hermes/run-chat/handle-ekko-agent-run.ts | 834 ------- .../src/services/hermes/run-chat/index.ts | 158 +- .../services/hermes/run-chat/load-state.ts | 7 +- .../hermes/run-chat/session-command.ts | 17 +- .../src/services/hermes/run-chat/sse-utils.ts | 5 - .../src/services/hermes/run-chat/types.ts | 5 +- packages/server/src/services/shutdown.ts | 4 - .../server/src/services/usage-recorder.ts | 4 +- .../server/src/services/workflow-manager.ts | 39 +- scripts/generate-openapi.mjs | 45 +- scripts/harness-check.mjs | 105 +- tests/client/api.test.ts | 4 +- tests/client/chat-input-draft.test.ts | 15 +- tests/client/chat-panel-session-click.test.ts | 21 +- ...chat-store-reasoning-tool-boundary.test.ts | 206 +- tests/client/coding-agent-api-modes.test.ts | 30 - tests/client/session-list-item.test.ts | 48 +- tests/ekko-agent/browser-tools.test.ts | 166 -- tests/ekko-agent/messages.test.ts | 142 -- tests/ekko-agent/model-request.test.ts | 432 ---- tests/ekko-agent/runtime.test.ts | 501 ----- tests/ekko-agent/tools.test.ts | 152 -- tests/server/agent-runner-adapters.test.ts | 687 ------ tests/server/agent-runner-gateway.test.ts | 89 - tests/server/agent-runner-utils.test.ts | 1676 -------------- tests/server/api-docs-controller.test.ts | 2 +- .../server/chat-run-bridge-readiness.test.ts | 64 +- tests/server/coding-agent-baseline.test.ts | 140 -- .../server/coding-agent-resume-config.test.ts | 384 ---- .../coding-agent-run-manager-windows.test.ts | 320 --- .../server/coding-agents-desktop-path.test.ts | 93 - tests/server/coding-agents-launch.test.ts | 1220 ---------- .../coding-agents-windows-execution.test.ts | 71 - tests/server/ekko-agent-manager.test.ts | 53 - tests/server/ekko-agent-mcp.test.ts | 98 - tests/server/handle-coding-agent-run.test.ts | 335 --- tests/server/hermes-ui-mcp.test.ts | 8 +- tests/server/run-chat-abort-goal.test.ts | 71 +- tests/server/run-chat-ekko-context.test.ts | 302 --- tests/server/run-chat-queued-item.test.ts | 47 +- tests/server/session-command-branch.test.ts | 19 - tests/server/sessions-controller.test.ts | 75 +- tests/server/usage-recorder.test.ts | 8 +- .../server/usage-store-agent-stats-db.test.ts | 8 +- tests/server/usage-store.test.ts | 4 +- tests/server/workflow-manager.test.ts | 29 +- 123 files changed, 567 insertions(+), 23599 deletions(-) create mode 100644 docs/chat-chain-changes/2026-10-16-phase003-remove-coding-agents.md delete mode 100644 packages/client/src/api/coding-agents.ts delete mode 100644 packages/client/src/views/hermes/CodingAgentsView.vue delete mode 100644 packages/ekko-agent/README.md delete mode 100644 packages/ekko-agent/docs/memory-chain-plan.md delete mode 100644 packages/ekko-agent/package.json delete mode 100644 packages/ekko-agent/src/index.ts delete mode 100644 packages/ekko-agent/src/model/errors.ts delete mode 100644 packages/ekko-agent/src/model/http.ts delete mode 100644 packages/ekko-agent/src/model/messages.ts delete mode 100644 packages/ekko-agent/src/model/provider-config.ts delete mode 100644 packages/ekko-agent/src/model/providers/anthropic.ts delete mode 100644 packages/ekko-agent/src/model/providers/custom-runtime.ts delete mode 100644 packages/ekko-agent/src/model/providers/gemini.ts delete mode 100644 packages/ekko-agent/src/model/providers/openai-compatible.ts delete mode 100644 packages/ekko-agent/src/model/providers/openai-responses.ts delete mode 100644 packages/ekko-agent/src/model/providers/prompt-completion.ts delete mode 100644 packages/ekko-agent/src/model/registry.ts delete mode 100644 packages/ekko-agent/src/model/types.ts delete mode 100644 packages/ekko-agent/src/runtime/events.ts delete mode 100644 packages/ekko-agent/src/runtime/runtime.ts delete mode 100644 packages/ekko-agent/src/runtime/system-prompt.ts delete mode 100644 packages/ekko-agent/src/runtime/types.ts delete mode 100644 packages/ekko-agent/src/skills/types.ts delete mode 100644 packages/ekko-agent/src/tools/browser.ts delete mode 100644 packages/ekko-agent/src/tools/files.ts delete mode 100644 packages/ekko-agent/src/tools/mcp.ts delete mode 100644 packages/ekko-agent/src/tools/path-safety.ts delete mode 100644 packages/ekko-agent/src/tools/registry.ts delete mode 100644 packages/ekko-agent/src/tools/terminal.ts delete mode 100644 packages/ekko-agent/src/tools/types.ts delete mode 100644 packages/ekko-agent/tsconfig.json delete mode 100644 packages/server/src/controllers/coding-agents.ts delete mode 100644 packages/server/src/routes/claude-code-proxy.ts delete mode 100644 packages/server/src/routes/codex-proxy.ts delete mode 100644 packages/server/src/routes/coding-agents.ts delete mode 100644 packages/server/src/services/agent-runner/adapters/anthropic-stream.ts delete mode 100644 packages/server/src/services/agent-runner/adapters/anthropic.ts delete mode 100644 packages/server/src/services/agent-runner/adapters/responses-stream.ts delete mode 100644 packages/server/src/services/agent-runner/adapters/responses.ts delete mode 100644 packages/server/src/services/agent-runner/coding-agent-event-mapper.ts delete mode 100644 packages/server/src/services/agent-runner/coding-agent-run-manager.ts delete mode 100644 packages/server/src/services/agent-runner/endpoint-resolver.ts delete mode 100644 packages/server/src/services/agent-runner/gateway.ts delete mode 100644 packages/server/src/services/agent-runner/proxies/claude-code-proxy.ts delete mode 100644 packages/server/src/services/agent-runner/proxies/codex-proxy.ts delete mode 100644 packages/server/src/services/agent-runner/sse.ts delete mode 100644 packages/server/src/services/agent-runner/stream-tee.ts delete mode 100644 packages/server/src/services/agent-runner/target-registry.ts delete mode 100644 packages/server/src/services/agent-runner/types.ts delete mode 100644 packages/server/src/services/coding-agents.ts delete mode 100644 packages/server/src/services/ekko-agent/manager.ts delete mode 100644 packages/server/src/services/ekko-agent/mcp.ts delete mode 100644 packages/server/src/services/hermes/run-chat/handle-coding-agent-run.ts delete mode 100644 packages/server/src/services/hermes/run-chat/handle-ekko-agent-run.ts delete mode 100644 packages/server/src/services/hermes/run-chat/sse-utils.ts delete mode 100644 tests/client/coding-agent-api-modes.test.ts delete mode 100644 tests/ekko-agent/browser-tools.test.ts delete mode 100644 tests/ekko-agent/messages.test.ts delete mode 100644 tests/ekko-agent/model-request.test.ts delete mode 100644 tests/ekko-agent/runtime.test.ts delete mode 100644 tests/ekko-agent/tools.test.ts delete mode 100644 tests/server/agent-runner-adapters.test.ts delete mode 100644 tests/server/agent-runner-gateway.test.ts delete mode 100644 tests/server/agent-runner-utils.test.ts delete mode 100644 tests/server/coding-agent-baseline.test.ts delete mode 100644 tests/server/coding-agent-resume-config.test.ts delete mode 100644 tests/server/coding-agent-run-manager-windows.test.ts delete mode 100644 tests/server/coding-agents-desktop-path.test.ts delete mode 100644 tests/server/coding-agents-launch.test.ts delete mode 100644 tests/server/coding-agents-windows-execution.test.ts delete mode 100644 tests/server/ekko-agent-manager.test.ts delete mode 100644 tests/server/ekko-agent-mcp.test.ts delete mode 100644 tests/server/handle-coding-agent-run.test.ts delete mode 100644 tests/server/run-chat-ekko-context.test.ts diff --git a/AGENTS.md b/AGENTS.md index 3788746..db9507b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,6 @@ Use the smallest relevant check while iterating. Before a broad PR, run - `packages/client/src` - Vue 3 client, stores, routes, i18n, API helpers. - `packages/server/src` - Koa API, Socket.IO, persistence, Hermes integration. - `packages/skills` - built-in skills injected into the Hermes skills directory. -- `packages/ekko-agent` - legacy in-house agent runtime, slated for removal in Phase 003. - `tests/client`, `tests/server`, `tests/shared` - Vitest coverage. - `tests/e2e` - Playwright browser coverage with mocked backend services. - `.github/workflows` - CI, release, and Docker packaging automation. @@ -56,8 +55,7 @@ Use the smallest relevant check while iterating. Before a broad PR, run - `services/hermes/group-chat/`, `lib/context-compressor/`, and `services/hermes/context-engine/` are the chat chain: changes require a `docs/chat-chain-changes/-.md` fragment. -- `packages/desktop`, `packages/website`, and `packages/ekko-agent` are removed or - being removed; do not reintroduce them. +- `packages/desktop` and `packages/website` are removed; do not reintroduce them. ## When The Agent Gets Stuck diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2b10bb8..3d7bfc2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,7 +11,6 @@ Agent Bridge (see "Hermes Integration" below). | Client | `packages/client/src` | Vue UI, routing, Pinia stores, API wrappers, i18n, browser-visible state. | | Server | `packages/server/src` | HTTP API, auth, Socket.IO, SQLite stores, file access, Hermes runtime integration. | | Skills | `packages/skills` | Built-in skills injected into the Hermes skills directory. | -| Legacy agent | `packages/ekko-agent` | In-house agent runtime. Slated for removal in Phase 003; do not extend it. | | Tests | `tests` | Vitest unit/integration tests and Playwright browser tests. | | CI | `.github/workflows` | Build, e2e, lockfile, Docker, and release automation. | diff --git a/bin/hermes-ui-mcp.mjs b/bin/hermes-ui-mcp.mjs index 85afa75..3c58700 100755 --- a/bin/hermes-ui-mcp.mjs +++ b/bin/hermes-ui-mcp.mjs @@ -856,13 +856,13 @@ const tools = [ }, source: { type: 'string', - enum: ['cli', 'coding_agent', 'global_agent'], - description: 'Optional run backend source.', + enum: ['cli', 'global_agent', 'workflow'], + description: 'Run backend source. Use cli for Hermes bridge runs or global_agent for global-agent sessions.', }, session_source: { type: 'string', - enum: ['global_agent'], - description: 'Optional session source marker.', + enum: ['global_agent', 'workflow'], + description: 'Marks the session as launched from the global agent or from a workflow run.', }, instructions: { type: 'string', @@ -876,34 +876,6 @@ const tools = [ type: 'string', description: 'Optional per-run reasoning effort override.', }, - coding_agent_id: { - type: 'string', - enum: ['claude-code', 'codex'], - description: 'Coding agent id when source is coding_agent.', - }, - agent_id: { - type: 'string', - enum: ['claude-code', 'codex'], - description: 'Alias for coding_agent_id.', - }, - mode: { - type: 'string', - enum: ['scoped', 'global'], - description: 'Coding-agent launch mode.', - }, - baseUrl: { - type: 'string', - description: 'Optional provider base URL for coding-agent runs.', - }, - apiKey: { - type: 'string', - description: 'Optional provider API key for coding-agent runs.', - }, - apiMode: { - type: 'string', - enum: ['chat_completions', 'codex_responses', 'anthropic_messages'], - description: 'Optional provider wire API mode for coding-agent runs.', - }, timeout_ms: { type: 'number', description: 'Maximum time to wait for a terminal run event.', @@ -1505,12 +1477,6 @@ async function callTool(name, args = {}) { 'instructions', 'workspace', 'reasoning_effort', - 'coding_agent_id', - 'agent_id', - 'mode', - 'baseUrl', - 'apiKey', - 'apiMode', 'timeout_ms', 'include_events', ]), diff --git a/docs/chat-chain-changes/2026-10-16-phase003-remove-coding-agents.md b/docs/chat-chain-changes/2026-10-16-phase003-remove-coding-agents.md new file mode 100644 index 0000000..85fcf5f --- /dev/null +++ b/docs/chat-chain-changes/2026-10-16-phase003-remove-coding-agents.md @@ -0,0 +1,107 @@ +--- +date: 2026-10-16 +pr: pending +feature: Phase 003 - remove the coding-agent and ekko-agent runtimes +impact: Every chat session now runs through the Hermes bridge; sessions stored with the old `coding_agent` source are normalized to `cli` on load. +--- + +# Phase 003 — Remove the coding-agent and ekko-agent runtimes + +Phase: `docs/phases/phase_003.md` (groups I and J) + +## What Changed + +The chat chain had three dispatch branches: bridge, coding agent (Claude Code / +Codex), and ekko-agent. Phase 003 deletes the latter two, leaving the bridge as +the single execution path. + +### Deleted modules + +- `packages/server/src/services/agent-runner/` — 13 files. Adapters for the + Anthropic and OpenAI Responses streaming shapes, the Claude Code / Codex HTTP + proxies, the SSE helpers, and `coding-agent-run-manager`. +- `packages/server/src/services/ekko-agent/` — 2 files (`manager.ts`, `mcp.ts`). +- `packages/ekko-agent/` — the in-house agent runtime package. +- `run-chat/handle-coding-agent-run.ts`, `run-chat/handle-ekko-agent-run.ts`. +- `run-chat/sse-utils.ts` — an orphan that only re-exported + `agent-runner/sse`. No production module imported it; the single remaining + reference was a `vi.mock` in `run-chat-load-state.test.ts`. +- The three route modules, the controller, the service, `CodingAgentsView.vue`, + and `packages/client/src/api/coding-agents.ts`. + +### Dispatch + +`runAndWait` in `run-chat/index.ts` was a three-way branch. It is now a single +call to `handleBridgeRun`. Two predicates went away with it: + +- `isCodingAgentExecution(source, data)` +- `isEkkoAgentExecution(data)` + +`isWorking` was computed as `!isCodingAgentExecution(...)` at three call sites +(queue start, direct run start, resumed run). All three are now `true`. + +### Types and session fields + +- `ChatCodingAgentId` — defined twice (server `run-chat/types.ts`, client + `api/coding-agents.ts`). Both definitions are gone; nothing imports them now. +- `ChatRunSource` lost `'coding_agent'`. +- `QueuedRun` lost `codingAgentId` / `agentId`. +- `StartRunRequest` lost `coding_agent_id` / `agent_id`. +- The client `Session` shape lost `codingAgentId` / `codingAgentMode`. + +### `ChatRunSource` compatibility + +`resolveRunSource` previously passed `'coding_agent'` through. It now falls +through to the default `'cli'`, so sessions stored with the old source run +through the bridge without a data migration. The same is true for the +value-based checks in `controllers/hermes/sessions.ts` — they read the stored +string, and any source that is not `global_agent` or `workflow` resolves to the +bridge path. + +`isCodingAgentSession` in the sessions controller controlled three things at +once: whether a workspace was created, whether the bridge was notified of a +model change, and whether `agent_native_session_id` was cleared. All three now +apply to every session, which is the intended post-Phase-003 behavior. + +### Workflow + +`WorkflowNodeAgent` narrowed to `'hermes'`. `resolveWorkflowNodeRunTarget` +ignores its argument and always returns the Hermes target, so saved workflows +that still name `claude-code` or `codex` keep loading. The node editor's +protocol dropdown and the `apiModeRequired` validation were removed, along with +the `apiMode` field on `WorkflowAgentNodeData`. + +### Usage recording + +`RecordSessionUsageInput.source` and `.agent` narrowed to `'hermes'`. The only +caller is `handle-bridge-run.ts`, which already passed those values. Rows +written before this phase keep their stored `source` / `agent` strings; the +client's `AgentBreakdown` maps anything it does not recognize to `unknown`. + +## Contract Check + +`/chat-run` event names, field names, and field types are unchanged. The +`socket.start_run` payload only ever sent `coding_agent_id` / `agent_id` for +sessions that were already coding-agent sessions; nothing new is sent. Two +optional request fields were removed, and every session that used them now +takes the bridge path instead. + +## Residual Strings + +Kept on purpose, so existing records still render a sensible label: + +- `packages/client/src/shared/session-display.ts` — `coding_agent: 'Coding Agent'` + in `SOURCE_LABELS`, which labels sessions already stored in the DB. +- `packages/server/src/controllers/hermes/sessions.ts` — the + `isVisibleWebUiSessionSource` check still lists `'coding_agent'` so old + sessions stay visible in the Web UI session list. + +Both are registered in the `scripts/harness-check.mjs` allow-list. + +## Verification + +```bash +npm run harness:check +npm run build +npm run test +``` diff --git a/docs/openapi.json b/docs/openapi.json index d53ba37..99a40a4 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -156,10 +156,6 @@ "name": "Devices", "description": "Device pairing and LAN peer operations" }, - { - "name": "Coding Agents", - "description": "Coding agent installation, config, and runs" - }, { "name": "API Docs", "description": "OpenAPI route catalog" @@ -871,17 +867,18 @@ "type": "string", "enum": [ "cli", - "coding_agent", - "global_agent" + "global_agent", + "workflow" ], - "description": "Run backend source. Use cli for Hermes bridge runs, coding_agent for Claude Code/Codex, or global_agent for global-agent sessions. Omit source for normal Hermes chat runs; do not use the legacy api_server source." + "description": "Run backend source. Use cli for Hermes bridge runs or global_agent for global-agent sessions. Omit source for normal Hermes chat runs; do not use the legacy api_server source." }, "session_source": { "type": "string", "enum": [ - "global_agent" + "global_agent", + "workflow" ], - "description": "Marks a coding-agent or bridge session as launched from the global agent." + "description": "Marks the session as launched from the global agent or from a workflow run." }, "instructions": { "type": "string", @@ -896,47 +893,6 @@ "type": "string", "description": "Optional per-run reasoning effort override." }, - "coding_agent_id": { - "type": "string", - "enum": [ - "claude-code", - "codex" - ], - "description": "Coding agent id when source is coding_agent." - }, - "agent_id": { - "type": "string", - "enum": [ - "claude-code", - "codex" - ], - "description": "Alias for coding_agent_id." - }, - "mode": { - "type": "string", - "enum": [ - "scoped", - "global" - ], - "description": "Coding-agent launch mode." - }, - "baseUrl": { - "type": "string", - "description": "Optional provider base URL for coding-agent runs." - }, - "apiKey": { - "type": "string", - "description": "Optional provider API key for coding-agent runs." - }, - "apiMode": { - "type": "string", - "enum": [ - "chat_completions", - "codex_responses", - "anthropic_messages" - ], - "description": "Optional provider wire API mode for coding-agent runs." - }, "timeout_ms": { "type": "integer", "minimum": 1, @@ -1013,502 +969,6 @@ } } }, - "/api/coding-agents": { - "get": { - "tags": [ - "Coding Agents" - ], - "summary": "Get coding-agents", - "description": "GET /api/coding-agents", - "operationId": "status", - "security": [ - { - "BearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "description": "Not found" - } - } - } - }, - "/api/coding-agents/runs/{sessionId}": { - "delete": { - "tags": [ - "Coding Agents" - ], - "summary": "Delete :sessionId", - "description": "DELETE /api/coding-agents/runs/:sessionId", - "operationId": "stopRun", - "security": [ - { - "BearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - }, - "parameters": [ - { - "name": "sessionId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/coding-agents/runs/{sessionId}/input": { - "post": { - "tags": [ - "Coding Agents" - ], - "summary": "Create input", - "description": "POST /api/coding-agents/runs/:sessionId/input", - "operationId": "sendRunInput", - "security": [ - { - "BearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - }, - "parameters": [ - { - "name": "sessionId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "input": { - "type": "string" - } - } - } - } - } - } - } - }, - "/api/coding-agents/{id}": { - "delete": { - "tags": [ - "Coding Agents" - ], - "summary": "Delete :id", - "description": "DELETE /api/coding-agents/:id", - "operationId": "remove", - "security": [ - { - "BearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - }, - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/coding-agents/{id}/config-files/{key}": { - "get": { - "tags": [ - "Coding Agents" - ], - "summary": "Get :key", - "description": "GET /api/coding-agents/:id/config-files/:key", - "operationId": "readConfigFile", - "security": [ - { - "BearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "404": { - "description": "Not found" - } - }, - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "key", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - }, - "put": { - "tags": [ - "Coding Agents" - ], - "summary": "Update :key", - "description": "PUT /api/coding-agents/:id/config-files/:key", - "operationId": "writeConfigFile", - "security": [ - { - "BearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - }, - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "key", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "content": { - "type": "string" - } - } - } - } - } - } - } - }, - "/api/coding-agents/{id}/install": { - "post": { - "tags": [ - "Coding Agents" - ], - "summary": "Create install", - "description": "POST /api/coding-agents/:id/install", - "operationId": "install", - "security": [ - { - "BearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - }, - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, - "/api/coding-agents/{id}/launch/native": { - "post": { - "tags": [ - "Coding Agents" - ], - "summary": "Create native", - "description": "POST /api/coding-agents/:id/launch/native", - "operationId": "nativeLaunch", - "security": [ - { - "BearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - }, - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "apiKey": { - "type": "string" - }, - "apiMode": { - "type": "object", - "additionalProperties": true - }, - "baseUrl": { - "type": "string" - }, - "mode": { - "type": "object", - "additionalProperties": true - }, - "model": { - "type": "string" - }, - "profile": { - "type": "string" - }, - "provider": { - "type": "string" - } - } - } - } - } - } - } - }, - "/api/coding-agents/{id}/launch/prepare": { - "post": { - "tags": [ - "Coding Agents" - ], - "summary": "Create prepare", - "description": "POST /api/coding-agents/:id/launch/prepare", - "operationId": "prepareLaunch", - "security": [ - { - "BearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - }, - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "apiKey": { - "type": "string" - }, - "apiMode": { - "type": "object", - "additionalProperties": true - }, - "baseUrl": { - "type": "string" - }, - "mode": { - "type": "object", - "additionalProperties": true - }, - "model": { - "type": "string" - }, - "profile": { - "type": "string" - }, - "provider": { - "type": "string" - } - } - } - } - } - } - } - }, - "/api/coding-agents/{id}/runs": { - "post": { - "tags": [ - "Coding Agents" - ], - "summary": "Create runs", - "description": "POST /api/coding-agents/:id/runs", - "operationId": "startRun", - "security": [ - { - "BearerAuth": [] - } - ], - "responses": { - "200": { - "description": "Success" - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - } - }, - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "apiKey": { - "type": "string" - }, - "apiMode": { - "type": "object", - "additionalProperties": true - }, - "baseUrl": { - "type": "string" - }, - "mode": { - "type": "object", - "additionalProperties": true - }, - "model": { - "type": "string" - }, - "profile": { - "type": "string" - }, - "provider": { - "type": "string" - }, - "sessionId": { - "type": "string" - } - } - } - } - } - } - } - }, "/api/cron-history": { "get": { "tags": [ diff --git a/docs/phases/phase_003.md b/docs/phases/phase_003.md index 77b0681..7bf8dd1 100644 --- a/docs/phases/phase_003.md +++ b/docs/phases/phase_003.md @@ -158,10 +158,9 @@ isEkkoAgentExecution() ```bash cd /Users/jack/source/MyProject/js/hermes-ui -# 残留检查 -grep -rn "coding_agent\|codingAgent\|CodingAgent\|ekko-agent\|ekkoAgent\|EkkoAgent" \ - packages/ tests/ --include="*.ts" --include="*.vue" --include="*.json" -# 期望:零命中 +# 残留检查(harness 机械门禁,RETIRED_RUNTIME_ALLOWED 登记保留理由) +npm run harness:check +# 期望:Harness check passed npm run build npm run test @@ -184,7 +183,7 @@ npm run test:e2e | # | 风险 | 缓解 | |---|---|---| -| P3-1 | `isWorking` 三处漏改 → 聊天状态卡死或一直转圈 | `grep -n isCodingAgentExecution` 必须零命中后才算改完 | +| P3-1 | `isWorking` 三处漏改 → 聊天状态卡死或一直转圈 | `npm run harness:check` 的 RETIRED_RUNTIME 扫描必须零命中 | | P3-2 | 历史会话带 `coding_agent_id` 导致渲染报错 | 读侧容忍未知字段;手工造一条旧会话验证 | | P3-3 | 删 `agent-runner/` 时误删共用工具 | 先 `grep -rn "agent-runner"` 确认引用方只在这两组内 | | P3-4 | e2e 有用例依赖 coding-agents API | `npm run test:e2e` 必跑,红了删对应用例 | diff --git a/packages/client/src/api/coding-agents.ts b/packages/client/src/api/coding-agents.ts deleted file mode 100644 index a37f0e2..0000000 --- a/packages/client/src/api/coding-agents.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { request } from './client' -import type { ProviderApiMode } from './hermes/system' - -export type CodingAgentId = 'claude-code' | 'codex' -export type ChatCodingAgentId = CodingAgentId | 'ekko-agent' -export const CODING_AGENT_API_MODES = [ - 'chat_completions', - 'codex_responses', - 'anthropic_messages', -] as const satisfies readonly ProviderApiMode[] - -export type CodingAgentApiMode = typeof CODING_AGENT_API_MODES[number] -export type CodingAgentLaunchMode = 'scoped' | 'global' - -export function isCodingAgentApiMode(value: unknown): value is CodingAgentApiMode { - return typeof value === 'string' && (CODING_AGENT_API_MODES as readonly string[]).includes(value) -} - -export function inferCodingAgentApiMode(provider?: string | null, baseUrl?: string | null): CodingAgentApiMode { - const providerKey = String(provider || '').toLowerCase() - const normalizedBaseUrl = String(baseUrl || '').toLowerCase() - - if ( - providerKey.includes('claude') || - providerKey === 'anthropic' || - normalizedBaseUrl.includes('anthropic') || - normalizedBaseUrl.includes('/anthropic') - ) { - return 'anthropic_messages' - } - - if ( - providerKey === 'deepseek' || - providerKey === 'lmstudio' || - normalizedBaseUrl.includes('deepseek') || - normalizedBaseUrl.includes('127.0.0.1') || - normalizedBaseUrl.includes('localhost') - ) { - return 'chat_completions' - } - - return 'chat_completions' -} - -export function normalizeCodingAgentApiMode( - value: unknown, - fallback: CodingAgentApiMode = 'codex_responses', -): CodingAgentApiMode { - return isCodingAgentApiMode(value) ? value : fallback -} - -export interface CodingAgentToolStatus { - id: CodingAgentId - name: string - provider: string - command: string - packageName: string - installed: boolean - version: string - rawVersion: string - error?: string -} - -export interface CodingAgentsStatus { - tools: CodingAgentToolStatus[] -} - -export interface CodingAgentMutationResult extends CodingAgentsStatus { - success: boolean - tool: CodingAgentToolStatus - message?: string - code?: string -} - -export interface CodingAgentConfigFileContent { - key: string - path: string - absolutePath: string - language: string - content: string - exists: boolean - size: number - profile: string - provider: string - rootDir: string -} - -export interface CodingAgentConfigScope { - profile?: string | null - provider?: string | null -} - -export interface CodingAgentLaunchRequest { - mode?: CodingAgentLaunchMode - profile?: string | null - provider?: string - model?: string - baseUrl?: string - apiKey?: string - apiMode?: CodingAgentApiMode -} - -export interface CodingAgentLaunchResult { - agentId: CodingAgentId - mode: CodingAgentLaunchMode - profile: string - provider: string - model: string - apiMode?: CodingAgentApiMode - rootDir: string - workspaceDir: string - command: string - args: string[] - env: Record - shellCommand: string - files: Array<{ key: string; path: string; absolutePath: string }> -} - -export interface CodingAgentNativeLaunchResult extends CodingAgentLaunchResult { - nativeTerminal: true - terminal: string -} - -export interface CodingAgentRunStartResult extends CodingAgentLaunchResult { - agentSessionId: string - sessionId: string - pid: number -} - -export async function fetchCodingAgentsStatus(): Promise { - return request('/api/coding-agents') -} - -export async function installCodingAgent(id: CodingAgentId): Promise { - return request(`/api/coding-agents/${id}/install`, { method: 'POST' }) -} - -export async function deleteCodingAgent(id: CodingAgentId): Promise { - return request(`/api/coding-agents/${id}`, { method: 'DELETE' }) -} - -export async function readCodingAgentConfigFile( - id: CodingAgentId, - key: string, - scope: CodingAgentConfigScope = {}, -): Promise { - const params = new URLSearchParams() - if (scope.profile) params.set('profile', scope.profile) - if (scope.provider) params.set('provider', scope.provider) - const query = params.toString() - return request( - `/api/coding-agents/${id}/config-files/${encodeURIComponent(key)}${query ? `?${query}` : ''}`, - ) -} - -export async function writeCodingAgentConfigFile( - id: CodingAgentId, - key: string, - content: string, - scope: CodingAgentConfigScope = {}, -): Promise { - return request(`/api/coding-agents/${id}/config-files/${encodeURIComponent(key)}`, { - method: 'PUT', - body: JSON.stringify({ content, profile: scope.profile, provider: scope.provider }), - }) -} - -export async function prepareCodingAgentLaunch( - id: CodingAgentId, - data: CodingAgentLaunchRequest, -): Promise { - return request(`/api/coding-agents/${id}/launch/prepare`, { - method: 'POST', - body: JSON.stringify(data), - }) -} - -export async function launchCodingAgentNativeTerminal( - id: CodingAgentId, - data: CodingAgentLaunchRequest, -): Promise { - return request(`/api/coding-agents/${id}/launch/native`, { - method: 'POST', - body: JSON.stringify(data), - }) -} - -export async function startCodingAgentRun( - id: CodingAgentId, - data: CodingAgentLaunchRequest & { sessionId: string }, -): Promise { - return request(`/api/coding-agents/${id}/runs`, { - method: 'POST', - body: JSON.stringify(data), - }) -} - -export async function sendCodingAgentRunInput(sessionId: string, input: string): Promise<{ runId: string }> { - return request<{ runId: string }>(`/api/coding-agents/runs/${encodeURIComponent(sessionId)}/input`, { - method: 'POST', - body: JSON.stringify({ input }), - }) -} - -export async function stopCodingAgentRun(sessionId: string): Promise<{ stopped: boolean }> { - return request<{ stopped: boolean }>(`/api/coding-agents/runs/${encodeURIComponent(sessionId)}`, { - method: 'DELETE', - }) -} diff --git a/packages/client/src/api/hermes/chat.ts b/packages/client/src/api/hermes/chat.ts index 7de9e69..f48df7d 100644 --- a/packages/client/src/api/hermes/chat.ts +++ b/packages/client/src/api/hermes/chat.ts @@ -1,6 +1,5 @@ import { io, type Socket } from 'socket.io-client' import { getBaseUrlValue, getApiKey } from '../client' -import type { ChatCodingAgentId } from '../coding-agents' import type { ProviderApiMode } from './system' export type ContentBlock = @@ -22,10 +21,8 @@ export interface StartRunRequest { provider?: string model_groups?: Array<{ provider: string; models: string[] }> queue_id?: string - source?: 'api_server' | 'cli' | 'coding_agent' | 'global_agent' | 'workflow' + source?: 'api_server' | 'cli' | 'global_agent' | 'workflow' session_source?: 'global_agent' | 'workflow' - coding_agent_id?: ChatCodingAgentId - agent_id?: ChatCodingAgentId mode?: 'scoped' | 'global' workspace?: string | null baseUrl?: string @@ -74,7 +71,7 @@ export interface RunEvent { output?: string | null /** Run-level workspace diff summary attached to terminal run events. */ workspace_run_change?: unknown - /** Provider/runtime context returned by Ekko Agent for follow-up runs. */ + /** Provider/runtime context returned by the agent for follow-up runs. */ context?: unknown usage?: { input_tokens: number diff --git a/packages/client/src/components/hermes/chat/ChatInput.vue b/packages/client/src/components/hermes/chat/ChatInput.vue index 56d5df3..4de7ee4 100644 --- a/packages/client/src/components/hermes/chat/ChatInput.vue +++ b/packages/client/src/components/hermes/chat/ChatInput.vue @@ -228,7 +228,7 @@ const skillPickerLoading = ref(false) let skillsLoadedKey = '' let skillsLoadRequest: Promise | null = null const isBridgeSession = computed(() => chatStore.activeSession?.source === 'cli') -const isForkCommandSession = computed(() => !!chatStore.activeSession && chatStore.activeSession.source !== 'coding_agent') +const isForkCommandSession = computed(() => !!chatStore.activeSession) const skillPickerItems = computed(() => { const byName = new Map() for (const category of skillCategories.value) { diff --git a/packages/client/src/components/hermes/chat/ChatPanel.vue b/packages/client/src/components/hermes/chat/ChatPanel.vue index b7faa7e..6582cc9 100644 --- a/packages/client/src/components/hermes/chat/ChatPanel.vue +++ b/packages/client/src/components/hermes/chat/ChatPanel.vue @@ -1,7 +1,5 @@ - - - - diff --git a/packages/client/src/views/hermes/HistoryView.vue b/packages/client/src/views/hermes/HistoryView.vue index 9a9c802..529dc0b 100644 --- a/packages/client/src/views/hermes/HistoryView.vue +++ b/packages/client/src/views/hermes/HistoryView.vue @@ -175,18 +175,11 @@ function mapHistoryMessages(messages: HermesMessage[]): Session['messages'] { }) } -function codingAgentFields(summary: SessionSummary): Pick { - const isCodingAgentSession = summary.source === 'coding_agent' || summary.agent === 'claude' || summary.agent === 'codex' +function agentFields(summary: SessionSummary): Pick { return { agent: summary.agent || undefined, agentSessionId: summary.agent_session_id || undefined, agentNativeSessionId: summary.agent_native_session_id || undefined, - codingAgentId: summary.agent === 'codex' ? 'codex' : summary.agent === 'claude' ? 'claude-code' : undefined, - codingAgentMode: isCodingAgentSession - ? (summary.agent_mode === 'global' || summary.agent_mode === 'scoped' - ? summary.agent_mode - : summary.provider === 'global' ? 'global' : 'scoped') - : undefined, } } @@ -196,7 +189,7 @@ function sessionFromSummary(summary: SessionSummary, messages: Session['messages profile: summary.profile || undefined, title: summary.title || '', source: summary.source, - ...codingAgentFields(summary), + ...agentFields(summary), createdAt: summary.started_at * 1000, updatedAt: (summary.last_active || summary.ended_at || summary.started_at) * 1000, model: summary.model, @@ -405,7 +398,7 @@ function sessionSummaryToSession(summary: SessionSummary): Session { profile: summary.profile || undefined, title: summary.title || '', source: summary.source, - ...codingAgentFields(summary), + ...agentFields(summary), createdAt: summary.started_at * 1000, updatedAt: (summary.last_active || summary.started_at) * 1000, model: summary.model, diff --git a/packages/client/src/views/hermes/WorkflowView.vue b/packages/client/src/views/hermes/WorkflowView.vue index e0c096f..cb0fa4f 100644 --- a/packages/client/src/views/hermes/WorkflowView.vue +++ b/packages/client/src/views/hermes/WorkflowView.vue @@ -49,7 +49,6 @@ import { } from '@/api/hermes/workflow-socket' import { fetchSkills } from '@/api/hermes/skills' import { fetchSession } from '@/api/hermes/sessions' -import { inferCodingAgentApiMode, normalizeCodingAgentApiMode } from '@/api/coding-agents' import { buildWorkflowSkillOptions, workflowAgentToSkillTarget } from '@/utils/hermes/workflow-skills' import type { WorkflowAgentNodeData, @@ -171,8 +170,6 @@ let workflowRunsLoadingSeq = 0 const agentOptions = computed(() => [ { label: 'Hermes', value: 'hermes' }, - { label: 'Claude Code', value: 'claude-code' }, - { label: 'Codex', value: 'codex' }, ]) const modelGroups = computed(() => appStore.modelGroups) @@ -358,7 +355,6 @@ function makeNode( agent: data.agent || agentOptions.value[0]?.value || 'hermes', provider: data.provider || defaultModelSelection.value.provider, model: data.model || defaultModelSelection.value.model, - apiMode: data.apiMode || defaultApiMode(data.provider || defaultModelSelection.value.provider), input: data.input || '', skills: data.skills || [], images: data.images || [], @@ -516,23 +512,14 @@ function closeWorkflowChatPanel() { workflowChatPanelTitle.value = '' } -function defaultApiMode(provider: string) { - const group = modelGroups.value.find(item => item.provider === provider) - return normalizeCodingAgentApiMode( - group?.api_mode, - inferCodingAgentApiMode(group?.provider || provider, group?.base_url), - ) -} - -function normalizeNodeModel(data: WorkflowAgentNodeData): Pick { +function normalizeNodeModel(data: WorkflowAgentNodeData): Pick { const currentGroup = modelGroups.value.find(group => group.provider === data.provider) if (currentGroup?.models.includes(data.model)) { - return { provider: data.provider, model: data.model, apiMode: data.apiMode || defaultApiMode(data.provider) } + return { provider: data.provider, model: data.model } } return { provider: defaultModelSelection.value.provider, model: defaultModelSelection.value.model, - apiMode: defaultApiMode(defaultModelSelection.value.provider), } } @@ -578,7 +565,6 @@ function serializeWorkflowNodes(source: WorkflowNode[]): unknown[] { agent: node.data.agent, provider: node.data.provider, model: node.data.model, - apiMode: node.data.apiMode, input: node.data.input, skills: [...node.data.skills], images: [...node.data.images], @@ -624,7 +610,6 @@ function normalizeStoredNode(raw: unknown, index: number): WorkflowNode { agent: data.agent, provider: data.provider, model: data.model, - apiMode: data.apiMode, input: data.input, skills: Array.isArray(data.skills) ? data.skills.filter(item => typeof item === 'string') : [], images: Array.isArray(data.images) ? data.images.filter(item => typeof item === 'string') : [], @@ -1338,9 +1323,6 @@ function workflowValidationError(): string | null { if (!node.data.title.trim()) return t('workflow.validation.nodeNameRequired', { node: node.id }) if (!node.data.provider.trim()) return t('workflow.validation.providerRequired', { node: label }) if (!node.data.model.trim()) return t('workflow.validation.modelRequired', { node: label }) - if (node.data.agent !== 'hermes' && !node.data.apiMode) { - return t('workflow.validation.apiModeRequired', { node: label }) - } if (!node.data.input.trim()) return t('workflow.validation.inputRequired', { node: label }) } diff --git a/packages/ekko-agent/README.md b/packages/ekko-agent/README.md deleted file mode 100644 index 31cc488..0000000 --- a/packages/ekko-agent/README.md +++ /dev/null @@ -1,133 +0,0 @@ -# Ekko Agent - -Ekko Agent is a package scaffold for future Hermes Web UI agent integration. - -The first implemented layer is model-provider requests. Internally, the agent -uses one request shape and provider adapters translate it to external APIs. - -Supported request styles: - -- OpenAI Chat Completions style -- OpenAI-compatible providers such as DeepSeek, Qwen, Moonshot, Ollama -- OpenAI Responses -- Anthropic Messages -- Gemini Contents -- prompt completion -- custom runtime - -Default endpoints: - -| Style | Default endpoint | -| --- | --- | -| `openai-chat` | `https://api.openai.com/v1/chat/completions` | -| `openai-responses` | `https://api.openai.com/v1/responses` | -| `anthropic-messages` | `https://api.anthropic.com/v1/messages` | -| `gemini-contents` | `https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent` | -| `prompt-completion` | `https://api.openai.com/v1/completions` | -| `custom-runtime` | `http://127.0.0.1:11434/v1/agent` | - -Use `baseUrl` and `endpointPath` to override these defaults. - -## Message Shape - -All adapters receive the same internal message shape: - -```ts -type AgentMessage = { - role: 'system' | 'user' | 'assistant' | 'tool' - content: string - name?: string - toolCallId?: string - toolCalls?: AgentToolCall[] -} -``` - -Use `normalizeAgentMessage()` or `normalizeAgentMessages()` at the boundary. -Model responses can be converted back to a single assistant message with -`modelResponseToAgentMessage()`. Streaming events can be collected into the same -shape with `collectModelEvents()`. - -## Tools - -Built-in tools: - -- `read_file` reads a text file. -- `write_file` writes text content and creates parent directories by default. -- `terminal_exec` runs a command with an argument array and `shell: false`. - -Use `workspaceRoot` to keep file and terminal working directories inside a -specific workspace. - -```ts -import { createDefaultToolRegistry } from './src/index' - -const tools = createDefaultToolRegistry() - -await tools.execute('write_file', { - path: 'notes/todo.txt', - content: 'ship tools', -}, { - workspaceRoot: process.cwd(), -}) - -const result = await tools.execute('terminal_exec', { - command: 'node', - args: ['-v'], -}, { - workspaceRoot: process.cwd(), -}) -``` - -## Runtime - -`AgentRuntime` ties messages, model requests, tools, skills, system prompt, and -events together. The default `maxSteps` is `90`, matching Hermes' regular agent -turn budget. - -```ts -import { AgentRuntime, createDefaultToolRegistry } from './src/index' - -const runtime = new AgentRuntime({ - modelClient: client, - tools: createDefaultToolRegistry(), - skills: [{ - id: 'project', - name: 'Project Skill', - instructions: 'Follow the project conventions before editing files.', - }], -}) - -const result = await runtime.run({ - messages: ['Read README.md and summarize it.'], - toolContext: { - workspaceRoot: process.cwd(), - }, - onEvent(event) { - console.log(event.type) - }, -}) -``` - -## Commands - -```bash -npm --prefix packages/ekko-agent run check -``` - -## Example - -```ts -import { createModelClient } from './src/index' - -const client = createModelClient({ - id: 'deepseek', - type: 'openai-compatible', - apiKey: process.env.DEEPSEEK_API_KEY, - baseUrl: 'https://api.deepseek.com/v1', - defaultModel: 'deepseek-chat', -}) - -const response = await client.create({ - messages: [{ role: 'user', content: 'Say hello.' }], -}) -``` diff --git a/packages/ekko-agent/docs/memory-chain-plan.md b/packages/ekko-agent/docs/memory-chain-plan.md deleted file mode 100644 index 044037e..0000000 --- a/packages/ekko-agent/docs/memory-chain-plan.md +++ /dev/null @@ -1,1274 +0,0 @@ -# Ekko Agent 链式记忆系统规划 - -本文档规划 Ekko Agent 的长期记忆能力。目标不是把完整聊天历史反复塞进模型上下文,而是把对话沉淀成可追踪、可更新、可检索的结构化记忆。 - -## 目标 - -- 支持跨轮、跨会话延续上下文。 -- 自动从对话中抽取用户偏好、项目事实、决策、任务和知识条目。 -- 用链式结构记录记忆演化,保留来源和覆盖关系。 -- 支持领域分类,例如 `生活技能 / 做饭 / 菜谱`。 -- 控制 token 成本,只在回复时注入少量相关记忆。 -- 后续可接入向量检索或知识图谱,但第一版不强依赖。 - -## 非目标 - -- 第一版不做完整知识图谱推理。 -- 第一版不要求部署 embedding 模型。 -- 第一版不让模型直接修改不可追溯的全局记忆。 -- 第一版不把所有历史总结成一个不可审计的大摘要。 - -## 记忆分层 - -### 1. 原始消息日志 - -保存完整用户消息、助手消息、工具调用、工具结果和运行元数据。 - -用途: - -- 审计和回放。 -- 重新生成摘要。 -- 追溯结构化记忆来源。 -- 调试模型错误抽取。 - -### 2. 链式摘要 - -每隔一段对话生成一个摘要节点。新摘要只依赖上一个摘要和新增消息,避免每次读取完整历史。 - -示例: - -```txt -summary_001 -> summary_002 -> summary_003 -``` - -摘要应包含: - -- 当前目标。 -- 已完成工作。 -- 未完成事项。 -- 关键约束。 -- 用户偏好。 -- 重要决策。 -- 已知问题。 - -### 3. 结构化记忆 - -结构化记忆保存可以被程序理解和筛选的事实。 - -推荐类型: - -- `preference`: 用户偏好。 -- `fact`: 稳定事实。 -- `decision`: 决策。 -- `task`: 待办或当前任务。 -- `recipe`: 菜谱类知识。 -- `skill`: 技能步骤或方法。 -- `constraint`: 约束。 -- `correction`: 用户纠正。 - -### 4. 可选检索索引 - -后续可增加: - -- keyword/full-text search。 -- embedding 向量索引。 -- entity/relationship 图索引。 - -第一版建议先做结构化字段和关键词检索,等数据量变大后再加向量。 - -## 核心数据模型 - -### MemoryMessage - -```ts -type MemoryMessage = { - id: string - sessionId: string - parentId?: string - role: 'system' | 'user' | 'assistant' | 'tool' - content: string - metadata?: Record - createdAt: string -} -``` - -### MemorySummary - -```ts -type MemorySummary = { - id: string - sessionId: string - parentSummaryId?: string - fromMessageId: string - toMessageId: string - summary: string - currentGoal?: string - constraints: string[] - preferences: string[] - decisions: string[] - completedWork: string[] - pendingWork: string[] - knownIssues: string[] - createdAt: string -} -``` - -### MemoryNode - -```ts -type MemoryNode = { - id: string - parentId?: string - supersedesId?: string - sessionId?: string - workspaceId?: string - userId?: string - scope: 'session' | 'workspace' | 'user' | 'global' - domain: string - categoryPath: string[] - type: 'preference' | 'fact' | 'decision' | 'task' | 'recipe' | 'skill' | 'constraint' | 'correction' - key?: string - valueJson?: unknown - title: string - content: string - status: 'active' | 'superseded' | 'expired' | 'deleted' - confidence: number - importance: number - tags: string[] - entities: string[] - sourceMessageIds: string[] - createdAt: string - updatedAt: string - expiresAt?: string -} -``` - -## 存取设计 - -记忆系统建议拆成三个物理表或集合: - -```txt -memory_messages -memory_summaries -memory_nodes -memory_audit_events -``` - -后续如果引入向量,再增加: - -```txt -memory_embeddings -``` - -### 怎么存 - -一次 agent turn 建议分两段存。 - -第一段是同步写入原始消息,发生在模型调用前后: - -```txt -用户消息进入 - -> append MemoryMessage(role = user) - -> 组装上下文 - -> 调模型 - -> append MemoryMessage(role = assistant) -``` - -第二段是异步整理记忆,发生在回复完成后: - -```txt -读取上一条 MemorySummary -读取本轮新增 MemoryMessage -调用 memory extractor -得到 MemoryExtraction -按 operation 写入 MemoryNode -必要时 append MemorySummary -``` - -写入规则: - -- 原始消息只追加,不覆盖。 -- 摘要只追加,通过 `parentSummaryId` 串成链。 -- 结构化记忆默认 upsert,但旧版本不硬删。 -- 用户纠正时新建节点,并把旧节点标记为 `superseded`。 -- 临时信息写 `expiresAt`,过期后默认不注入上下文。 - -### 怎么取 - -回复前取记忆时,不应该读取全部历史。推荐分四路查询后合并: - -```txt -1. recentMessages: 当前 session 最近 3-8 条原始消息 -2. latestSummary: 当前 session 最新摘要 -3. activeNodes: 当前 scope 下高优先级 active 结构化记忆 -4. relevantNodes: 根据当前输入做关键词、分类、标签或向量召回 -``` - -查询优先级: - -```txt -session scope -> workspace scope -> user scope -> global scope -``` - -同类记忆冲突时: - -```txt -更新的 > 旧的 -高置信 > 低置信 -临时有效约束 > 长期偏好 -用户明确纠正 > 模型推断 -``` - -### Store 接口 - -第一版可以先定义接口,不绑定具体数据库。 - -```ts -type MemoryStore = { - appendMessage(message: MemoryMessage): Promise - listRecentMessages(input: { - sessionId: string - limit: number - }): Promise - - appendSummary(summary: MemorySummary): Promise - getLatestSummary(input: { - sessionId: string - }): Promise - - upsertNode(node: MemoryNode): Promise - supersedeNode(input: { - oldNodeId: string - newNode: MemoryNode - }): Promise - deleteNode(input: { - nodeId: string - mode: 'soft' | 'hard' - reason: string - }): Promise - listActiveNodes(input: { - userId?: string - sessionId?: string - workspaceId?: string - domain?: string - categoryPath?: string[] - types?: MemoryNode['type'][] - limit: number - }): Promise - searchNodes(input: { - query: string - userId?: string - sessionId?: string - workspaceId?: string - domain?: string - tags?: string[] - limit: number - }): Promise -} -``` - -### 精确检索字段 - -如果要精确回答“用户是不是不吃香菜”这种问题,不能只靠 `title` 和 `content`。需要把可判断的事实写入 `key` 和 `valueJson`。 - -示例: - -```json -{ - "type": "preference", - "key": "avoid_ingredient", - "valueJson": "香菜" -} -``` - -另一个例子: - -```json -{ - "type": "preference", - "key": "flavor_profile", - "valueJson": { - "oil": "low", - "spicy": "low" - } -} -``` - -精确检索优先使用: - -```txt -scope + domain + categoryPath + type + key + valueJson + status + expiresAt -``` - -模糊检索才使用: - -```txt -title + content + tags + entities + FTS/embedding -``` - -### Memory Query 接口 - -`MemoryStore` 面向数据库操作,`MemoryService` 面向 agent 运行时。运行时不应该拼 SQL,而是传入结构化查询。 - -```ts -type MemoryQuery = { - userId?: string - workspaceId?: string - sessionId?: string - scopes?: MemoryNode['scope'][] - domain?: string - categoryPathPrefix?: string[] - types?: MemoryNode['type'][] - key?: string - valueJson?: unknown - tags?: string[] - entities?: string[] - queryText?: string - includeExpired?: boolean - limit?: number -} -``` - -返回值不是数据库裸结果,而是已经过滤、排序、去冲突后的结果: - -```ts -type MemoryQueryResult = { - exact: MemoryNode[] - relevant: MemoryNode[] - omitted: Array<{ - nodeId: string - reason: 'expired' | 'superseded' | 'low_confidence' | 'conflict_lost' | 'over_limit' - }> -} -``` - -## Agent 和 Memory Service 边界 - -记忆系统不建议设计成一个永远自主运行的 agent。第一版更适合设计成常驻存储和运行时服务: - -```txt -AgentRuntime - -> MemoryService - -> MemoryStore(SQLite) - -> MemoryRetriever - -> MemoryExtractor - -> MemoryContextBuilder -``` - -职责边界: - -- 主 Agent 负责理解用户请求、推理、调用业务工具、产出回复。 -- Memory Service 负责查库、过滤、排序、冲突消解和上下文压缩。 -- Memory Extractor 负责在回复后从新增对话中抽取候选记忆。 -- Memory Store 负责 SQLite 持久化。 - -主 Agent 不应该直接: - -- 拼 SQL。 -- 遍历完整记忆库。 -- 判断过期和覆盖关系。 -- 自己解决同类记忆冲突。 - -主 Agent 可以: - -- 接收 runtime 自动注入的 `MemoryContext`。 -- 在复杂任务里通过 memory tool 请求更多记忆。 -- 提出记忆写入建议,但最终由 Memory Service 校验和落库。 -- 当用户明确要求记录某件事时,立即调用 memory tool 提交写入请求。 - -### 默认自动检索 - -每次调用主模型前,`AgentRuntime` 自动执行一次轻量检索: - -```txt -用户消息 - -> buildDefaultMemoryQuery() - -> MemoryService.retrieve() - -> buildMemoryContext() - -> 注入主模型上下文 -``` - -默认检索内容: - -- 当前 session 最近消息。 -- 当前 session 最新摘要。 -- 当前 session active tasks。 -- 当前 workspace active constraints / decisions / facts。 -- 用户级高优先级 preferences。 - -这部分不需要主 Agent 主动调用。 - -### 按需 Memory Tool - -复杂场景给主 Agent 暴露工具,但工具背后仍然走 Memory Service。 - -建议工具: - -```ts -memory_search(input: MemoryQuery): Promise -``` - -```ts -memory_get(input: { - id?: string - type?: MemoryNode['type'] - key?: string - valueJson?: unknown - scope?: MemoryNode['scope'] - domain?: string -}): Promise -``` - -```ts -memory_propose_update(input: { - operation: 'create' | 'update' | 'supersede' | 'expire' | 'delete' - targetId?: string - node: Partial - reason: string -}): Promise<{ - accepted: boolean - nodeId?: string - reason?: string -}> -``` - -`memory_propose_update` 使用 propose 命名,是为了避免主 Agent 未经校验直接污染长期记忆。Memory Service 需要检查 scope、来源、置信度和冲突关系后再写入。 - -```ts -memory_forget(input: { - id?: string - scope?: MemoryNode['scope'] - domain?: string - categoryPathPrefix?: string[] - type?: MemoryNode['type'] - key?: string - valueJson?: unknown - mode?: 'soft' | 'hard' - reason: string -}): Promise<{ - deletedIds: string[] - mode: 'soft' | 'hard' - requiresConfirmation?: boolean - reason?: string -}> -``` - -### 明确记忆写入 - -后台 Memory Extractor 适合低频、批量整理;但用户明确要求“记住”“以后都按这个来”“帮我记录一下”时,不应该等到下一次批处理。主 Agent 应该立即调用 `memory_propose_update`,由 Memory Service 校验后同步或准同步写入。 - -明确写入示例: - -```txt -用户:记住,我以后做饭不要香菜。 -``` - -主 Agent 不直接写数据库,而是调用: - -```ts -memory_propose_update({ - operation: 'create', - node: { - scope: 'user', - domain: '生活技能', - categoryPath: ['生活技能', '做饭', '饮食偏好'], - type: 'preference', - key: 'avoid_ingredient', - valueJson: '香菜', - title: '用户不吃香菜', - content: '用户明确要求以后做饭推荐不要香菜。', - status: 'active', - confidence: 0.99, - importance: 0.95, - tags: ['忌口', '菜谱推荐'], - entities: ['香菜'], - }, - reason: '用户明确要求记录长期饮食偏好', -}) -``` - -Memory Service 接收到请求后需要: - -- 校验 `scope` 是否允许写入。 -- 绑定 `sourceMessageIds`,保证可追溯。 -- 检查是否已有同类 active 记忆。 -- 如果冲突,执行 `supersede` 或要求更明确的用户确认。 -- 写入 SQLite 事务。 -- 返回是否 accepted。 - -写入成功后,主 Agent 可以自然确认: - -```txt -记住了,以后推荐做饭相关内容时会避开香菜。 -``` - -不建议立即写入的情况: - -- 用户只是临时描述状态,例如“今天有点胃不舒服”。 -- 模型自己推断出来的偏好,用户没有明确表达。 -- 敏感信息或可能涉及隐私的信息。 -- scope 不明确且可能影响长期行为的记忆。 - -这类内容可以进入后台抽取流程,或者先作为 session-scope 临时记忆。 - -### 删除和遗忘 - -记忆系统必须支持删除。用户说“忘掉这个”“不要再记这个”“删除我的做饭偏好”时,主 Agent 应调用 `memory_forget`,由 Memory Service 执行删除策略。 - -删除分两种: - -```txt -soft delete: 把 memory_nodes.status 改成 deleted,默认不再检索和注入,但保留审计记录。 -hard delete: 从 SQLite 中物理删除节点,并清理 FTS/embedding 索引。 -``` - -第一版默认使用 soft delete。hard delete 适合用户明确要求清除隐私数据,或者执行数据合规删除。 - -示例: - -```txt -用户:忘掉我不吃香菜这件事。 -``` - -主 Agent 调用: - -```ts -memory_forget({ - scope: 'user', - domain: '生活技能', - categoryPathPrefix: ['生活技能', '做饭', '饮食偏好'], - type: 'preference', - key: 'avoid_ingredient', - valueJson: '香菜', - mode: 'soft', - reason: '用户明确要求忘记该饮食偏好', -}) -``` - -Memory Service 需要: - -- 先精确查找候选节点。 -- 如果命中多条,返回 `requiresConfirmation = true`,让主 Agent 向用户确认范围。 -- soft delete 时设置 `status = deleted`,更新 `updatedAt`。 -- hard delete 时删除节点、FTS 记录、embedding 记录。 -- 记录删除原因和操作者,方便审计。 - -删除范围建议: - -```txt -删除单条记忆:按 id 删除。 -删除某类记忆:按 scope + domain + categoryPath + type 删除。 -删除某个 workspace 记忆:按 workspaceId 删除。 -删除某个用户长期记忆:按 userId + scope=user 删除。 -删除来源消息关联记忆:按 sourceMessageIds 删除。 -``` - -注意:删除结构化记忆不一定等于删除原始消息日志。用户如果要求“彻底删除相关对话”,需要同时清理 `memory_messages`、`memory_summaries` 中的相关内容,或者重新生成受影响摘要。 - -### 冲突消解 - -Memory Service 在返回记忆前做冲突消解。 - -排序规则: - -```txt -session > workspace > user > global -未过期 > 已过期 -用户明确表达 > 模型推断 -临时有效约束 > 长期偏好 -更新的 > 旧的 -高置信 > 低置信 -高重要性 > 低重要性 -``` - -示例: - -```txt -长期偏好:用户喜欢川菜。 -临时约束:用户今天胃不舒服,少辣。 -``` - -当天推荐晚餐时,Memory Service 应返回“少辣”作为更高优先级约束,同时可以保留“喜欢川菜”作为低优先级偏好。 - -### 写入示例 - -用户说: - -```txt -以后做饭别给我推荐香菜,我喜欢少油少辣。 -``` - -同步写入: - -```json -{ - "role": "user", - "content": "以后做饭别给我推荐香菜,我喜欢少油少辣。" -} -``` - -异步抽取后写入两个节点: - -```json -{ - "scope": "user", - "domain": "生活技能", - "categoryPath": ["生活技能", "做饭", "饮食偏好"], - "type": "preference", - "title": "用户不吃香菜", - "content": "推荐菜谱时避免香菜。", - "status": "active", - "confidence": 0.98, - "importance": 0.9, - "tags": ["忌口", "菜谱推荐"], - "entities": ["香菜"] -} -``` - -```json -{ - "scope": "user", - "domain": "生活技能", - "categoryPath": ["生活技能", "做饭", "饮食偏好"], - "type": "preference", - "title": "用户偏好少油少辣", - "content": "推荐菜谱时优先少油少辣。", - "status": "active", - "confidence": 0.95, - "importance": 0.85, - "tags": ["口味", "菜谱推荐"], - "entities": ["少油", "少辣"] -} -``` - -### 读取示例 - -用户下一次问: - -```txt -今晚吃什么? -``` - -检索条件: - -```txt -domain = 生活技能 -categoryPath startsWith 生活技能/做饭 -types = preference, constraint, recipe -status = active -``` - -组装给模型的上下文片段: - -```txt -用户做饭偏好: -- 推荐菜谱时避免香菜。 -- 推荐菜谱时优先少油少辣。 -``` - -模型最终推荐时就不需要知道完整历史,只需要这两条结构化记忆。 - -### 更新示例 - -用户后来补充: - -```txt -香菜现在可以接受一点,但不要太多。 -``` - -处理方式: - -```txt -旧节点:用户不吃香菜 -> status = superseded -新节点:用户可接受少量香菜 -> status = active, supersedesId = oldNodeId -``` - -这样系统不会丢掉历史,也不会继续按旧偏好推荐。 - -## SQLite 落地方案 - -第一版推荐使用 SQLite。它比 JSONL 更适合做查询、索引、事务和状态更新,同时不需要引入服务端数据库。 - -### 文件位置 - -建议根据记忆 scope 分开落盘: - -```txt -workspace scope: /.ekko/memory.db -user scope: ~/.ekko/memory.db -session scope: 可以存入 workspace db,也可以存入服务端会话库 -global scope: 随包发布的只读 seed 数据,或服务端同步 -``` - -如果第一版只做 workspace 本地记忆,可以先统一使用: - -```txt -/.ekko/memory.db -``` - -### 表结构 - -SQLite 不需要把所有数组拆成多张表。第一版可以把数组字段存成 JSON text,常用筛选字段单独建列。 - -```sql -create table if not exists memory_messages ( - id text primary key, - session_id text not null, - parent_id text, - role text not null, - content text not null, - metadata_json text, - created_at text not null -); - -create table if not exists memory_summaries ( - id text primary key, - session_id text not null, - parent_summary_id text, - from_message_id text not null, - to_message_id text not null, - summary text not null, - current_goal text, - constraints_json text not null default '[]', - preferences_json text not null default '[]', - decisions_json text not null default '[]', - completed_work_json text not null default '[]', - pending_work_json text not null default '[]', - known_issues_json text not null default '[]', - created_at text not null -); - -create table if not exists memory_nodes ( - id text primary key, - parent_id text, - supersedes_id text, - session_id text, - workspace_id text, - user_id text, - scope text not null, - domain text not null, - category_path_json text not null, - category_path_text text not null, - type text not null, - key text, - value_json text, - title text not null, - content text not null, - status text not null, - confidence real not null, - importance real not null, - tags_json text not null default '[]', - entities_json text not null default '[]', - source_message_ids_json text not null default '[]', - created_at text not null, - updated_at text not null, - expires_at text -); - -create table if not exists memory_audit_events ( - id text primary key, - event_type text not null, - node_id text, - session_id text, - workspace_id text, - user_id text, - actor text not null, - reason text not null, - payload_json text, - created_at text not null -); -``` - -`category_path_text` 用 `/` 拼接,方便做前缀查询: - -```txt -生活技能/做饭/饮食偏好 -``` - -### 索引 - -```sql -create index if not exists idx_memory_messages_session_created - on memory_messages (session_id, created_at); - -create index if not exists idx_memory_summaries_session_created - on memory_summaries (session_id, created_at); - -create index if not exists idx_memory_nodes_lookup - on memory_nodes (scope, status, domain, type, importance, updated_at); - -create index if not exists idx_memory_nodes_key - on memory_nodes (scope, status, domain, type, key, updated_at); - -create index if not exists idx_memory_nodes_category - on memory_nodes (category_path_text); - -create index if not exists idx_memory_nodes_session - on memory_nodes (session_id, status, updated_at); - -create index if not exists idx_memory_nodes_workspace - on memory_nodes (workspace_id, status, updated_at); - -create index if not exists idx_memory_nodes_user - on memory_nodes (user_id, status, updated_at); - -create index if not exists idx_memory_audit_events_node - on memory_audit_events (node_id, created_at); -``` - -可选增加 FTS5,用于关键词检索: - -```sql -create virtual table if not exists memory_nodes_fts using fts5( - title, - content, - tags, - entities, - content='memory_nodes', - content_rowid='rowid' -); -``` - -如果使用 FTS,需要在 upsert memory node 时同步维护 FTS 表,或使用 trigger。 - -### 典型查询 - -取最近消息: - -```sql -select * -from memory_messages -where session_id = ? -order by created_at desc -limit ?; -``` - -取最新摘要: - -```sql -select * -from memory_summaries -where session_id = ? -order by created_at desc -limit 1; -``` - -取做饭相关 active 记忆: - -```sql -select * -from memory_nodes -where status = 'active' - and domain = '生活技能' - and category_path_text like '生活技能/做饭/%' - and (expires_at is null or expires_at > ?) -order by importance desc, confidence desc, updated_at desc -limit ?; -``` - -取当前 workspace 的约束和决策: - -```sql -select * -from memory_nodes -where status = 'active' - and workspace_id = ? - and type in ('constraint', 'decision', 'fact') - and (expires_at is null or expires_at > ?) -order by importance desc, updated_at desc -limit ?; -``` - -### 事务写入 - -一次记忆整理应该放在事务里: - -```txt -begin - insert summary if needed - insert new nodes - update superseded old nodes - update fts index if enabled -commit -``` - -这样可以避免出现“新记忆写入了,但旧记忆没失效”的半完成状态。 - -每次 create、update、supersede、expire、delete 都应该追加一条 `memory_audit_events`。审计事件不参与模型上下文注入,只用于调试、用户解释和回滚。 - -### 迁移 - -建议维护一个简单版本表: - -```sql -create table if not exists memory_schema_migrations ( - version integer primary key, - applied_at text not null -); -``` - -Ekko Agent 启动 memory store 时: - -```txt -打开 SQLite -读取 migration version -按顺序执行缺失 migration -创建索引 -返回 SqliteMemoryStore -``` - -## 受控 Schema - -精确检索依赖稳定 schema。不能让模型自由发明 `key`,否则同一个意思可能被写成 `avoid_food`、`avoid_ingredient`、`excluded_ingredient`,后续 SQL 很难稳定命中。 - -建议按 domain 维护受控 schema: - -```ts -type MemorySchemaRule = { - domain: string - categoryPathPrefix?: string[] - type: MemoryNode['type'] - allowedKeys: string[] - valueShape: 'string' | 'number' | 'boolean' | 'string[]' | 'object' - defaultScope: MemoryNode['scope'] - requiresExplicitUserIntent: boolean -} -``` - -做饭偏好示例: - -```ts -const cookingPreferenceSchema = { - domain: '生活技能', - categoryPathPrefix: ['生活技能', '做饭'], - type: 'preference', - allowedKeys: [ - 'avoid_ingredient', - 'preferred_ingredient', - 'flavor_profile', - 'dietary_restriction', - 'cooking_time_preference', - ], - valueShape: 'object', - defaultScope: 'user', - requiresExplicitUserIntent: true, -} -``` - -Memory Service 在写入前需要做 normalization: - -```txt -同义 key 归一:disliked_ingredient -> avoid_ingredient -中文值归一:少辣 -> { spicy: 'low' } -scope 归一:项目技术约束默认 workspace,不默认 user -status 归一:没有 expiresAt 的临时状态不能进入长期 user-scope -``` - -模型抽取结果如果不符合受控 schema,应被拒绝或降级为 session-scope 临时记忆。 - -## 权限和确认策略 - -不同 scope 的写入风险不同: - -```txt -session: 默认允许,适合临时状态和当前任务。 -workspace: 允许明确项目事实、技术约束、任务状态。 -user: 只允许用户明确表达的长期偏好和稳定事实。 -global: 第一版不允许 agent 写入。 -``` - -需要确认的情况: - -- 写入 user-scope 长期记忆,但用户没有使用“记住”“以后都”等明确表达。 -- 删除命中多条记忆。 -- hard delete。 -- 写入或删除敏感信息。 -- 覆盖高置信历史记忆,但新消息表达不够明确。 - -不需要确认的情况: - -- session-scope 临时记忆。 -- 用户明确要求记录的低风险偏好。 -- workspace-scope 的当前任务状态。 -- soft delete 单条精确命中的记忆。 - -## 失败和降级 - -记忆系统不能阻塞主对话。 - -失败处理: - -```txt -SQLite 打不开:跳过记忆检索,主 Agent 正常回答,并记录 runtime warning。 -检索失败:只使用最近消息和当前输入。 -抽取失败:不重试阻塞当前回复,放入后台 retry queue。 -写入失败:告诉主 Agent 写入未成功,不要回复“记住了”。 -迁移失败:禁用 memory store,避免写入未知 schema。 -``` - -`MemoryContext` 应包含诊断字段: - -```ts -type MemoryContextDiagnostics = { - enabled: boolean - storeStatus: 'ok' | 'disabled' | 'degraded' - warnings: string[] - retrievedNodeCount: number - omittedNodeCount: number -} -``` - -## 可观测性和解释 - -每次回复最好保留本轮使用过的记忆 id,但不一定展示给用户。 - -```ts -type MemoryContext = { - latestSummary?: MemorySummary - recentMessages: MemoryMessage[] - activeTasks: MemoryNode[] - relevantNodes: MemoryNode[] - constraints: MemoryNode[] - preferences: MemoryNode[] - usedMemoryIds: string[] - diagnostics: MemoryContextDiagnostics -} -``` - -当用户问“你为什么这么推荐”时,主 Agent 可以基于 `usedMemoryIds` 解释: - -```txt -因为我记录到你做饭偏好少油少辣,并且不希望推荐香菜。 -``` - -解释时不要暴露内部 SQL、embedding 分数或无关历史消息。 - -## 测试验收 - -第一版至少覆盖这些测试: - -- append message 后可以按 session 取最近消息。 -- append summary 后可以取最新摘要。 -- create node 后 active 查询能命中。 -- `key/valueJson` 精确查询能命中做饭偏好。 -- supersede 后旧节点不再进入默认上下文。 -- expiresAt 过期后不再注入。 -- soft delete 后不再检索。 -- hard delete 后节点、FTS、embedding 记录被清理。 -- 命中多条删除候选时返回 `requiresConfirmation`。 -- SQLite 事务失败时不会出现半写入状态。 -- memory store 失败时主 Agent 能降级继续运行。 -- schema normalization 能把同义 key 归一到受控 key。 - -## 分类体系 - -分类树用于归档,记忆节点用于表达事实和演化。不要把目录树当成全部记忆。 - -示例: - -```txt -生活技能 - 做饭 - 菜谱 - 食材处理 - 厨房工具 - 烹饪技巧 - 营养搭配 - 采购清单 - 饮食偏好 - 家务 - 理财 - 健康 - 出行 -``` - -一条记忆可以挂在一个主分类下,同时用 tags 和 entities 表达交叉关系。 - -示例: - -```json -{ - "domain": "生活技能", - "categoryPath": ["生活技能", "做饭", "饮食偏好"], - "type": "preference", - "title": "用户不吃香菜", - "content": "用户明确表示以后不要推荐带香菜的菜。", - "tags": ["做饭", "菜谱推荐", "忌口"], - "entities": ["香菜"], - "confidence": 0.98 -} -``` - -菜谱示例: - -```json -{ - "domain": "生活技能", - "categoryPath": ["生活技能", "做饭", "菜谱"], - "type": "recipe", - "title": "番茄炒蛋", - "content": "家常快手菜,主要食材是番茄和鸡蛋。可按用户偏好少油、不放香菜。", - "tags": ["家常菜", "快手菜", "少辣"], - "entities": ["番茄", "鸡蛋"] -} -``` - -## 抽取流程 - -在 agent 回复后异步执行记忆整理。 - -触发条件: - -- 新增 6-10 条消息。 -- 新增上下文超过指定 token 阈值。 -- 用户表达明确偏好、决策、纠正或长期目标。 -- 任务状态发生变化。 - -流程: - -```txt -新增消息 - -> 保存原始日志 - -> 读取上一条摘要 - -> 抽取结构化候选记忆 - -> 判断新增、更新、覆盖或过期 - -> 写入 MemoryNode - -> 必要时生成 MemorySummary -``` - -抽取器输出建议: - -```ts -type MemoryExtraction = { - summaryPatch?: string - nodes: Array<{ - operation: 'create' | 'update' | 'supersede' | 'expire' | 'ignore' - targetId?: string - node: Partial - reason: string - }> -} -``` - -## 覆盖和冲突 - -用户纠正优先级高于历史记忆。 - -示例: - -```txt -旧记忆:用户喜欢吃辣。 -新消息:最近胃不舒服,先别推荐辣的。 -``` - -处理方式: - -- 不一定删除旧记忆。 -- 新建一条临时偏好,`status = active`。 -- 旧记忆仍保留,但检索注入时被新记忆压过。 -- 如果用户明确说“以后都不吃辣”,再 supersede 旧记忆。 - -## 回复前上下文组装 - -每次模型回复前只注入最小必要记忆。 - -优先级: - -```txt -当前用户消息 -> 最近几轮原始消息 -> 当前任务状态 -> 最新摘要 -> 高置信结构化记忆 -> 相关历史记忆 -> 低置信或过期记忆 -``` - -上下文包: - -```ts -type MemoryContext = { - latestSummary?: MemorySummary - recentMessages: MemoryMessage[] - activeTasks: MemoryNode[] - relevantNodes: MemoryNode[] - constraints: MemoryNode[] - preferences: MemoryNode[] -} -``` - -建议限制: - -- 最新摘要:1 条。 -- 最近消息:3-8 条。 -- 结构化记忆:最多 10-20 条。 -- 单条记忆内容保持短文本。 - -## Token 成本策略 - -- 不每轮抽取,按阈值批处理。 -- 抽取只看上一条摘要和新增消息。 -- 记忆抽取可使用便宜模型或本地小模型。 -- 回复时只注入相关记忆,不注入完整记忆库。 -- 已完成任务和临时日志降低重要性或设置过期时间。 - -## 第一版落地建议 - -### Phase 1: 本地结构化记忆 - -- 增加 memory 模块。 -- 保存 messages、summaries、nodes。 -- 支持基础分类、状态、来源追踪。 -- 实现基于规则和模型 JSON 输出的抽取接口。 -- 回复前注入最近摘要和 active nodes。 - -### Phase 2: 检索和覆盖 - -- 增加关键词搜索。 -- 增加 `supersedesId` 和冲突处理。 -- 增加 task/preference/decision 的专用 upsert 逻辑。 -- 增加记忆重要性和过期机制。 - -### Phase 3: 向量和图关系 - -- 对 MemoryNode 生成 embedding。 -- 增加 topK 语义召回。 -- 抽取 entities 和 relationships。 -- 支持 temporal graph 查询。 - -## 与 Ekko Agent 当前模块的关系 - -建议新增目录: - -```txt -packages/ekko-agent/src/memory/ - context.ts - extraction.ts - store.ts - types.ts - retrieval.ts -``` - -职责: - -- `types.ts`: 记忆数据结构。 -- `store.ts`: 存储接口和 SQLite 实现。 -- `extraction.ts`: 从消息中抽取结构化记忆。 -- `retrieval.ts`: 根据当前输入检索相关记忆。 -- `context.ts`: 组装给模型的 MemoryContext。 - -`AgentRuntime` 后续可以在两个位置接入: - -- 回复前:调用 retrieval/context,把记忆注入系统提示或上下文消息。 -- 回复后:调用 extraction/store,异步更新记忆。 - -## 开放问题 - -- 记忆存储落在哪里:服务端数据库、workspace 文件,还是用户级 profile。 -- 哪些记忆需要用户可见和可编辑。 -- 是否允许 agent 自动写入 user-scope 长期记忆。 -- 记忆抽取使用哪个模型和预算。 -- 是否需要多租户隔离和加密。 diff --git a/packages/ekko-agent/package.json b/packages/ekko-agent/package.json deleted file mode 100644 index b2fa5a8..0000000 --- a/packages/ekko-agent/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "ekko-agent", - "version": "0.1.0", - "private": true, - "type": "commonjs", - "description": "Ekko Agent package scaffold for Hermes Web UI.", - "main": "src/index.ts", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "check": "tsc --noEmit -p tsconfig.json" - }, - "dependencies": { - "agent-browser": "^0.26.0" - } -} diff --git a/packages/ekko-agent/src/index.ts b/packages/ekko-agent/src/index.ts deleted file mode 100644 index 815d4fd..0000000 --- a/packages/ekko-agent/src/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -export interface EkkoAgentInfo { - name: string - displayName: string - packageName: string -} - -export function createEkkoAgentInfo(): EkkoAgentInfo { - return { - name: 'ekko-agent', - displayName: 'Ekko Agent', - packageName: 'ekko-agent', - } -} - -export * from './model/errors' -export * from './model/messages' -export * from './model/provider-config' -export * from './model/registry' -export * from './model/types' -export * from './runtime/events' -export * from './runtime/runtime' -export * from './runtime/system-prompt' -export * from './runtime/types' -export * from './skills/types' -export * from './tools/browser' -export * from './tools/files' -export * from './tools/registry' -export * from './tools/terminal' -export * from './tools/types' -export { - AnthropicMessagesModelClient, - normalizeAnthropicResponse, - toAnthropicMessagesPayload, -} from './model/providers/anthropic' -export { - CustomRuntimeModelClient, -} from './model/providers/custom-runtime' -export { - GeminiContentsModelClient, - normalizeGeminiResponse, - toGeminiContentsPayload, -} from './model/providers/gemini' -export { - OpenAICompatibleModelClient, - normalizeOpenAIChatResponse, - toOpenAIChatPayload, -} from './model/providers/openai-compatible' -export { - OpenAIResponsesModelClient, - normalizeOpenAIResponsesResponse, - toOpenAIResponsesPayload, -} from './model/providers/openai-responses' -export { - PromptCompletionModelClient, - normalizePromptCompletionResponse, - toPromptCompletionPayload, -} from './model/providers/prompt-completion' diff --git a/packages/ekko-agent/src/model/errors.ts b/packages/ekko-agent/src/model/errors.ts deleted file mode 100644 index 37beae9..0000000 --- a/packages/ekko-agent/src/model/errors.ts +++ /dev/null @@ -1,27 +0,0 @@ -export class ModelProviderError extends Error { - readonly provider: string - readonly statusCode?: number - readonly retryable: boolean - readonly details?: unknown - - constructor( - message: string, - options: { - provider: string - statusCode?: number - retryable?: boolean - details?: unknown - }, - ) { - super(message) - this.name = 'ModelProviderError' - this.provider = options.provider - this.statusCode = options.statusCode - this.retryable = options.retryable ?? false - this.details = options.details - } -} - -export function isRetryableStatus(statusCode: number): boolean { - return statusCode === 408 || statusCode === 409 || statusCode === 425 || statusCode === 429 || statusCode >= 500 -} diff --git a/packages/ekko-agent/src/model/http.ts b/packages/ekko-agent/src/model/http.ts deleted file mode 100644 index 28bd245..0000000 --- a/packages/ekko-agent/src/model/http.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { ModelProviderError, isRetryableStatus } from './errors' -import type { ModelProviderConfig } from './types' - -export function requestHeaders(config: ModelProviderConfig, defaults: Record = {}): HeadersInit { - const headers: Record = { - 'content-type': 'application/json', - ...defaults, - ...config.headers, - } - if (config.apiKey) { - headers.authorization = `Bearer ${config.apiKey}` - } - return headers -} - -export function abortSignal(timeoutMs?: number, signal?: AbortSignal): AbortSignal | undefined { - const signals = [ - signal, - timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, - ].filter(Boolean) as AbortSignal[] - if (signals.length === 0) return undefined - if (signals.length === 1) return signals[0] - return AbortSignal.any(signals) -} - -export function providerUrl(config: ModelProviderConfig, fallbackBaseUrl: string, path: string): string { - const baseUrl = (config.baseUrl ?? fallbackBaseUrl).replace(/\/+$/, '') - return `${baseUrl}/${path.replace(/^\/+/, '')}` -} - -export async function parseResponseJson(provider: string, response: Response): Promise { - try { - return await response.json() as T - } catch (error) { - throw new ModelProviderError('Model provider returned invalid JSON.', { - provider, - statusCode: response.status, - details: error, - }) - } -} - -export async function providerHttpError(provider: string, response: Response): Promise { - let details: unknown - let message = `Model provider request failed with HTTP ${response.status}.` - try { - details = await response.json() - if (isPlainRecord(details) && isPlainRecord(details.error) && typeof details.error.message === 'string') { - message = details.error.message - } else if (isPlainRecord(details) && typeof details.message === 'string') { - message = details.message - } - } catch { - details = await response.text().catch(() => undefined) - } - - return new ModelProviderError(message, { - provider, - statusCode: response.status, - retryable: isRetryableStatus(response.status), - details, - }) -} - -export async function postJson( - config: ModelProviderConfig, - fetchImpl: (input: string | URL, init?: RequestInit) => Promise, - url: string, - payload: unknown, - headers: HeadersInit = requestHeaders(config), - signal?: AbortSignal, -): Promise { - const response = await fetchImpl(url, { - method: 'POST', - headers, - body: JSON.stringify(payload), - signal: abortSignal(config.timeoutMs, signal), - }) - - if (!response.ok) { - throw await providerHttpError(config.id, response) - } - - return parseResponseJson(config.id, response) -} - -export async function postStream( - config: ModelProviderConfig, - fetchImpl: (input: string | URL, init?: RequestInit) => Promise, - url: string, - payload: unknown, - headers: HeadersInit = requestHeaders(config), - signal?: AbortSignal, -): Promise { - const response = await fetchImpl(url, { - method: 'POST', - headers, - body: JSON.stringify(payload), - signal: abortSignal(config.timeoutMs, signal), - }) - - if (!response.ok) { - throw await providerHttpError(config.id, response) - } - - return response -} - -export async function *readServerSentEvents(response: Response): AsyncIterable { - if (!response.body) return - - const reader = response.body.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(/\r?\n/) - buffer = lines.pop() ?? '' - - for (const line of lines) { - const event = parseServerSentEventLine(line) - if (event) yield event - } - } - } finally { - reader.releaseLock() - } -} - -export function parseServerSentEventLine(line: string): string | undefined { - const trimmed = line.trim() - if (!trimmed.startsWith('data:')) return undefined - return trimmed.slice(5).trim() -} - -export function parseJson(value: string): T | undefined { - try { - return JSON.parse(value) as T - } catch { - return undefined - } -} - -export function isPlainRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/packages/ekko-agent/src/model/messages.ts b/packages/ekko-agent/src/model/messages.ts deleted file mode 100644 index 0efe7d2..0000000 --- a/packages/ekko-agent/src/model/messages.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { AgentMessage, AgentMessageRole, AgentToolCall, ModelEvent, ModelResponse, ModelUsage } from './types' - -export type AgentMessageInput = - | string - | AgentMessage - | AgentMessageLike - -interface AgentMessageLike { - role?: AgentMessageRole - content?: unknown - reasoning?: unknown - reasoning_content?: unknown - text?: unknown - name?: string - toolCallId?: string - tool_call_id?: string - toolCalls?: AgentToolCall[] - tool_calls?: AgentToolCall[] -} - -export interface AgentOutputMessage extends AgentMessage { - role: 'assistant' - id?: string - model?: string - usage?: ModelUsage - finishReason?: string - context?: unknown - reasoning?: string - raw?: unknown -} - -export interface AgentStreamOutput { - message: AgentOutputMessage - events: ModelEvent[] -} - -export function normalizeAgentMessage(input: AgentMessageInput, fallbackRole: AgentMessageRole = 'user'): AgentMessage { - if (typeof input === 'string') { - return { - role: fallbackRole, - content: input, - } - } - - const message = input as AgentMessageLike - const role = normalizeRole(message.role, fallbackRole) - const content = normalizeContent(message.content ?? message.text) - return { - role, - content, - reasoning: normalizeReasoning(message.reasoning ?? message.reasoning_content), - name: message.name, - toolCallId: message.toolCallId ?? message.tool_call_id, - toolCalls: message.toolCalls ?? message.tool_calls, - } -} - -export function normalizeAgentMessages(inputs: AgentMessageInput[], fallbackRole: AgentMessageRole = 'user'): AgentMessage[] { - return inputs.map(input => normalizeAgentMessage(input, fallbackRole)) -} - -export function createSystemMessage(content: string): AgentMessage { - return { role: 'system', content } -} - -export function createUserMessage(content: string): AgentMessage { - return { role: 'user', content } -} - -export function createAssistantMessage(content: string, toolCalls?: AgentToolCall[]): AgentMessage { - return { role: 'assistant', content, toolCalls } -} - -export function createToolResultMessage(toolCallId: string, content: string, name?: string): AgentMessage { - return { - role: 'tool', - content, - toolCallId, - name, - } -} - -export function modelResponseToAgentMessage(response: ModelResponse): AgentOutputMessage { - return { - role: 'assistant', - id: response.id, - model: response.model, - content: response.content, - reasoning: response.reasoning, - toolCalls: response.toolCalls, - usage: response.usage, - finishReason: response.finishReason, - context: response.context, - raw: response.raw, - } -} - -export async function collectModelEvents(events: AsyncIterable): Promise { - const collected: ModelEvent[] = [] - let content = '' - let reasoning = '' - const toolCalls: AgentToolCall[] = [] - let usage: ModelUsage | undefined - let done: Partial | undefined - - for await (const event of events) { - collected.push(event) - if (event.type === 'text-delta') { - content += event.text - } else if (event.type === 'reasoning-delta') { - reasoning += event.text - } else if (event.type === 'tool-call') { - toolCalls.push(event.toolCall) - } else if (event.type === 'usage') { - usage = event.usage - } else if (event.type === 'done') { - done = event.response - } - } - - return { - events: collected, - message: { - role: 'assistant', - id: done?.id, - model: done?.model, - content: done?.content ?? content, - reasoning: done?.reasoning ?? (reasoning || undefined), - toolCalls: done?.toolCalls ?? (toolCalls.length ? toolCalls : undefined), - usage: done?.usage ?? usage, - finishReason: done?.finishReason, - context: done?.context, - raw: done?.raw, - }, - } -} - -function normalizeRole(role: AgentMessageRole | undefined, fallbackRole: AgentMessageRole): AgentMessageRole { - if (role === 'system' || role === 'user' || role === 'assistant' || role === 'tool') { - return role - } - return fallbackRole -} - -function normalizeContent(content: unknown): string { - if (typeof content === 'string') return content - if (content === undefined || content === null) return '' - return JSON.stringify(content) -} - -function normalizeReasoning(reasoning: unknown): string | undefined { - if (typeof reasoning === 'string') return reasoning || undefined - if (reasoning === undefined || reasoning === null) return undefined - return JSON.stringify(reasoning) -} diff --git a/packages/ekko-agent/src/model/provider-config.ts b/packages/ekko-agent/src/model/provider-config.ts deleted file mode 100644 index bef8d23..0000000 --- a/packages/ekko-agent/src/model/provider-config.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { ModelProviderConfig, ModelProviderType, ModelRequestStyle } from './types' - -export interface ResolveModelProviderConfigInput { - provider: string - baseUrl?: string - apiKey?: string - model: string - apiMode?: string - timeoutMs?: number -} - -export interface ResolvedModelProviderConfigs { - providerConfig: ModelProviderConfig - fallbackProviderConfig?: ModelProviderConfig - requestStyle: ModelRequestStyle - inferredRequestStyle: ModelRequestStyle -} - -export function requestStyleFromApiMode(apiMode?: string): ModelRequestStyle | undefined { - const normalized = String(apiMode || '').toLowerCase() - if (normalized === 'chat_completions') return 'openai-chat' - if (normalized === 'codex_responses') return 'openai-responses' - if (normalized === 'anthropic_messages') return 'anthropic-messages' - return undefined -} - -export function inferredRequestStyleForConfig(provider: string, baseUrl = ''): ModelRequestStyle { - const key = provider.toLowerCase() - const url = baseUrl.toLowerCase() - if (url.endsWith('/anthropic') || url.includes('api.anthropic.com')) return 'anthropic-messages' - if (key.includes('gemini') || key.includes('google') || url.includes('generativelanguage.googleapis.com')) return 'gemini-contents' - if (url.includes('api.openai.com') || url.includes('api.x.ai')) return 'openai-responses' - return 'openai-chat' -} - -export function requestStyleForConfig(provider: string, baseUrl = '', apiMode?: string): ModelRequestStyle { - return requestStyleFromApiMode(apiMode) || inferredRequestStyleForConfig(provider, baseUrl) -} - -export function providerTypeForStyle(provider: string, style: ModelRequestStyle): ModelProviderType { - const key = provider.toLowerCase() - if (style === 'anthropic-messages') return 'anthropic' - if (style === 'gemini-contents') return 'gemini' - if (key.includes('ollama')) return 'ollama' - if (key === 'openai') return 'openai' - return 'openai-compatible' -} - -export function createProviderConfig(input: { - provider: string - requestStyle: ModelRequestStyle - baseUrl?: string - apiKey?: string - model: string - timeoutMs?: number -}): ModelProviderConfig { - return { - id: input.provider || 'openai', - type: providerTypeForStyle(input.provider, input.requestStyle), - requestStyle: input.requestStyle, - baseUrl: input.baseUrl || undefined, - apiKey: input.apiKey || undefined, - defaultModel: input.model, - timeoutMs: input.timeoutMs, - } -} - -export function resolveModelProviderConfigs(input: ResolveModelProviderConfigInput): ResolvedModelProviderConfigs { - const baseUrl = input.baseUrl || '' - const timeoutMs = input.timeoutMs ?? 120_000 - const requestStyle = requestStyleForConfig(input.provider, baseUrl, input.apiMode) - const inferredRequestStyle = inferredRequestStyleForConfig(input.provider, baseUrl) - const providerConfig = createProviderConfig({ - provider: input.provider, - requestStyle, - baseUrl, - apiKey: input.apiKey, - model: input.model, - timeoutMs, - }) - const fallbackProviderConfig = requestStyleFromApiMode(input.apiMode) && inferredRequestStyle !== requestStyle - ? createProviderConfig({ - provider: input.provider, - requestStyle: inferredRequestStyle, - baseUrl, - apiKey: input.apiKey, - model: input.model, - timeoutMs, - }) - : undefined - - return { - providerConfig, - fallbackProviderConfig, - requestStyle, - inferredRequestStyle, - } -} diff --git a/packages/ekko-agent/src/model/providers/anthropic.ts b/packages/ekko-agent/src/model/providers/anthropic.ts deleted file mode 100644 index fe6db38..0000000 --- a/packages/ekko-agent/src/model/providers/anthropic.ts +++ /dev/null @@ -1,293 +0,0 @@ -import type { - AgentMessage, - AgentToolCall, - AgentToolDefinition, - FetchLike, - ModelCapabilities, - ModelClient, - ModelClientOptions, - ModelEvent, - ModelProviderConfig, - ModelRequest, - ModelResponse, - ModelUsage, -} from '../types' -import { ModelProviderError } from '../errors' -import { isPlainRecord, parseJson, postJson, postStream, providerUrl, readServerSentEvents, requestHeaders } from '../http' - -interface AnthropicPayload { - model: string - system?: string - messages: Array<{ - role: 'user' | 'assistant' - content: Array - }> - max_tokens: number - temperature?: number - tools?: Array<{ - name: string - description?: string - input_schema?: Record - }> - tool_choice?: { type: 'auto' | 'none' | 'any' } - stream?: boolean -} - -type AnthropicContentBlock = - | { type: 'text'; text: string } - | { type: 'thinking'; thinking: string } - | { type: 'tool_use'; id: string; name: string; input: Record } - | { type: 'tool_result'; tool_use_id: string; content: string } - -interface AnthropicResponse { - id?: string - model?: string - content?: AnthropicContentBlock[] - usage?: { - input_tokens?: number - output_tokens?: number - cache_read_input_tokens?: number - cache_creation_input_tokens?: number - } - stop_reason?: string -} - -const capabilities: ModelCapabilities = { - streaming: true, - tools: true, - vision: false, - jsonMode: false, - systemPrompt: true, -} - -export class AnthropicMessagesModelClient implements ModelClient { - readonly provider: string - readonly requestStyle = 'anthropic-messages' - readonly capabilities: ModelCapabilities - - private readonly config: ModelProviderConfig - private readonly fetchImpl: FetchLike - - constructor(config: ModelProviderConfig, options: ModelClientOptions = {}) { - this.config = config - this.provider = config.id - this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis) - this.capabilities = { ...capabilities, ...config.capabilities } - } - - async create(request: ModelRequest): Promise { - const response = await postJson( - this.config, - this.fetchImpl, - anthropicUrl(this.config), - toAnthropicMessagesPayload(this.config, { ...request, stream: false }), - anthropicHeaders(this.config), - request.signal, - ) - assertAnthropicSuccess(this.config, response) - return normalizeAnthropicResponse(response) - } - - async *stream(request: ModelRequest): AsyncIterable { - const response = await postStream( - this.config, - this.fetchImpl, - anthropicUrl(this.config), - toAnthropicMessagesPayload(this.config, { ...request, stream: true }), - anthropicHeaders(this.config), - request.signal, - ) - - const toolCallBlocks = new Map() - let finishReason: string | undefined - let usage: ModelUsage | undefined - - for await (const event of readServerSentEvents(response)) { - const chunk = parseJson>(event) - if (!chunk) continue - - if (chunk.type === 'message_start' && isPlainRecord(chunk.message) && isPlainRecord(chunk.message.usage)) { - usage = mergeUsage(usage, normalizeUsage(chunk.message.usage as NonNullable)) - } - - if (chunk.type === 'message_delta' && isPlainRecord(chunk.usage)) { - usage = mergeUsage(usage, normalizeUsage(chunk.usage as NonNullable)) - } - - if (chunk.type === 'content_block_start' && isPlainRecord(chunk.content_block)) { - const index = typeof chunk.index === 'number' ? chunk.index : 0 - const block = chunk.content_block - if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') { - toolCallBlocks.set(index, { id: block.id, name: block.name, argumentsText: '' }) - } - } - - if (chunk.type === 'content_block_delta' && isPlainRecord(chunk.delta)) { - if (chunk.delta.type === 'text_delta' && typeof chunk.delta.text === 'string') { - yield { type: 'text-delta', text: chunk.delta.text } - } - if (chunk.delta.type === 'thinking_delta' && typeof chunk.delta.thinking === 'string') { - yield { type: 'reasoning-delta', text: chunk.delta.thinking } - } - if (chunk.delta.type === 'input_json_delta' && typeof chunk.delta.partial_json === 'string') { - const index = typeof chunk.index === 'number' ? chunk.index : 0 - const current = toolCallBlocks.get(index) - if (current) current.argumentsText += chunk.delta.partial_json - } - } - - if (chunk.type === 'message_delta' && isPlainRecord(chunk.delta) && typeof chunk.delta.stop_reason === 'string') { - finishReason = chunk.delta.stop_reason - } - - if (chunk.type === 'message_stop') { - for (const toolCall of toolCallBlocks.values()) { - yield { type: 'tool-call', toolCall: normalizeToolCall(toolCall.id, toolCall.name, toolCall.argumentsText) } - } - if (usage) yield { type: 'usage', usage } - yield { type: 'done', response: { finishReason } } - return - } - } - } -} - -export function toAnthropicMessagesPayload(config: ModelProviderConfig, request: ModelRequest): AnthropicPayload { - return { - model: request.model ?? config.defaultModel, - system: request.messages.filter(message => message.role === 'system').map(message => message.content).join('\n\n') || undefined, - messages: request.messages.filter(message => message.role !== 'system').map(toAnthropicMessage), - max_tokens: request.maxTokens ?? 4096, - temperature: request.temperature, - tools: request.tools?.map(toAnthropicTool), - tool_choice: request.toolChoice ? { type: request.toolChoice === 'required' ? 'any' : request.toolChoice } : undefined, - stream: request.stream, - } -} - -export function normalizeAnthropicResponse(response: AnthropicResponse): ModelResponse { - return { - id: response.id, - model: response.model, - content: response.content?.filter(block => block.type === 'text').map(block => block.text).join('') ?? '', - reasoning: response.content?.filter(block => block.type === 'thinking').map(block => block.thinking).join('') || undefined, - toolCalls: response.content?.filter(block => block.type === 'tool_use').map(block => ({ - id: block.id, - name: block.name, - arguments: block.input, - rawArguments: JSON.stringify(block.input), - })), - usage: response.usage ? normalizeUsage(response.usage) : undefined, - finishReason: response.stop_reason, - raw: response, - } -} - -function toAnthropicMessage(message: AgentMessage): AnthropicPayload['messages'][number] { - if (message.role === 'assistant') { - return { - role: 'assistant', - content: [ - ...(message.content ? [{ type: 'text' as const, text: message.content }] : []), - ...(message.toolCalls?.map(toolCall => ({ - type: 'tool_use' as const, - id: toolCall.id, - name: toolCall.name, - input: toolCall.arguments, - })) ?? []), - ], - } - } - - if (message.role === 'tool') { - return { - role: 'user', - content: [{ type: 'tool_result', tool_use_id: message.toolCallId ?? '', content: message.content }], - } - } - - return { role: 'user', content: [{ type: 'text', text: message.content }] } -} - -function toAnthropicTool(tool: AgentToolDefinition): NonNullable[number] { - return { - name: tool.name, - description: tool.description, - input_schema: tool.parameters, - } -} - -function normalizeToolCall(id: string, name: string, argumentsText: string): AgentToolCall { - const parsedArguments = parseJson(argumentsText) - return { - id, - name, - arguments: isPlainRecord(parsedArguments) ? parsedArguments : {}, - rawArguments: argumentsText, - } -} - -function normalizeUsage(usage: NonNullable): ModelUsage { - return { - inputTokens: usage.input_tokens, - outputTokens: usage.output_tokens, - totalTokens: (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0), - cacheReadTokens: usage.cache_read_input_tokens, - cacheWriteTokens: usage.cache_creation_input_tokens, - } -} - -function mergeUsage(current: ModelUsage | undefined, next: ModelUsage): ModelUsage { - const merged = { - inputTokens: next.inputTokens ?? current?.inputTokens, - outputTokens: next.outputTokens ?? current?.outputTokens, - cacheReadTokens: next.cacheReadTokens ?? current?.cacheReadTokens, - cacheWriteTokens: next.cacheWriteTokens ?? current?.cacheWriteTokens, - reasoningTokens: next.reasoningTokens ?? current?.reasoningTokens, - } - return { - ...merged, - totalTokens: (merged.inputTokens ?? 0) + (merged.outputTokens ?? 0), - } -} - -function anthropicUrl(config: ModelProviderConfig): string { - return providerUrl(config, 'https://api.anthropic.com/v1', config.endpointPath ?? defaultAnthropicEndpointPath(config)) -} - -function anthropicHeaders(config: ModelProviderConfig): HeadersInit { - const headers = requestHeaders(config, { 'anthropic-version': '2023-06-01' }) as Record - if (isOfficialAnthropicBaseUrl(config.baseUrl)) delete headers.authorization - if (config.apiKey) headers['x-api-key'] = config.apiKey - return headers -} - -function isOfficialAnthropicBaseUrl(baseUrl: string | undefined): boolean { - return !baseUrl || baseUrl.includes('api.anthropic.com') -} - -function defaultAnthropicEndpointPath(config: ModelProviderConfig): string { - const baseUrl = (config.baseUrl || '').replace(/\/+$/, '') - return baseUrl.endsWith('/anthropic') ? 'v1/messages' : 'messages' -} - -function assertAnthropicSuccess(config: ModelProviderConfig, response: AnthropicResponse): void { - const payload = response as unknown - if (!isPlainRecord(payload)) return - const hasAnthropicContent = Array.isArray(payload.content) - const failed = payload.success === false || (!hasAnthropicContent && ('code' in payload || 'error' in payload)) - if (!failed) return - - const error = isPlainRecord(payload.error) ? payload.error : undefined - const message = typeof error?.message === 'string' - ? error.message - : typeof payload.msg === 'string' - ? payload.msg - : typeof payload.message === 'string' - ? payload.message - : 'Model provider returned an error response.' - throw new ModelProviderError(message, { - provider: config.id, - details: payload, - }) -} diff --git a/packages/ekko-agent/src/model/providers/custom-runtime.ts b/packages/ekko-agent/src/model/providers/custom-runtime.ts deleted file mode 100644 index cd0c6d0..0000000 --- a/packages/ekko-agent/src/model/providers/custom-runtime.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { - FetchLike, - ModelCapabilities, - ModelClient, - ModelClientOptions, - ModelEvent, - ModelProviderConfig, - ModelRequest, - ModelResponse, -} from '../types' -import { postJson, postStream, providerUrl, readServerSentEvents, parseJson } from '../http' - -const capabilities: ModelCapabilities = { - streaming: true, - tools: true, - vision: true, - jsonMode: true, - systemPrompt: true, -} - -export class CustomRuntimeModelClient implements ModelClient { - readonly provider: string - readonly requestStyle = 'custom-runtime' - readonly capabilities: ModelCapabilities - - private readonly config: ModelProviderConfig - private readonly fetchImpl: FetchLike - - constructor(config: ModelProviderConfig, options: ModelClientOptions = {}) { - this.config = config - this.provider = config.id - this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis) - this.capabilities = { ...capabilities, ...config.capabilities } - } - - async create(request: ModelRequest): Promise { - return postJson( - this.config, - this.fetchImpl, - customRuntimeUrl(this.config), - { ...request, model: request.model ?? this.config.defaultModel, stream: false }, - undefined, - request.signal, - ) - } - - async *stream(request: ModelRequest): AsyncIterable { - const response = await postStream( - this.config, - this.fetchImpl, - customRuntimeUrl(this.config), - { ...request, model: request.model ?? this.config.defaultModel, stream: true }, - undefined, - request.signal, - ) - - for await (const event of readServerSentEvents(response)) { - if (event === '[DONE]') { - yield { type: 'done' } - return - } - const chunk = parseJson(event) - if (chunk) yield chunk - } - } -} - -function customRuntimeUrl(config: ModelProviderConfig): string { - return providerUrl(config, 'http://127.0.0.1:11434', config.endpointPath ?? 'v1/agent') -} diff --git a/packages/ekko-agent/src/model/providers/gemini.ts b/packages/ekko-agent/src/model/providers/gemini.ts deleted file mode 100644 index 8d3cb24..0000000 --- a/packages/ekko-agent/src/model/providers/gemini.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { - AgentMessage, - AgentToolCall, - AgentToolDefinition, - FetchLike, - ModelCapabilities, - ModelClient, - ModelClientOptions, - ModelEvent, - ModelProviderConfig, - ModelRequest, - ModelResponse, - ModelUsage, -} from '../types' -import { isPlainRecord, postJson, postStream, readServerSentEvents, parseJson } from '../http' - -interface GeminiPayload { - contents: Array<{ - role: 'user' | 'model' | 'function' - parts: GeminiPart[] - }> - systemInstruction?: { - parts: Array<{ text: string }> - } - generationConfig?: { - temperature?: number - maxOutputTokens?: number - } - tools?: Array<{ - functionDeclarations: Array<{ - name: string - description?: string - parameters?: Record - }> - }> -} - -type GeminiPart = - | { text: string } - | { functionCall: { name: string; args: Record } } - | { functionResponse: { name: string; response: Record } } - -interface GeminiResponse { - candidates?: Array<{ - content?: { - parts?: GeminiPart[] - } - finishReason?: string - }> - usageMetadata?: { - promptTokenCount?: number - candidatesTokenCount?: number - totalTokenCount?: number - cachedContentTokenCount?: number - thoughtsTokenCount?: number - } -} - -const capabilities: ModelCapabilities = { - streaming: true, - tools: true, - vision: false, - jsonMode: true, - systemPrompt: true, -} - -export class GeminiContentsModelClient implements ModelClient { - readonly provider: string - readonly requestStyle = 'gemini-contents' - readonly capabilities: ModelCapabilities - - private readonly config: ModelProviderConfig - private readonly fetchImpl: FetchLike - - constructor(config: ModelProviderConfig, options: ModelClientOptions = {}) { - this.config = config - this.provider = config.id - this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis) - this.capabilities = { ...capabilities, ...config.capabilities } - } - - async create(request: ModelRequest): Promise { - const response = await postJson( - this.config, - this.fetchImpl, - geminiUrl(this.config, request.model, false), - toGeminiContentsPayload(this.config, request), - undefined, - request.signal, - ) - return normalizeGeminiResponse(response, request.model ?? this.config.defaultModel) - } - - async *stream(request: ModelRequest): AsyncIterable { - const response = await postStream( - this.config, - this.fetchImpl, - geminiUrl(this.config, request.model, true), - toGeminiContentsPayload(this.config, request), - undefined, - request.signal, - ) - - for await (const event of readServerSentEvents(response)) { - const chunk = parseJson(event) - if (!chunk) continue - const normalized = normalizeGeminiResponse(chunk, request.model ?? this.config.defaultModel) - if (normalized.content) yield { type: 'text-delta', text: normalized.content } - for (const toolCall of normalized.toolCalls ?? []) yield { type: 'tool-call', toolCall } - if (normalized.usage) yield { type: 'usage', usage: normalized.usage } - } - yield { type: 'done' } - } -} - -export function toGeminiContentsPayload(config: ModelProviderConfig, request: ModelRequest): GeminiPayload { - return { - systemInstruction: request.messages.some(message => message.role === 'system') - ? { parts: request.messages.filter(message => message.role === 'system').map(message => ({ text: message.content })) } - : undefined, - contents: request.messages.filter(message => message.role !== 'system').map(toGeminiContent), - generationConfig: request.temperature !== undefined || request.maxTokens !== undefined - ? { temperature: request.temperature, maxOutputTokens: request.maxTokens } - : undefined, - tools: request.tools?.length ? [{ functionDeclarations: request.tools.map(toGeminiTool) }] : undefined, - } -} - -export function normalizeGeminiResponse(response: GeminiResponse, model?: string): ModelResponse { - const parts = response.candidates?.[0]?.content?.parts ?? [] - return { - model, - content: parts.filter(hasTextPart).map(part => part.text).join(''), - toolCalls: parts.filter(hasFunctionCallPart).map((part, index) => ({ - id: `gemini_call_${index}`, - name: part.functionCall.name, - arguments: part.functionCall.args, - rawArguments: JSON.stringify(part.functionCall.args), - })), - usage: response.usageMetadata ? normalizeUsage(response.usageMetadata) : undefined, - finishReason: response.candidates?.[0]?.finishReason, - raw: response, - } -} - -function toGeminiContent(message: AgentMessage): GeminiPayload['contents'][number] { - if (message.role === 'assistant') { - return { - role: 'model', - parts: [ - ...(message.content ? [{ text: message.content }] : []), - ...(message.toolCalls?.map(toolCall => ({ functionCall: { name: toolCall.name, args: toolCall.arguments } })) ?? []), - ], - } - } - - if (message.role === 'tool') { - return { - role: 'function', - parts: [{ - functionResponse: { - name: message.name ?? message.toolCallId ?? 'tool', - response: { content: message.content }, - }, - }], - } - } - - return { role: 'user', parts: [{ text: message.content }] } -} - -function toGeminiTool(tool: AgentToolDefinition): NonNullable[number]['functionDeclarations']>[number] { - return { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - } -} - -function normalizeUsage(usage: NonNullable): ModelUsage { - return { - inputTokens: usage.promptTokenCount, - outputTokens: usage.candidatesTokenCount, - totalTokens: usage.totalTokenCount, - cacheReadTokens: usage.cachedContentTokenCount, - reasoningTokens: usage.thoughtsTokenCount, - } -} - -function geminiUrl(config: ModelProviderConfig, model: string | undefined, stream: boolean): string { - if (config.endpointPath) return `${(config.baseUrl ?? 'https://generativelanguage.googleapis.com/v1beta').replace(/\/+$/, '')}/${config.endpointPath}` - const baseUrl = (config.baseUrl ?? 'https://generativelanguage.googleapis.com/v1beta').replace(/\/+$/, '') - const method = stream ? 'streamGenerateContent?alt=sse' : 'generateContent' - const separator = method.includes('?') ? '&' : '?' - const key = config.apiKey ? `${separator}key=${encodeURIComponent(config.apiKey)}` : '' - return `${baseUrl}/models/${encodeURIComponent(model ?? config.defaultModel)}:${method}${key}` -} - -function hasTextPart(part: GeminiPart): part is { text: string } { - return 'text' in part && typeof part.text === 'string' -} - -function hasFunctionCallPart(part: GeminiPart): part is { functionCall: { name: string; args: Record } } { - return 'functionCall' in part && isPlainRecord(part.functionCall) && typeof part.functionCall.name === 'string' && isPlainRecord(part.functionCall.args) -} diff --git a/packages/ekko-agent/src/model/providers/openai-compatible.ts b/packages/ekko-agent/src/model/providers/openai-compatible.ts deleted file mode 100644 index 0665ca4..0000000 --- a/packages/ekko-agent/src/model/providers/openai-compatible.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { ModelProviderError } from '../errors' -import { - abortSignal, - isPlainRecord, - parseJson, - parseResponseJson, - parseServerSentEventLine, - providerHttpError, - providerUrl, - requestHeaders, -} from '../http' -import type { - AgentMessage, - AgentToolCall, - AgentToolDefinition, - FetchLike, - ModelCapabilities, - ModelClient, - ModelClientOptions, - ModelEvent, - ModelProviderConfig, - ModelRequest, - ModelResponse, - ModelUsage, -} from '../types' - -interface OpenAIChatMessage { - role: 'system' | 'user' | 'assistant' | 'tool' - content: string | null - name?: string - tool_call_id?: string - tool_calls?: OpenAIToolCall[] -} - -interface OpenAIToolCall { - id: string - type: 'function' - function: { - name: string - arguments: string - } -} - -interface OpenAIToolDefinition { - type: 'function' - function: { - name: string - description?: string - parameters?: Record - } -} - -type OpenAIMessageContent = string | Array<{ type?: string; text?: string }> | null | undefined - -interface OpenAIChatResponseMessage { - content?: OpenAIMessageContent - reasoning?: string - reasoning_content?: string - reasoning_details?: unknown - tool_calls?: OpenAIToolCall[] -} - -interface OpenAIChatPayload { - model: string - messages: OpenAIChatMessage[] - temperature?: number - max_tokens?: number - tools?: OpenAIToolDefinition[] - tool_choice?: 'auto' | 'none' | 'required' - stream?: boolean - stream_options?: { - include_usage: boolean - } - metadata?: Record -} - -interface OpenAIChatResponse { - id?: string - model?: string - choices?: Array<{ - message?: OpenAIChatResponseMessage - delta?: { - content?: string | null - reasoning?: string | null - reasoning_content?: string | null - tool_calls?: Array<{ - index?: number - id?: string - type?: 'function' - function?: { - name?: string - arguments?: string - } - }> - } - finish_reason?: string | null - }> - usage?: { - prompt_tokens?: number - completion_tokens?: number - total_tokens?: number - prompt_tokens_details?: { cached_tokens?: number } - completion_tokens_details?: { reasoning_tokens?: number } - } | null - error?: { - message?: string - type?: string - code?: string - } -} - -const defaultCapabilities: ModelCapabilities = { - streaming: true, - tools: true, - vision: false, - jsonMode: false, - systemPrompt: true, -} - -export class OpenAICompatibleModelClient implements ModelClient { - readonly provider: string - readonly requestStyle = 'openai-chat' - readonly capabilities: ModelCapabilities - - private readonly config: ModelProviderConfig - private readonly fetchImpl: FetchLike - - constructor(config: ModelProviderConfig, options: ModelClientOptions = {}) { - this.config = config - this.provider = config.id - this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis) - this.capabilities = { - ...defaultCapabilities, - ...config.capabilities, - } - } - - async create(request: ModelRequest): Promise { - const payload = toOpenAIChatPayload(this.config, { ...request, stream: false }) - const response = await this.postJson(payload, request.signal) - return normalizeOpenAIChatResponse(this.provider, response) - } - - async *stream(request: ModelRequest): AsyncIterable { - const payload = toOpenAIChatPayload(this.config, { ...request, stream: true }) - const response = await this.post(payload, request.signal) - - if (!response.body) { - throw new ModelProviderError('Model provider returned an empty stream body.', { - provider: this.provider, - statusCode: response.status, - }) - } - - const reader = response.body.getReader() - const decoder = new TextDecoder() - const toolCalls = new Map() - let buffer = '' - let finishReason: string | undefined - let responseId: string | undefined - let responseModel: string | undefined - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split(/\r?\n/) - buffer = lines.pop() ?? '' - - for (const line of lines) { - const event = parseServerSentEventLine(line) - if (!event) continue - if (event === '[DONE]') { - yield { type: 'done', response: { id: responseId, model: responseModel, finishReason } } - return - } - - const chunk = parseJson(event) - if (!chunk) continue - responseId = chunk.id ?? responseId - responseModel = chunk.model ?? responseModel - - if (chunk.usage) { - yield { type: 'usage', usage: normalizeUsage(chunk.usage) } - } - - const choice = chunk.choices?.[0] - if (!choice) continue - finishReason = choice.finish_reason ?? finishReason - - const content = choice.delta?.content - if (content) { - yield { type: 'text-delta', text: content } - } - const reasoning = choice.delta?.reasoning_content ?? choice.delta?.reasoning - if (reasoning) { - yield { type: 'reasoning-delta', text: reasoning } - } - - for (const toolCallDelta of choice.delta?.tool_calls ?? []) { - const index = toolCallDelta.index ?? 0 - const current = toolCalls.get(index) ?? { id: '', name: '', argumentsText: '' } - current.id = toolCallDelta.id ?? current.id - current.name = toolCallDelta.function?.name ?? current.name - current.argumentsText += toolCallDelta.function?.arguments ?? '' - toolCalls.set(index, current) - } - - if (choice.finish_reason === 'tool_calls') { - for (const toolCall of toolCalls.values()) { - yield { type: 'tool-call', toolCall: normalizeToolCall(toolCall.id, toolCall.name, toolCall.argumentsText) } - } - toolCalls.clear() - } - } - } - } finally { - reader.releaseLock() - } - - yield { type: 'done', response: { id: responseId, model: responseModel, finishReason } } - } - - private async postJson(payload: OpenAIChatPayload, signal?: AbortSignal): Promise { - const response = await this.post(payload, signal) - return parseResponseJson(this.provider, response) - } - - private async post(payload: OpenAIChatPayload, signal?: AbortSignal): Promise { - const response = await this.fetchImpl(chatCompletionsUrl(this.config), { - method: 'POST', - headers: requestHeaders(this.config), - body: JSON.stringify(payload), - signal: abortSignal(this.config.timeoutMs, signal), - }) - - if (!response.ok) { - throw await providerHttpError(this.provider, response) - } - - return response - } -} - -export function toOpenAIChatPayload(config: ModelProviderConfig, request: ModelRequest): OpenAIChatPayload { - return { - model: request.model ?? config.defaultModel, - messages: request.messages.map(toOpenAIChatMessage), - temperature: request.temperature, - max_tokens: request.maxTokens, - tools: request.tools?.map(toOpenAIToolDefinition), - tool_choice: request.toolChoice, - stream: request.stream, - stream_options: request.stream ? { include_usage: true } : undefined, - metadata: request.metadata, - } -} - -export function normalizeOpenAIChatResponse(provider: string, response: OpenAIChatResponse): ModelResponse { - if (response.error) { - throw new ModelProviderError(response.error.message ?? 'Model provider returned an error.', { - provider, - details: response.error, - }) - } - - const choice = response.choices?.[0] - return { - id: response.id, - model: response.model, - content: normalizeContent(choice?.message?.content), - reasoning: normalizeReasoning(choice?.message), - toolCalls: choice?.message?.tool_calls?.map(toAgentToolCall), - usage: response.usage ? normalizeUsage(response.usage) : undefined, - finishReason: choice?.finish_reason ?? undefined, - raw: response, - } -} - -function normalizeReasoning(message: OpenAIChatResponseMessage | undefined): string | undefined { - if (!message) return undefined - if (typeof message.reasoning_content === 'string' && message.reasoning_content) return message.reasoning_content - if (typeof message.reasoning === 'string' && message.reasoning) return message.reasoning - if (message.reasoning_details !== undefined && message.reasoning_details !== null) return JSON.stringify(message.reasoning_details) - return undefined -} - -function toOpenAIChatMessage(message: AgentMessage): OpenAIChatMessage { - return { - role: message.role, - content: message.role === 'assistant' && message.toolCalls?.length ? message.content || null : message.content, - name: message.name, - tool_call_id: message.toolCallId, - tool_calls: message.toolCalls?.map(toOpenAIToolCall), - } -} - -function toOpenAIToolDefinition(tool: AgentToolDefinition): OpenAIToolDefinition { - return { - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - }, - } -} - -function toOpenAIToolCall(toolCall: AgentToolCall): OpenAIToolCall { - return { - id: toolCall.id, - type: 'function', - function: { - name: toolCall.name, - arguments: JSON.stringify(toolCall.arguments), - }, - } -} - -function toAgentToolCall(toolCall: OpenAIToolCall): AgentToolCall { - return normalizeToolCall(toolCall.id, toolCall.function.name, toolCall.function.arguments) -} - -function normalizeToolCall(id: string, name: string, argumentsText: string): AgentToolCall { - const parsedArguments = parseJson(argumentsText) - if (isPlainRecord(parsedArguments)) { - return { id, name, arguments: parsedArguments, rawArguments: argumentsText } - } - return { id, name, arguments: {}, rawArguments: argumentsText } -} - -function normalizeContent(content: OpenAIMessageContent): string { - if (!content) return '' - if (typeof content === 'string') return content - return content.map(part => part.text ?? '').join('') -} - -function normalizeUsage(usage: NonNullable): ModelUsage { - const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0 - const inputTokens = usage.prompt_tokens ?? 0 - return { - inputTokens: Math.max(0, inputTokens - cacheReadTokens), - outputTokens: usage.completion_tokens, - totalTokens: usage.total_tokens, - cacheReadTokens, - reasoningTokens: usage.completion_tokens_details?.reasoning_tokens, - } -} - -function chatCompletionsUrl(config: ModelProviderConfig): string { - return providerUrl(config, 'https://api.openai.com/v1', 'chat/completions') -} diff --git a/packages/ekko-agent/src/model/providers/openai-responses.ts b/packages/ekko-agent/src/model/providers/openai-responses.ts deleted file mode 100644 index 600e6ee..0000000 --- a/packages/ekko-agent/src/model/providers/openai-responses.ts +++ /dev/null @@ -1,228 +0,0 @@ -import type { - AgentMessage, - AgentToolCall, - AgentToolDefinition, - FetchLike, - ModelCapabilities, - ModelClient, - ModelClientOptions, - ModelEvent, - ModelProviderConfig, - ModelRequest, - ModelResponse, - ModelUsage, -} from '../types' -import { isPlainRecord, parseJson, postJson, postStream, providerUrl, readServerSentEvents } from '../http' - -interface OpenAIResponsesPayload { - model: string - instructions?: string - input: Array<{ - role: 'user' | 'assistant' | 'developer' - content: string - }> - temperature?: number - max_output_tokens?: number - tools?: Array<{ - type: 'function' - name: string - description?: string - parameters?: Record - }> - tool_choice?: 'auto' | 'none' | 'required' - stream?: boolean - metadata?: Record - previous_response_id?: string -} - -interface OpenAIResponsesResponse { - id?: string - model?: string - output_text?: string - output?: Array<{ - type?: string - content?: Array<{ type?: string; text?: string }> - summary?: Array<{ type?: string; text?: string }> - name?: string - call_id?: string - arguments?: string - }> - reasoning?: string - reasoning_text?: string - reasoning_summary?: string - usage?: { - input_tokens?: number - output_tokens?: number - total_tokens?: number - input_tokens_details?: { cached_tokens?: number } - output_tokens_details?: { reasoning_tokens?: number } - } - status?: string - error?: { - message?: string - } -} - -const capabilities: ModelCapabilities = { - streaming: true, - tools: true, - vision: false, - jsonMode: true, - systemPrompt: true, -} - -export class OpenAIResponsesModelClient implements ModelClient { - readonly provider: string - readonly requestStyle = 'openai-responses' - readonly capabilities: ModelCapabilities - - private readonly config: ModelProviderConfig - private readonly fetchImpl: FetchLike - - constructor(config: ModelProviderConfig, options: ModelClientOptions = {}) { - this.config = config - this.provider = config.id - this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis) - this.capabilities = { ...capabilities, ...config.capabilities } - } - - async create(request: ModelRequest): Promise { - const response = await postJson( - this.config, - this.fetchImpl, - responsesUrl(this.config), - toOpenAIResponsesPayload(this.config, { ...request, stream: false }), - undefined, - request.signal, - ) - return normalizeOpenAIResponsesResponse(response) - } - - async *stream(request: ModelRequest): AsyncIterable { - const response = await postStream( - this.config, - this.fetchImpl, - responsesUrl(this.config), - toOpenAIResponsesPayload(this.config, { ...request, stream: true }), - undefined, - request.signal, - ) - - for await (const event of readServerSentEvents(response)) { - if (event === '[DONE]') { - yield { type: 'done' } - return - } - const chunk = parseJson>(event) - if (!chunk) continue - - if (chunk.type === 'response.output_text.delta' && typeof chunk.delta === 'string') { - yield { type: 'text-delta', text: chunk.delta } - } - if ( - (chunk.type === 'response.reasoning.delta' || - chunk.type === 'response.reasoning_text.delta' || - chunk.type === 'response.reasoning_summary_text.delta') && - typeof chunk.delta === 'string' - ) { - yield { type: 'reasoning-delta', text: chunk.delta } - } - if (chunk.type === 'response.completed' && isPlainRecord(chunk.response)) { - yield { type: 'done', response: normalizeOpenAIResponsesResponse(chunk.response as OpenAIResponsesResponse) } - } - } - } -} - -export function toOpenAIResponsesPayload(config: ModelProviderConfig, request: ModelRequest): OpenAIResponsesPayload { - const systemMessages = request.messages.filter(message => message.role === 'system') - return { - model: request.model ?? config.defaultModel, - instructions: systemMessages.map(message => message.content).join('\n\n') || undefined, - input: request.messages.filter(message => message.role !== 'system').map(toOpenAIResponseInput), - temperature: request.temperature, - max_output_tokens: request.maxTokens, - tools: request.tools?.map(toOpenAIResponseTool), - tool_choice: request.toolChoice, - stream: request.stream, - metadata: request.metadata, - previous_response_id: openAIResponsesContext(request.context)?.responseId, - } -} - -export function normalizeOpenAIResponsesResponse(response: OpenAIResponsesResponse): ModelResponse { - const toolCalls = response.output - ?.filter(item => item.type === 'function_call' && item.name && item.call_id) - .map(item => normalizeToolCall(item.call_id ?? '', item.name ?? '', item.arguments ?? '{}')) - - return { - id: response.id, - model: response.model, - content: response.output_text ?? response.output?.flatMap(item => item.content ?? []).map(part => part.text ?? '').join('') ?? '', - reasoning: normalizeReasoning(response), - toolCalls, - usage: response.usage ? normalizeUsage(response.usage) : undefined, - finishReason: response.status, - context: response.id ? { responseId: response.id } : undefined, - raw: response, - } -} - -function openAIResponsesContext(context: unknown): { responseId?: string } | undefined { - if (!context || typeof context !== 'object') return undefined - const responseId = (context as { responseId?: unknown }).responseId - return typeof responseId === 'string' && responseId ? { responseId } : undefined -} - -function normalizeReasoning(response: OpenAIResponsesResponse): string | undefined { - if (typeof response.reasoning === 'string' && response.reasoning) return response.reasoning - if (typeof response.reasoning_text === 'string' && response.reasoning_text) return response.reasoning_text - if (typeof response.reasoning_summary === 'string' && response.reasoning_summary) return response.reasoning_summary - - const reasoning = response.output - ?.filter(item => item.type === 'reasoning') - .flatMap(item => [...(item.content ?? []), ...(item.summary ?? [])]) - .map(part => part.text ?? '') - .join('') - return reasoning || undefined -} - -function toOpenAIResponseInput(message: AgentMessage): OpenAIResponsesPayload['input'][number] { - if (message.role === 'assistant') return { role: 'assistant', content: message.content } - return { role: 'user', content: message.content } -} - -function toOpenAIResponseTool(tool: AgentToolDefinition): NonNullable[number] { - return { - type: 'function', - name: tool.name, - description: tool.description, - parameters: tool.parameters, - } -} - -function normalizeToolCall(id: string, name: string, argumentsText: string): AgentToolCall { - const parsedArguments = parseJson(argumentsText) - return { - id, - name, - arguments: isPlainRecord(parsedArguments) ? parsedArguments : {}, - rawArguments: argumentsText, - } -} - -function normalizeUsage(usage: NonNullable): ModelUsage { - const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? 0 - const inputTokens = usage.input_tokens ?? 0 - return { - inputTokens: Math.max(0, inputTokens - cacheReadTokens), - outputTokens: usage.output_tokens, - totalTokens: usage.total_tokens, - cacheReadTokens, - reasoningTokens: usage.output_tokens_details?.reasoning_tokens, - } -} - -function responsesUrl(config: ModelProviderConfig): string { - return providerUrl(config, 'https://api.openai.com/v1', config.endpointPath ?? 'responses') -} diff --git a/packages/ekko-agent/src/model/providers/prompt-completion.ts b/packages/ekko-agent/src/model/providers/prompt-completion.ts deleted file mode 100644 index fbd10ad..0000000 --- a/packages/ekko-agent/src/model/providers/prompt-completion.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { - FetchLike, - ModelCapabilities, - ModelClient, - ModelClientOptions, - ModelEvent, - ModelProviderConfig, - ModelRequest, - ModelResponse, - ModelUsage, -} from '../types' -import { parseJson, postJson, postStream, providerUrl, readServerSentEvents } from '../http' - -interface PromptCompletionPayload { - model: string - prompt: string - temperature?: number - max_tokens?: number - stream?: boolean -} - -interface PromptCompletionResponse { - id?: string - model?: string - choices?: Array<{ - text?: string - finish_reason?: string - }> - usage?: { - prompt_tokens?: number - completion_tokens?: number - total_tokens?: number - } -} - -const capabilities: ModelCapabilities = { - streaming: true, - tools: false, - vision: false, - jsonMode: false, - systemPrompt: false, -} - -export class PromptCompletionModelClient implements ModelClient { - readonly provider: string - readonly requestStyle = 'prompt-completion' - readonly capabilities: ModelCapabilities - - private readonly config: ModelProviderConfig - private readonly fetchImpl: FetchLike - - constructor(config: ModelProviderConfig, options: ModelClientOptions = {}) { - this.config = config - this.provider = config.id - this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis) - this.capabilities = { ...capabilities, ...config.capabilities } - } - - async create(request: ModelRequest): Promise { - const response = await postJson( - this.config, - this.fetchImpl, - completionsUrl(this.config), - toPromptCompletionPayload(this.config, { ...request, stream: false }), - undefined, - request.signal, - ) - return normalizePromptCompletionResponse(response) - } - - async *stream(request: ModelRequest): AsyncIterable { - const response = await postStream( - this.config, - this.fetchImpl, - completionsUrl(this.config), - toPromptCompletionPayload(this.config, { ...request, stream: true }), - undefined, - request.signal, - ) - - for await (const event of readServerSentEvents(response)) { - if (event === '[DONE]') { - yield { type: 'done' } - return - } - const chunk = parseJson(event) - const text = chunk?.choices?.[0]?.text - if (text) yield { type: 'text-delta', text } - if (chunk?.usage) yield { type: 'usage', usage: normalizeUsage(chunk.usage) } - } - } -} - -export function toPromptCompletionPayload(config: ModelProviderConfig, request: ModelRequest): PromptCompletionPayload { - return { - model: request.model ?? config.defaultModel, - prompt: request.messages.map(message => `${message.role.toUpperCase()}: ${message.content}`).join('\n\n'), - temperature: request.temperature, - max_tokens: request.maxTokens, - stream: request.stream, - } -} - -export function normalizePromptCompletionResponse(response: PromptCompletionResponse): ModelResponse { - return { - id: response.id, - model: response.model, - content: response.choices?.[0]?.text ?? '', - usage: response.usage ? normalizeUsage(response.usage) : undefined, - finishReason: response.choices?.[0]?.finish_reason, - raw: response, - } -} - -function normalizeUsage(usage: NonNullable): ModelUsage { - return { - inputTokens: usage.prompt_tokens, - outputTokens: usage.completion_tokens, - totalTokens: usage.total_tokens, - } -} - -function completionsUrl(config: ModelProviderConfig): string { - return providerUrl(config, 'https://api.openai.com/v1', config.endpointPath ?? 'completions') -} diff --git a/packages/ekko-agent/src/model/registry.ts b/packages/ekko-agent/src/model/registry.ts deleted file mode 100644 index 5107080..0000000 --- a/packages/ekko-agent/src/model/registry.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { ModelClient, ModelClientOptions, ModelProviderConfig, ModelRequestStyle } from './types' -import { AnthropicMessagesModelClient } from './providers/anthropic' -import { CustomRuntimeModelClient } from './providers/custom-runtime' -import { GeminiContentsModelClient } from './providers/gemini' -import { OpenAICompatibleModelClient } from './providers/openai-compatible' -import { OpenAIResponsesModelClient } from './providers/openai-responses' -import { PromptCompletionModelClient } from './providers/prompt-completion' - -export function resolveRequestStyle(config: ModelProviderConfig): ModelRequestStyle { - if (config.requestStyle) return config.requestStyle - if (config.type === 'anthropic') return 'anthropic-messages' - if (config.type === 'gemini') return 'gemini-contents' - if (config.type === 'custom') return 'custom-runtime' - return 'openai-chat' -} - -export function createModelClient(config: ModelProviderConfig, options: ModelClientOptions = {}): ModelClient { - const requestStyle = resolveRequestStyle(config) - - if (requestStyle === 'openai-chat') { - return new OpenAICompatibleModelClient({ ...config, requestStyle }, options) - } - if (requestStyle === 'openai-responses') { - return new OpenAIResponsesModelClient({ ...config, requestStyle }, options) - } - if (requestStyle === 'anthropic-messages') { - return new AnthropicMessagesModelClient({ ...config, requestStyle }, options) - } - if (requestStyle === 'gemini-contents') { - return new GeminiContentsModelClient({ ...config, requestStyle }, options) - } - if (requestStyle === 'prompt-completion') { - return new PromptCompletionModelClient({ ...config, requestStyle }, options) - } - if (requestStyle === 'custom-runtime') { - return new CustomRuntimeModelClient({ ...config, requestStyle }, options) - } - - throw new Error(`Unsupported model request style: ${requestStyle}`) -} - -export class ModelProviderRegistry { - private readonly providers = new Map() - - register(config: ModelProviderConfig): void { - this.providers.set(config.id, config) - } - - getConfig(providerId: string): ModelProviderConfig | undefined { - return this.providers.get(providerId) - } - - create(providerId: string, options: ModelClientOptions = {}): ModelClient { - const config = this.providers.get(providerId) - if (!config) { - throw new Error(`Unknown model provider: ${providerId}`) - } - return createModelClient(config, options) - } - - list(): ModelProviderConfig[] { - return [...this.providers.values()] - } -} diff --git a/packages/ekko-agent/src/model/types.ts b/packages/ekko-agent/src/model/types.ts deleted file mode 100644 index 18f10b9..0000000 --- a/packages/ekko-agent/src/model/types.ts +++ /dev/null @@ -1,117 +0,0 @@ -export type ModelRequestStyle = - | 'openai-chat' - | 'openai-responses' - | 'anthropic-messages' - | 'gemini-contents' - | 'prompt-completion' - | 'custom-runtime' - -export type ModelProviderType = - | 'openai' - | 'openai-compatible' - | 'anthropic' - | 'gemini' - | 'ollama' - | 'custom' - -export type AgentMessageRole = 'system' | 'user' | 'assistant' | 'tool' - -export interface AgentMessage { - role: AgentMessageRole - content: string - reasoning?: string - name?: string - toolCallId?: string - toolCalls?: AgentToolCall[] -} - -export interface AgentToolDefinition { - name: string - description?: string - parameters?: Record -} - -export interface AgentToolCall { - id: string - name: string - arguments: Record - rawArguments?: string -} - -export interface ModelUsage { - inputTokens?: number - outputTokens?: number - totalTokens?: number - cacheReadTokens?: number - cacheWriteTokens?: number - reasoningTokens?: number -} - -export interface ModelRequest { - model?: string - messages: AgentMessage[] - signal?: AbortSignal - temperature?: number - maxTokens?: number - tools?: AgentToolDefinition[] - toolChoice?: 'auto' | 'none' | 'required' - stream?: boolean - metadata?: Record - context?: unknown -} - -export interface ModelResponse { - id?: string - model?: string - content: string - reasoning?: string - toolCalls?: AgentToolCall[] - usage?: ModelUsage - finishReason?: string - context?: unknown - raw?: unknown -} - -export type ModelEvent = - | { type: 'text-delta'; text: string } - | { type: 'reasoning-delta'; text: string } - | { type: 'tool-call'; toolCall: AgentToolCall } - | { type: 'usage'; usage: ModelUsage } - | { type: 'done'; response?: Partial } - | { type: 'error'; error: string } - -export interface ModelCapabilities { - streaming: boolean - tools: boolean - vision: boolean - jsonMode: boolean - systemPrompt: boolean - maxInputTokens?: number -} - -export interface ModelProviderConfig { - id: string - type: ModelProviderType - requestStyle?: ModelRequestStyle - apiKey?: string - baseUrl?: string - endpointPath?: string - defaultModel: string - headers?: Record - timeoutMs?: number - capabilities?: Partial -} - -export interface ModelClient { - provider: string - requestStyle: ModelRequestStyle - capabilities: ModelCapabilities - create(request: ModelRequest): Promise - stream(request: ModelRequest): AsyncIterable -} - -export type FetchLike = (input: string | URL, init?: RequestInit) => Promise - -export interface ModelClientOptions { - fetch?: FetchLike -} diff --git a/packages/ekko-agent/src/runtime/events.ts b/packages/ekko-agent/src/runtime/events.ts deleted file mode 100644 index 04f4805..0000000 --- a/packages/ekko-agent/src/runtime/events.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { AgentOutputMessage } from '../model/messages' -import type { AgentToolCall, ModelUsage } from '../model/types' -import type { AgentToolResult } from '../tools/types' -import type { AgentRuntimeContextEstimate } from './types' - -export type AgentRuntimeEvent = - | { type: 'run.started'; runId: string; maxSteps: number } - | { type: 'model.started'; runId: string; step: number } - | { type: 'context.estimated'; runId: string; step: number; estimate: AgentRuntimeContextEstimate } - | { type: 'model.retry'; runId: string; step: number; retry: number; maxRetries: number; error: string } - | { type: 'model.message'; runId: string; step: number; message: AgentOutputMessage } - | { type: 'model.delta'; runId: string; step: number; text: string } - | { type: 'model.reasoning'; runId: string; step: number; text: string } - | { type: 'model.tool_call'; runId: string; step: number; toolCall: AgentToolCall } - | { type: 'model.usage'; runId: string; step: number; usage: ModelUsage } - | { type: 'model.context'; runId: string; step: number; context: unknown } - | { type: 'tool.started'; runId: string; step: number; toolCallId: string; toolName: string; arguments: Record } - | { type: 'tool.completed'; runId: string; step: number; toolCallId: string; toolName: string; result: AgentToolResult; durationMs: number } - | { type: 'tool.failed'; runId: string; step: number; toolCallId: string; toolName: string; result: AgentToolResult; durationMs: number } - | { type: 'run.tool_failure_limit'; runId: string; failures: number } - | { type: 'run.completed'; runId: string; output: AgentOutputMessage; steps: number; context?: unknown; contextEstimate?: AgentRuntimeContextEstimate } - | { type: 'run.failed'; runId: string; error: string; steps: number } - | { type: 'run.max_steps'; runId: string; maxSteps: number } diff --git a/packages/ekko-agent/src/runtime/runtime.ts b/packages/ekko-agent/src/runtime/runtime.ts deleted file mode 100644 index 3fa1d10..0000000 --- a/packages/ekko-agent/src/runtime/runtime.ts +++ /dev/null @@ -1,483 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { getEncoding } from 'js-tiktoken' -import { - createSystemMessage, - createToolResultMessage, - collectModelEvents, - modelResponseToAgentMessage, - normalizeAgentMessages, -} from '../model/messages' -import type { AgentOutputMessage } from '../model/messages' -import type { AgentMessage, AgentToolCall, ModelRequest, ModelResponse } from '../model/types' -import type { AgentSkill } from '../skills/types' -import { AgentToolRegistry, createDefaultToolRegistry } from '../tools/registry' -import type { AgentToolContext, AgentToolResult } from '../tools/types' -import type { AgentRuntimeEvent } from './events' -import { buildSystemPrompt } from './system-prompt' -import type { AgentRuntimeContextEstimate, AgentRuntimeOptions, AgentRuntimeRunInput, AgentRuntimeRunResult, AgentRuntimeStep } from './types' - -export const DEFAULT_AGENT_MAX_STEPS = 90 -export const DEFAULT_AGENT_MODEL_MAX_RETRIES = 3 -export const DEFAULT_AGENT_MAX_CONSECUTIVE_TOOL_FAILURES = 6 -export const DEFAULT_AGENT_TOOL_DELAY_MS = 1000 - -interface ModelResponseResult { - response: ModelResponse - emittedReasoning: boolean -} - -export class AgentRuntime { - private readonly modelClient?: AgentRuntimeOptions['modelClient'] - private readonly tools: AgentToolRegistry - private readonly skills: AgentSkill[] - private readonly systemPrompt?: string - private readonly runtimeInstructions: string[] - private readonly maxSteps: number - private readonly toolContext?: AgentToolContext - private readonly modelDefaults?: AgentRuntimeOptions['modelDefaults'] - private readonly maxModelRetries: number - private readonly maxConsecutiveToolFailures: number - private readonly toolDelayMs: number - private readonly defaultContextKey?: string - private readonly modelContexts = new Map() - - constructor(options: AgentRuntimeOptions) { - this.modelClient = options.modelClient - this.tools = options.tools ?? createDefaultToolRegistry() - this.skills = options.skills ?? [] - this.systemPrompt = options.systemPrompt - this.runtimeInstructions = options.runtimeInstructions ?? [] - this.maxSteps = options.maxSteps ?? DEFAULT_AGENT_MAX_STEPS - this.toolContext = options.toolContext - this.modelDefaults = options.modelDefaults - this.maxModelRetries = options.maxModelRetries ?? DEFAULT_AGENT_MODEL_MAX_RETRIES - this.maxConsecutiveToolFailures = options.maxConsecutiveToolFailures ?? DEFAULT_AGENT_MAX_CONSECUTIVE_TOOL_FAILURES - this.toolDelayMs = options.toolDelayMs ?? DEFAULT_AGENT_TOOL_DELAY_MS - this.defaultContextKey = options.contextKey - this.registerSkillTools(this.skills) - } - - registerSkill(skill: AgentSkill): void { - this.skills.push(skill) - this.registerSkillTools([skill]) - } - - registerSkills(skills: AgentSkill[]): void { - for (const skill of skills) { - this.registerSkill(skill) - } - } - - async refreshTools(context?: AgentToolContext): Promise { - await this.tools.refreshTools(context) - } - - async run(input: AgentRuntimeRunInput): Promise { - await this.refreshTools(this.runToolContext(input)) - - const runId = randomUUID() - const events: AgentRuntimeEvent[] = [] - const steps: AgentRuntimeStep[] = [] - const maxSteps = input.maxSteps ?? this.maxSteps - const maxModelRetries = input.maxModelRetries ?? this.maxModelRetries - const maxConsecutiveToolFailures = input.maxConsecutiveToolFailures ?? this.maxConsecutiveToolFailures - const toolDelayMs = input.toolDelayMs ?? this.toolDelayMs - const emit = (event: AgentRuntimeEvent) => { - events.push(event) - input.onEvent?.(event) - } - - emit({ type: 'run.started', runId, maxSteps }) - - const runSkills = [...this.skills, ...(input.skills ?? [])] - this.registerSkillTools(input.skills ?? []) - const messages = this.prepareMessages(input, runSkills) - let output: AgentOutputMessage = { - role: 'assistant', - content: '', - } - const contextKey = this.contextKeyFor(input) - let contextEstimate: AgentRuntimeContextEstimate | undefined - let consecutiveToolFailures = 0 - - try { - for (let step = 1; step <= maxSteps; step += 1) { - throwIfAborted(input.signal) - const modelClient = this.modelClientFor(input) - emit({ type: 'model.started', runId, step }) - const request = this.modelRequest(input, messages, modelClient, contextKey) - contextEstimate = estimateModelRequestContext(request) - emit({ type: 'context.estimated', runId, step, estimate: contextEstimate }) - const modelResult = await this.createModelResponseWithRetries( - request, - modelClient, - runId, - step, - maxModelRetries, - emit, - ) - const response = modelResult.response - const assistantMessage = modelResponseToAgentMessage(response) - output = assistantMessage - messages.push(assistantMessage) - steps.push({ type: 'model', step, message: assistantMessage }) - if (assistantMessage.reasoning && !modelResult.emittedReasoning) { - emit({ type: 'model.reasoning', runId, step, text: assistantMessage.reasoning }) - } - if (assistantMessage.context !== undefined) { - if (contextKey) this.modelContexts.set(contextKey, assistantMessage.context) - emit({ type: 'model.context', runId, step, context: assistantMessage.context }) - } - emit({ type: 'model.message', runId, step, message: assistantMessage }) - - const toolCalls = assistantMessage.toolCalls ?? [] - if (toolCalls.length === 0) { - const context = contextKey ? this.modelContexts.get(contextKey) : assistantMessage.context - emit({ type: 'run.completed', runId, output, steps: step, context, contextEstimate }) - return { runId, messages, output, steps, events, context, contextEstimate } - } - - for (const toolCall of toolCalls) { - throwIfAborted(input.signal) - const result = await this.executeTool( - runId, - step, - toolCall, - this.runToolContext(input), - emit, - input.signal, - ) - throwIfAborted(input.signal) - messages.push(createToolResultMessage(toolCall.id, result.content, toolCall.name)) - steps.push({ type: 'tool', step, toolCallId: toolCall.id, toolName: toolCall.name, result }) - consecutiveToolFailures = result.ok ? 0 : consecutiveToolFailures + 1 - if (maxConsecutiveToolFailures > 0 && consecutiveToolFailures >= maxConsecutiveToolFailures) { - emit({ type: 'run.tool_failure_limit', runId, failures: consecutiveToolFailures }) - output = { - role: 'assistant', - content: `Stopped after ${consecutiveToolFailures} consecutive tool failures.`, - finishReason: 'tool_failure_limit', - } - const context = contextKey ? this.modelContexts.get(contextKey) : undefined - emit({ type: 'run.completed', runId, output, steps: step, context, contextEstimate }) - return { runId, messages, output, steps, events, context, contextEstimate } - } - await delay(toolDelayMs, input.signal) - } - } - - emit({ type: 'run.max_steps', runId, maxSteps }) - output = { - role: 'assistant', - content: `Stopped after reaching maxSteps (${maxSteps}).`, - finishReason: 'max_steps', - } - const context = contextKey ? this.modelContexts.get(contextKey) : undefined - emit({ type: 'run.completed', runId, output, steps: maxSteps, context, contextEstimate }) - return { runId, messages, output, steps, events, context, contextEstimate } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - emit({ type: 'run.failed', runId, error: message, steps: steps.length }) - throw error - } - } - - private async createModelResponseWithRetries( - request: ModelRequest, - modelClient: NonNullable, - runId: string, - step: number, - maxRetries: number, - emit: (event: AgentRuntimeEvent) => void, - ): Promise { - for (let attempt = 1; attempt <= maxRetries + 1; attempt += 1) { - try { - throwIfAborted(request.signal) - if (request.stream && modelClient.capabilities.streaming) { - return await this.streamModelResponse(request, modelClient, runId, step, emit) - } - const response = await modelClient.create(request) - if (response.usage) emit({ type: 'model.usage', runId, step, usage: response.usage }) - return { - response, - emittedReasoning: false, - } - } catch (error) { - throwIfAborted(request.signal) - if (attempt > maxRetries) throw error - emit({ - type: 'model.retry', - runId, - step, - retry: attempt, - maxRetries, - error: error instanceof Error ? error.message : String(error), - }) - } - } - throw new Error('Model request retry loop exited unexpectedly.') - } - - private async streamModelResponse( - request: ModelRequest, - modelClient: NonNullable, - runId: string, - step: number, - emit: (event: AgentRuntimeEvent) => void, - ): Promise { - let emittedReasoning = false - const events = modelClient.stream({ ...request, stream: true }) - const output = await collectModelEvents((async function *streamAndEmit() { - for await (const event of events) { - if (event.type === 'text-delta') { - emit({ type: 'model.delta', runId, step, text: event.text }) - } else if (event.type === 'reasoning-delta') { - emittedReasoning = true - emit({ type: 'model.reasoning', runId, step, text: event.text }) - } else if (event.type === 'tool-call') { - emit({ type: 'model.tool_call', runId, step, toolCall: event.toolCall }) - } else if (event.type === 'error') { - throw new Error(event.error) - } - yield event - } - })()) - if (output.message.usage) { - emit({ type: 'model.usage', runId, step, usage: output.message.usage }) - } - if (isEmptyModelResponse(output.message)) { - const response = await modelClient.create({ ...request, stream: false }) - if (response.usage) emit({ type: 'model.usage', runId, step, usage: response.usage }) - return { - response, - emittedReasoning: false, - } - } - return { - response: output.message, - emittedReasoning, - } - } - - private prepareMessages(input: AgentRuntimeRunInput, skills: AgentSkill[]): AgentMessage[] { - const normalized = normalizeAgentMessages(input.messages) - const userSystemMessages = normalized.filter(message => message.role === 'system').map(message => message.content) - const nonSystemMessages = normalized.filter(message => message.role !== 'system') - const systemPrompt = buildSystemPrompt({ - basePrompt: input.systemPrompt ?? this.systemPrompt, - runtimeInstructions: this.runtimeInstructions, - userSystemMessages, - skills, - context: input.toolContext ?? this.toolContext, - }) - - return [ - createSystemMessage(systemPrompt), - ...nonSystemMessages, - ] - } - - private modelRequest( - input: AgentRuntimeRunInput, - messages: AgentMessage[], - modelClient: NonNullable, - contextKey: string | undefined, - ): ModelRequest { - const modelDefaults = input.modelDefaults ?? this.modelDefaults - return { - ...modelDefaults, - model: input.model ?? modelDefaults?.model, - temperature: input.temperature ?? modelDefaults?.temperature, - maxTokens: input.maxTokens ?? modelDefaults?.maxTokens, - metadata: input.metadata ?? modelDefaults?.metadata, - messages, - signal: input.signal, - tools: this.tools.definitions(), - stream: modelClient.capabilities.streaming, - context: input.context ?? (contextKey ? this.modelContexts.get(contextKey) : modelDefaults?.context), - } - } - - private contextKeyFor(input: AgentRuntimeRunInput): string | undefined { - return input.contextKey || - (typeof input.metadata?.session_id === 'string' ? input.metadata.session_id : undefined) || - input.toolContext?.sessionId || - this.defaultContextKey - } - - private modelClientFor(input: AgentRuntimeRunInput): NonNullable { - const modelClient = input.modelClient ?? this.modelClient - if (!modelClient) { - throw new Error('AgentRuntime requires a modelClient in constructor options or run input.') - } - return modelClient - } - - private runToolContext(input: AgentRuntimeRunInput): AgentToolContext | undefined { - const context = input.toolContext ?? this.toolContext - if (!input.signal) return context - return { - ...context, - signal: input.signal, - } - } - - private async executeTool( - runId: string, - step: number, - toolCall: AgentToolCall, - context: AgentToolContext | undefined, - emit: (event: AgentRuntimeEvent) => void, - signal?: AbortSignal, - ): Promise { - const startedAt = Date.now() - emit({ - type: 'tool.started', - runId, - step, - toolCallId: toolCall.id, - toolName: toolCall.name, - arguments: toolCall.arguments, - }) - - try { - throwIfAborted(signal) - const result = await this.tools.execute(toolCall.name, toolCall.arguments, context) - throwIfAborted(signal) - emit({ - type: result.ok ? 'tool.completed' : 'tool.failed', - runId, - step, - toolCallId: toolCall.id, - toolName: toolCall.name, - result, - durationMs: Date.now() - startedAt, - }) - return result - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - const result: AgentToolResult = { - ok: false, - content: message, - error: message, - } - emit({ - type: 'tool.failed', - runId, - step, - toolCallId: toolCall.id, - toolName: toolCall.name, - result, - durationMs: Date.now() - startedAt, - }) - return result - } - } - - private registerSkillTools(skills: AgentSkill[]): void { - for (const skill of skills) { - if (skill.tools?.length) { - this.tools.registerMany(skill.tools) - } - } - } -} - -function delay(ms: number, signal?: AbortSignal): Promise { - if (!Number.isFinite(ms) || ms <= 0) return Promise.resolve() - throwIfAborted(signal) - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal?.removeEventListener('abort', onAbort) - resolve() - }, ms) - const onAbort = () => { - clearTimeout(timer) - reject(abortError()) - } - signal?.addEventListener('abort', onAbort, { once: true }) - }) -} - -function throwIfAborted(signal?: AbortSignal): void { - if (signal?.aborted) throw abortError() -} - -function abortError(): Error { - const error = new Error('Run aborted.') - error.name = 'AbortError' - return error -} - -function isEmptyModelResponse(response: ModelResponse): boolean { - return ( - !response.content?.trim() && - !response.reasoning?.trim() && - !(response.toolCalls?.length) - ) -} - -function estimateModelRequestContext(request: ModelRequest): AgentRuntimeContextEstimate { - const systemMessages = request.messages.filter(message => message.role === 'system') - const nonSystemMessages = request.messages.filter(message => message.role !== 'system') - const systemPrompt = systemMessages.map(message => message.content || '').join('\n\n') - const systemPromptTokens = countTokensLocal(systemPrompt) - const messageTokens = nonSystemMessages.reduce((sum, message) => { - return sum + countTokensLocal(message.content || '') + countTokensLocal(JSON.stringify(message.toolCalls || '')) - }, 0) - const toolTokens = countTokensLocal(JSON.stringify(request.tools || [])) - const modelContextTokens = request.context == null ? 0 : countTokensLocal(JSON.stringify(request.context)) - - return { - contextTokens: systemPromptTokens + messageTokens + toolTokens + modelContextTokens, - systemPromptTokens, - messageTokens, - toolTokens, - modelContextTokens, - messageCount: request.messages.length, - toolCount: request.tools?.length || 0, - systemPromptChars: systemPrompt.length, - } -} - -function countTokensLocal(text: string): number { - if (!text) return 0 - if (hasPathologicalRun(text)) return heuristicTokens(text) - try { - return getEncoder().encode(text).length - } catch { - return heuristicTokens(text) - } -} - -let cachedEncoder: ReturnType | null = null - -function getEncoder(): ReturnType { - if (!cachedEncoder) cachedEncoder = getEncoding('cl100k_base') - return cachedEncoder -} - -function heuristicTokens(text: string): number { - const cjk = (text.match(/[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff00-\uffef]/g) || []).length - const other = text.length - cjk - return Math.ceil(cjk * 1.5 + other / 4) -} - -function hasPathologicalRun(text: string): boolean { - const maxRun = 20_000 - let run = 0 - for (let i = 0; i < text.length; i += 1) { - const code = text.charCodeAt(i) - const isLetterOrDigit = - (code >= 48 && code <= 57) || - (code >= 65 && code <= 90) || - (code >= 97 && code <= 122) - if (isLetterOrDigit) { - run += 1 - if (run > maxRun) return true - } else { - run = 0 - } - } - return false -} diff --git a/packages/ekko-agent/src/runtime/system-prompt.ts b/packages/ekko-agent/src/runtime/system-prompt.ts deleted file mode 100644 index dc48560..0000000 --- a/packages/ekko-agent/src/runtime/system-prompt.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { AgentSkill } from '../skills/types' - -export interface SystemPromptInput { - basePrompt?: string - runtimeInstructions?: string[] - userSystemMessages?: string[] - skills?: AgentSkill[] - context?: { - cwd?: string - workspaceRoot?: string - } -} - -const DEFAULT_BASE_PROMPT = 'You are Ekko Agent, a pragmatic AI agent that can reason, use tools, and return concise results.' - -export function buildSystemPrompt(input: SystemPromptInput = {}): string { - const sections: string[] = [] - sections.push(input.basePrompt?.trim() || DEFAULT_BASE_PROMPT) - - if (input.runtimeInstructions?.length) { - sections.push(section('Runtime Instructions', input.runtimeInstructions.filter(Boolean).join('\n'))) - } - - if (input.context?.workspaceRoot || input.context?.cwd) { - const lines = [ - input.context.workspaceRoot ? `workspaceRoot: ${input.context.workspaceRoot}` : '', - input.context.cwd ? `cwd: ${input.context.cwd}` : '', - ].filter(Boolean) - sections.push(section('Runtime Context', lines.join('\n'))) - } - - if (input.skills?.length) { - sections.push(section('Skills', input.skills.map(skill => [ - `# ${skill.name}`, - skill.description ? `Description: ${skill.description}` : '', - skill.instructions, - ].filter(Boolean).join('\n')).join('\n\n'))) - } - - if (input.userSystemMessages?.length) { - sections.push(section('User System Messages', input.userSystemMessages.filter(Boolean).join('\n\n'))) - } - - return sections.filter(Boolean).join('\n\n') -} - -function section(title: string, content: string): string { - return `## ${title}\n${content.trim()}` -} diff --git a/packages/ekko-agent/src/runtime/types.ts b/packages/ekko-agent/src/runtime/types.ts deleted file mode 100644 index 89624b1..0000000 --- a/packages/ekko-agent/src/runtime/types.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { AgentMessage, ModelClient, ModelRequest } from '../model/types' -import type { AgentMessageInput, AgentOutputMessage } from '../model/messages' -import type { AgentSkill } from '../skills/types' -import type { AgentToolRegistry } from '../tools/registry' -import type { AgentToolContext, AgentToolResult } from '../tools/types' -import type { AgentRuntimeEvent } from './events' - -export interface AgentRuntimeContextEstimate { - contextTokens: number - systemPromptTokens: number - messageTokens: number - toolTokens: number - modelContextTokens: number - messageCount: number - toolCount: number - systemPromptChars: number -} - -export interface AgentRuntimeOptions { - modelClient?: ModelClient - tools?: AgentToolRegistry - skills?: AgentSkill[] - systemPrompt?: string - runtimeInstructions?: string[] - maxSteps?: number - maxModelRetries?: number - maxConsecutiveToolFailures?: number - toolDelayMs?: number - toolContext?: AgentToolContext - modelDefaults?: Omit - contextKey?: string -} - -export interface AgentRuntimeRunInput { - messages: AgentMessageInput[] - signal?: AbortSignal - systemPrompt?: string - skills?: AgentSkill[] - maxSteps?: number - maxModelRetries?: number - maxConsecutiveToolFailures?: number - toolDelayMs?: number - toolContext?: AgentToolContext - model?: string - temperature?: number - maxTokens?: number - metadata?: Record - modelClient?: ModelClient - modelDefaults?: Omit - contextKey?: string - context?: unknown - onEvent?: (event: AgentRuntimeEvent) => void -} - -export type AgentRuntimeStep = - | { type: 'model'; step: number; message: AgentOutputMessage } - | { type: 'tool'; step: number; toolCallId: string; toolName: string; result: AgentToolResult } - -export interface AgentRuntimeRunResult { - runId: string - messages: AgentMessage[] - output: AgentOutputMessage - steps: AgentRuntimeStep[] - events: AgentRuntimeEvent[] - context?: unknown - contextEstimate?: AgentRuntimeContextEstimate -} diff --git a/packages/ekko-agent/src/skills/types.ts b/packages/ekko-agent/src/skills/types.ts deleted file mode 100644 index 0c0157d..0000000 --- a/packages/ekko-agent/src/skills/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AgentTool } from '../tools/types' - -export interface AgentSkill { - id: string - name: string - description?: string - instructions: string - tools?: AgentTool[] -} diff --git a/packages/ekko-agent/src/tools/browser.ts b/packages/ekko-agent/src/tools/browser.ts deleted file mode 100644 index c8b58c6..0000000 --- a/packages/ekko-agent/src/tools/browser.ts +++ /dev/null @@ -1,795 +0,0 @@ -import { spawn } from 'node:child_process' -import { createHash } from 'node:crypto' -import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import type { AgentTool, AgentToolContext, AgentToolResult } from './types' - -type BrowserCommand = { - command: string - args: string[] -} - -interface BrowserToolInput extends Record { - url?: string - ref?: string - text?: string - direction?: string - key?: string - full?: boolean - clear?: boolean - expression?: string - question?: string - annotate?: boolean -} - -const BROWSER_TOOL_TIMEOUT_MS = 120_000 -const BROWSER_OPEN_TIMEOUT_MS = 180_000 -const SCROLL_PIXELS = 500 -const METADATA_HOSTS = new Set([ - '169.254.169.254', - 'metadata.google.internal', - '100.100.100.200', -]) - -const BROWSER_PASSTHROUGH_ENV = [ - 'AGENT_BROWSER_ARGS', - 'AGENT_BROWSER_CHROME_FLAGS', - 'AGENT_BROWSER_ENGINE', - 'AGENT_BROWSER_IDLE_TIMEOUT_MS', - 'BROWSERBASE_API_KEY', - 'BROWSERBASE_PROJECT_ID', - 'BROWSER_USE_API_KEY', - 'FIRECRAWL_API_KEY', - 'FIRECRAWL_API_URL', - 'FIRECRAWL_BROWSER_TTL', - 'PLAYWRIGHT_BROWSERS_PATH', -] - -const browserToolDefinitions: AgentTool['definition'][] = [ - { - name: 'browser_navigate', - description: 'Navigate to a URL in a browser session. Use this before other browser tools. Returns page metadata and a compact accessibility snapshot with refs.', - parameters: { - type: 'object', - properties: { - url: { type: 'string', description: 'URL to open.' }, - }, - required: ['url'], - additionalProperties: false, - }, - }, - { - name: 'browser_snapshot', - description: 'Get an accessibility-tree snapshot of the current page. The snapshot includes refs such as @e1 for browser_click and browser_type.', - parameters: { - type: 'object', - properties: { - full: { type: 'boolean', description: 'Return the full snapshot instead of the compact interactive view.' }, - }, - additionalProperties: false, - }, - }, - { - name: 'browser_click', - description: 'Click an element by ref from browser_snapshot, for example @e5.', - parameters: { - type: 'object', - properties: { - ref: { type: 'string', description: 'Element ref, for example @e5.' }, - }, - required: ['ref'], - additionalProperties: false, - }, - }, - { - name: 'browser_type', - description: 'Clear and type text into an input element by ref from browser_snapshot.', - parameters: { - type: 'object', - properties: { - ref: { type: 'string', description: 'Element ref, for example @e3.' }, - text: { type: 'string', description: 'Text to type.' }, - }, - required: ['ref', 'text'], - additionalProperties: false, - }, - }, - { - name: 'browser_scroll', - description: 'Scroll the current page up or down.', - parameters: { - type: 'object', - properties: { - direction: { type: 'string', enum: ['up', 'down'], description: 'Scroll direction.' }, - }, - required: ['direction'], - additionalProperties: false, - }, - }, - { - name: 'browser_back', - description: 'Go back in browser history.', - parameters: { - type: 'object', - properties: {}, - additionalProperties: false, - }, - }, - { - name: 'browser_press', - description: 'Press a keyboard key in the browser, such as Enter, Tab, Escape, ArrowDown, or Meta+K.', - parameters: { - type: 'object', - properties: { - key: { type: 'string', description: 'Key or key chord to press.' }, - }, - required: ['key'], - additionalProperties: false, - }, - }, - { - name: 'browser_get_images', - description: 'List images on the current page, including src, alt text, and natural dimensions.', - parameters: { - type: 'object', - properties: {}, - additionalProperties: false, - }, - }, - { - name: 'browser_vision', - description: 'Capture a screenshot of the current page and return its file path for visual inspection.', - parameters: { - type: 'object', - properties: { - question: { type: 'string', description: 'What to inspect visually on the page.' }, - annotate: { type: 'boolean', description: 'Overlay numeric labels on interactive elements if supported.' }, - }, - required: ['question'], - additionalProperties: false, - }, - }, - { - name: 'browser_console', - description: 'Read browser console messages and JavaScript errors. Optionally evaluate a small JavaScript expression in the page context.', - parameters: { - type: 'object', - properties: { - clear: { type: 'boolean', description: 'Clear console and error buffers after reading.' }, - expression: { type: 'string', description: 'Optional JavaScript expression to evaluate.' }, - }, - additionalProperties: false, - }, - }, -] - -export class AgentBrowserTool implements AgentTool { - readonly definition: AgentTool['definition'] - - constructor(definition: AgentTool['definition']) { - this.definition = definition - } - - async execute(input: BrowserToolInput, context: AgentToolContext = {}): Promise { - switch (this.definition.name) { - case 'browser_navigate': - return this.navigate(input, context) - case 'browser_snapshot': - return this.snapshot(input, context) - case 'browser_click': - return this.simpleRefCommand('click', 'clicked', input, context) - case 'browser_type': - return this.type(input, context) - case 'browser_scroll': - return this.scroll(input, context) - case 'browser_back': - return this.simpleCommand('back', [], context) - case 'browser_press': - return this.press(input, context) - case 'browser_get_images': - return this.getImages(context) - case 'browser_vision': - return this.vision(input, context) - case 'browser_console': - return this.console(input, context) - default: - return failure(`Unsupported browser tool: ${this.definition.name}`) - } - } - - private async navigate(input: BrowserToolInput, context: AgentToolContext): Promise { - const rawUrl = getString(input.url) - if (!rawUrl) return failure('browser_navigate requires url.') - const url = normalizeUrl(rawUrl) - const blocked = validateNavigationUrl(url) - if (blocked) return failure(blocked, { url }) - - const openResult = await runBrowserCommand(context, 'open', [url], { timeoutMs: BROWSER_OPEN_TIMEOUT_MS }) - if (!openResult.ok) return openResult - - const payload = objectFromResult(openResult) - const response: Record = { - success: true, - url: getNestedString(payload, ['data', 'url']) || getString(payload.url) || url, - title: getNestedString(payload, ['data', 'title']) || getString(payload.title), - } - - const snapshot = await runBrowserCommand(context, 'snapshot', ['-c']) - if (snapshot.ok) { - const snapshotPayload = objectFromResult(snapshot) - const data = objectValue(snapshotPayload.data) - const snapshotText = getString(data.snapshot) || getString(snapshotPayload.snapshot) - const refs = objectValue(data.refs) || objectValue(snapshotPayload.refs) - if (snapshotText) response.snapshot = truncateSnapshot(snapshotText) - if (refs) response.element_count = Object.keys(refs).length - } - - return okJson(response, { raw: payload }) - } - - private async snapshot(input: BrowserToolInput, context: AgentToolContext): Promise { - const args = input.full ? [] : ['-c'] - const result = await runBrowserCommand(context, 'snapshot', args) - if (!result.ok) return result - const payload = objectFromResult(result) - const data = objectValue(payload.data) - const snapshot = getString(data.snapshot) || getString(payload.snapshot) - const refs = objectValue(data.refs) || objectValue(payload.refs) - return okJson({ - success: true, - snapshot: truncateSnapshot(snapshot), - element_count: refs ? Object.keys(refs).length : 0, - }, { raw: payload }) - } - - private async simpleRefCommand(command: string, resultKey: string, input: BrowserToolInput, context: AgentToolContext): Promise { - const ref = normalizeRef(getString(input.ref)) - if (!ref) return failure(`${this.definition.name} requires ref.`) - const result = await runBrowserCommand(context, command, [ref]) - if (!result.ok) return result - return okJson({ success: true, [resultKey]: ref }, { raw: objectFromResult(result) }) - } - - private async type(input: BrowserToolInput, context: AgentToolContext): Promise { - const ref = normalizeRef(getString(input.ref)) - if (!ref) return failure('browser_type requires ref.') - const text = getString(input.text) - const result = await runBrowserCommand(context, 'fill', [ref, text]) - if (!result.ok) return result - return okJson({ - success: true, - element: ref, - typed: redactTypedText(text), - }, { raw: objectFromResult(result) }) - } - - private async scroll(input: BrowserToolInput, context: AgentToolContext): Promise { - const direction = getString(input.direction) || 'down' - if (direction !== 'up' && direction !== 'down') { - return failure(`Invalid direction '${direction}'. Use 'up' or 'down'.`) - } - const result = await runBrowserCommand(context, 'scroll', [direction, String(SCROLL_PIXELS)]) - if (!result.ok) return result - return okJson({ success: true, scrolled: direction }, { raw: objectFromResult(result) }) - } - - private async simpleCommand(command: string, args: string[], context: AgentToolContext): Promise { - const result = await runBrowserCommand(context, command, args) - if (!result.ok) return result - const payload = objectFromResult(result) - return okJson({ - success: true, - ...objectValue(payload.data), - }, { raw: payload }) - } - - private async press(input: BrowserToolInput, context: AgentToolContext): Promise { - const key = getString(input.key) - if (!key) return failure('browser_press requires key.') - const result = await runBrowserCommand(context, 'press', [key]) - if (!result.ok) return result - return okJson({ success: true, pressed: key }, { raw: objectFromResult(result) }) - } - - private async getImages(context: AgentToolContext): Promise { - const js = 'JSON.stringify([...document.images].map(img=>({src:img.src,alt:img.alt||"",width:img.naturalWidth,height:img.naturalHeight})).filter(img=>img.src&&!img.src.startsWith("data:")))' - const result = await runBrowserCommand(context, 'eval', [js]) - if (!result.ok) return result - const payload = objectFromResult(result) - const raw = getNestedValue(payload, ['data', 'result']) ?? payload.result ?? '[]' - let images: unknown[] = [] - try { - const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw - images = Array.isArray(parsed) ? parsed : [] - } catch { - images = [] - } - return okJson({ success: true, images, count: images.length }, { raw: payload }) - } - - private async vision(input: BrowserToolInput, context: AgentToolContext): Promise { - const question = getString(input.question) - if (!question) return failure('browser_vision requires question.') - const screenshotPath = browserScreenshotPath(context) - const args = input.annotate ? ['--annotate', '--full', screenshotPath] : ['--full', screenshotPath] - const result = await runBrowserCommand(context, 'screenshot', args, { timeoutMs: BROWSER_OPEN_TIMEOUT_MS }) - if (!result.ok) return result - const payload = objectFromResult(result) - const actualPath = getNestedString(payload, ['data', 'path']) || getString(payload.path) || screenshotPath - return okJson({ - success: true, - question, - screenshot_path: actualPath, - note: 'Screenshot captured. Inspect the image at screenshot_path to answer visual questions.', - annotations: getNestedValue(payload, ['data', 'annotations']) ?? payload.annotations, - }, { raw: payload }) - } - - private async console(input: BrowserToolInput, context: AgentToolContext): Promise { - const expression = getString(input.expression) - if (expression) { - const policyError = validateConsoleExpression(expression) - if (policyError) return failure(policyError) - const result = await runBrowserCommand(context, 'eval', [expression]) - if (!result.ok) return result - const payload = objectFromResult(result) - return okJson({ - success: true, - result: getNestedValue(payload, ['data', 'result']) ?? payload.result, - }, { raw: payload }) - } - - const args = input.clear ? ['--clear'] : [] - const consoleResult = await runBrowserCommand(context, 'console', args) - const errorsResult = await runBrowserCommand(context, 'errors', args) - const consolePayload = objectFromResult(consoleResult) - const errorsPayload = objectFromResult(errorsResult) - const messages = arrayValue(getNestedValue(consolePayload, ['data', 'messages']) ?? consolePayload.messages) - const errors = arrayValue(getNestedValue(errorsPayload, ['data', 'errors']) ?? errorsPayload.errors) - return okJson({ - success: consoleResult.ok || errorsResult.ok, - console_messages: messages, - js_errors: errors, - total_messages: messages.length, - total_errors: errors.length, - }, { raw: { console: consolePayload, errors: errorsPayload } }) - } -} - -export function createBrowserTools(): AgentTool[] { - return browserToolDefinitions.map(definition => new AgentBrowserTool(definition)) -} - -export function isAgentBrowserAvailable(): boolean { - return resolveAgentBrowser() !== null -} - -async function runBrowserCommand( - context: AgentToolContext, - browserCommand: string, - browserArgs: string[], - options: { timeoutMs?: number } = {}, -): Promise { - if (context.signal?.aborted) return failure('Browser command aborted.', { aborted: true }) - - const resolved = resolveAgentBrowser() - if (!resolved) { - return failure( - 'agent-browser CLI not found. Browser tools require an existing agent-browser installation from this project or the Hermes runtime.', - ) - } - - const sessionName = browserSessionName(context) - const socketDir = browserSocketDir(sessionName) - mkdirSync(socketDir, { recursive: true, mode: 0o700 }) - - const args = [ - ...resolved.args, - '--session', - sessionName, - '--json', - browserCommand, - ...browserArgs, - ] - const timeoutMs = options.timeoutMs ?? context.timeoutMs ?? BROWSER_TOOL_TIMEOUT_MS - const cwd = context.cwd || context.workspaceRoot || process.cwd() - const env = browserEnv(socketDir) - - return new Promise((resolve) => { - const stdoutPath = path.join(socketDir, `_stdout_${browserCommand}_${Date.now()}`) - const stderrPath = path.join(socketDir, `_stderr_${browserCommand}_${Date.now()}`) - const stdoutFd = openSync(stdoutPath, 'w', 0o600) - const stderrFd = openSync(stderrPath, 'w', 0o600) - const child = spawn(resolved.command, args, { - cwd, - env, - shell: false, - windowsHide: true, - stdio: ['ignore', stdoutFd, stderrFd], - }) - closeSync(stdoutFd) - closeSync(stderrFd) - let stdout = '' - let stderr = '' - let settled = false - let timedOut = false - let aborted = false - - const finish = (result: AgentToolResult) => { - if (settled) return - settled = true - clearTimeout(timer) - context.signal?.removeEventListener('abort', onAbort) - cleanupCommandOutput(stdoutPath, stderrPath) - resolve(result) - } - const timer = setTimeout(() => { - timedOut = true - child.kill('SIGTERM') - }, timeoutMs) - const onAbort = () => { - aborted = true - child.kill('SIGTERM') - } - - context.signal?.addEventListener('abort', onAbort, { once: true }) - child.on('error', error => { - const output = readCommandOutput(stdoutPath, stderrPath) - stdout = output.stdout - stderr = output.stderr - finish(failure(error.message, commandData(resolved, args, browserCommand, browserArgs, stdout, stderr))) - }) - child.on('close', code => { - const output = readCommandOutput(stdoutPath, stderrPath) - stdout = output.stdout - stderr = output.stderr - if (aborted) { - finish(failure('Browser command aborted.', commandData(resolved, args, browserCommand, browserArgs, stdout, stderr, { - exitCode: code, - aborted: true, - }))) - return - } - if (timedOut) { - finish(failure(`Browser command timed out after ${timeoutMs}ms`, commandData(resolved, args, browserCommand, browserArgs, stdout, stderr, { - exitCode: code, - timedOut: true, - }))) - return - } - - const parsed = parseBrowserJson(stdout) - const success = parsed ? parsed.success !== false : code === 0 - const content = parsed - ? JSON.stringify(parsed) - : [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join('\n') - finish({ - ok: code === 0 && success, - content, - error: code === 0 && success ? undefined : errorFromPayload(parsed) || stderr.trim() || `Browser command exited with code ${code}`, - data: commandData(resolved, args, browserCommand, browserArgs, stdout, stderr, { - exitCode: code, - parsed, - }), - }) - }) - }) -} - -function readCommandOutput(stdoutPath: string, stderrPath: string): { stdout: string; stderr: string } { - return { - stdout: readFileIfExists(stdoutPath), - stderr: readFileIfExists(stderrPath), - } -} - -function readFileIfExists(filePath: string): string { - try { - return readFileSync(filePath, 'utf8') - } catch { - return '' - } -} - -function cleanupCommandOutput(stdoutPath: string, stderrPath: string): void { - for (const filePath of [stdoutPath, stderrPath]) { - try { - unlinkSync(filePath) - } catch { - // Best-effort cleanup only. - } - } -} - -function resolveAgentBrowser(): BrowserCommand | null { - const override = process.env.AGENT_BROWSER_BIN - if (override) return { command: override, args: [] } - - const binary = process.platform === 'win32' ? 'agent-browser.cmd' : 'agent-browser' - const localCandidates = [ - ...workspaceRootCandidates().flatMap(root => [ - path.resolve(root, 'node_modules', '.bin', binary), - path.resolve(root, 'packages', 'ekko-agent', 'node_modules', '.bin', binary), - ]), - path.resolve(packageRoot(), 'node_modules', '.bin', binary), - path.join(os.homedir(), '.hermes', 'node', 'bin', binary), - path.join(os.homedir(), '.hermes', 'node_modules', '.bin', binary), - ] - for (const candidate of localCandidates) { - if (existsSync(candidate)) return { command: candidate, args: [] } - } - - const fromPath = findExecutable(binary, browserPath()) - if (fromPath) return { command: fromPath, args: [] } - - return null -} - -function browserEnv(socketDir: string): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { - PATH: browserPath(), - HOME: process.env.HOME, - USERPROFILE: process.env.USERPROFILE, - TMPDIR: process.env.TMPDIR, - TEMP: process.env.TEMP, - TMP: process.env.TMP, - AGENT_BROWSER_SOCKET_DIR: socketDir, - AGENT_BROWSER_IDLE_TIMEOUT_MS: process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS || '600000', - } - for (const key of BROWSER_PASSTHROUGH_ENV) { - if (process.env[key]) env[key] = process.env[key] - } - return env -} - -function browserPath(): string { - const parts = [ - ...workspaceRootCandidates().flatMap(root => [ - path.resolve(root, 'node_modules', '.bin'), - path.resolve(root, 'packages', 'ekko-agent', 'node_modules', '.bin'), - ]), - path.resolve(packageRoot(), 'node_modules', '.bin'), - path.join(os.homedir(), '.hermes', 'node', 'bin'), - path.join(os.homedir(), '.hermes', 'node'), - path.join(os.homedir(), '.hermes', 'node_modules', '.bin'), - '/opt/homebrew/bin', - '/usr/local/bin', - '/usr/bin', - '/bin', - process.env.PATH || '', - ] - return unique(parts.filter(Boolean)).join(path.delimiter) -} - -function workspaceRootCandidates(): string[] { - return unique([ - process.env.HERMES_WEB_UI_DIR || '', - process.env.INIT_CWD || '', - process.cwd(), - path.resolve(__dirname, '..', '..', '..', '..'), - ].filter(Boolean)) -} - -function packageRoot(): string { - return path.resolve(__dirname, '..', '..') -} - -function findExecutable(name: string, searchPath: string): string | null { - for (const dir of searchPath.split(path.delimiter)) { - const candidate = path.join(dir, name) - if (existsSync(candidate)) return candidate - } - return null -} - -function browserSessionName(context: AgentToolContext): string { - const raw = context.browserSessionId || context.sessionId || 'default' - return `e_${shortHash(raw)}` -} - -function browserSocketDir(sessionName: string): string { - return path.join(shortTempDir(), `eab_${sessionName}`) -} - -function shortTempDir(): string { - if (process.env.EKKO_AGENT_BROWSER_TMPDIR) return process.env.EKKO_AGENT_BROWSER_TMPDIR - if (process.platform !== 'win32' && existsSync('/tmp')) return '/tmp' - return os.tmpdir() -} - -function shortHash(value: string): string { - return createHash('sha256').update(value || 'default').digest('hex').slice(0, 10) -} - -function browserScreenshotPath(context: AgentToolContext): string { - const dir = path.join(os.tmpdir(), 'ekko-agent-browser-screenshots', browserSessionName(context)) - mkdirSync(dir, { recursive: true, mode: 0o700 }) - return path.join(dir, `browser_screenshot_${Date.now()}_${Math.random().toString(16).slice(2)}.png`) -} - -function normalizeUrl(rawUrl: string): string { - const trimmed = rawUrl.trim() - if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) || trimmed.startsWith('about:')) return trimmed - return `https://${trimmed}` -} - -function validateNavigationUrl(url: string): string | null { - if (/(sk-|xox[baprs]-|gh[pousr]_|AIza)[A-Za-z0-9_\-]{12,}/.test(decodeURIComponentSafe(url))) { - return 'Blocked: URL contains what appears to be an API key or token. Secrets must not be sent in URLs.' - } - if (url.startsWith('about:')) return null - try { - const parsed = new URL(url) - if (METADATA_HOSTS.has(parsed.hostname.toLowerCase()) || parsed.hostname.toLowerCase().endsWith('.metadata.google.internal')) { - return 'Blocked: URL targets a cloud metadata endpoint.' - } - } catch { - return 'Invalid URL.' - } - return null -} - -function validateConsoleExpression(expression: string): string | null { - const lowered = expression.toLowerCase() - const blocked = [ - 'process.env', - 'localstorage', - 'sessionstorage', - 'document.cookie', - 'indexeddb', - 'fetch("file:', - "fetch('file:", - ] - return blocked.some(pattern => lowered.includes(pattern)) - ? 'Blocked: browser_console expression attempts to access sensitive browser or runtime state.' - : null -} - -function parseBrowserJson(stdout: string): Record | null { - const trimmed = stdout.trim() - if (!trimmed) return null - try { - const parsed = JSON.parse(trimmed) - return objectValue(parsed) - } catch { - const lines = trimmed.split(/\r?\n/).map(line => line.trim()).filter(Boolean) - for (const line of lines.reverse()) { - try { - return objectValue(JSON.parse(line)) - } catch { - // Try the previous line. - } - } - const start = trimmed.indexOf('{') - if (start >= 0) { - try { - return objectValue(JSON.parse(trimmed.slice(start))) - } catch { - return null - } - } - return null - } -} - -function okJson(payload: Record, extra?: Record): AgentToolResult { - return { - ok: payload.success !== false, - content: JSON.stringify(payload), - data: extra ? { ...payload, ...extra } : payload, - error: payload.success === false ? getString(payload.error) || 'Browser tool failed.' : undefined, - } -} - -function failure(message: string, data?: Record): AgentToolResult { - return { - ok: false, - content: JSON.stringify({ success: false, error: message }), - error: message, - data, - } -} - -function commandData( - resolved: BrowserCommand, - args: string[], - browserCommand: string, - browserArgs: string[], - stdout: string, - stderr: string, - extra: Record = {}, -): Record { - return { - command: resolved.command, - args: redactCommandArgs(args, browserCommand, browserArgs), - browserCommand, - browserArgs: redactBrowserArgs(browserCommand, browserArgs), - stdout, - stderr, - ...extra, - } -} - -function redactCommandArgs(args: string[], browserCommand: string, browserArgs: string[]): string[] { - if (!browserArgs.length) return args - return [ - ...args.slice(0, args.length - browserArgs.length), - ...redactBrowserArgs(browserCommand, browserArgs), - ] -} - -function redactBrowserArgs(command: string, args: string[]): string[] { - if (command === 'fill' && args.length >= 2) return [args[0], redactTypedText(args[1])] - return args.map(value => value.length > 1000 ? `[${value.length} chars]` : value) -} - -function objectFromResult(result: AgentToolResult): Record { - const data = objectValue(result.data) - const parsed = objectValue(data.parsed) - if (parsed) return parsed - try { - return objectValue(JSON.parse(result.content)) || {} - } catch { - return {} - } -} - -function errorFromPayload(payload: Record | null): string | undefined { - return getString(payload?.error) || getNestedString(payload ?? {}, ['data', 'error']) -} - -function getString(value: unknown): string { - return typeof value === 'string' ? value : value == null ? '' : String(value) -} - -function getNestedString(value: Record, pathParts: string[]): string { - return getString(getNestedValue(value, pathParts)) -} - -function getNestedValue(value: Record, pathParts: string[]): unknown { - let current: unknown = value - for (const part of pathParts) { - const object = objectValue(current) - if (!object) return undefined - current = object[part] - } - return current -} - -function objectValue(value: unknown): Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record : {} -} - -function arrayValue(value: unknown): unknown[] { - return Array.isArray(value) ? value : [] -} - -function normalizeRef(ref: string): string { - const trimmed = ref.trim() - if (!trimmed) return '' - return trimmed.startsWith('@') ? trimmed : `@${trimmed}` -} - -function truncateSnapshot(snapshot: string): string { - if (snapshot.length <= 12_000) return snapshot - return `${snapshot.slice(0, 12_000)}\n\n[Snapshot truncated]` -} - -function redactTypedText(text: string): string { - if (text.length > 200) return `[${text.length} chars]` - if (/(sk-|xox[baprs]-|gh[pousr]_|AIza)[A-Za-z0-9_\-]{12,}/.test(text)) return '[redacted]' - return text -} - -function decodeURIComponentSafe(value: string): string { - try { - return decodeURIComponent(value) - } catch { - return value - } -} - -function unique(values: string[]): string[] { - return [...new Set(values)] -} diff --git a/packages/ekko-agent/src/tools/files.ts b/packages/ekko-agent/src/tools/files.ts deleted file mode 100644 index d397b5e..0000000 --- a/packages/ekko-agent/src/tools/files.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import path from 'node:path' -import type { AgentTool, AgentToolContext, AgentToolResult } from './types' -import { resolveToolPath } from './path-safety' - -export interface ReadFileInput extends Record { - path: string - encoding?: BufferEncoding -} - -export interface WriteFileInput extends Record { - path: string - content: string - encoding?: BufferEncoding - createDirs?: boolean -} - -export class ReadFileTool implements AgentTool { - readonly definition = { - name: 'read_file', - description: 'Read a UTF-8 text file from the workspace.', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'File path relative to the current workspace.' }, - encoding: { type: 'string', description: 'Text encoding. Defaults to utf8.' }, - }, - required: ['path'], - additionalProperties: false, - }, - } - - async execute(input: ReadFileInput, context: AgentToolContext = {}): Promise { - const filePath = resolveToolPath(input.path, context) - const content = await readFile(filePath, input.encoding || 'utf8') - return { - ok: true, - content, - data: { - path: filePath, - bytes: Buffer.byteLength(content, input.encoding || 'utf8'), - }, - } - } -} - -export class WriteFileTool implements AgentTool { - readonly definition = { - name: 'write_file', - description: 'Write UTF-8 text content to a file in the workspace.', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'File path relative to the current workspace.' }, - content: { type: 'string', description: 'Text content to write.' }, - encoding: { type: 'string', description: 'Text encoding. Defaults to utf8.' }, - createDirs: { type: 'boolean', description: 'Create parent directories before writing. Defaults to true.' }, - }, - required: ['path', 'content'], - additionalProperties: false, - }, - } - - async execute(input: WriteFileInput, context: AgentToolContext = {}): Promise { - const filePath = resolveToolPath(input.path, context) - if (input.createDirs !== false) { - await mkdir(path.dirname(filePath), { recursive: true }) - } - await writeFile(filePath, input.content, input.encoding || 'utf8') - return { - ok: true, - content: `Wrote ${Buffer.byteLength(input.content, input.encoding || 'utf8')} bytes to ${filePath}`, - data: { - path: filePath, - bytes: Buffer.byteLength(input.content, input.encoding || 'utf8'), - }, - } - } -} - -export function createFileTools(): AgentTool[] { - return [ - new ReadFileTool(), - new WriteFileTool(), - ] -} diff --git a/packages/ekko-agent/src/tools/mcp.ts b/packages/ekko-agent/src/tools/mcp.ts deleted file mode 100644 index f3cba84..0000000 --- a/packages/ekko-agent/src/tools/mcp.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { spawn } from 'node:child_process' -import type { AgentTool, AgentToolContext, AgentToolProvider, AgentToolResult } from './types' - -interface McpServerConfig { - command?: unknown - args?: unknown - env?: unknown - enabled?: unknown -} - -interface JsonRpcResponse { - id?: number - result?: any - error?: { message?: string } -} - -const DEFAULT_MCP_TIMEOUT_MS = 30_000 - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object' && !Array.isArray(value) -} - -function normalizeServerConfig(value: unknown): McpServerConfig | null { - if (!isRecord(value)) return null - if (value.enabled === false) return null - if (typeof value.command !== 'string' || !value.command.trim()) return null - return value -} - -function normalizeArgs(args: unknown): string[] { - return Array.isArray(args) ? args.map(arg => String(arg)) : [] -} - -function normalizeEnv(env: unknown): NodeJS.ProcessEnv { - if (!isRecord(env)) return process.env - const normalized: NodeJS.ProcessEnv = { ...process.env } - for (const [key, value] of Object.entries(env)) { - if (value != null) normalized[key] = String(value) - } - return normalized -} - -function responseContentToText(result: any): string { - const content = Array.isArray(result?.content) ? result.content : [] - const text = content - .map((item: any) => { - if (item?.type === 'text') return String(item.text ?? '') - return JSON.stringify(item) - }) - .filter(Boolean) - .join('\n') - return text || JSON.stringify(result ?? {}) -} - -async function runMcpExchange(server: McpServerConfig, messages: Array>, timeoutMs: number): Promise { - const command = String(server.command) - const child = spawn(command, normalizeArgs(server.args), { - env: normalizeEnv(server.env), - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }) - - let stdout = '' - let stderr = '' - const responses: JsonRpcResponse[] = [] - let settled = false - - return await new Promise((resolve, reject) => { - const finish = (error?: Error) => { - if (settled) return - settled = true - clearTimeout(timer) - child.kill() - if (error) reject(error) - else resolve(responses) - } - - const timer = setTimeout(() => { - finish(new Error(`MCP server timed out after ${timeoutMs}ms: ${command}`)) - }, timeoutMs) - - child.on('error', finish) - child.stderr?.on('data', chunk => { - stderr += String(chunk) - }) - child.stdout?.on('data', chunk => { - stdout += String(chunk) - let newline = stdout.indexOf('\n') - while (newline >= 0) { - const line = stdout.slice(0, newline).trim() - stdout = stdout.slice(newline + 1) - if (line) { - try { - responses.push(JSON.parse(line)) - } catch { - // Ignore non-JSON log lines from MCP servers. - } - } - newline = stdout.indexOf('\n') - } - if (responses.length >= messages.length) finish() - }) - child.on('exit', code => { - if (responses.length >= messages.length) finish() - else finish(new Error(`MCP server exited before responding: ${command}${code == null ? '' : ` code=${code}`}${stderr ? ` stderr=${stderr.trim()}` : ''}`)) - }) - - for (const message of messages) { - child.stdin?.write(`${JSON.stringify(message)}\n`) - } - }) -} - -async function listMcpTools(server: McpServerConfig, timeoutMs: number): Promise { - const responses = await runMcpExchange(server, [ - { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'ekko-agent', version: '0.1.0' }, - }, - }, - { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, - ], timeoutMs) - const response = responses.find(item => item.id === 2) - if (response?.error) throw new Error(response.error.message || 'MCP tools/list failed') - return Array.isArray(response?.result?.tools) ? response.result.tools : [] -} - -async function callMcpTool(server: McpServerConfig, name: string, input: Record, timeoutMs: number): Promise { - const responses = await runMcpExchange(server, [ - { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'ekko-agent', version: '0.1.0' }, - }, - }, - { - jsonrpc: '2.0', - id: 2, - method: 'tools/call', - params: { name, arguments: input }, - }, - ], timeoutMs) - const response = responses.find(item => item.id === 2) - if (response?.error) { - return { ok: false, content: response.error.message || 'MCP tools/call failed', error: response.error.message || 'MCP tools/call failed' } - } - const result = response?.result - const content = responseContentToText(result) - return { - ok: result?.isError !== true, - content, - data: result, - error: result?.isError === true ? content : undefined, - } -} - -class McpTool implements AgentTool { - readonly definition: AgentTool['definition'] - - constructor( - private readonly serverName: string, - private readonly remoteName: string, - tool: any, - ) { - this.definition = { - name: String(tool.name || remoteName), - description: String(tool.description || `MCP tool ${remoteName} from ${serverName}`), - parameters: isRecord(tool.inputSchema) ? tool.inputSchema : { type: 'object', properties: {} }, - } - } - - async execute(input: Record, context: AgentToolContext = {}): Promise { - const server = normalizeServerConfig(context.mcpServers?.[this.serverName]) - if (!server) { - return { - ok: false, - content: `MCP server is not configured: ${this.serverName}`, - error: `MCP server is not configured: ${this.serverName}`, - } - } - return await callMcpTool(server, this.remoteName, input, context.timeoutMs || DEFAULT_MCP_TIMEOUT_MS) - } -} - -export function createMcpToolProvider(): AgentToolProvider { - return { - id: 'mcp', - async listTools(context?: AgentToolContext): Promise { - if (!context?.mcpServers) return [] - const timeoutMs = context.timeoutMs || DEFAULT_MCP_TIMEOUT_MS - const tools: AgentTool[] = [] - const usedNames = new Set() - - for (const [serverName, rawConfig] of Object.entries(context.mcpServers)) { - const server = normalizeServerConfig(rawConfig) - if (!server) continue - - try { - for (const tool of await listMcpTools(server, timeoutMs)) { - if (!tool?.name || usedNames.has(String(tool.name))) continue - usedNames.add(String(tool.name)) - tools.push(new McpTool(serverName, String(tool.name), tool)) - } - } catch { - // A broken MCP server should not prevent the rest of the agent run. - } - } - - return tools - }, - } -} diff --git a/packages/ekko-agent/src/tools/path-safety.ts b/packages/ekko-agent/src/tools/path-safety.ts deleted file mode 100644 index 9dc2c35..0000000 --- a/packages/ekko-agent/src/tools/path-safety.ts +++ /dev/null @@ -1,17 +0,0 @@ -import path from 'node:path' -import { AgentToolError } from './types' - -export function resolveToolPath(inputPath: string, context: { cwd?: string; workspaceRoot?: string } = {}): string { - const base = context.cwd || context.workspaceRoot || process.cwd() - const resolved = path.resolve(base, inputPath) - - if (context.workspaceRoot) { - const root = path.resolve(context.workspaceRoot) - const relative = path.relative(root, resolved) - if (relative.startsWith('..') || path.isAbsolute(relative)) { - throw new AgentToolError(`Path is outside workspaceRoot: ${inputPath}`, 'PATH_OUTSIDE_WORKSPACE') - } - } - - return resolved -} diff --git a/packages/ekko-agent/src/tools/registry.ts b/packages/ekko-agent/src/tools/registry.ts deleted file mode 100644 index b288cb5..0000000 --- a/packages/ekko-agent/src/tools/registry.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { AgentTool, AgentToolContext, AgentToolProvider, AgentToolResult } from './types' -import { createBrowserTools } from './browser' -import { createFileTools } from './files' -import { createMcpToolProvider } from './mcp' -import { createTerminalTools } from './terminal' - -export class AgentToolRegistry { - private readonly tools = new Map() - private readonly providers = new Map() - private readonly providerTools = new Map>() - - register(tool: AgentTool): void { - this.tools.set(tool.definition.name, tool) - } - - registerMany(tools: AgentTool[]): void { - for (const tool of tools) { - this.register(tool) - } - } - - unregister(name: string): boolean { - return this.tools.delete(name) - } - - registerProvider(provider: AgentToolProvider): void { - this.providers.set(provider.id, provider) - } - - unregisterProvider(providerId: string): boolean { - return this.providers.delete(providerId) - } - - async refreshTools(context?: AgentToolContext): Promise { - for (const provider of this.providers.values()) { - const previous = this.providerTools.get(provider.id) - if (previous) { - for (const name of previous) { - this.tools.delete(name) - } - } - const tools = await provider.listTools(context) - this.registerMany(tools) - this.providerTools.set(provider.id, new Set(tools.map(tool => tool.definition.name))) - } - } - - get(name: string): AgentTool | undefined { - return this.tools.get(name) - } - - definitions() { - return [...this.tools.values()].map(tool => tool.definition) - } - - async execute(name: string, input: Record, context?: AgentToolContext): Promise { - const tool = this.tools.get(name) - if (!tool) { - return { - ok: false, - content: `Unknown tool: ${name}`, - error: `Unknown tool: ${name}`, - } - } - return tool.execute(input, context) - } -} - -export function createDefaultToolRegistry(): AgentToolRegistry { - const registry = new AgentToolRegistry() - for (const tool of [...createFileTools(), ...createTerminalTools(), ...createBrowserTools()]) { - registry.register(tool) - } - registry.registerProvider(createMcpToolProvider()) - return registry -} diff --git a/packages/ekko-agent/src/tools/terminal.ts b/packages/ekko-agent/src/tools/terminal.ts deleted file mode 100644 index d37ee24..0000000 --- a/packages/ekko-agent/src/tools/terminal.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { spawn } from 'node:child_process' -import type { AgentTool, AgentToolContext, AgentToolResult } from './types' -import { resolveToolPath } from './path-safety' - -export interface TerminalExecInput extends Record { - command: string - args?: string[] - cwd?: string - timeoutMs?: number -} - -export class TerminalExecTool implements AgentTool { - readonly definition = { - name: 'terminal_exec', - description: 'Run a terminal command. Prefer command as the executable and args as the argument array; shell string execution is not used.', - parameters: { - type: 'object', - properties: { - command: { type: 'string', description: 'Executable command to run. Prefer a bare executable such as "node", "ls", or "/bin/sh".' }, - args: { type: 'array', items: { type: 'string' }, description: 'Command arguments.' }, - cwd: { type: 'string', description: 'Working directory relative to the workspace.' }, - timeoutMs: { type: 'number', description: 'Timeout in milliseconds.' }, - }, - required: ['command'], - additionalProperties: false, - }, - } - - async execute(input: TerminalExecInput, context: AgentToolContext = {}): Promise { - const normalized = normalizeTerminalCommand(input.command, input.args) - const args = normalized.args - const cwd = input.cwd ? resolveToolPath(input.cwd, context) : context.cwd || context.workspaceRoot || process.cwd() - const timeoutMs = input.timeoutMs ?? context.timeoutMs ?? 30_000 - if (context.signal?.aborted) { - return { - ok: false, - content: 'Command aborted.', - error: 'Command aborted.', - data: { command: normalized.command, args, cwd, originalCommand: input.command, aborted: true }, - } - } - - return new Promise((resolve) => { - const child = spawn(normalized.command, args, { - cwd, - shell: false, - windowsHide: true, - }) - let stdout = '' - let stderr = '' - let timedOut = false - let aborted = false - - const timer = setTimeout(() => { - timedOut = true - child.kill('SIGTERM') - }, timeoutMs) - const onAbort = () => { - aborted = true - child.kill('SIGTERM') - } - context.signal?.addEventListener('abort', onAbort, { once: true }) - - child.stdout?.setEncoding('utf8') - child.stderr?.setEncoding('utf8') - child.stdout?.on('data', chunk => { stdout += chunk }) - child.stderr?.on('data', chunk => { stderr += chunk }) - child.on('error', error => { - clearTimeout(timer) - context.signal?.removeEventListener('abort', onAbort) - resolve({ - ok: false, - content: error.message, - error: error.message, - data: { command: normalized.command, args, cwd, originalCommand: input.command }, - }) - }) - child.on('close', code => { - clearTimeout(timer) - context.signal?.removeEventListener('abort', onAbort) - const content = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join('\n') - resolve({ - ok: code === 0 && !timedOut && !aborted, - content: content || (aborted ? 'Command aborted.' : content), - error: aborted ? 'Command aborted.' : timedOut ? `Command timed out after ${timeoutMs}ms` : code === 0 ? undefined : `Command exited with code ${code}`, - data: { - command: normalized.command, - args, - cwd, - originalCommand: input.command, - exitCode: code, - stdout, - stderr, - timedOut, - aborted, - }, - }) - }) - }) - } -} - -export function createTerminalTools(): AgentTool[] { - return [new TerminalExecTool()] -} - -function normalizeTerminalCommand(command: string, args?: string[]): { command: string; args: string[] } { - const normalizedArgs = Array.isArray(args) ? args.map(String) : [] - if (normalizedArgs.length > 0 || !/\s/.test(command.trim())) { - return { command, args: normalizedArgs } - } - - const parts = splitCommandLine(command) - if (parts.length <= 1) return { command, args: normalizedArgs } - return { - command: parts[0], - args: parts.slice(1), - } -} - -function splitCommandLine(command: string): string[] { - const parts: string[] = [] - let current = '' - let quote: '"' | "'" | null = null - let escaped = false - - for (const char of command.trim()) { - if (escaped) { - current += char - escaped = false - continue - } - if (char === '\\') { - escaped = true - continue - } - if (quote) { - if (char === quote) { - quote = null - } else { - current += char - } - continue - } - if (char === '"' || char === "'") { - quote = char - continue - } - if (/\s/.test(char)) { - if (current) { - parts.push(current) - current = '' - } - continue - } - current += char - } - - if (escaped) current += '\\' - if (current) parts.push(current) - return parts -} diff --git a/packages/ekko-agent/src/tools/types.ts b/packages/ekko-agent/src/tools/types.ts deleted file mode 100644 index 5e1e927..0000000 --- a/packages/ekko-agent/src/tools/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { AgentToolDefinition } from '../model/types' - -export interface AgentToolContext { - cwd?: string - workspaceRoot?: string - sessionId?: string - browserSessionId?: string - mcpServers?: Record - timeoutMs?: number - signal?: AbortSignal -} - -export interface AgentToolResult { - ok: boolean - content: string - data?: unknown - error?: string -} - -export interface AgentTool = Record> { - definition: AgentToolDefinition - execute(input: TInput, context?: AgentToolContext): Promise -} - -export interface AgentToolProvider { - id: string - listTools(context?: AgentToolContext): Promise -} - -export class AgentToolError extends Error { - constructor( - message: string, - readonly code: string, - ) { - super(message) - this.name = 'AgentToolError' - } -} diff --git a/packages/ekko-agent/tsconfig.json b/packages/ekko-agent/tsconfig.json deleted file mode 100644 index 30be28f..0000000 --- a/packages/ekko-agent/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "module": "commonjs", - "strict": true, - "noEmit": true, - "skipLibCheck": true, - "types": ["node"] - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/server/src/controllers/coding-agents.ts b/packages/server/src/controllers/coding-agents.ts deleted file mode 100644 index 744f849..0000000 --- a/packages/server/src/controllers/coding-agents.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { Context } from 'koa' -import { - deleteCodingAgent, - getCodingAgentsStatus, - installCodingAgent, - openCodingAgentNativeTerminal, - prepareCodingAgentLaunch, - readCodingAgentConfigFile, - sendCodingAgentRunInput, - startCodingAgentRun, - stopCodingAgentRun, - writeCodingAgentConfigFile, - type CodingAgentConfigScope, -} from '../services/coding-agents' - -function configScope(ctx: Context): CodingAgentConfigScope { - const body = ctx.request.body as { profile?: unknown; provider?: unknown } | undefined - return { - profile: ctx.state.profile?.name || (typeof ctx.query.profile === 'string' ? ctx.query.profile : '') || (typeof body?.profile === 'string' ? body.profile : ''), - provider: (typeof ctx.query.provider === 'string' ? ctx.query.provider : '') || (typeof body?.provider === 'string' ? body.provider : ''), - } -} - -export async function status(ctx: Context) { - try { - ctx.body = await getCodingAgentsStatus() - } catch (err: any) { - ctx.status = 500 - ctx.body = { error: err.message || 'Failed to inspect coding agents' } - } -} - -export async function install(ctx: Context) { - try { - const result = await installCodingAgent(ctx.params.id) - ctx.body = result - } catch (err: any) { - ctx.status = err.status || 500 - ctx.body = { error: err.message || 'Failed to install coding agent' } - } -} - -export async function remove(ctx: Context) { - try { - const result = await deleteCodingAgent(ctx.params.id) - ctx.body = result - } catch (err: any) { - ctx.status = err.status || 500 - ctx.body = { error: err.message || 'Failed to delete coding agent' } - } -} - -export async function readConfigFile(ctx: Context) { - try { - ctx.body = await readCodingAgentConfigFile(ctx.params.id, ctx.params.key, configScope(ctx)) - } catch (err: any) { - ctx.status = err.status || 500 - ctx.body = { error: err.message || 'Failed to read coding agent config file' } - } -} - -export async function writeConfigFile(ctx: Context) { - try { - const { content } = ctx.request.body as { content?: string } - ctx.body = await writeCodingAgentConfigFile(ctx.params.id, ctx.params.key, content || '', configScope(ctx)) - } catch (err: any) { - ctx.status = err.status || 500 - ctx.body = { error: err.message || 'Failed to write coding agent config file' } - } -} - -export async function prepareLaunch(ctx: Context) { - try { - const body = ctx.request.body as { - mode?: any - profile?: string - provider?: string - model?: string - baseUrl?: string - apiKey?: string - apiMode?: any - } - ctx.body = await prepareCodingAgentLaunch(ctx.params.id, { - mode: body.mode, - profile: ctx.state.profile?.name || body.profile, - provider: body.provider, - model: body.model, - baseUrl: body.baseUrl, - apiKey: body.apiKey, - apiMode: body.apiMode, - }) - } catch (err: any) { - ctx.status = err.status || 500 - ctx.body = { error: err.message || 'Failed to prepare coding agent launch' } - } -} - -export async function nativeLaunch(ctx: Context) { - try { - const body = ctx.request.body as { - mode?: any - profile?: string - provider?: string - model?: string - baseUrl?: string - apiKey?: string - apiMode?: any - } - ctx.body = await openCodingAgentNativeTerminal(ctx.params.id, { - mode: body.mode, - profile: ctx.state.profile?.name || body.profile, - provider: body.provider, - model: body.model, - baseUrl: body.baseUrl, - apiKey: body.apiKey, - apiMode: body.apiMode, - }) - } catch (err: any) { - ctx.status = err.status || 500 - ctx.body = { error: err.message || 'Failed to launch native terminal' } - } -} - -export async function startRun(ctx: Context) { - try { - const body = ctx.request.body as { - sessionId?: string - mode?: any - profile?: string - provider?: string - model?: string - baseUrl?: string - apiKey?: string - apiMode?: any - } - ctx.body = await startCodingAgentRun(ctx.params.id, { - sessionId: String(body.sessionId || ''), - mode: body.mode, - profile: ctx.state.profile?.name || body.profile, - provider: body.provider, - model: body.model, - baseUrl: body.baseUrl, - apiKey: body.apiKey, - apiMode: body.apiMode, - }) - } catch (err: any) { - ctx.status = err.status || 500 - ctx.body = { error: err.message || 'Failed to start coding agent run' } - } -} - -export async function sendRunInput(ctx: Context) { - try { - const body = ctx.request.body as { input?: string } - ctx.body = await sendCodingAgentRunInput(String(ctx.params.sessionId || ''), String(body.input || '')) - } catch (err: any) { - ctx.status = err.status || 500 - ctx.body = { error: err.message || 'Failed to send coding agent input' } - } -} - -export async function stopRun(ctx: Context) { - try { - ctx.body = await stopCodingAgentRun(String(ctx.params.sessionId || '')) - } catch (err: any) { - ctx.status = err.status || 500 - ctx.body = { error: err.message || 'Failed to stop coding agent run' } - } -} diff --git a/packages/server/src/controllers/hermes/sessions.ts b/packages/server/src/controllers/hermes/sessions.ts index dfcd6ce..b90b843 100644 --- a/packages/server/src/controllers/hermes/sessions.ts +++ b/packages/server/src/controllers/hermes/sessions.ts @@ -31,7 +31,6 @@ import { logger } from '../../services/logger' import type { ConversationSummary } from '../../services/hermes/conversations' import { listUserProfiles } from '../../db/hermes/users-store' import { readConfigYamlForProfile } from '../../services/config-helpers' -import { codingAgentRunManager } from '../../services/agent-runner/coding-agent-run-manager' import { AgentBridgeClient, getAgentBridgeManager } from '../../services/hermes/agent-bridge' import { ensureHermesRunWorkspace } from '../../services/hermes/run-chat/workspace' import { isSensitivePath, MAX_EDIT_SIZE } from '../../services/hermes/file-provider' @@ -135,13 +134,6 @@ function isHermesHistorySessionSource(source?: string | null): boolean { return source !== 'api_server' && source !== 'global_agent' && source !== 'workflow' } -function isCodingAgentSession(session?: { source?: string | null; agent?: string | null; agent_session_id?: string | null } | null): boolean { - return session?.source === 'coding_agent' || - session?.agent === 'claude' || - session?.agent === 'codex' || - Boolean(session?.agent_session_id) -} - interface HermesDeleteResult { attempted: boolean deleted: boolean @@ -448,9 +440,9 @@ export async function listHermesSessions(ctx: any) { for (const session of localSessions) { if (historySessionsById.has(session.id)) continue // Surface local-only sessions that are absent from the Hermes state.db - // (e.g. coding_agent runs started via the Web UI such as Claude Code / - // Codex). Without this, the History view cannot list or open them, and the - // chat panel's "view in history" link dead-ends to an empty list. + // (e.g. runs started via the Web UI rather than the CLI). Without this, the + // History view cannot list or open them, and the chat panel's "view in + // history" link dead-ends to an empty list. if (!isArchivedSession(session) && !isHermesHistorySessionSource(session.source)) continue historySessionsById.set(session.id, { ...session, webui_imported: true }) } @@ -908,11 +900,7 @@ export async function remove(ctx: any) { const existing = localGetSession(sessionId) if (denySessionAccess(ctx, existing)) return const hermesProfile = requestedProfile(ctx) || existing?.profile || getActiveProfileName() - const codingAgentSession = isCodingAgentSession(existing) - if (codingAgentSession) codingAgentRunManager.stop(sessionId, { reportClosed: false }) - const hermes = codingAgentSession - ? { attempted: false, deleted: false, profile: hermesProfile } - : await deleteHermesSessionIfPresent(sessionId, hermesProfile) + const hermes = await deleteHermesSessionIfPresent(sessionId, hermesProfile) const localDeleted = existing ? localDeleteSession(sessionId) : true if (!localDeleted) { ctx.status = 500 @@ -978,11 +966,7 @@ export async function batchRemove(ctx: any) { continue } - const codingAgentSession = isCodingAgentSession(existing) - if (codingAgentSession) codingAgentRunManager.stop(id, { reportClosed: false }) - const hermes = codingAgentSession - ? { attempted: false, deleted: false, profile: targetProfile || 'default' } - : await deleteHermesSessionIfPresent(id, targetProfile) + const hermes = await deleteHermesSessionIfPresent(id, targetProfile) if (hermes.deleted) { results.hermesDeleted++ } else if (hermes.attempted && hermes.error) { @@ -1136,28 +1120,15 @@ export async function setModel(ctx: any) { const cleanModel = model.trim() const cleanProvider = (provider || '').trim() const cleanApiMode = normalizeSessionApiMode(apiMode ?? api_mode) - const codingAgentSession = isCodingAgentSession(existing) - const workspace = !codingAgentSession - ? await ensureHermesRunWorkspace(profile, existing?.workspace) - : undefined + const workspace = await ensureHermesRunWorkspace(profile, existing?.workspace) if (!existing) { createSession({ id, profile, title: '', model: cleanModel, provider: cleanProvider, api_mode: cleanApiMode || '', workspace }) } const updates: Record = { model: cleanModel, provider: cleanProvider } if (cleanApiMode) updates.api_mode = cleanApiMode - else if (codingAgentSession && existing && existing.provider !== cleanProvider) updates.api_mode = '' - if (!codingAgentSession && existing && !existing.workspace && workspace) updates.workspace = workspace - if ( - codingAgentSession && - existing && - (existing.model !== cleanModel || existing.provider !== cleanProvider || (cleanApiMode && existing.api_mode !== cleanApiMode)) - ) { - updates.agent_native_session_id = '' - } + if (existing && !existing.workspace && workspace) updates.workspace = workspace updateSession(id, updates as any) - if (!codingAgentSession) { - await notifyBridgeSessionModelChanged(id, cleanModel, cleanProvider, profile) - } + await notifyBridgeSessionModelChanged(id, cleanModel, cleanProvider, profile) ctx.body = { ok: true } } diff --git a/packages/server/src/routes/claude-code-proxy.ts b/packages/server/src/routes/claude-code-proxy.ts deleted file mode 100644 index 061e14f..0000000 --- a/packages/server/src/routes/claude-code-proxy.ts +++ /dev/null @@ -1,7 +0,0 @@ -import Router from '@koa/router' -import { claudeProxyMessages, claudeProxyModels } from '../services/agent-runner/proxies/claude-code-proxy' - -export const claudeCodeProxyRoutes = new Router() - -claudeCodeProxyRoutes.get('/api/claude-code-proxy/:key/v1/models', claudeProxyModels) -claudeCodeProxyRoutes.post('/api/claude-code-proxy/:key/v1/messages', claudeProxyMessages) diff --git a/packages/server/src/routes/codex-proxy.ts b/packages/server/src/routes/codex-proxy.ts deleted file mode 100644 index 769df7b..0000000 --- a/packages/server/src/routes/codex-proxy.ts +++ /dev/null @@ -1,7 +0,0 @@ -import Router from '@koa/router' -import { codexProxyModels, codexProxyResponses } from '../services/agent-runner/proxies/codex-proxy' - -export const codexProxyRoutes = new Router() - -codexProxyRoutes.get('/api/codex-proxy/:key/v1/models', codexProxyModels) -codexProxyRoutes.post('/api/codex-proxy/:key/v1/responses', codexProxyResponses) diff --git a/packages/server/src/routes/coding-agents.ts b/packages/server/src/routes/coding-agents.ts deleted file mode 100644 index fa5656c..0000000 --- a/packages/server/src/routes/coding-agents.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Router from '@koa/router' -import * as ctrl from '../controllers/coding-agents' - -export const codingAgentRoutes = new Router() - -codingAgentRoutes.get('/api/coding-agents', ctrl.status) -codingAgentRoutes.post('/api/coding-agents/:id/install', ctrl.install) -codingAgentRoutes.post('/api/coding-agents/:id/launch/prepare', ctrl.prepareLaunch) -codingAgentRoutes.post('/api/coding-agents/:id/launch/native', ctrl.nativeLaunch) -codingAgentRoutes.post('/api/coding-agents/:id/runs', ctrl.startRun) -codingAgentRoutes.post('/api/coding-agents/runs/:sessionId/input', ctrl.sendRunInput) -codingAgentRoutes.delete('/api/coding-agents/runs/:sessionId', ctrl.stopRun) -codingAgentRoutes.delete('/api/coding-agents/:id', ctrl.remove) -codingAgentRoutes.get('/api/coding-agents/:id/config-files/:key', ctrl.readConfigFile) -codingAgentRoutes.put('/api/coding-agents/:id/config-files/:key', ctrl.writeConfigFile) diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts index 8e175c3..b2a671d 100644 --- a/packages/server/src/routes/index.ts +++ b/packages/server/src/routes/index.ts @@ -8,10 +8,7 @@ import { updateRoutes } from './update' import { authPublicRoutes, authProtectedRoutes } from './auth' import { devicePublicRoutes, deviceRoutes } from './devices' import { mcuDeviceRoutes } from './mcu-devices' -import { codingAgentRoutes } from './coding-agents' import { apiDocsRoutes } from './api-docs' -import { claudeCodeProxyRoutes } from './claude-code-proxy' -import { codexProxyRoutes } from './codex-proxy' // Hermes route modules import { sessionRoutes } from './hermes/sessions' @@ -61,8 +58,6 @@ export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, ne app.use(webhookRoutes.routes()) app.use(authPublicRoutes.routes()) app.use(devicePublicRoutes.routes()) - app.use(claudeCodeProxyRoutes.routes()) - app.use(codexProxyRoutes.routes()) app.use(ttsRoutes.routes()) app.use(apiDocsRoutes.routes()) app.use(petdexPublicRoutes.routes()) @@ -76,7 +71,6 @@ export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, ne app.use(mcuDeviceRoutes.routes()) app.use(uploadRoutes.routes()) app.use(updateRoutes.routes()) // Must be before proxy (proxy catch-all matches everything) - app.use(codingAgentRoutes.routes()) app.use(sessionRoutes.routes()) app.use(profileRoutes.routes()) app.use(skillRoutes.routes()) diff --git a/packages/server/src/services/agent-runner/adapters/anthropic-stream.ts b/packages/server/src/services/agent-runner/adapters/anthropic-stream.ts deleted file mode 100644 index 4ee000f..0000000 --- a/packages/server/src/services/agent-runner/adapters/anthropic-stream.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { readSseFrameTexts } from '../sse' -import { mapStopReason, shouldPreserveReasoningContent, type AnthropicAdapterTarget } from './anthropic' - -export interface AnthropicStreamEvent { - type: string - data: Record -} - -function safeJsonParse(value: string): any { - try { - return JSON.parse(value) - } catch { - return {} - } -} - -function parseSseFrames(buffer: string): { events: string[]; rest: string } { - const parsed = readSseFrameTexts(buffer) - return { events: parsed.frames, rest: parsed.rest } -} - -function extractSseData(event: string): string[] { - return event - .split(/\r?\n/) - .filter(line => line.startsWith('data:')) - .map(line => line.slice(5).trimStart()) -} - -function openAiFinishToAnthropic(finishReason: string | null | undefined, sawTool: boolean): string { - return mapStopReason(finishReason, sawTool) -} - -export async function* openAiChatSseToAnthropicEvents( - stream: AsyncIterable, - target: AnthropicAdapterTarget, -): AsyncGenerator { - const decoder = new TextDecoder() - const messageId = `msg_${Date.now()}` - let buffer = '' - let thinkingBlockIndex: number | null = null - let thinkingBlockStopped = false - let textBlockStarted = false - let textBlockStopped = false - let textBlockIndex: number | null = null - let nextIndex = 0 - let stopReason: string | null = null - let outputTokens = 0 - const toolBlocks = new Map() - - yield { - type: 'message_start', - data: { - type: 'message_start', - message: { - id: messageId, - type: 'message', - role: 'assistant', - model: target.model, - content: [], - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 0, output_tokens: 0 }, - }, - }, - } - - const ensureThinkingBlock = function* (): Generator { - if (thinkingBlockIndex == null) { - thinkingBlockIndex = nextIndex++ - yield { - type: 'content_block_start', - data: { - type: 'content_block_start', - index: thinkingBlockIndex, - content_block: { type: 'thinking', thinking: '' }, - }, - } - } - return thinkingBlockIndex - } - - const stopThinkingBlock = function* (): Generator { - if (thinkingBlockIndex != null && !thinkingBlockStopped) { - thinkingBlockStopped = true - yield { - type: 'content_block_stop', - data: { type: 'content_block_stop', index: thinkingBlockIndex }, - } - } - } - - const ensureTextBlock = function* (): Generator { - if (!textBlockStarted) { - textBlockStarted = true - textBlockIndex = nextIndex - yield { - type: 'content_block_start', - data: { - type: 'content_block_start', - index: textBlockIndex, - content_block: { type: 'text', text: '' }, - }, - } - nextIndex += 1 - } - return textBlockIndex ?? 0 - } - - const ensureToolBlock = function* (toolIndex: number, id?: string, name?: string): Generator { - let block = toolBlocks.get(toolIndex) - if (!block) { - block = { - blockIndex: nextIndex++, - id: id || `toolu_${toolIndex}`, - name: name || 'tool', - started: false, - } - toolBlocks.set(toolIndex, block) - } else { - if (id) block.id = id - if (name) block.name = name - } - if (!block.started && block.name) { - block.started = true - yield { - type: 'content_block_start', - data: { - type: 'content_block_start', - index: block.blockIndex, - content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} }, - }, - } - } - return block - } - - for await (const chunk of stream) { - buffer += decoder.decode(chunk, { stream: true }) - const parsed = parseSseFrames(buffer) - buffer = parsed.rest - - for (const event of parsed.events) { - for (const dataLine of extractSseData(event)) { - if (!dataLine || dataLine === '[DONE]') continue - const data = safeJsonParse(dataLine) - if (data?.usage?.completion_tokens != null) { - outputTokens = Number(data.usage.completion_tokens) - } - const choice = data?.choices?.[0] - if (!choice) continue - - const delta = choice.delta || {} - if (shouldPreserveReasoningContent(target) && typeof delta.reasoning_content === 'string' && delta.reasoning_content) { - const index = yield* ensureThinkingBlock() - yield { - type: 'content_block_delta', - data: { - type: 'content_block_delta', - index, - delta: { type: 'thinking_delta', thinking: delta.reasoning_content }, - }, - } - } - - if (typeof delta.content === 'string' && delta.content) { - yield* stopThinkingBlock() - const index = yield* ensureTextBlock() - yield { - type: 'content_block_delta', - data: { - type: 'content_block_delta', - index, - delta: { type: 'text_delta', text: delta.content }, - }, - } - } - - for (const toolCall of Array.isArray(delta.tool_calls) ? delta.tool_calls : []) { - yield* stopThinkingBlock() - if (textBlockStarted && !textBlockStopped) { - textBlockStopped = true - yield { - type: 'content_block_stop', - data: { type: 'content_block_stop', index: textBlockIndex ?? 0 }, - } - } - const toolIndex = Number(toolCall.index || 0) - const block = yield* ensureToolBlock( - toolIndex, - toolCall.id ? String(toolCall.id) : undefined, - toolCall.function?.name ? String(toolCall.function.name) : undefined, - ) - const argsDelta = toolCall.function?.arguments - if (typeof argsDelta === 'string' && argsDelta) { - yield { - type: 'content_block_delta', - data: { - type: 'content_block_delta', - index: block.blockIndex, - delta: { type: 'input_json_delta', partial_json: argsDelta }, - }, - } - } - } - - if (choice.finish_reason) stopReason = String(choice.finish_reason) - } - } - } - - yield* stopThinkingBlock() - if (textBlockStarted && !textBlockStopped) { - yield { - type: 'content_block_stop', - data: { type: 'content_block_stop', index: textBlockIndex ?? 0 }, - } - } - for (const block of toolBlocks.values()) { - if (block.started) { - yield { - type: 'content_block_stop', - data: { type: 'content_block_stop', index: block.blockIndex }, - } - } - } - yield { - type: 'message_delta', - data: { - type: 'message_delta', - delta: { stop_reason: openAiFinishToAnthropic(stopReason, toolBlocks.size > 0), stop_sequence: null }, - usage: { output_tokens: outputTokens }, - }, - } - yield { type: 'message_stop', data: { type: 'message_stop' } } -} -export async function* openAiResponsesSseToAnthropicEvents( - stream: AsyncIterable, - target: AnthropicAdapterTarget, -): AsyncGenerator { - const decoder = new TextDecoder() - let messageId = `msg_${Date.now()}` - let buffer = '' - let textBlockIndex: number | null = null - let textBlockStopped = false - let nextIndex = 0 - let stopReason: string | null = null - let outputTokens = 0 - const toolBlocks = new Map() - - yield { - type: 'message_start', - data: { - type: 'message_start', - message: { - id: messageId, - type: 'message', - role: 'assistant', - model: target.model, - content: [], - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 0, output_tokens: 0 }, - }, - }, - } - - const ensureTextBlock = function* (): Generator { - if (textBlockIndex == null) { - textBlockIndex = nextIndex++ - yield { - type: 'content_block_start', - data: { - type: 'content_block_start', - index: textBlockIndex, - content_block: { type: 'text', text: '' }, - }, - } - } - return textBlockIndex - } - - const ensureToolBlock = function* (key: string, id?: string, name?: string): Generator { - let block = toolBlocks.get(key) - if (!block) { - block = { - blockIndex: nextIndex++, - id: id || key || `toolu_${toolBlocks.size}`, - name: name || 'tool', - argsDeltaSeen: false, - stopped: false, - } - toolBlocks.set(key, block) - yield { - type: 'content_block_start', - data: { - type: 'content_block_start', - index: block.blockIndex, - content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} }, - }, - } - } else { - if (id) block.id = id - if (name && block.name === 'tool') block.name = name - } - return block - } - - for await (const chunk of stream) { - buffer += decoder.decode(chunk, { stream: true }) - const parsed = parseSseFrames(buffer) - buffer = parsed.rest - - for (const event of parsed.events) { - for (const dataLine of extractSseData(event)) { - if (!dataLine || dataLine === '[DONE]') continue - const data = safeJsonParse(dataLine) - const eventType = data?.type - - if (eventType === 'response.created') { - messageId = String(data?.response?.id || messageId) - } - - if (eventType === 'response.output_text.delta') { - const deltaText = String(data?.delta || data?.text || '') - if (deltaText) { - const index = yield* ensureTextBlock() - yield { - type: 'content_block_delta', - data: { - type: 'content_block_delta', - index, - delta: { type: 'text_delta', text: deltaText }, - }, - } - } - } - - if (eventType === 'response.output_text.done' && textBlockIndex != null && !textBlockStopped) { - textBlockStopped = true - yield { - type: 'content_block_stop', - data: { type: 'content_block_stop', index: textBlockIndex }, - } - } - - if (eventType === 'response.output_item.added') { - const item = data?.item || data?.output_item - if (item?.type === 'function_call') { - const key = String(item.call_id || item.id || data.output_index || toolBlocks.size) - yield* ensureToolBlock(key, String(item.call_id || item.id || key), item.name ? String(item.name) : undefined) - } - } - - if (eventType === 'response.function_call_arguments.delta') { - const key = String(data.call_id || data.item_id || data.output_index || toolBlocks.size) - const block = yield* ensureToolBlock(key) - const argsDelta = String(data.delta || '') - if (argsDelta) { - block.argsDeltaSeen = true - yield { - type: 'content_block_delta', - data: { - type: 'content_block_delta', - index: block.blockIndex, - delta: { type: 'input_json_delta', partial_json: argsDelta }, - }, - } - } - } - - if (eventType === 'response.output_item.done') { - const item = data?.item || data?.output_item - if (item?.type === 'function_call') { - const key = String(item.call_id || item.id || data.output_index || toolBlocks.size) - const block = yield* ensureToolBlock(key, String(item.call_id || item.id || key), item.name ? String(item.name) : undefined) - const args = String(item.arguments || '') - if (args && !block.argsDeltaSeen) { - yield { - type: 'content_block_delta', - data: { - type: 'content_block_delta', - index: block.blockIndex, - delta: { type: 'input_json_delta', partial_json: args }, - }, - } - } - if (!block.stopped) { - block.stopped = true - yield { - type: 'content_block_stop', - data: { type: 'content_block_stop', index: block.blockIndex }, - } - } - } - } - - if (eventType === 'response.completed') { - const response = data?.response || data - outputTokens = Number(response?.usage?.output_tokens || outputTokens) - stopReason = response?.status === 'incomplete' ? 'length' : 'stop' - } - } - } - } - - if (textBlockIndex != null && !textBlockStopped) { - yield { - type: 'content_block_stop', - data: { type: 'content_block_stop', index: textBlockIndex }, - } - } - for (const block of toolBlocks.values()) { - if (!block.stopped) { - yield { - type: 'content_block_stop', - data: { type: 'content_block_stop', index: block.blockIndex }, - } - } - } - yield { - type: 'message_delta', - data: { - type: 'message_delta', - delta: { stop_reason: openAiFinishToAnthropic(stopReason, toolBlocks.size > 0), stop_sequence: null }, - usage: { output_tokens: outputTokens }, - }, - } - yield { type: 'message_stop', data: { type: 'message_stop' } } -} diff --git a/packages/server/src/services/agent-runner/adapters/anthropic.ts b/packages/server/src/services/agent-runner/adapters/anthropic.ts deleted file mode 100644 index 42776e0..0000000 --- a/packages/server/src/services/agent-runner/adapters/anthropic.ts +++ /dev/null @@ -1,292 +0,0 @@ -export interface AnthropicAdapterTarget { - provider: string - model: string - baseUrl: string -} - -export function targetReasoningEffort(target: any): string { - const effort = String(target?.reasoningEffort || '').trim() - return ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(effort) ? effort : '' -} - -function stringifyContent(value: unknown): string { - if (typeof value === 'string') return value - if (Array.isArray(value)) { - return value.map((item) => { - if (typeof item === 'string') return item - if (item && typeof item === 'object' && 'text' in item) return String((item as any).text || '') - return JSON.stringify(item) - }).filter(Boolean).join('\n') - } - if (value == null) return '' - return JSON.stringify(value) -} - -function safeJsonParse(value: string): any { - try { - return JSON.parse(value) - } catch { - return {} - } -} -export function shouldPreserveReasoningContent(target: AnthropicAdapterTarget): boolean { - const identifier = `${target.provider} ${target.model} ${target.baseUrl}`.toLowerCase() - return [ - 'deepseek', - 'moonshot', - 'kimi', - 'mimo', - 'xiaomimimo', - ].some(part => identifier.includes(part)) -} - -function anthropicContentToOpenAiMessages(message: any, preserveReasoningContent = false): any[] { - const content = message?.content - if (!Array.isArray(content)) { - return [{ role: message.role, content: stringifyContent(content) }] - } - - if (message.role === 'assistant') { - const textParts: string[] = [] - const reasoningParts: string[] = [] - const toolCalls: any[] = [] - for (const block of content) { - if (block?.type === 'text') textParts.push(String(block.text || '')) - if (block?.type === 'thinking' && block.thinking) reasoningParts.push(String(block.thinking)) - if (block?.type === 'redacted_thinking' && preserveReasoningContent) reasoningParts.push('[redacted thinking]') - if (block?.type === 'tool_use') { - toolCalls.push({ - id: String(block.id || `tool_${toolCalls.length}`), - type: 'function', - function: { - name: String(block.name || 'tool'), - arguments: JSON.stringify(block.input || {}), - }, - }) - } - } - const openAiMessage: any = { - role: 'assistant', - content: textParts.join('\n') || null, - ...(toolCalls.length ? { tool_calls: toolCalls } : {}), - } - if (preserveReasoningContent && (reasoningParts.length || toolCalls.length)) { - openAiMessage.reasoning_content = reasoningParts.join('\n') || 'tool call' - } - return [openAiMessage] - } - - const messages: any[] = [] - const textParts: string[] = [] - for (const block of content) { - if (block?.type === 'text') textParts.push(String(block.text || '')) - if (block?.type === 'tool_result') { - if (textParts.length) { - messages.push({ role: 'user', content: textParts.splice(0).join('\n') }) - } - messages.push({ - role: 'tool', - tool_call_id: String(block.tool_use_id || ''), - content: stringifyContent(block.content), - }) - } - } - if (textParts.length) messages.push({ role: message.role || 'user', content: textParts.join('\n') }) - return messages.length ? messages : [{ role: message.role || 'user', content: '' }] -} - -export function anthropicToOpenAiChat(body: any, target: AnthropicAdapterTarget, stream = false): any { - const messages: any[] = [] - const preserveReasoningContent = shouldPreserveReasoningContent(target) - const system = body?.system - if (system) messages.push({ role: 'system', content: stringifyContent(system) }) - for (const message of Array.isArray(body?.messages) ? body.messages : []) { - messages.push(...anthropicContentToOpenAiMessages(message, preserveReasoningContent)) - } - - const reasoningEffort = targetReasoningEffort(target) - const tools = Array.isArray(body?.tools) - ? body.tools.map((tool: any) => ({ - type: 'function', - function: { - name: String(tool.name || ''), - description: String(tool.description || ''), - parameters: tool.input_schema || { type: 'object', properties: {} }, - }, - })).filter((tool: any) => tool.function.name) - : undefined - - return { - model: target.model, - messages, - ...(typeof body?.max_tokens === 'number' ? { max_tokens: body.max_tokens } : {}), - ...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}), - ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), - ...(tools?.length ? { tools } : {}), - stream, - ...(stream ? { stream_options: { include_usage: true } } : {}), - } -} - -function anthropicToOpenAiResponsesInput(message: any): any[] { - const content = Array.isArray(message?.content) ? message.content : [{ type: 'text', text: stringifyContent(message?.content) }] - - if (message.role === 'assistant') { - const items: any[] = [] - const textParts: string[] = [] - for (const block of content) { - if (block?.type === 'text') textParts.push(String(block.text || '')) - if (block?.type === 'tool_use') { - if (textParts.length) { - items.push({ role: 'assistant', content: textParts.splice(0).join('\n') }) - } - items.push({ - type: 'function_call', - call_id: String(block.id || `tool_${items.length}`), - name: String(block.name || 'tool'), - arguments: JSON.stringify(block.input || {}), - }) - } - } - if (textParts.length) items.push({ role: 'assistant', content: textParts.join('\n') }) - return items - } - - const items: any[] = [] - const textParts: string[] = [] - for (const block of content) { - if (block?.type === 'text') textParts.push(String(block.text || '')) - if (block?.type === 'tool_result') { - if (textParts.length) { - items.push({ role: 'user', content: textParts.splice(0).join('\n') }) - } - items.push({ - type: 'function_call_output', - call_id: String(block.tool_use_id || ''), - output: stringifyContent(block.content), - }) - } - } - if (textParts.length) items.push({ role: message.role || 'user', content: textParts.join('\n') }) - return items.length ? items : [{ role: message.role || 'user', content: '' }] -} - -export function anthropicToOpenAiResponses(body: any, target: AnthropicAdapterTarget, stream = false): any { - const input: any[] = [] - for (const message of Array.isArray(body?.messages) ? body.messages : []) { - input.push(...anthropicToOpenAiResponsesInput(message)) - } - - const reasoningEffort = targetReasoningEffort(target) - const tools = Array.isArray(body?.tools) - ? body.tools.map((tool: any) => ({ - type: 'function', - name: String(tool.name || ''), - description: String(tool.description || ''), - parameters: tool.input_schema || { type: 'object', properties: {} }, - })).filter((tool: any) => tool.name) - : undefined - - return { - model: target.model, - input, - ...(body?.system ? { instructions: stringifyContent(body.system) } : {}), - ...(typeof body?.max_tokens === 'number' ? { max_output_tokens: body.max_tokens } : {}), - ...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}), - ...(reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {}), - ...(tools?.length ? { tools } : {}), - stream, - store: false, - } -} - -export function mapStopReason(reason: string | null | undefined, hasTools: boolean): string { - if (hasTools) return 'tool_use' - if (reason === 'length') return 'max_tokens' - if (reason === 'content_filter') return 'stop_sequence' - return 'end_turn' -} - -export function openAiToAnthropicMessage(data: any, target: AnthropicAdapterTarget): any { - const choice = data?.choices?.[0] || {} - const message = choice.message || {} - const content: any[] = [] - if (shouldPreserveReasoningContent(target) && message.reasoning_content) { - content.push({ type: 'thinking', thinking: String(message.reasoning_content) }) - } - if (message.content) content.push({ type: 'text', text: String(message.content) }) - for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) { - content.push({ - type: 'tool_use', - id: String(call.id || `toolu_${content.length}`), - name: String(call.function?.name || 'tool'), - input: safeJsonParse(String(call.function?.arguments || '{}')), - }) - } - - const hasTools = content.some(block => block.type === 'tool_use') - return { - id: String(data?.id || `msg_${Date.now()}`), - type: 'message', - role: 'assistant', - model: target.model, - content, - stop_reason: mapStopReason(choice.finish_reason, hasTools), - stop_sequence: null, - usage: { - input_tokens: Number(data?.usage?.prompt_tokens || 0), - output_tokens: Number(data?.usage?.completion_tokens || 0), - }, - } -} - -function responseOutputText(item: any): string { - if (item?.type === 'output_text') return String(item.text || '') - if (item?.type === 'message' && Array.isArray(item.content)) { - return item.content - .map((part: any) => { - if (part?.type === 'output_text' || part?.type === 'text') return String(part.text || '') - return '' - }) - .filter(Boolean) - .join('') - } - return '' -} - -export function openAiResponsesToAnthropicMessage(data: any, target: AnthropicAdapterTarget): any { - const content: any[] = [] - const output = Array.isArray(data?.output) ? data.output : [] - - for (const item of output) { - const text = responseOutputText(item) - if (text) content.push({ type: 'text', text }) - if (item?.type === 'function_call') { - content.push({ - type: 'tool_use', - id: String(item.call_id || item.id || `toolu_${content.length}`), - name: String(item.name || 'tool'), - input: safeJsonParse(String(item.arguments || '{}')), - }) - } - } - - if (!content.length && data?.output_text) { - content.push({ type: 'text', text: String(data.output_text) }) - } - - const hasTools = content.some(block => block.type === 'tool_use') - return { - id: String(data?.id || `msg_${Date.now()}`), - type: 'message', - role: 'assistant', - model: target.model, - content, - stop_reason: hasTools ? 'tool_use' : (data?.status === 'incomplete' ? 'max_tokens' : 'end_turn'), - stop_sequence: null, - usage: { - input_tokens: Number(data?.usage?.input_tokens || 0), - output_tokens: Number(data?.usage?.output_tokens || 0), - }, - } -} diff --git a/packages/server/src/services/agent-runner/adapters/responses-stream.ts b/packages/server/src/services/agent-runner/adapters/responses-stream.ts deleted file mode 100644 index 55c6671..0000000 --- a/packages/server/src/services/agent-runner/adapters/responses-stream.ts +++ /dev/null @@ -1,535 +0,0 @@ -import { readSseFrameTexts } from '../sse' -import { normalizeResponseFunctionCall, responseToolNamespaceForName } from './responses' - -export interface ResponsesStreamAdapterTarget { - model: string - annotateMcpToolNamespaces?: boolean -} - -export interface CanonicalResponsesEvent { - type: string - data: Record -} - -function safeJsonParse(value: string): any { - try { - return JSON.parse(value) - } catch { - return {} - } -} - -function functionCallItem(input: { - id: string - callId?: string - name: string - arguments: string - annotateNamespace?: boolean -}) { - const normalized = input.annotateNamespace - ? normalizeResponseFunctionCall(input.name, input.arguments) - : { name: input.name, arguments: input.arguments, namespace: undefined } - const namespace = input.annotateNamespace ? normalized.namespace || responseToolNamespaceForName(input.name) : undefined - return { - type: 'function_call', - id: input.id, - call_id: input.callId || input.id, - name: normalized.name, - arguments: normalized.arguments, - ...(namespace ? { namespace } : {}), - } -} - -function parseSseFrames(buffer: string): { events: string[]; rest: string } { - const parsed = readSseFrameTexts(buffer) - return { events: parsed.frames, rest: parsed.rest } -} - -function extractSseData(event: string): string[] { - return event - .split(/\r?\n/) - .filter(line => line.startsWith('data:')) - .map(line => line.slice(5).trimStart()) -} - -function extractSseEventName(event: string): string { - return event - .split(/\r?\n/) - .find(line => line.startsWith('event:')) - ?.slice(6) - .trim() || '' -} - -function openAiChatUsageToResponsesUsage(usage: Record): Record { - const inputTokens = Number(usage.prompt_tokens ?? usage.input_tokens ?? 0) - const outputTokens = Number(usage.completion_tokens ?? usage.output_tokens ?? 0) - const promptDetails = usage.prompt_tokens_details || usage.input_tokens_details - const completionDetails = usage.completion_tokens_details || usage.output_tokens_details - return { - input_tokens: inputTokens, - output_tokens: outputTokens, - total_tokens: Number(usage.total_tokens ?? inputTokens + outputTokens), - ...(promptDetails ? { input_tokens_details: promptDetails } : {}), - ...(completionDetails ? { output_tokens_details: completionDetails } : {}), - } -} - -export async function* openAiChatSseToResponsesEvents( - stream: AsyncIterable, - target: ResponsesStreamAdapterTarget, -): AsyncGenerator { - const decoder = new TextDecoder() - const id = `resp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` - const messageId = `msg_${id}` - let buffer = '' - let textStarted = false - let text = '' - let reasoning = '' - let usage: Record | undefined - const toolCalls = new Map() - - yield { - type: 'response.created', - data: { - type: 'response.created', - response: { id, object: 'response', status: 'in_progress', model: target.model, output: [] }, - }, - } - - for await (const chunk of stream) { - buffer += decoder.decode(chunk, { stream: true }) - const parsed = parseSseFrames(buffer) - buffer = parsed.rest - - for (const event of parsed.events) { - for (const dataLine of extractSseData(event)) { - if (!dataLine || dataLine === '[DONE]') continue - const data = safeJsonParse(dataLine) - if (data?.usage && typeof data.usage === 'object') { - usage = { ...(usage || {}), ...data.usage } - } - const choice = data?.choices?.[0] - if (!choice) continue - - const delta = choice.delta || {} - if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) { - reasoning += delta.reasoning_content - yield { - type: 'response.reasoning.delta', - data: { - type: 'response.reasoning.delta', - item_id: messageId, - output_index: 0, - delta: delta.reasoning_content, - }, - } - } - - if (typeof delta.content === 'string' && delta.content) { - if (!textStarted) { - textStarted = true - yield { - type: 'response.output_item.added', - data: { - type: 'response.output_item.added', - output_index: 0, - item: { - type: 'message', - id: messageId, - status: 'in_progress', - role: 'assistant', - content: [], - }, - }, - } - yield { - type: 'response.content_part.added', - data: { - type: 'response.content_part.added', - item_id: messageId, - output_index: 0, - content_index: 0, - part: { type: 'output_text', text: '', annotations: [] }, - }, - } - } - text += delta.content - yield { - type: 'response.output_text.delta', - data: { - type: 'response.output_text.delta', - item_id: messageId, - output_index: 0, - content_index: 0, - delta: delta.content, - }, - } - } - - for (const toolCall of Array.isArray(delta.tool_calls) ? delta.tool_calls : []) { - const index = Number(toolCall.index || 0) - let call = toolCalls.get(index) - if (!call) { - call = { - id: String(toolCall.id || `call_${index}`), - name: String(toolCall.function?.name || 'tool'), - arguments: '', - added: false, - } - toolCalls.set(index, call) - } - if (toolCall.id) call.id = String(toolCall.id) - if (toolCall.function?.name) call.name = String(toolCall.function.name) - if (!call.added && call.name) { - call.added = true - yield { - type: 'response.output_item.added', - data: { - type: 'response.output_item.added', - output_index: textStarted ? index + 1 : index, - item: functionCallItem({ id: call.id, name: call.name, arguments: '', annotateNamespace: target.annotateMcpToolNamespaces }), - }, - } - } - const argsDelta = toolCall.function?.arguments - if (typeof argsDelta === 'string' && argsDelta) { - call.arguments += argsDelta - yield { - type: 'response.function_call_arguments.delta', - data: { - type: 'response.function_call_arguments.delta', - item_id: call.id, - output_index: textStarted ? index + 1 : index, - delta: argsDelta, - }, - } - } - } - } - } - } - - const output: any[] = [] - if (reasoning) { - output.push({ - type: 'reasoning', - id: `rs_${id}`, - summary: [{ type: 'summary_text', text: reasoning }], - }) - } - if (textStarted) { - const messageItem = { - type: 'message', - id: messageId, - status: 'completed', - role: 'assistant', - content: [{ type: 'output_text', text, annotations: [] }], - } - output.push(messageItem) - yield { - type: 'response.output_text.done', - data: { - type: 'response.output_text.done', - item_id: messageId, - output_index: 0, - content_index: 0, - text, - }, - } - yield { - type: 'response.content_part.done', - data: { - type: 'response.content_part.done', - item_id: messageId, - output_index: 0, - content_index: 0, - part: { type: 'output_text', text, annotations: [] }, - }, - } - yield { - type: 'response.output_item.done', - data: { - type: 'response.output_item.done', - output_index: 0, - item: messageItem, - }, - } - } - - for (const [index, call] of toolCalls.entries()) { - const outputIndex = textStarted ? index + 1 : index - const callItem = functionCallItem({ id: call.id, name: call.name, arguments: call.arguments || '{}', annotateNamespace: target.annotateMcpToolNamespaces }) - output.push(callItem) - yield { - type: 'response.output_item.done', - data: { - type: 'response.output_item.done', - output_index: outputIndex, - item: callItem, - }, - } - } - yield { - type: 'response.completed', - data: { - type: 'response.completed', - response: { - id, - object: 'response', - status: 'completed', - model: target.model, - output, - ...(usage ? { usage: openAiChatUsageToResponsesUsage(usage) } : {}), - }, - }, - } -} - -export async function* openAiResponsesSseToResponsesEvents( - stream: AsyncIterable, -): AsyncGenerator { - const decoder = new TextDecoder() - let buffer = '' - - for await (const chunk of stream) { - buffer += decoder.decode(chunk, { stream: true }) - const parsed = parseSseFrames(buffer) - buffer = parsed.rest - - for (const event of parsed.events) { - const eventName = extractSseEventName(event) - for (const dataLine of extractSseData(event)) { - if (!dataLine || dataLine === '[DONE]') continue - const data = safeJsonParse(dataLine) - const type = String(data?.type || eventName || data?.event || '').trim() - if (!type) continue - if (!data.type) data.type = type - yield { type, data } - } - } - } -} - -export async function* anthropicMessagesSseToResponsesEvents( - stream: AsyncIterable, - target: ResponsesStreamAdapterTarget, -): AsyncGenerator { - const decoder = new TextDecoder() - let id = `resp_${Date.now()}` - let messageId = `msg_${id}` - let buffer = '' - let textStarted = false - let text = '' - let reasoning = '' - let usage: Record | undefined - const toolBlocks = new Map() - - yield { - type: 'response.created', - data: { - type: 'response.created', - response: { id, object: 'response', status: 'in_progress', model: target.model, output: [] }, - }, - } - - const ensureText = function* (): Generator { - if (!textStarted) { - textStarted = true - yield { - type: 'response.output_item.added', - data: { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'message', id: messageId, status: 'in_progress', role: 'assistant', content: [] }, - }, - } - yield { - type: 'response.content_part.added', - data: { - type: 'response.content_part.added', - item_id: messageId, - output_index: 0, - content_index: 0, - part: { type: 'output_text', text: '', annotations: [] }, - }, - } - } - } - - const ensureTool = function* (index: number, idValue?: string, name?: string): Generator { - let block = toolBlocks.get(index) - if (!block) { - block = { id: idValue || `toolu_${index}`, name: name || 'tool', arguments: '', added: false } - toolBlocks.set(index, block) - } - if (idValue) block.id = idValue - if (name) block.name = name - if (!block.added) { - block.added = true - yield { - type: 'response.output_item.added', - data: { - type: 'response.output_item.added', - output_index: textStarted ? index + 1 : index, - item: functionCallItem({ id: block.id, name: block.name, arguments: '', annotateNamespace: target.annotateMcpToolNamespaces }), - }, - } - } - return block - } - - for await (const chunk of stream) { - buffer += decoder.decode(chunk, { stream: true }) - const parsed = parseSseFrames(buffer) - buffer = parsed.rest - - for (const event of parsed.events) { - const eventName = extractSseEventName(event) - for (const dataLine of extractSseData(event)) { - if (!dataLine || dataLine === '[DONE]') continue - const data = safeJsonParse(dataLine) - - if (eventName === 'message_start' || data?.type === 'message_start') { - id = String(data?.message?.id || id) - messageId = `msg_${id}` - if (data?.message?.usage && typeof data.message.usage === 'object') { - usage = { ...(usage || {}), ...data.message.usage } - } - } - - if (eventName === 'message_delta' || data?.type === 'message_delta') { - if (data?.usage && typeof data.usage === 'object') { - usage = { ...(usage || {}), ...data.usage } - } - } - - if (eventName === 'content_block_start' || data?.type === 'content_block_start') { - const contentBlock = data?.content_block || {} - if (contentBlock.type === 'tool_use') { - yield* ensureTool(Number(data.index || 0), String(contentBlock.id || ''), String(contentBlock.name || 'tool')) - } - } - - if (eventName === 'content_block_delta' || data?.type === 'content_block_delta') { - const delta = data?.delta || {} - if (delta.type === 'thinking_delta' && delta.thinking) { - const textDelta = String(delta.thinking) - reasoning += textDelta - yield { - type: 'response.reasoning.delta', - data: { - type: 'response.reasoning.delta', - item_id: messageId, - output_index: Number(data.index || 0), - delta: textDelta, - }, - } - } - if (delta.type === 'text_delta' && delta.text) { - yield* ensureText() - text += String(delta.text) - yield { - type: 'response.output_text.delta', - data: { - type: 'response.output_text.delta', - item_id: messageId, - output_index: 0, - content_index: 0, - delta: String(delta.text), - }, - } - } - if (delta.type === 'input_json_delta' && delta.partial_json) { - const index = Number(data.index || 0) - const block = yield* ensureTool(index) - const argsDelta = String(delta.partial_json) - block.arguments += argsDelta - yield { - type: 'response.function_call_arguments.delta', - data: { - type: 'response.function_call_arguments.delta', - item_id: block.id, - output_index: textStarted ? index + 1 : index, - delta: argsDelta, - }, - } - } - } - } - } - } - - const output: any[] = [] - if (reasoning) { - output.push({ - type: 'reasoning', - id: `rs_${id}`, - summary: [{ type: 'summary_text', text: reasoning }], - }) - } - if (textStarted) { - const messageItem = { - type: 'message', - id: messageId, - status: 'completed', - role: 'assistant', - content: [{ type: 'output_text', text, annotations: [] }], - } - output.push(messageItem) - yield { - type: 'response.output_text.done', - data: { - type: 'response.output_text.done', - item_id: messageId, - output_index: 0, - content_index: 0, - text, - }, - } - yield { - type: 'response.content_part.done', - data: { - type: 'response.content_part.done', - item_id: messageId, - output_index: 0, - content_index: 0, - part: { type: 'output_text', text, annotations: [] }, - }, - } - yield { - type: 'response.output_item.done', - data: { - type: 'response.output_item.done', - output_index: 0, - item: messageItem, - }, - } - } - for (const [index, block] of toolBlocks.entries()) { - const outputIndex = textStarted ? index + 1 : index - const item = functionCallItem({ id: block.id, name: block.name, arguments: block.arguments || '{}', annotateNamespace: target.annotateMcpToolNamespaces }) - output.push(item) - yield { - type: 'response.output_item.done', - data: { - type: 'response.output_item.done', - output_index: outputIndex, - item, - }, - } - } - yield { - type: 'response.completed', - data: { - type: 'response.completed', - response: { - id, - object: 'response', - status: 'completed', - model: target.model, - output, - ...(usage ? { usage } : {}), - }, - }, - } -} diff --git a/packages/server/src/services/agent-runner/adapters/responses.ts b/packages/server/src/services/agent-runner/adapters/responses.ts deleted file mode 100644 index 81ac681..0000000 --- a/packages/server/src/services/agent-runner/adapters/responses.ts +++ /dev/null @@ -1,584 +0,0 @@ -export interface ResponsesAdapterTarget { - model: string -} - -const HERMES_STUDIO_NAMESPACE = 'mcp__hermes_studio' - -const HERMES_STUDIO_MCP_TOOLS = [ - { - name: 'hermes_studio_lan_devices_list', - description: 'List known LAN and remote devices from Hermes Web UI, including pairing and online status.', - inputSchema: inputSchema(), - }, - { - name: 'hermes_studio_lan_devices_scan', - description: 'Refresh LAN device discovery cache and return known devices with pairing and online status.', - inputSchema: inputSchema(), - }, - { - name: 'hermes_studio_lan_peer_connect', - description: 'Connect to a paired LAN device by device id.', - inputSchema: inputSchema({ device_id: { type: 'string' } }, ['device_id']), - }, - { - name: 'hermes_studio_lan_peer_connections', - description: 'List active LAN peer socket connections.', - inputSchema: inputSchema(), - }, - { - name: 'hermes_studio_lan_peer_disconnect', - description: 'Disconnect an active LAN peer socket connection.', - inputSchema: inputSchema({ connection_id: { type: 'string' } }, ['connection_id']), - }, - { - name: 'hermes_studio_lan_terminal_create', - description: 'Create an interactive terminal on a connected LAN peer.', - inputSchema: inputSchema({ - connection_id: { type: 'string' }, - shell: { type: 'string' }, - cols: { type: 'number' }, - rows: { type: 'number' }, - }, ['connection_id']), - }, - { - name: 'hermes_studio_lan_terminal_list', - description: 'List interactive terminals tracked for a connected LAN peer, including IDs that can be read or closed.', - inputSchema: inputSchema({ connection_id: { type: 'string' } }, ['connection_id']), - }, - { - name: 'hermes_studio_lan_terminal_input', - description: 'Write input to an interactive terminal on a connected LAN peer.', - inputSchema: inputSchema({ - connection_id: { type: 'string' }, - terminal_id: { type: 'string' }, - data: { type: 'string' }, - }, ['connection_id', 'terminal_id', 'data']), - }, - { - name: 'hermes_studio_lan_terminal_read', - description: 'Read buffered terminal output from an interactive terminal.', - inputSchema: inputSchema({ - connection_id: { type: 'string' }, - terminal_id: { type: 'string' }, - }, ['connection_id', 'terminal_id']), - }, - { - name: 'hermes_studio_lan_terminal_resize', - description: 'Resize an interactive terminal on a connected LAN peer.', - inputSchema: inputSchema({ - connection_id: { type: 'string' }, - terminal_id: { type: 'string' }, - cols: { type: 'number' }, - rows: { type: 'number' }, - }, ['connection_id', 'terminal_id', 'cols', 'rows']), - }, - { - name: 'hermes_studio_lan_terminal_close', - description: 'Close an interactive terminal on a connected LAN peer.', - inputSchema: inputSchema({ - connection_id: { type: 'string' }, - terminal_id: { type: 'string' }, - }, ['connection_id', 'terminal_id']), - }, - { - name: 'hermes_studio_lan_command_exec', - description: 'Run a command on a connected LAN peer using command plus args, without shell string execution.', - inputSchema: inputSchema({ - connection_id: { type: 'string' }, - command: { type: 'string' }, - args: { type: 'array', items: { type: 'string' } }, - cwd: { type: 'string' }, - timeout_ms: { type: 'number' }, - }, ['connection_id', 'command']), - }, - { - name: 'hermes_studio_lan_file_download', - description: 'Download a file from a connected LAN peer remote path to a local path on this machine.', - inputSchema: inputSchema({ - connection_id: { type: 'string' }, - remote_path: { type: 'string' }, - local_path: { type: 'string' }, - timeout_ms: { type: 'number' }, - }, ['connection_id', 'remote_path', 'local_path']), - }, - { - name: 'hermes_studio_lan_file_upload', - description: 'Upload a local file path from this machine to a connected LAN peer remote path.', - inputSchema: inputSchema({ - connection_id: { type: 'string' }, - local_path: { type: 'string' }, - remote_path: { type: 'string' }, - timeout_ms: { type: 'number' }, - }, ['connection_id', 'local_path', 'remote_path']), - }, -] - -const HERMES_STUDIO_MCP_TOOL_NAMES = new Set(HERMES_STUDIO_MCP_TOOLS.map(tool => tool.name)) - -function inputSchema(properties: Record = {}, required: string[] = []) { - return { - type: 'object', - properties: { - token: { - type: 'string', - description: 'Optional Hermes Web UI bearer token. Usually omit this and pass profile so the MCP server can read the temporary profile token.', - }, - profile: { - type: 'string', - description: 'Hermes profile name for profile-scoped Web UI requests and temporary profile token lookup.', - }, - ...properties, - }, - ...(required.length ? { required } : {}), - additionalProperties: false, - } -} - -function normalizedNamespaceName(value: unknown): string { - return String(value || '').trim().replace(/-/g, '_') -} - -function expandedResponseTools(tools: unknown): any[] { - if (!Array.isArray(tools)) return [] - const mapped: any[] = [] - const seen = new Set() - const addFunctionTool = (tool: any) => { - const name = String(tool?.name || '').trim() - if (!name || seen.has(name)) return - seen.add(name) - mapped.push({ - type: 'function', - name, - description: String(tool?.description || ''), - parameters: tool?.parameters || tool?.inputSchema || { type: 'object', properties: {} }, - ...(tool?.namespace ? { namespace: tool.namespace } : {}), - }) - } - for (const tool of tools) { - if (tool?.type === 'function') { - addFunctionTool(tool) - continue - } - if (tool?.type === 'namespace' && normalizedNamespaceName(tool?.name) === HERMES_STUDIO_NAMESPACE) { - for (const mcpTool of HERMES_STUDIO_MCP_TOOLS) { - addFunctionTool({ - type: 'function', - name: mcpTool.name, - description: `${mcpTool.description} MCP namespace: ${HERMES_STUDIO_NAMESPACE}.`, - parameters: mcpTool.inputSchema, - namespace: HERMES_STUDIO_NAMESPACE, - }) - } - continue - } - if (tool?.type === 'namespace') { - const namespace = normalizedNamespaceName(tool?.name) - if (namespace.startsWith('mcp__')) { - addFunctionTool({ - type: 'function', - name: namespace, - description: `${String(tool?.description || `Tools in the ${namespace} MCP namespace.`)} Call a tool in this MCP namespace by passing the tool name and its JSON arguments.`, - parameters: { - type: 'object', - properties: { - tool: { - type: 'string', - description: 'Name of the MCP tool to call inside this namespace.', - }, - arguments: { - type: 'object', - description: 'JSON arguments for the MCP tool.', - additionalProperties: true, - }, - }, - required: ['tool', 'arguments'], - additionalProperties: false, - }, - namespace, - }) - } - } - } - return mapped -} - -export function responseToolNamespaceForName(name: unknown): string | undefined { - return HERMES_STUDIO_MCP_TOOL_NAMES.has(String(name || '')) ? HERMES_STUDIO_NAMESPACE : undefined -} - -export function normalizeResponseFunctionCall(name: unknown, argumentsValue: unknown): { name: string; arguments: string; namespace?: string } { - const rawName = String(name || 'tool') - const rawArguments = String(argumentsValue || '{}') - const namespace = normalizedNamespaceName(rawName) - if (namespace.startsWith('mcp__')) { - const parsed = safeJsonParse(rawArguments) - const toolName = String(parsed?.tool || parsed?.name || '').trim() - if (toolName) { - const toolArguments = parsed?.arguments && typeof parsed.arguments === 'object' - ? parsed.arguments - : parsed?.input && typeof parsed.input === 'object' - ? parsed.input - : {} - return { - name: toolName, - arguments: JSON.stringify(toolArguments), - namespace, - } - } - } - - const knownNamespace = responseToolNamespaceForName(rawName) - return { - name: rawName, - arguments: rawArguments, - ...(knownNamespace ? { namespace: knownNamespace } : {}), - } -} - -export function targetReasoningEffort(target: any): string { - const effort = String(target?.reasoningEffort || '').trim() - return ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'].includes(effort) ? effort : '' -} - -function stringifyContent(value: unknown): string { - if (typeof value === 'string') return value - if (Array.isArray(value)) { - return value.map((item) => { - if (typeof item === 'string') return item - if (item && typeof item === 'object') { - const block = item as any - if (typeof block.text === 'string') return block.text - if (typeof block.output === 'string') return block.output - } - return JSON.stringify(item) - }).filter(Boolean).join('\n') - } - if (value == null) return '' - return JSON.stringify(value) -} - -function safeJsonParse(value: string): any { - try { - return JSON.parse(value) - } catch { - return {} - } -} -function responseContentToText(content: unknown): string { - if (typeof content === 'string') return content - if (!Array.isArray(content)) return stringifyContent(content) - return content.map((part: any) => { - if (typeof part === 'string') return part - if (part?.type === 'input_text' || part?.type === 'output_text' || part?.type === 'text') { - return String(part.text || '') - } - return stringifyContent(part) - }).filter(Boolean).join('\n') -} - -function chatRoleForResponsesRole(role: unknown): string { - const value = String(role || '').trim() - if (value === 'developer') return 'system' - if (value === 'system' || value === 'user' || value === 'assistant' || value === 'tool') return value - return 'user' -} - -function responsesInputToChatMessages(body: any): any[] { - const messages: any[] = [] - if (body?.instructions) { - messages.push({ role: 'system', content: stringifyContent(body.instructions) }) - } - - const input = body?.input - if (typeof input === 'string') { - messages.push({ role: 'user', content: input }) - return messages - } - - let pendingToolCalls: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }> = [] - let pendingToolOutputs = new Map() - const flushCompletedToolCalls = () => { - if (!pendingToolCalls.length) return - const outputs = pendingToolCalls.map(call => pendingToolOutputs.get(call.id)) - if (outputs.every(Boolean)) { - messages.push({ - role: 'assistant', - content: null, - tool_calls: pendingToolCalls, - }) - for (let index = 0; index < pendingToolCalls.length; index += 1) { - messages.push({ - role: 'tool', - tool_call_id: pendingToolCalls[index].id, - content: stringifyContent(outputs[index].output), - }) - } - } - pendingToolCalls = [] - pendingToolOutputs = new Map() - } - - for (const item of Array.isArray(input) ? input : []) { - if (!item || typeof item !== 'object') continue - if (item.type === 'function_call') { - const callId = String(item.call_id || item.id || `call_${messages.length}`) - pendingToolCalls.push({ - id: callId, - type: 'function', - function: { - name: String(item.name || 'tool'), - arguments: String(item.arguments || '{}'), - }, - }) - continue - } - if (item.type === 'function_call_output') { - const callId = String(item.call_id || '') - if (pendingToolCalls.some(call => call.id === callId)) { - pendingToolOutputs.set(callId, item) - if (pendingToolCalls.every(call => pendingToolOutputs.has(call.id))) { - flushCompletedToolCalls() - } - } - continue - } - flushCompletedToolCalls() - if (item.role) { - messages.push({ - role: chatRoleForResponsesRole(item.role), - content: responseContentToText(item.content), - }) - } - } - flushCompletedToolCalls() - - return messages.length ? messages : [{ role: 'user', content: '' }] -} - -function responsesToolsToChatTools(tools: unknown): any[] | undefined { - const mapped = expandedResponseTools(tools).map((tool: any) => { - if (tool?.type !== 'function') return null - return { - type: 'function', - function: { - name: String(tool.name || ''), - description: String(tool.description || ''), - parameters: tool.parameters || { type: 'object', properties: {} }, - }, - } - }).filter((tool: any) => tool?.function?.name) - return mapped.length ? mapped : undefined -} - -export function responsesToOpenAiChat(body: any, target: ResponsesAdapterTarget, stream = false): any { - const tools = responsesToolsToChatTools(body?.tools) - const reasoningEffort = targetReasoningEffort(target) - return { - model: target.model, - messages: responsesInputToChatMessages(body), - ...(typeof body?.max_output_tokens === 'number' ? { max_tokens: body.max_output_tokens } : {}), - ...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}), - ...(typeof body?.top_p === 'number' ? { top_p: body.top_p } : {}), - ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), - ...(tools?.length ? { tools } : {}), - stream, - } -} - -function responsesRoleToAnthropicRole(role: unknown): 'user' | 'assistant' { - return String(role || '') === 'assistant' ? 'assistant' : 'user' -} - -function responsesContentToAnthropicContent(content: unknown, role: 'user' | 'assistant'): any[] { - const parts = Array.isArray(content) ? content : [{ type: role === 'assistant' ? 'output_text' : 'input_text', text: stringifyContent(content) }] - const mapped = parts.map((part: any) => { - if (typeof part === 'string') return { type: 'text', text: part } - if (part?.type === 'input_text' || part?.type === 'output_text' || part?.type === 'text') { - return { type: 'text', text: String(part.text || '') } - } - return null - }).filter(Boolean) - return mapped.length ? mapped : [{ type: 'text', text: '' }] -} - -function responsesInputToAnthropicMessages(body: any): any[] { - const messages: any[] = [] - const input = body?.input - if (typeof input === 'string') return [{ role: 'user', content: [{ type: 'text', text: input }] }] - - for (const item of Array.isArray(input) ? input : []) { - if (!item || typeof item !== 'object') continue - if (item.type === 'function_call') { - messages.push({ - role: 'assistant', - content: [{ - type: 'tool_use', - id: String(item.call_id || item.id || `toolu_${messages.length}`), - name: String(item.name || 'tool'), - input: safeJsonParse(String(item.arguments || '{}')), - }], - }) - continue - } - if (item.type === 'function_call_output') { - messages.push({ - role: 'user', - content: [{ - type: 'tool_result', - tool_use_id: String(item.call_id || ''), - content: stringifyContent(item.output), - }], - }) - continue - } - if (item.role) { - const role = responsesRoleToAnthropicRole(item.role) - messages.push({ - role, - content: responsesContentToAnthropicContent(item.content, role), - }) - } - } - - return messages.length ? messages : [{ role: 'user', content: [{ type: 'text', text: '' }] }] -} - -function responsesToolsToAnthropicTools(tools: unknown): any[] | undefined { - const mapped = expandedResponseTools(tools).map((tool: any) => { - if (tool?.type !== 'function') return null - return { - name: String(tool.name || ''), - description: String(tool.description || ''), - input_schema: tool.parameters || { type: 'object', properties: {} }, - } - }).filter((tool: any) => tool?.name) - return mapped.length ? mapped : undefined -} - -export function responsesToAnthropicMessages(body: any, target: ResponsesAdapterTarget, stream = false): any { - const tools = responsesToolsToAnthropicTools(body?.tools) - const reasoningEffort = targetReasoningEffort(target) - return { - model: target.model, - messages: responsesInputToAnthropicMessages(body), - ...(body?.instructions ? { system: stringifyContent(body.instructions) } : {}), - ...(typeof body?.max_output_tokens === 'number' ? { max_tokens: body.max_output_tokens } : { max_tokens: 4096 }), - ...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}), - ...(typeof body?.top_p === 'number' ? { top_p: body.top_p } : {}), - ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), - ...(tools?.length ? { tools } : {}), - stream, - } -} - -function responseId(data: any): string { - return String(data?.id || `resp_${Date.now()}`) -} - -function usageFromChat(data: any) { - return { - input_tokens: Number(data?.usage?.prompt_tokens || 0), - output_tokens: Number(data?.usage?.completion_tokens || 0), - total_tokens: Number(data?.usage?.total_tokens || 0), - } -} - -function usageFromAnthropic(data: any) { - const inputTokens = Number(data?.usage?.input_tokens || 0) - const outputTokens = Number(data?.usage?.output_tokens || 0) - return { - input_tokens: inputTokens, - output_tokens: outputTokens, - total_tokens: inputTokens + outputTokens, - } -} - -export function openAiChatToResponses(data: any, target: ResponsesAdapterTarget): any { - const choice = data?.choices?.[0] || {} - const message = choice.message || {} - const output: any[] = [] - - if (message.reasoning_content) { - output.push({ - type: 'reasoning', - id: `rs_${responseId(data)}`, - summary: [{ type: 'summary_text', text: String(message.reasoning_content) }], - }) - } - - if (message.content) { - output.push({ - type: 'message', - id: `msg_${responseId(data)}`, - status: 'completed', - role: 'assistant', - content: [{ type: 'output_text', text: String(message.content), annotations: [] }], - }) - } - - for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) { - const normalizedCall = normalizeResponseFunctionCall(call.function?.name || 'tool', call.function?.arguments || '{}') - output.push({ - type: 'function_call', - id: String(call.id || `fc_${output.length}`), - call_id: String(call.id || `call_${output.length}`), - ...normalizedCall, - }) - } - - return { - id: responseId(data), - object: 'response', - created_at: Number(data?.created || Math.floor(Date.now() / 1000)), - status: 'completed', - model: target.model, - output, - usage: usageFromChat(data), - } -} - -export function anthropicMessageToResponses(data: any, target: ResponsesAdapterTarget): any { - const output: any[] = [] - const textParts: string[] = [] - const reasoningParts: string[] = [] - for (const block of Array.isArray(data?.content) ? data.content : []) { - if (block?.type === 'text' && block.text) textParts.push(String(block.text)) - if (block?.type === 'thinking' && block.thinking) reasoningParts.push(String(block.thinking)) - if (block?.type === 'redacted_thinking') reasoningParts.push('[redacted thinking]') - if (block?.type === 'tool_use') { - const normalizedCall = normalizeResponseFunctionCall(block.name || 'tool', JSON.stringify(block.input || {})) - output.push({ - type: 'function_call', - id: String(block.id || `fc_${output.length}`), - call_id: String(block.id || `call_${output.length}`), - ...normalizedCall, - }) - } - } - if (textParts.length) { - output.unshift({ - type: 'message', - id: `msg_${responseId(data)}`, - status: 'completed', - role: 'assistant', - content: [{ type: 'output_text', text: textParts.join('\n'), annotations: [] }], - }) - } - if (reasoningParts.length) { - output.unshift({ - type: 'reasoning', - id: `rs_${responseId(data)}`, - summary: [{ type: 'summary_text', text: reasoningParts.join('\n') }], - }) - } - - return { - id: responseId(data), - object: 'response', - created_at: Math.floor(Date.now() / 1000), - status: 'completed', - model: target.model, - output, - usage: usageFromAnthropic(data), - } -} diff --git a/packages/server/src/services/agent-runner/coding-agent-event-mapper.ts b/packages/server/src/services/agent-runner/coding-agent-event-mapper.ts deleted file mode 100644 index aa06270..0000000 --- a/packages/server/src/services/agent-runner/coding-agent-event-mapper.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { CanonicalResponsesEvent } from './adapters/responses-stream' - -export interface CodingAgentMappedEvent { - event: string - payload: Record -} - -function responseIdFrom(data: any): string | undefined { - return data?.response?.id || data?.id || data?.item_id || undefined -} - -function reasoningText(data: any): string { - if (typeof data?.delta === 'string') return data.delta - if (typeof data?.text === 'string') return data.text - if (typeof data?.summary === 'string') return data.summary - if (typeof data?.reasoning === 'string') return data.reasoning - return '' -} - -export function mapCodingAgentResponseEvent(event: CanonicalResponsesEvent): CodingAgentMappedEvent[] { - const data: any = event.data || {} - const runId = responseIdFrom(data) - const mapped: CodingAgentMappedEvent[] = [] - - if ( - event.type === 'response.reasoning_text.delta' || - event.type === 'response.reasoning.delta' || - event.type === 'response.reasoning_summary_text.delta' - ) { - const text = reasoningText(data) - if (text) { - mapped.push({ - event: 'reasoning.delta', - payload: { - event: 'reasoning.delta', - run_id: runId, - response_id: runId, - delta: text, - }, - }) - } - } - - return mapped -} diff --git a/packages/server/src/services/agent-runner/coding-agent-run-manager.ts b/packages/server/src/services/agent-runner/coding-agent-run-manager.ts deleted file mode 100644 index 52e45f4..0000000 --- a/packages/server/src/services/agent-runner/coding-agent-run-manager.ts +++ /dev/null @@ -1,1974 +0,0 @@ -import { dirname, join } from 'path' -import { existsSync, accessSync, chmodSync, constants as fsConstants, readFileSync } from 'fs' -import { homedir } from 'os' -import { spawn, type ChildProcess } from 'child_process' -import { createSession, addMessage, getSession, updateSession, updateSessionStats } from '../../db/hermes/session-store' -import type { ApiMode } from './types' -import { logger } from '../logger' -import { normalizeTokenUsage, recordSessionUsage } from '../usage-recorder' -import { applyResponseStreamEvent, flushResponseRunToDb } from '../hermes/run-chat/response-stream' -import { calcAndUpdateUsage, estimateUsageTokensFromMessages, updateContextTokenUsage } from '../hermes/run-chat/usage' -import { extractResponseText } from '../hermes/run-chat/response-utils' -import type { SessionState } from '../hermes/run-chat/types' -import type { CanonicalResponsesEvent } from './adapters/responses-stream' -import { mapCodingAgentResponseEvent } from './coding-agent-event-mapper' -import { normalizeWindowsCommandPath, windowsCmdShimExecution, windowsCommandNeedsShell } from '../windows-command' -import { completeWorkspaceRunCheckpoint, startWorkspaceRunCheckpoint } from '../hermes/run-chat/workspace-diff-tracker' -import { buildDbHistory, buildSnapshotAwareHistory } from '../hermes/run-chat/compression' - -const DEFAULT_IDLE_MS = 30 * 60 * 1000 -const TERMINAL_OUTPUT_FLUSH_MS = 120 -const MAX_TERMINAL_EVENT_CHARS = 4000 -const CHILD_STDERR_TAIL_CHARS = 8 * 1024 -const CODING_AGENT_TOOL_OUTPUT_STORAGE_LIMIT = 32 * 1024 -const CODING_AGENT_TOOL_OUTPUT_HEAD_CHARS = 24 * 1024 -const CODING_AGENT_TOOL_OUTPUT_TAIL_CHARS = 8 * 1024 -const CODEX_REASONING_SUMMARY_ARGS = ['-c', 'model_reasoning_summary="auto"'] -const HERMES_MCP_SERVER_NAME = 'hermes-studio' - -let pty: any = null - -function ensureNodePtySpawnHelperExecutable() { - if (process.platform !== 'darwin') return - try { - const nodePtyRoot = dirname(require.resolve('node-pty/package.json')) - const candidates = [ - join(nodePtyRoot, 'build', 'Release', 'spawn-helper'), - join(nodePtyRoot, 'build', 'Debug', 'spawn-helper'), - join(nodePtyRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'), - ] - for (const helperPath of candidates) { - if (!existsSync(helperPath)) continue - try { - accessSync(helperPath, fsConstants.X_OK) - } catch { - chmodSync(helperPath, 0o755) - } - } - } catch (err) { - logger.warn(err, '[coding-agent-run] failed to normalize node-pty helper permissions') - } -} - -try { - ensureNodePtySpawnHelperExecutable() - // eslint-disable-next-line @typescript-eslint/no-require-imports - pty = require('node-pty') -} catch (err) { - logger.warn(err, '[coding-agent-run] node-pty unavailable; hidden coding agent sessions disabled') -} - -export interface CodingAgentRunLaunch { - agentSessionId: string - agentId: string - mode: 'scoped' | 'global' - profile: string - provider: string - model: string - apiMode?: ApiMode - sessionId: string - agentNativeSessionId?: string - nativeResume?: boolean - command: string - args: string[] - shellCommand: string - workspaceDir: string - env?: NodeJS.ProcessEnv - state?: SessionState - sessionSource?: 'global_agent' | 'workflow' - reasoningEffort?: string -} - -interface ManagedCodingAgentRun { - id: string - launch: CodingAgentRunLaunch - pty?: { pid: number; write: (data: string) => void; kill: (signal?: string) => void; onData: (cb: (data: string) => void) => void; onExit: (cb: (event: { exitCode: number }) => void) => void } - state: SessionState - runMarker?: string - lastActiveAt: number - idleTimer?: ReturnType - terminalBuffer?: string - terminalFlushTimer?: ReturnType - apiKeyPromptAnswered?: boolean - startedAt: number - exited: boolean - currentChild?: ChildProcess - currentChildKillTimer?: ReturnType - currentChildStderr?: string - printResponseId?: string - printMessageId?: string - printTextStarted?: boolean - printText?: string - printCompleted?: boolean - responseStartEmitted?: boolean - terminalEventHandled?: boolean - acceptingPrintEvent?: boolean - printToolBlocks?: Map - nativeResumeReady?: boolean - codexToolBlocks?: Map - codexChatText?: string - codexPendingUsage?: any - terminalUsageRefresh?: Promise - stoppedByUser?: boolean - pendingChatCompletionEvent?: 'run.completed' | 'run.failed' - pendingChatCompletionPayload?: Record - memoryExportStarted?: boolean -} - -interface CodingAgentRunSendOptions { - systemPrompt?: string -} - -function nowSeconds(): number { - return Math.floor(Date.now() / 1000) -} - -function makeId(): string { - return `car_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` -} - -export function codingAgentGatewayErrorMessage(text: string): string | null { - const value = String(text || '').trim() - if (!value) return null - if (/^API Error:\s*\d+\b/i.test(value)) return value - if (/^Provider returned HTTP\s+\d+\b/i.test(value)) return value - return null -} - -function responseErrorMessage(error: unknown): string { - if (!error) return '' - if (typeof error === 'string') return error - if (typeof error === 'object') { - const record = error as Record - const message = record.message || record.error || record.detail - if (typeof message === 'string') return message - } - return String(error) -} - -function isProxyToolEvent(event: CanonicalResponsesEvent): boolean { - const data: any = event.data || {} - const item = data.item || data.output_item || data - return event.type === 'response.function_call_arguments.delta' || - ((event.type === 'response.output_item.added' || event.type === 'response.output_item.done') && item?.type === 'function_call') -} - -function isCodexProxyExecToolEvent(event: CanonicalResponsesEvent): boolean { - const data: any = event.data || {} - const item = data.item || data.output_item || data - if ( - (event.type !== 'response.output_item.added' && event.type !== 'response.output_item.done') || - item?.type !== 'function_call' - ) { - return false - } - const name = String(item.name || item.function?.name || '').trim() - return name === 'exec_command' || name === 'functions.exec_command' -} - -function truncateCodingAgentToolOutputForStorage(output: unknown): string { - const text = typeof output === 'string' ? output : JSON.stringify(output ?? '') - if (text.length <= CODING_AGENT_TOOL_OUTPUT_STORAGE_LIMIT) return text - const head = text.slice(0, CODING_AGENT_TOOL_OUTPUT_HEAD_CHARS) - const tail = text.slice(-CODING_AGENT_TOOL_OUTPUT_TAIL_CHARS) - const omitted = text.length - head.length - tail.length - return [ - head, - '', - `[Hermes Web UI: coding-agent tool output truncated for storage; original_chars=${text.length}; omitted_chars=${omitted}]`, - '', - tail, - ].join('\n') -} - -function truncateCodingAgentToolOutputItem(item: any): any { - if (!item || item.type !== 'function_call_output') return item - const nextOutput = truncateCodingAgentToolOutputForStorage(item.output) - if (nextOutput === item.output) return item - return { ...item, output: nextOutput } -} - -function truncateCodingAgentToolOutputEvent(event: CanonicalResponsesEvent): CanonicalResponsesEvent { - const data: any = event.data || {} - if (event.type === 'response.output_item.done') { - const item = data.item || data.output_item || data - const nextItem = truncateCodingAgentToolOutputItem(item) - if (nextItem === item) return event - if (data.item) return { ...event, data: { ...data, item: nextItem } } - if (data.output_item) return { ...event, data: { ...data, output_item: nextItem } } - return { ...event, data: nextItem } - } - - if (event.type === 'response.completed') { - const response = data.response || data - const output = Array.isArray(response?.output) ? response.output : null - if (!output) return event - let changed = false - const nextOutput = output.map((item: any) => { - const nextItem = truncateCodingAgentToolOutputItem(item) - if (nextItem !== item) changed = true - return nextItem - }) - if (!changed) return event - const nextResponse = { ...response, output: nextOutput } - return data.response - ? { ...event, data: { ...data, response: nextResponse } } - : { ...event, data: nextResponse } - } - - return event -} - -function isPrintAgent(agentId: string): boolean { - return agentId === 'claude-code' || agentId === 'codex' -} - -function hasManagedHermesMcpConfig(run: ManagedCodingAgentRun): boolean { - if (run.launch.agentId !== 'codex' || run.launch.mode !== 'scoped') return true - const codexHome = String(run.launch.env?.CODEX_HOME || '').trim() - if (!codexHome) return false - try { - const config = readFileSync(join(codexHome, 'config.toml'), 'utf-8') - return config.includes(`[mcp_servers.${HERMES_MCP_SERVER_NAME}]`) - } catch { - return false - } -} - -function childIsRunning(child?: ChildProcess): boolean { - return Boolean(child && child.exitCode == null && child.signalCode == null && !child.killed) -} - -function normalizeCliPromptArgument(prompt: string): string { - const text = String(prompt || '').trim() - if (!text || process.platform !== 'win32') return text - return text - .split(/\r\n|\n|\r/) - .map(line => line.trim()) - .filter(Boolean) - .join(' / ') -} - -function hasArg(args: string[], name: string): boolean { - return args.includes(name) -} - -function decodeChildChunk(chunk: Buffer): string { - const utf8 = chunk.toString('utf8') - if (process.platform !== 'win32' || !utf8.includes('\uFFFD')) return utf8 - try { - return new TextDecoder('gb18030').decode(chunk) - } catch { - return utf8 - } -} - -function spawnCodingAgentChild(command: string, args: string[], options: { - cwd: string - env: NodeJS.ProcessEnv -}): ChildProcess { - const normalizedCommand = process.platform === 'win32' ? normalizeWindowsCommandPath(command) : command - if (process.platform === 'win32' && windowsCommandNeedsShell(command)) { - const execution = windowsCmdShimExecution(normalizedCommand, args) - return spawn(execution.command, execution.args, { - cwd: options.cwd, - env: options.env, - stdio: ['ignore', 'pipe', 'pipe'], - windowsVerbatimArguments: execution.windowsVerbatimArguments, - windowsHide: true, - }) - } - - return spawn(normalizedCommand, args, { - cwd: options.cwd, - env: options.env, - stdio: ['ignore', 'pipe', 'pipe'], - detached: process.platform !== 'win32', - windowsHide: process.platform === 'win32', - }) -} - -function childProcessErrorMessage(err: unknown): string { - if (err instanceof Error) return err.message - if (!err || typeof err !== 'object') return String(err || 'Process failed') - const record = err as Record - const message = record.message - if (typeof message === 'string' && message.trim()) return message - try { - return JSON.stringify(record) - } catch { - return String(err) - } -} - -function appendChildStderr(run: ManagedCodingAgentRun, chunk: Buffer): string { - const text = sanitizeCodingAgentTerminalOutput(decodeChildChunk(chunk)) - run.currentChildStderr = `${run.currentChildStderr || ''}${text}`.slice(-CHILD_STDERR_TAIL_CHARS) - return text.trim() -} - -function exitErrorMessage(agentName: string, code: number | null, stderr?: string): string { - const message = `${agentName} exited with code ${code ?? 'unknown'}` - const detail = String(stderr || '').trim() - return detail ? `${message}: ${detail}` : message -} - -function appendedTextDelta(existing: string, next: string): string { - if (!existing || !next) return next - if (next.startsWith(existing)) return next.slice(existing.length) - const max = Math.min(existing.length, next.length) - for (let length = max; length >= 16; length--) { - if (existing.endsWith(next.slice(0, length))) return next.slice(length) - } - return next -} - -function terminateChildProcess(child?: ChildProcess) { - if (!child || !child.pid || !childIsRunning(child)) return - if (process.platform === 'win32') { - spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }).on('error', () => {}) - return - } - try { - process.kill(-child.pid, 'SIGINT') - } catch { - try { process.kill(child.pid, 'SIGINT') } catch {} - } -} - -function forceKillChildProcess(child?: ChildProcess) { - if (!child || !child.pid || !childIsRunning(child)) return - if (process.platform === 'win32') { - spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }).on('error', () => {}) - return - } - try { - process.kill(-child.pid, 'SIGKILL') - } catch { - try { process.kill(child.pid, 'SIGKILL') } catch {} - } -} - -function claudeContentToText(content: unknown): string { - if (typeof content === 'string') return content - if (!Array.isArray(content)) { - if (content == null) return '' - try { - return JSON.stringify(content) - } catch { - return String(content) - } - } - return content.map((block) => { - if (typeof block === 'string') return block - if (!block || typeof block !== 'object') return '' - const record = block as Record - if (typeof record.text === 'string') return record.text - if (typeof record.content === 'string') return record.content - try { - return JSON.stringify(record) - } catch { - return String(record) - } - }).filter(Boolean).join('\n') -} - -function defaultShell(): string { - if (process.platform === 'win32') return 'powershell.exe' - const shell = process.env.SHELL || '' - if (shell && existsSync(shell)) return shell - if (existsSync('/bin/zsh')) return '/bin/zsh' - if (existsSync('/bin/bash')) return '/bin/bash' - return '/bin/sh' -} - -export function sanitizeCodingAgentTerminalOutput(value: string): string { - return String(value || '') - .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g, '') - .replace(/\r\n/g, '\n') - .replace(/\r/g, '\n') - .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]') - .replace(/\b(?:sk|sk-ant|sk-proj|sk-or)-[A-Za-z0-9._-]{8,}\b/g, '[redacted-api-key]') - .replace(/(api[_-]?key["'\s:=]+)[A-Za-z0-9._~+/=-]{8,}/gi, '$1[redacted]') -} - -export class CodingAgentRunManager { - private runs = new Map() - private sessionIndex = new Map() - - constructor(private readonly idleMs = DEFAULT_IDLE_MS) {} - - isAvailable(): boolean { - return !!pty - } - - hasSession(sessionId: string): boolean { - const run = this.getBySession(sessionId) - return Boolean(run && !run.exited) - } - - isSessionProcessing(sessionId: string): boolean { - const run = this.getBySession(sessionId) - return childIsRunning(run?.currentChild) - } - - runIdForSession(sessionId: string): string | undefined { - const run = this.getBySession(sessionId) - return run && !run.exited ? run.id : undefined - } - - isSessionLaunchCompatible(sessionId: string, launch: { - agentId: string - mode?: 'scoped' | 'global' - provider?: string - model?: string - reasoningEffort?: string - apiMode?: ApiMode - }): boolean { - const run = this.getBySession(sessionId) - if (!run || run.exited) return false - const mode = launch.mode === 'global' ? 'global' : 'scoped' - if (run.launch.agentId !== launch.agentId) return false - if (run.launch.mode !== mode) return false - if (mode === 'scoped') { - const provider = String(launch.provider || '').trim() - const model = String(launch.model || '').trim() - if (provider && run.launch.provider !== provider) return false - if (model && run.launch.model !== model) return false - const apiMode = String(launch.apiMode || '').trim() - if (apiMode && String(run.launch.apiMode || '').trim() !== apiMode) return false - } - if (String(run.launch.reasoningEffort || '').trim() !== String(launch.reasoningEffort || '').trim()) return false - if (!hasManagedHermesMcpConfig(run)) return false - return true - } - - start(launch: CodingAgentRunLaunch): { runId: string; pid: number } { - const existingRunId = this.sessionIndex.get(launch.sessionId) - if (existingRunId) { - const existing = this.runs.get(existingRunId) - if (existing && !existing.exited) return { runId: existing.id, pid: existing.pty?.pid || existing.currentChild?.pid || 0 } - } - - const runId = launch.agentSessionId || makeId() - const state = launch.state || { messages: [], isWorking: false, events: [], queue: [] } - state.isWorking = true - state.profile = launch.profile - state.source = launch.sessionSource === 'workflow' ? 'workflow' : 'coding_agent' - state.runId = runId - - if (isPrintAgent(launch.agentId)) { - const run: ManagedCodingAgentRun = { - id: runId, - launch, - state, - lastActiveAt: Date.now(), - startedAt: Date.now(), - exited: false, - nativeResumeReady: launch.nativeResume === true, - } - this.runs.set(run.id, run) - this.sessionIndex.set(launch.sessionId, run.id) - this.ensureDbSession(run) - this.touch(run) - this.emitTerminalStatus(run, `${launch.agentId === 'codex' ? 'Codex' : 'Claude Code'} chat runner ready.`) - logger.info({ - runId: run.id, - sessionId: launch.sessionId, - agentId: launch.agentId, - mode: launch.mode, - profile: launch.profile, - provider: launch.provider, - model: launch.model, - }, '[coding-agent-run] print runner started') - return { runId: run.id, pid: 0 } - } - - if (!pty) throw new Error('Hidden coding agent terminal is unavailable because node-pty is not installed') - - const shell = defaultShell() - const args = process.platform === 'win32' - ? ['-NoExit', '-Command', launch.shellCommand] - : ['-lc', launch.shellCommand] - const proc = pty.spawn(shell, args, { - name: 'xterm-color', - cols: 120, - rows: 30, - cwd: existsSync(launch.workspaceDir) ? launch.workspaceDir : homedir(), - env: { - ...process.env, - ...(launch.env || {}), - }, - }) - - const run: ManagedCodingAgentRun = { - id: runId, - launch, - pty: proc, - state, - lastActiveAt: Date.now(), - startedAt: Date.now(), - exited: false, - } - - this.runs.set(run.id, run) - this.sessionIndex.set(launch.sessionId, run.id) - this.ensureDbSession(run) - this.touch(run) - this.emitTerminalStatus(run, `${launch.agentId === 'codex' ? 'Codex' : 'Claude Code'} session started.`) - - proc.onData((data: string) => { - this.touch(run) - logger.debug({ runId: run.id, bytes: Buffer.byteLength(data || '', 'utf8') }, '[coding-agent-run] pty output') - this.maybeAnswerClaudeApiKeyPrompt(run, data) - this.bufferTerminalOutput(run, data) - }) - proc.onExit(({ exitCode }: { exitCode: number }) => { - run.exited = true - this.cleanupRun(run, { kill: false }) - logger.info({ runId: run.id, sessionId: launch.sessionId, exitCode }, '[coding-agent-run] process exited') - }) - - logger.info({ - runId: run.id, - sessionId: launch.sessionId, - agentId: launch.agentId, - mode: launch.mode, - profile: launch.profile, - provider: launch.provider, - model: launch.model, - pid: proc.pid, - }, '[coding-agent-run] hidden session started') - - return { runId: run.id, pid: proc.pid } - } - - send(sessionId: string, input: string, options: CodingAgentRunSendOptions = {}): { runId: string } { - const run = this.getBySession(sessionId) - if (!run) throw new Error('Coding agent session not found') - const text = String(input || '').trim() - if (!text) throw new Error('Input is required') - const systemPrompt = String(options.systemPrompt || '').trim() - this.ensureDbSession(run) - this.addUserMessage(run, text) - this.touch(run) - this.emitTerminalStatus(run, 'Input sent to coding agent.') - this.startWorkspaceRunDiff(run) - if (run.launch.agentId === 'claude-code') { - this.startClaudePrintTurn(run, text, systemPrompt) - return { runId: run.id } - } - if (run.launch.agentId === 'codex') { - this.startCodexExecTurn(run, text, systemPrompt) - return { runId: run.id } - } - if (!run.pty) throw new Error('Coding agent terminal is not available') - run.pty.write(`${text}\r`) - return { runId: run.id } - } - - stop(sessionId: string, options: { reportClosed?: boolean } = {}): boolean { - const run = this.getBySession(sessionId) - if (!run) return false - if (options.reportClosed === false) run.stoppedByUser = true - this.cleanupRun(run, { kill: true, reportClosed: options.reportClosed ?? true }) - return true - } - - touchByAgentSession(agentSessionId?: string | null) { - if (!agentSessionId) return - const run = this.runs.get(agentSessionId) - if (run) this.touch(run) - } - - handleProxyUsageEvent(agentSessionId: string | undefined, event: CanonicalResponsesEvent) { - if (!agentSessionId || event.type !== 'response.completed') return - const run = this.runs.get(agentSessionId) - if (!run || run.launch.mode !== 'scoped') return - const final = (event.data as any).response || event.data - if (!final?.usage) return - const usage = normalizeTokenUsage(final.usage, {}, { - inputIncludesCache: run.launch.apiMode !== 'anthropic_messages', - }) - if (usage.isEstimated) { - logger.warn({ - runId: run.id, - sessionId: run.launch.sessionId, - responseId: final?.id, - provider: run.launch.provider, - model: final?.model || run.launch.model, - }, '[coding-agent-run] scoped proxy response omitted token usage') - return - } - recordSessionUsage({ - sessionId: run.launch.sessionId, - runId: final?.id, - source: 'coding_agent', - agent: run.launch.agentId === 'codex' ? 'codex' : 'claude_code', - usageScope: 'model_call', - apiCalls: 1, - usage, - profile: run.launch.profile, - model: final?.model || run.launch.model, - provider: run.launch.provider, - isEstimated: false, - }) - } - - handleResponseEvent(agentSessionId: string | undefined, event: CanonicalResponsesEvent) { - if (!agentSessionId) return - const run = this.runs.get(agentSessionId) - if (!run) return - if (run.launch.agentId === 'codex' && isCodexProxyExecToolEvent(event)) return - const responseEvent = this.normalizeCodexChatTextEvent(run, event) - if (!responseEvent) return - const storageSafeResponseEvent = truncateCodingAgentToolOutputEvent(responseEvent) - if (run.launch.agentId === 'claude-code' && run.currentChild && !run.acceptingPrintEvent && !isProxyToolEvent(event)) return - if (storageSafeResponseEvent.type === 'response.created') { - if (run.responseStartEmitted) return - run.responseStartEmitted = true - } - const isTerminalEvent = storageSafeResponseEvent.type === 'response.completed' || storageSafeResponseEvent.type === 'response.failed' - if ( - run.launch.agentId === 'codex' && - storageSafeResponseEvent.type === 'response.completed' && - childIsRunning(run.currentChild) - ) { - const final = (storageSafeResponseEvent.data as any).response || storageSafeResponseEvent.data - run.codexPendingUsage = final?.usage ?? run.codexPendingUsage - return - } - if (isTerminalEvent) { - if (run.terminalEventHandled) return - run.terminalEventHandled = true - } - this.touch(run) - this.ensureDbSession(run) - if (!run.runMarker) run.runMarker = `coding_agent_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` - run.state.isWorking = true - run.state.profile = run.launch.profile - run.state.source = run.launch.sessionSource === 'workflow' ? 'workflow' : 'coding_agent' - run.state.runId = run.id - for (const mappedEvent of mapCodingAgentResponseEvent(storageSafeResponseEvent)) { - this.emitToChat(run.launch.sessionId, mappedEvent.event, mappedEvent.payload) - } - const mapped = applyResponseStreamEvent(run.state, run.launch.sessionId, run.runMarker, storageSafeResponseEvent.type, storageSafeResponseEvent.data) - if (mapped) { - this.emitToChat(run.launch.sessionId, mapped.event, mapped.payload) - } - if (isTerminalEvent) { - flushResponseRunToDb(run.state, run.launch.sessionId) - run.state.responseRun = undefined - updateSessionStats(run.launch.sessionId) - run.terminalUsageRefresh = this.refreshCodingAgentUsage(run) - const final = (storageSafeResponseEvent.data as any).response || storageSafeResponseEvent.data - if (run.launch.mode !== 'scoped' && final?.usage) { - const usage = normalizeTokenUsage(final.usage) - if (!usage.isEstimated) { - recordSessionUsage({ - sessionId: run.launch.sessionId, - runId: final?.id || run.printResponseId || run.runMarker, - source: 'coding_agent', - agent: run.launch.agentId === 'codex' ? 'codex' : 'claude_code', - usageScope: 'run', - usage: final.usage, - profile: run.launch.profile, - model: final?.model || run.launch.model, - provider: run.launch.provider, - isEstimated: false, - }) - } - } - const finalText = extractResponseText(final) - const terminalError = storageSafeResponseEvent.type === 'response.failed' - ? responseErrorMessage(final?.error || (responseEvent.data as any).error) || 'Coding agent run failed' - : codingAgentGatewayErrorMessage(finalText) - const chatCompletionEvent = terminalError ? 'run.failed' : 'run.completed' - const chatCompletionPayload: Record = { - event: chatCompletionEvent, - run_id: final?.id, - response_id: final?.id, - output: finalText, - error: terminalError || undefined, - } - if (childIsRunning(run.currentChild)) { - run.pendingChatCompletionEvent = chatCompletionEvent - run.pendingChatCompletionPayload = chatCompletionPayload - } else { - void this.emitAndMarkPrintChatRunCompletedAfterUsage(run, chatCompletionEvent, chatCompletionPayload) - } - } - } - - private async refreshCodingAgentUsage(run: ManagedCodingAgentRun) { - const emitUsage = (event: string, payload: any) => { - this.emitToChat(run.launch.sessionId, event, payload) - } - const usage = await calcAndUpdateUsage(run.launch.sessionId, run.state, emitUsage) - const contextTokens = await this.estimateCodingAgentContextTokens(run) - if (contextTokens != null) { - updateContextTokenUsage(run.launch.sessionId, run.state, emitUsage, contextTokens, usage) - } - } - - private async estimateCodingAgentContextTokens(run: ManagedCodingAgentRun): Promise { - try { - const dbHistory = await buildDbHistory(run.launch.sessionId, { excludeLastUser: false }) - const snapshotHistory = await buildSnapshotAwareHistory( - run.launch.sessionId, - run.launch.profile || 'default', - dbHistory, - { model: run.launch.model, provider: run.launch.provider }, - ) - const usage = estimateUsageTokensFromMessages(snapshotHistory) - const contextTokens = usage.inputTokens + usage.outputTokens - if (contextTokens > 0) return contextTokens - } catch (err) { - logger.warn(err, '[coding-agent-run] failed to calculate context tokens for session %s', run.launch.sessionId) - } - return undefined - } - - private normalizeCodexChatTextEvent(run: ManagedCodingAgentRun, event: CanonicalResponsesEvent): CanonicalResponsesEvent | null { - if (run.launch.agentId !== 'codex' || event.type !== 'response.output_text.delta') return event - const data: any = event.data || {} - const text = typeof data.delta === 'string' - ? data.delta - : typeof data.text === 'string' - ? data.text - : '' - if (!text) return event - const existing = run.codexChatText || '' - const delta = text.length >= 16 ? appendedTextDelta(existing, text) : text - if (!delta) return null - run.codexChatText = `${existing}${delta}` - if (delta === text) return event - return { - ...event, - data: { - ...data, - delta, - text: typeof data.text === 'string' ? delta : data.text, - }, - } - } - - shutdown() { - for (const run of [...this.runs.values()]) this.cleanupRun(run, { kill: true }) - } - - private getBySession(sessionId: string): ManagedCodingAgentRun | null { - const runId = this.sessionIndex.get(sessionId) - return runId ? this.runs.get(runId) || null : null - } - - private ensureDbSession(run: ManagedCodingAgentRun) { - if (getSession(run.launch.sessionId)) return - const source = run.launch.sessionSource === 'global_agent' - ? 'global_agent' - : run.launch.sessionSource === 'workflow' - ? 'workflow' - : 'coding_agent' - createSession({ - id: run.launch.sessionId, - profile: run.launch.profile, - source, - agent: run.launch.agentId === 'codex' ? 'codex' : 'claude', - agent_session_id: run.id, - agent_native_session_id: run.launch.agentNativeSessionId, - model: run.launch.model, - provider: run.launch.provider, - api_mode: run.launch.apiMode || '', - title: '', - workspace: run.launch.workspaceDir, - }) - } - - private addUserMessage(run: ManagedCodingAgentRun, content: string) { - const timestamp = nowSeconds() - run.state.messages.push({ - id: run.state.messages.length + 1, - session_id: run.launch.sessionId, - runMarker: run.runMarker, - role: 'user', - content, - timestamp, - }) - const id = addMessage({ session_id: run.launch.sessionId, role: 'user', content, timestamp }) - logger.debug({ runId: run.id, sessionId: run.launch.sessionId, messageId: id }, '[coding-agent-run] recorded user message') - } - - private touch(run: ManagedCodingAgentRun) { - run.lastActiveAt = Date.now() - if (run.idleTimer) clearTimeout(run.idleTimer) - run.idleTimer = setTimeout(() => { - const current = this.runs.get(run.id) - if (!current) return - const remaining = this.idleMs - (Date.now() - current.lastActiveAt) - if (remaining > 0) { - this.touch(current) - return - } - logger.info({ runId: current.id, sessionId: current.launch.sessionId, idleMs: this.idleMs }, '[coding-agent-run] closing idle hidden session') - this.cleanupRun(current, { kill: true }) - }, this.idleMs) - } - - private cleanupRun(run: ManagedCodingAgentRun, options: { kill: boolean; reportClosed?: boolean }) { - const shouldReportClosed = options.reportClosed !== false && (run.state.isWorking || Boolean(run.currentChild && !run.currentChild.killed)) - if (run.idleTimer) clearTimeout(run.idleTimer) - if (run.currentChildKillTimer) clearTimeout(run.currentChildKillTimer) - this.flushTerminalOutput(run) - if (run.terminalFlushTimer) clearTimeout(run.terminalFlushTimer) - this.runs.delete(run.id) - if (this.sessionIndex.get(run.launch.sessionId) === run.id) this.sessionIndex.delete(run.launch.sessionId) - if (options.kill && !run.exited) { - try { run.pty?.kill() } catch {} - terminateChildProcess(run.currentChild) - if (childIsRunning(run.currentChild)) { - run.currentChildKillTimer = setTimeout(() => forceKillChildProcess(run.currentChild), 1500) - } - } - run.exited = true - run.state.isWorking = false - if (shouldReportClosed) { - const workspaceRunChange = this.completeWorkspaceRunDiff(run) - this.emitToChat(run.launch.sessionId, 'run.failed', { - event: 'run.failed', - error: 'Coding agent session closed', - workspace_run_change: workspaceRunChange, - }) - this.markChatRunCompleted(run.launch.sessionId, 'run.failed') - } - } - - private startClaudePrintTurn(run: ManagedCodingAgentRun, input: string, systemPrompt = '') { - if (childIsRunning(run.currentChild)) { - throw new Error('Claude Code is still processing the previous input') - } - - const responseId = `resp_${Date.now()}` - run.printResponseId = responseId - run.printMessageId = `msg_${responseId}` - run.printTextStarted = false - run.printText = '' - run.printCompleted = false - run.responseStartEmitted = false - run.terminalEventHandled = false - run.printToolBlocks = new Map() - run.currentChildStderr = '' - run.runMarker = undefined - run.memoryExportStarted = false - - this.handleClaudePrintResponseEvent(run, { - type: 'response.created', - data: { - type: 'response.created', - response: { id: responseId, object: 'response', status: 'in_progress', model: run.launch.model, output: [] }, - }, - }) - - const nativeSessionArgs = run.launch.agentNativeSessionId - ? (run.nativeResumeReady - ? ['--resume', run.launch.agentNativeSessionId] - : ['--session-id', run.launch.agentNativeSessionId]) - : [] - const promptArgument = hasArg(run.launch.args, '--append-system-prompt-file') - ? '' - : normalizeCliPromptArgument(systemPrompt) - const args = [ - ...run.launch.args, - ...nativeSessionArgs, - ...(promptArgument ? ['--append-system-prompt', promptArgument] : []), - '-p', - '--output-format', - 'stream-json', - '--include-partial-messages', - '--verbose', - input, - ] - const child = spawnCodingAgentChild(run.launch.command, args, { - cwd: existsSync(run.launch.workspaceDir) ? run.launch.workspaceDir : homedir(), - env: { - ...process.env, - ...(run.launch.env || {}), - }, - }) - run.currentChild = child - - let stdoutBuffer = '' - child.stdout?.on('data', (chunk: Buffer) => { - this.touch(run) - stdoutBuffer += chunk.toString('utf8') - const lines = stdoutBuffer.split(/\r?\n/) - stdoutBuffer = lines.pop() || '' - for (const line of lines) this.handleClaudePrintLine(run, line) - }) - - child.stderr?.on('data', (chunk: Buffer) => { - this.touch(run) - const text = appendChildStderr(run, chunk) - if (text) logger.debug({ runId: run.id, sessionId: run.launch.sessionId, text }, '[coding-agent-run] claude print stderr') - }) - - child.on('error', (err) => { - if (run.currentChildKillTimer) clearTimeout(run.currentChildKillTimer) - run.currentChildKillTimer = undefined - run.currentChild = undefined - logger.warn({ err, runId: run.id, sessionId: run.launch.sessionId }, '[coding-agent-run] claude print failed to start') - this.handleClaudePrintResponseEvent(run, { - type: 'response.failed', - data: { - type: 'response.failed', - response: { - id: run.printResponseId, - object: 'response', - status: 'failed', - model: run.launch.model, - error: { message: childProcessErrorMessage(err) }, - output: [], - }, - }, - }) - }) - - child.on('exit', (code) => { - if (stdoutBuffer.trim()) this.handleClaudePrintLine(run, stdoutBuffer) - if (run.currentChildKillTimer) clearTimeout(run.currentChildKillTimer) - run.currentChildKillTimer = undefined - run.currentChild = undefined - logger.info({ runId: run.id, sessionId: run.launch.sessionId, code }, '[coding-agent-run] claude print exited') - if (run.stoppedByUser) return - if (run.pendingChatCompletionEvent) { - void this.emitAndMarkPrintChatRunCompletedAfterUsage(run, run.pendingChatCompletionEvent, run.pendingChatCompletionPayload) - return - } - if (code === 0) { - this.completeClaudePrintTurn(run) - return - } - this.handleClaudePrintResponseEvent(run, { - type: 'response.failed', - data: { - type: 'response.failed', - response: { - id: run.printResponseId, - object: 'response', - status: 'failed', - model: run.launch.model, - error: { message: exitErrorMessage('Claude Code', code, run.currentChildStderr) }, - output: [], - }, - }, - }) - }) - } - - private handleClaudePrintResponseEvent(run: ManagedCodingAgentRun, event: CanonicalResponsesEvent) { - run.acceptingPrintEvent = true - try { - this.handleResponseEvent(run.id, event) - } finally { - run.acceptingPrintEvent = false - } - } - - private handleClaudePrintLine(run: ManagedCodingAgentRun, line: string) { - const trimmed = line.trim() - if (!trimmed) return - let event: any - try { - event = JSON.parse(trimmed) - } catch { - logger.debug({ runId: run.id, line: sanitizeCodingAgentTerminalOutput(trimmed) }, '[coding-agent-run] ignored non-json Claude print line') - return - } - - if (event.type === 'stream_event' && event.event) { - this.handleClaudeAnthropicStreamEvent(run, event.event) - return - } - - if (typeof event.session_id === 'string' && event.session_id.trim()) { - this.recordClaudeNativeSessionId(run, event.session_id.trim()) - } - - if ((event.type === 'assistant' || event.type === 'user') && event.message) { - this.handleClaudeTopLevelMessage(run, event.message) - return - } - - if (event.type === 'result') { - if (run.printCompleted) return - const resultText = String(event.result || '') - if (resultText && !run.printTextStarted) { - this.ensureClaudePrintText(run) - run.printText = `${run.printText || ''}${resultText}` - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_text.delta', - data: { - type: 'response.output_text.delta', - item_id: run.printMessageId, - output_index: 0, - content_index: 0, - delta: resultText, - }, - }) - } - this.completeClaudePrintTurn(run, event.usage) - } - } - - private recordClaudeNativeSessionId(run: ManagedCodingAgentRun, nativeSessionId: string) { - if (!nativeSessionId) return - if (run.launch.agentNativeSessionId === nativeSessionId && run.nativeResumeReady) return - run.launch.agentNativeSessionId = nativeSessionId - run.nativeResumeReady = true - try { - updateSession(run.launch.sessionId, { agent_native_session_id: nativeSessionId }) - } catch (err) { - logger.warn({ err, runId: run.id, sessionId: run.launch.sessionId }, '[coding-agent-run] failed to persist Claude native session id') - } - } - - private handleClaudeTopLevelMessage(run: ManagedCodingAgentRun, message: any) { - const role = String(message?.role || '') - const content = Array.isArray(message?.content) ? message.content : [] - if (!content.length) return - - if (role === 'assistant') { - for (const [index, block] of content.entries()) { - if (block?.type !== 'tool_use') continue - const toolBlock = { - id: String(block.id || `toolu_${index}`), - name: String(block.name || 'tool'), - arguments: JSON.stringify(block.input || {}), - done: false, - } - run.printToolBlocks?.set(index, toolBlock) - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_item.added', - data: { - type: 'response.output_item.added', - output_index: index, - item: { - type: 'function_call', - id: toolBlock.id, - call_id: toolBlock.id, - name: toolBlock.name, - arguments: toolBlock.arguments, - }, - }, - }) - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_item.done', - data: { - type: 'response.output_item.done', - output_index: index, - item: { - type: 'function_call', - id: toolBlock.id, - call_id: toolBlock.id, - name: toolBlock.name, - arguments: toolBlock.arguments, - }, - }, - }) - } - return - } - - if (role === 'user') { - for (const [index, block] of content.entries()) { - if (block?.type !== 'tool_result') continue - const callId = String(block.tool_use_id || block.id || `toolu_${index}`) - const output = claudeContentToText(block.content) - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_item.done', - data: { - type: 'response.output_item.done', - output_index: index, - item: { - type: 'function_call_output', - id: callId, - call_id: callId, - output, - }, - }, - }) - } - } - } - - private handleClaudeAnthropicStreamEvent(run: ManagedCodingAgentRun, event: any) { - const type = String(event?.type || '') - if (type === 'message_start') { - const id = String(event?.message?.id || run.printResponseId || `resp_${Date.now()}`) - run.printResponseId = id - run.printMessageId = `msg_${id}` - return - } - - if (type === 'content_block_start') { - const index = Number(event.index || 0) - const block = event.content_block || {} - if (block.type === 'text') { - this.ensureClaudePrintText(run) - return - } - if (block.type === 'tool_use') { - const toolBlock = { - id: String(block.id || `toolu_${index}`), - name: String(block.name || 'tool'), - arguments: block.input ? JSON.stringify(block.input) : '', - done: false, - } - run.printToolBlocks?.set(index, toolBlock) - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_item.added', - data: { - type: 'response.output_item.added', - output_index: index, - item: { - type: 'function_call', - id: toolBlock.id, - call_id: toolBlock.id, - name: toolBlock.name, - arguments: toolBlock.arguments, - }, - }, - }) - } - return - } - - if (type === 'content_block_delta') { - const index = Number(event.index || 0) - const delta = event.delta || {} - if (delta.type === 'thinking_delta' && delta.thinking) { - this.handleClaudePrintResponseEvent(run, { - type: 'response.reasoning.delta', - data: { - type: 'response.reasoning.delta', - item_id: run.printMessageId, - output_index: index, - delta: String(delta.thinking), - }, - }) - return - } - if (delta.type === 'text_delta' && delta.text) { - this.ensureClaudePrintText(run) - const text = String(delta.text) - run.printText = `${run.printText || ''}${text}` - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_text.delta', - data: { - type: 'response.output_text.delta', - item_id: run.printMessageId, - output_index: 0, - content_index: 0, - delta: text, - }, - }) - return - } - if (delta.type === 'input_json_delta' && delta.partial_json) { - let toolBlock = run.printToolBlocks?.get(index) - if (!toolBlock) { - toolBlock = { id: `toolu_${index}`, name: 'tool', arguments: '', done: false } - run.printToolBlocks?.set(index, toolBlock) - } - const argsDelta = String(delta.partial_json) - toolBlock.arguments += argsDelta - this.handleClaudePrintResponseEvent(run, { - type: 'response.function_call_arguments.delta', - data: { - type: 'response.function_call_arguments.delta', - item_id: toolBlock.id, - output_index: index, - delta: argsDelta, - }, - }) - } - return - } - - if (type === 'content_block_stop') { - const index = Number(event.index || 0) - const toolBlock = run.printToolBlocks?.get(index) - if (!toolBlock || toolBlock.done) return - toolBlock.done = true - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_item.done', - data: { - type: 'response.output_item.done', - output_index: index, - item: { - type: 'function_call', - id: toolBlock.id, - call_id: toolBlock.id, - name: toolBlock.name, - arguments: toolBlock.arguments || '{}', - }, - }, - }) - } - } - - private ensureClaudePrintText(run: ManagedCodingAgentRun) { - if (run.printTextStarted) return - run.printTextStarted = true - const item = { type: 'message', id: run.printMessageId, status: 'in_progress', role: 'assistant', content: [] } - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_item.added', - data: { type: 'response.output_item.added', output_index: 0, item }, - }) - this.handleClaudePrintResponseEvent(run, { - type: 'response.content_part.added', - data: { - type: 'response.content_part.added', - item_id: run.printMessageId, - output_index: 0, - content_index: 0, - part: { type: 'output_text', text: '', annotations: [] }, - }, - }) - } - - private completeClaudePrintTurn(run: ManagedCodingAgentRun, usage?: any) { - if (run.printCompleted) return - run.printCompleted = true - const text = run.printText || '' - const output = run.printTextStarted - ? [{ - type: 'message', - id: run.printMessageId, - status: 'completed', - role: 'assistant', - content: [{ type: 'output_text', text, annotations: [] }], - }] - : [] - if (run.printTextStarted) { - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_text.done', - data: { - type: 'response.output_text.done', - item_id: run.printMessageId, - output_index: 0, - content_index: 0, - text, - }, - }) - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_item.done', - data: { - type: 'response.output_item.done', - output_index: 0, - item: output[0], - }, - }) - } - this.handleClaudePrintResponseEvent(run, { - type: 'response.completed', - data: { - type: 'response.completed', - response: { - id: run.printResponseId, - object: 'response', - status: 'completed', - model: run.launch.model, - output, - usage, - }, - }, - }) - } - - private startCodexExecTurn(run: ManagedCodingAgentRun, input: string, systemPrompt = '') { - if (childIsRunning(run.currentChild)) { - throw new Error('Codex is still processing the previous input') - } - - const responseId = `resp_${Date.now()}` - run.printResponseId = responseId - run.printMessageId = `msg_${responseId}` - run.printTextStarted = false - run.printText = '' - run.printCompleted = false - run.responseStartEmitted = false - run.terminalEventHandled = false - run.codexToolBlocks = new Map() - run.codexChatText = '' - run.codexPendingUsage = undefined - run.currentChildStderr = '' - run.runMarker = undefined - run.memoryExportStarted = false - - this.handleClaudePrintResponseEvent(run, { - type: 'response.created', - data: { - type: 'response.created', - response: { id: responseId, object: 'response', status: 'in_progress', model: run.launch.model, output: [] }, - }, - }) - - const promptArgument = run.launch.mode === 'scoped' ? '' : normalizeCliPromptArgument(systemPrompt) - const commonArgs = [ - '--json', - ...CODEX_REASONING_SUMMARY_ARGS, - ...(promptArgument ? ['-c', `developer_instructions=${JSON.stringify(promptArgument)}`] : []), - ...run.launch.args, - '--skip-git-repo-check', - '--dangerously-bypass-approvals-and-sandbox', - ] - const args = run.launch.agentNativeSessionId && run.nativeResumeReady - ? ['exec', 'resume', ...commonArgs, run.launch.agentNativeSessionId, input] - : ['exec', ...commonArgs, '--cd', run.launch.workspaceDir, input] - - const child = spawnCodingAgentChild(run.launch.command, args, { - cwd: existsSync(run.launch.workspaceDir) ? run.launch.workspaceDir : homedir(), - env: { - ...process.env, - ...(run.launch.env || {}), - }, - }) - run.currentChild = child - - let stdoutBuffer = '' - child.stdout?.on('data', (chunk: Buffer) => { - this.touch(run) - stdoutBuffer += chunk.toString('utf8') - const lines = stdoutBuffer.split(/\r?\n/) - stdoutBuffer = lines.pop() || '' - for (const line of lines) this.handleCodexExecLine(run, line) - }) - - child.stderr?.on('data', (chunk: Buffer) => { - this.touch(run) - const text = appendChildStderr(run, chunk) - if (text) logger.debug({ runId: run.id, sessionId: run.launch.sessionId, text }, '[coding-agent-run] codex exec stderr') - }) - - child.on('error', (err) => { - if (run.currentChildKillTimer) clearTimeout(run.currentChildKillTimer) - run.currentChildKillTimer = undefined - run.currentChild = undefined - logger.warn({ err, runId: run.id, sessionId: run.launch.sessionId }, '[coding-agent-run] codex exec failed to start') - this.handleClaudePrintResponseEvent(run, { - type: 'response.failed', - data: { - type: 'response.failed', - response: { - id: run.printResponseId, - object: 'response', - status: 'failed', - model: run.launch.model, - error: { message: childProcessErrorMessage(err) }, - output: [], - }, - }, - }) - }) - - child.on('exit', (code) => { - if (stdoutBuffer.trim()) this.handleCodexExecLine(run, stdoutBuffer) - if (run.currentChildKillTimer) clearTimeout(run.currentChildKillTimer) - run.currentChildKillTimer = undefined - run.currentChild = undefined - logger.info({ runId: run.id, sessionId: run.launch.sessionId, code }, '[coding-agent-run] codex exec exited') - if (run.stoppedByUser) return - if (run.pendingChatCompletionEvent) { - void this.emitAndMarkPrintChatRunCompletedAfterUsage(run, run.pendingChatCompletionEvent, run.pendingChatCompletionPayload) - return - } - if (code === 0) { - this.completeCodexExecTurn(run, run.codexPendingUsage) - return - } - this.handleClaudePrintResponseEvent(run, { - type: 'response.failed', - data: { - type: 'response.failed', - response: { - id: run.printResponseId, - object: 'response', - status: 'failed', - model: run.launch.model, - error: { message: exitErrorMessage('Codex', code, run.currentChildStderr) }, - output: [], - }, - }, - }) - }) - } - - private handleCodexExecLine(run: ManagedCodingAgentRun, line: string) { - const trimmed = line.trim() - if (!trimmed) return - let event: any - try { - event = JSON.parse(trimmed) - } catch { - logger.debug({ runId: run.id, line: sanitizeCodingAgentTerminalOutput(trimmed) }, '[coding-agent-run] ignored non-json Codex exec line') - return - } - - this.recordCodexNativeSessionId(run, this.codexNativeSessionIdFrom(event)) - - const method = String(event.method || '').trim() - if (method) { - this.handleCodexProtocolEvent(run, method, event.params || {}) - return - } - - const msg = event.msg || event.message - if (msg && (typeof msg.content === 'string' || typeof msg.text === 'string')) { - this.appendCodexFinalText(run, String(msg.content || msg.text || '')) - return - } - - const type = String(event.type || '').trim() - if (type === 'thread.started') { - this.recordCodexNativeSessionId(run, String(event.thread_id || event.threadId || '').trim()) - return - } - if (type === 'item.started') { - this.handleCodexItemStarted(run, event.item || event) - return - } - if (type === 'item.completed') { - this.handleCodexItemCompleted(run, event.item || event) - return - } - if (type === 'response_item') { - this.handleCodexResponseItem(run, event.payload || event.item || event) - return - } - if (type === 'turn.completed') { - run.codexPendingUsage = event.usage - return - } - if (type === 'turn.failed' || type === 'error') { - this.failCodexExecTurn(run, event.error?.message || event.message || 'Codex run failed') - } - } - - private handleCodexProtocolEvent(run: ManagedCodingAgentRun, method: string, params: any) { - this.recordCodexNativeSessionId(run, this.codexNativeSessionIdFrom(params)) - if (method === 'thread/started') { - this.recordCodexNativeSessionId(run, String(params.thread_id || params.threadId || '').trim()) - return - } - if (method === 'item/agentMessage/delta' || method === 'item/assistantMessage/delta') { - this.appendCodexText(run, String(params.delta || params.text || '')) - return - } - if ( - method === 'item/reasoning/delta' || - method === 'item/reasoningText/delta' || - method === 'item/reasoningSummary/delta' || - method === 'item/thinking/delta' - ) { - this.appendCodexReasoning(run, this.codexReasoningText(params)) - return - } - if (method === 'item/started') { - this.handleCodexItemStarted(run, params.item || params) - return - } - if (method === 'item/completed') { - this.handleCodexItemCompleted(run, params.item || params) - return - } - if (method === 'turn/completed') { - run.codexPendingUsage = params.usage - return - } - if (method === 'turn/failed' || method === 'error') { - this.failCodexExecTurn(run, params.error?.message || params.message || 'Codex run failed') - } - } - - private failCodexExecTurn(run: ManagedCodingAgentRun, message: string) { - this.handleClaudePrintResponseEvent(run, { - type: 'response.failed', - data: { - type: 'response.failed', - response: { - id: run.printResponseId, - object: 'response', - status: 'failed', - model: run.launch.model, - error: { message }, - output: [], - }, - }, - }) - } - - private handleCodexItemStarted(run: ManagedCodingAgentRun, item: any) { - const itemType = this.codexItemType(item) - if (!this.isCodexToolItem(itemType)) return - if (this.isRedundantCodexExecToolItem(item, itemType)) return - const toolBlock = this.codexToolBlock(item, itemType) - run.codexToolBlocks?.set(toolBlock.id, toolBlock) - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_item.added', - data: { - type: 'response.output_item.added', - output_index: run.codexToolBlocks?.size || 0, - item: { - type: 'function_call', - id: toolBlock.id, - call_id: toolBlock.id, - name: toolBlock.name, - arguments: toolBlock.arguments, - }, - }, - }) - } - - private handleCodexItemCompleted(run: ManagedCodingAgentRun, item: any) { - const itemType = this.codexItemType(item) - if (this.isCodexReasoningItem(itemType)) { - this.appendCodexReasoning(run, this.codexReasoningText(item)) - return - } - if ( - itemType === 'agent_message' || - itemType === 'assistant_message' || - itemType === 'agentMessage' || - itemType === 'assistantMessage' - ) { - this.appendCodexFinalText(run, String(item.text || item.message || item.content || '')) - return - } - if (!this.isCodexToolItem(itemType)) return - if (this.isRedundantCodexExecToolItem(item, itemType)) return - let toolBlock = run.codexToolBlocks?.get(String(item.id || item.call_id || item.callId || itemType)) - if (!toolBlock) { - toolBlock = this.codexToolBlock(item, itemType) - run.codexToolBlocks?.set(toolBlock.id, toolBlock) - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_item.added', - data: { - type: 'response.output_item.added', - output_index: run.codexToolBlocks?.size || 0, - item: { - type: 'function_call', - id: toolBlock.id, - call_id: toolBlock.id, - name: toolBlock.name, - arguments: toolBlock.arguments, - }, - }, - }) - } - if (toolBlock.done) return - toolBlock.done = true - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_item.done', - data: { - type: 'response.output_item.done', - output_index: run.codexToolBlocks?.size || 0, - item: { - type: 'function_call', - id: toolBlock.id, - call_id: toolBlock.id, - name: toolBlock.name, - arguments: toolBlock.arguments, - }, - }, - }) - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_item.done', - data: { - type: 'response.output_item.done', - output_index: run.codexToolBlocks?.size || 0, - item: { - type: 'function_call_output', - id: toolBlock.id, - call_id: toolBlock.id, - output: this.codexToolOutput(item), - }, - }, - }) - } - - private handleCodexResponseItem(run: ManagedCodingAgentRun, item: any) { - const itemType = this.codexItemType(item) - if (this.isCodexReasoningItem(itemType)) { - this.appendCodexReasoning(run, this.codexReasoningText(item)) - return - } - if ( - itemType === 'agent_message' || - itemType === 'assistant_message' || - itemType === 'agentMessage' || - itemType === 'assistantMessage' || - itemType === 'message' - ) { - this.appendCodexFinalText(run, String(item.text || item.message || item.content || '')) - } - } - - private codexItemType(item: any): string { - return String(item?.type || item?.item_type || item?.itemType || '').trim() - } - - private isCodexToolItem(itemType: string): boolean { - return itemType === 'command_execution' || - itemType === 'mcp_tool_call' || - itemType === 'web_search' || - itemType === 'file_change' - } - - private isCodexReasoningItem(itemType: string): boolean { - return itemType === 'reasoning' || - itemType === 'reasoning_text' || - itemType === 'reasoning_summary' || - itemType === 'thinking' - } - - private codexReasoningText(item: any): string { - if (typeof item?.delta === 'string') return item.delta - if (typeof item?.text === 'string') return item.text - if (typeof item?.summary === 'string') return item.summary - if (typeof item?.reasoning === 'string') return item.reasoning - if (Array.isArray(item?.summary)) { - return item.summary - .map((part: any) => typeof part?.text === 'string' ? part.text : typeof part === 'string' ? part : '') - .filter(Boolean) - .join('') - } - return '' - } - - private isRedundantCodexExecToolItem(item: any, itemType: string): boolean { - if (itemType !== 'mcp_tool_call') return false - const name = String(item.tool || item.name || item.function?.name || '').trim() - return name === 'exec_command' || name === 'functions.exec_command' - } - - private codexToolBlock(item: any, itemType: string): { id: string; name: string; arguments: string; done: boolean } { - const id = String(item.id || item.call_id || item.callId || `codex_${itemType}_${Date.now()}`) - const name = itemType === 'command_execution' - ? 'Command' - : itemType === 'mcp_tool_call' - ? String(item.tool || item.name || 'MCP Tool') - : itemType === 'web_search' - ? 'Web Search' - : 'File Change' - const args = itemType === 'command_execution' - ? { command: item.command || item.cmd || '' } - : itemType === 'mcp_tool_call' - ? { server: item.server, tool: item.tool || item.name, arguments: item.arguments || item.input } - : itemType === 'web_search' - ? { query: item.query || item.text || '' } - : { path: item.path || item.file || '', action: item.action || item.change || '' } - return { id, name, arguments: JSON.stringify(args), done: false } - } - - private codexToolOutput(item: any): string { - const value = item.aggregated_output ?? item.output ?? item.result ?? item.error?.message ?? '' - if (typeof value === 'string') return value - try { - return JSON.stringify(value) - } catch { - return String(value) - } - } - - private appendCodexFinalText(run: ManagedCodingAgentRun, text: string) { - if (!text) return - const existing = run.printText || '' - if (!existing) { - this.appendCodexText(run, text) - return - } - if (text === existing) return - if (text.startsWith(existing)) { - this.appendCodexText(run, text.slice(existing.length)) - return - } - const existingTrimmed = existing.trimEnd() - const textTrimmed = text.trimEnd() - if (textTrimmed === existingTrimmed || existingTrimmed.endsWith(textTrimmed)) return - if (textTrimmed.startsWith(existingTrimmed)) { - this.appendCodexText(run, text.slice(existingTrimmed.length)) - return - } - if (this.codexLastRunMessageIsToolBoundary(run)) { - this.appendCodexText(run, text) - } - } - - private codexLastRunMessageIsToolBoundary(run: ManagedCodingAgentRun): boolean { - const marker = run.runMarker - if (!marker) return false - for (let index = run.state.messages.length - 1; index >= 0; index--) { - const message = run.state.messages[index] - if (message.runMarker !== marker) continue - return message.role === 'tool' || Boolean(message.tool_calls?.length) - } - return false - } - - private appendCodexText(run: ManagedCodingAgentRun, text: string) { - if (!text) return - const existing = run.printText || '' - const delta = text.length >= 16 ? appendedTextDelta(existing, text) : text - if (!delta) return - this.ensureClaudePrintText(run) - run.printText = `${existing}${delta}` - this.handleClaudePrintResponseEvent(run, { - type: 'response.output_text.delta', - data: { - type: 'response.output_text.delta', - item_id: run.printMessageId, - output_index: 0, - content_index: 0, - delta, - }, - }) - } - - private appendCodexReasoning(run: ManagedCodingAgentRun, text: string) { - if (!text) return - this.handleClaudePrintResponseEvent(run, { - type: 'response.reasoning.delta', - data: { - type: 'response.reasoning.delta', - item_id: run.printMessageId, - output_index: 0, - delta: text, - }, - }) - } - - private completeCodexExecTurn(run: ManagedCodingAgentRun, usage?: any) { - this.completeClaudePrintTurn(run, usage) - } - - private async emitAndMarkPrintChatRunCompletedAfterUsage( - run: ManagedCodingAgentRun, - event: 'run.completed' | 'run.failed', - payload?: Record, - ) { - const usageRefresh = run.terminalUsageRefresh - run.terminalUsageRefresh = undefined - if (usageRefresh) { - try { - await usageRefresh - } catch (err) { - logger.warn({ err, runId: run.id, sessionId: run.launch.sessionId }, '[coding-agent-run] terminal usage refresh failed before completion') - } - } - this.emitAndMarkPrintChatRunCompleted(run, event, payload) - } - - private emitAndMarkPrintChatRunCompleted(run: ManagedCodingAgentRun, event: 'run.completed' | 'run.failed', payload?: Record) { - run.pendingChatCompletionEvent = undefined - run.pendingChatCompletionPayload = undefined - const queueRemaining = run.state.queue.length - const workspaceRunChange = this.completeWorkspaceRunDiff(run) - this.emitToChat(run.launch.sessionId, event, { - ...(payload || { event }), - ...(queueRemaining > 0 ? { queue_remaining: queueRemaining } : {}), - workspace_run_change: workspaceRunChange, - }) - if (queueRemaining === 0) { - try { - updateSession(run.launch.sessionId, { - ended_at: nowSeconds(), - end_reason: event === 'run.failed' ? 'error' : 'complete', - }) - } catch (err) { - logger.warn({ err, runId: run.id, sessionId: run.launch.sessionId }, '[coding-agent-run] failed to write coding-agent session end marker') - } - } - run.state.isWorking = false - run.state.runId = undefined - run.state.abortController = undefined - run.state.activeRunMarker = undefined - run.state.events = [] - this.markChatRunCompleted(run.launch.sessionId, event) - if (event === 'run.completed') this.startCodingAgentMemoryExport(run) - run.runMarker = undefined - } - - private startCodingAgentMemoryExport(run: ManagedCodingAgentRun) { - if (run.memoryExportStarted) return - const agentId = run.launch.agentId - const source = agentId === 'codex' ? 'codex' : agentId === 'claude-code' ? 'claude-code' : '' - if (!source) return - const nativeSessionId = String(run.launch.agentNativeSessionId || '').trim() - if (!nativeSessionId) { - logger.debug({ runId: run.id, sessionId: run.launch.sessionId, agentId }, '[coding-agent-run] memory export skipped: missing native session id') - return - } - run.memoryExportStarted = true - - const args = ['threads', 'save', '--from', source, '--truncate', '--session-id', nativeSessionId] - const env: NodeJS.ProcessEnv = { ...process.env } - if (source === 'codex') { - const codexHome = run.launch.env?.CODEX_HOME || process.env.CODEX_HOME - if (codexHome) env.CODEX_HOME = codexHome - } - - let child: ChildProcess - try { - child = spawnCodingAgentChild('nmem', args, { - cwd: existsSync(run.launch.workspaceDir) ? run.launch.workspaceDir : homedir(), - env, - }) - } catch (err) { - logger.debug({ err, runId: run.id, sessionId: run.launch.sessionId, agentId, nativeSessionId }, '[coding-agent-run] memory export failed to start') - return - } - - let stdout = '' - let stderr = '' - const timeout = setTimeout(() => { - try { child.kill() } catch {} - }, 60_000) - timeout.unref?.() - child.stdout?.on('data', (chunk: Buffer) => { - stdout = `${stdout}${chunk.toString('utf8')}`.slice(-4000) - }) - child.stderr?.on('data', (chunk: Buffer) => { - stderr = `${stderr}${chunk.toString('utf8')}`.slice(-4000) - }) - child.on('error', (err) => { - clearTimeout(timeout) - logger.debug({ err, runId: run.id, sessionId: run.launch.sessionId, agentId, nativeSessionId }, '[coding-agent-run] memory export process error') - }) - child.on('exit', (code) => { - clearTimeout(timeout) - if (code === 0) { - logger.info({ runId: run.id, sessionId: run.launch.sessionId, agentId, nativeSessionId }, '[coding-agent-run] memory export completed') - } else { - logger.debug({ - runId: run.id, - sessionId: run.launch.sessionId, - agentId, - nativeSessionId, - code, - stdout: stdout.slice(0, 1000), - stderr: stderr.slice(0, 1000), - }, '[coding-agent-run] memory export failed') - } - }) - } - - private codexNativeSessionIdFrom(value: any): string { - if (!value || typeof value !== 'object') return '' - const direct = value.thread_id || value.threadId || value.session_id || value.sessionId || value.conversation_id || value.conversationId - if (typeof direct === 'string' && direct.trim()) return direct.trim() - const nested = value.thread || value.session || value.conversation || value.params || value.msg || value.message - if (nested && nested !== value) return this.codexNativeSessionIdFrom(nested) - return '' - } - - private recordCodexNativeSessionId(run: ManagedCodingAgentRun, nativeSessionId: string) { - if (!nativeSessionId) return - if (run.launch.agentNativeSessionId === nativeSessionId && run.nativeResumeReady) return - run.launch.agentNativeSessionId = nativeSessionId - run.nativeResumeReady = true - try { - updateSession(run.launch.sessionId, { agent_native_session_id: nativeSessionId }) - logger.info({ runId: run.id, sessionId: run.launch.sessionId, nativeSessionId }, '[coding-agent-run] recorded Codex native session id') - } catch (err) { - logger.warn({ err, runId: run.id, sessionId: run.launch.sessionId }, '[coding-agent-run] failed to persist Codex native session id') - } - } - - private startWorkspaceRunDiff(run: ManagedCodingAgentRun) { - try { - startWorkspaceRunCheckpoint({ - sessionId: run.launch.sessionId, - runId: run.id, - workspace: run.launch.workspaceDir, - }) - } catch (err) { - logger.warn({ err, runId: run.id, sessionId: run.launch.sessionId }, '[workspace-diff] failed to start coding agent run checkpoint') - } - } - - private completeWorkspaceRunDiff(run: ManagedCodingAgentRun) { - try { - const change = completeWorkspaceRunCheckpoint({ - sessionId: run.launch.sessionId, - runId: run.id, - workspace: run.launch.workspaceDir, - }) - if (!change) return null - this.emitToChat(run.launch.sessionId, 'workspace.diff.completed', { - event: 'workspace.diff.completed', - run_id: run.id, - change_id: change.change_id, - change, - }) - return change - } catch (err) { - logger.warn({ err, runId: run.id, sessionId: run.launch.sessionId }, '[workspace-diff] failed to complete coding agent run checkpoint') - return null - } - } - - private emitToChat(sessionId: string, event: string, payload: any) { - try { - // Lazy require avoids coupling the service to bootstrap order. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { getChatRunServer } = require('../../routes/hermes/chat-run') - getChatRunServer()?.emitExternalEvent?.(sessionId, event, payload) - } catch {} - } - - private emitTerminalStatus(run: ManagedCodingAgentRun, text: string) { - logger.debug({ runId: run.id, sessionId: run.launch.sessionId, text }, '[coding-agent-run] status') - } - - private bufferTerminalOutput(run: ManagedCodingAgentRun, chunk: string) { - const sanitized = sanitizeCodingAgentTerminalOutput(chunk) - if (!sanitized.trim()) return - run.terminalBuffer = `${run.terminalBuffer || ''}${sanitized}` - if (run.terminalBuffer.length > MAX_TERMINAL_EVENT_CHARS * 2) { - run.terminalBuffer = run.terminalBuffer.slice(-MAX_TERMINAL_EVENT_CHARS * 2) - } - if (run.terminalFlushTimer) return - run.terminalFlushTimer = setTimeout(() => { - run.terminalFlushTimer = undefined - this.flushTerminalOutput(run) - }, TERMINAL_OUTPUT_FLUSH_MS) - } - - private maybeAnswerClaudeApiKeyPrompt(run: ManagedCodingAgentRun, chunk: string) { - if (run.launch.agentId !== 'claude-code' || run.apiKeyPromptAnswered) return - const text = sanitizeCodingAgentTerminalOutput(`${run.terminalBuffer || ''}${chunk}`).toLowerCase() - if (!text.includes('detected a custom api key') && !text.includes('detectedacustomapikey')) return - if (!text.includes('do you want to use this api key') && !text.includes('doyouwanttousethisapikey')) return - run.apiKeyPromptAnswered = true - this.emitTerminalStatus(run, 'Confirmed scoped Claude Code API key.') - try { - run.pty?.write('1\r') - } catch (err) { - logger.warn({ err, runId: run.id, sessionId: run.launch.sessionId }, '[coding-agent-run] failed to confirm Claude Code API key prompt') - } - } - - private flushTerminalOutput(run: ManagedCodingAgentRun) { - run.terminalBuffer = '' - } - - private markChatRunCompleted(sessionId: string, event: string) { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { getChatRunServer } = require('../../routes/hermes/chat-run') - getChatRunServer()?.markExternalRunCompleted?.(sessionId, event) - } catch {} - } -} - -export const codingAgentRunManager = new CodingAgentRunManager() diff --git a/packages/server/src/services/agent-runner/endpoint-resolver.ts b/packages/server/src/services/agent-runner/endpoint-resolver.ts deleted file mode 100644 index 8ad298f..0000000 --- a/packages/server/src/services/agent-runner/endpoint-resolver.ts +++ /dev/null @@ -1,71 +0,0 @@ -export type AgentEndpointKind = 'chat_completions' | 'responses' | 'anthropic_messages' - -function normalizedBaseUrl(baseUrl: string): URL { - const trimmed = String(baseUrl || '').trim().replace(/\/+$/, '') - if (!trimmed) throw new Error('baseUrl is required') - return new URL(trimmed) -} - -function normalizedPath(url: URL): string { - return url.pathname.replace(/\/+$/, '') -} - -function appendPath(baseUrl: string, path: string): string { - return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}` -} - -function pathEndsWith(pathname: string, suffix: string): boolean { - return pathname.toLowerCase().endsWith(`/${suffix.toLowerCase()}`) -} - -function looksLikeOpenAiRoot(pathname: string): boolean { - const path = pathname.toLowerCase() - const segments = path.split('/').filter(Boolean) - const last = segments[segments.length - 1] || '' - if (/^v\d+(?:beta)?$/.test(last)) return true - if (last === 'openai') return true - if (/\/api\/paas\/v\d+$/.test(path)) return true - if (/\/coding\/v\d+$/.test(path)) return true - if (/\/step_plan\/v\d+$/.test(path)) return true - return false -} - -function looksLikeAnthropicRoot(pathname: string): boolean { - const path = pathname.toLowerCase() - const segments = path.split('/').filter(Boolean) - const last = segments[segments.length - 1] || '' - return /^v\d+(?:beta)?$/.test(last) -} - -export function chatCompletionsUrl(baseUrl: string): string { - const url = normalizedBaseUrl(baseUrl) - const path = normalizedPath(url) - const base = url.toString().replace(/\/+$/, '') - if (pathEndsWith(path, 'chat/completions')) return base - if (looksLikeOpenAiRoot(path)) return appendPath(base, 'chat/completions') - return appendPath(base, 'v1/chat/completions') -} - -export function responsesUrl(baseUrl: string): string { - const url = normalizedBaseUrl(baseUrl) - const path = normalizedPath(url) - const base = url.toString().replace(/\/+$/, '') - if (pathEndsWith(path, 'responses')) return base - if (looksLikeOpenAiRoot(path)) return appendPath(base, 'responses') - return appendPath(base, 'v1/responses') -} - -export function anthropicMessagesUrl(baseUrl: string): string { - const url = normalizedBaseUrl(baseUrl) - const path = normalizedPath(url) - const base = url.toString().replace(/\/+$/, '') - if (pathEndsWith(path, 'messages')) return base - if (looksLikeAnthropicRoot(path)) return appendPath(base, 'messages') - return appendPath(base, 'v1/messages') -} - -export function providerEndpointUrl(kind: AgentEndpointKind, baseUrl: string): string { - if (kind === 'chat_completions') return chatCompletionsUrl(baseUrl) - if (kind === 'responses') return responsesUrl(baseUrl) - return anthropicMessagesUrl(baseUrl) -} diff --git a/packages/server/src/services/agent-runner/gateway.ts b/packages/server/src/services/agent-runner/gateway.ts deleted file mode 100644 index f73913e..0000000 --- a/packages/server/src/services/agent-runner/gateway.ts +++ /dev/null @@ -1,75 +0,0 @@ -export interface AgentGatewayRequest { - url: string - apiKey: string - body: unknown - headers?: Record - signal?: AbortSignal -} - -export class ProviderApiError extends Error { - status: number - providerError: unknown - - constructor(status: number, providerError: unknown, message: string) { - super(message) - this.name = 'ProviderApiError' - this.status = status - this.providerError = providerError - } -} - -export class AgentRunGateway { - async completeJson(request: AgentGatewayRequest): Promise { - const res = await this.post(request) - const data = await readProviderJson(res) - if (!res.ok) throwProviderError(res, data) - return data as T - } - - async streamBytes(request: AgentGatewayRequest): Promise> { - const res = await this.post(request) - if (!res.ok) { - const data = await readProviderJson(res) - throwProviderError(res, data) - } - const contentType = res.headers.get('content-type') || '' - if (contentType && !/text\/event-stream|application\/x-ndjson|octet-stream/i.test(contentType)) { - const data = await readProviderJson(res) - throwProviderError(res, data) - } - if (!res.body) throw new Error('Provider returned an empty stream') - return res.body as any - } - - private post(request: AgentGatewayRequest): Promise { - return fetch(request.url, { - method: 'POST', - headers: { - Authorization: `Bearer ${request.apiKey}`, - 'Content-Type': 'application/json', - ...request.headers, - }, - body: JSON.stringify(request.body), - signal: request.signal, - }) - } -} - -export async function readProviderJson(res: Response): Promise { - const text = await res.text() - try { - return JSON.parse(text) - } catch { - return { error: { message: text || `Provider returned HTTP ${res.status}` } } - } -} - -export function throwProviderError(res: Response, data: any): never { - throw new ProviderApiError( - res.status, - data, - data?.error?.message || `Provider returned HTTP ${res.status}`, - ) -} - -export const agentRunGateway = new AgentRunGateway() diff --git a/packages/server/src/services/agent-runner/proxies/claude-code-proxy.ts b/packages/server/src/services/agent-runner/proxies/claude-code-proxy.ts deleted file mode 100644 index 133efbe..0000000 --- a/packages/server/src/services/agent-runner/proxies/claude-code-proxy.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { Readable } from 'stream' -import type { Context } from 'koa' -import { config } from '../../../config' -import { - anthropicMessagesUrl as resolveAnthropicMessagesUrl, - chatCompletionsUrl as resolveChatCompletionsUrl, - responsesUrl as resolveResponsesUrl, -} from '../endpoint-resolver' -import { sseEvent } from '../sse' -import { AgentTargetRegistry, type AgentTargetInput, type RegisteredAgentTarget } from '../target-registry' -import type { ApiMode } from '../types' -import { - anthropicToOpenAiChat, - anthropicToOpenAiResponses, - openAiResponsesToAnthropicMessage, - openAiToAnthropicMessage, -} from '../adapters/anthropic' -import { - openAiChatSseToAnthropicEvents, - openAiResponsesSseToAnthropicEvents, - type AnthropicStreamEvent, -} from '../adapters/anthropic-stream' -import { - anthropicMessagesSseToResponsesEvents, - openAiChatSseToResponsesEvents, - openAiResponsesSseToResponsesEvents, - type CanonicalResponsesEvent, -} from '../adapters/responses-stream' -import { agentRunGateway } from '../gateway' -import { teeAsyncIterable } from '../stream-tee' -import { codingAgentRunManager } from '../coding-agent-run-manager' - -export type { ApiMode } from '../types' - -export interface ClaudeCodeProxyTargetInput extends AgentTargetInput {} - -type ClaudeCodeProxyTarget = RegisteredAgentTarget - -const targetRegistry = new AgentTargetRegistry( - input => [input.provider, input.model, input.apiMode, input.baseUrl, input.agentSessionId || '', input.chatSessionId || ''], -) -const CLAUDE_PROXY_VISIBLE_MODELS = [ - 'claude-haiku-4-5', - 'claude-sonnet-4-6', - 'claude-opus-4-7', -] - -function localProxyBaseUrl(routeKey: string): string { - return `http://127.0.0.1:${config.port}/api/claude-code-proxy/${routeKey}` -} - -export function registerClaudeCodeProxyTarget(input: ClaudeCodeProxyTargetInput): { baseUrl: string; token: string; routeKey: string } { - const target = targetRegistry.register(input) - - return { baseUrl: localProxyBaseUrl(target.routeKey), token: target.token, routeKey: target.routeKey } -} - -function findTarget(routeKey: string): ClaudeCodeProxyTarget | null { - return targetRegistry.find(routeKey) -} - -function authToken(ctx: Context): string { - const apiKey = ctx.get('x-api-key').trim() - if (apiKey) return apiKey - const auth = ctx.get('authorization').trim() - const match = auth.match(/^Bearer\s+(.+)$/i) - return match?.[1]?.trim() || '' -} - -function requireTarget(ctx: Context): ClaudeCodeProxyTarget | null { - const target = findTarget(String(ctx.params.key || '')) - if (!target) { - ctx.status = 404 - ctx.body = { type: 'error', error: { type: 'not_found_error', message: 'Claude proxy target not found' } } - return null - } - if (authToken(ctx) !== target.token) { - ctx.status = 401 - ctx.body = { type: 'error', error: { type: 'authentication_error', message: 'Invalid Claude proxy token' } } - return null - } - return target -} - -function anthropicMessagesUrl(target: ClaudeCodeProxyTarget): string { - return resolveAnthropicMessagesUrl(target.baseUrl) -} - -function anthropicRequestBody(body: any, target: ClaudeCodeProxyTarget): any { - return { - ...body, - model: target.model, - } -} - -async function callAnthropicMessages(target: ClaudeCodeProxyTarget, body: any): Promise { - if (target.apiMode !== 'anthropic_messages') { - const err = new Error(`Claude proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`) - ;(err as any).status = 501 - throw err - } - return agentRunGateway.completeJson({ - url: anthropicMessagesUrl(target), - apiKey: target.apiKey, - headers: { - 'x-api-key': target.apiKey, - 'anthropic-version': '2023-06-01', - }, - body: anthropicRequestBody(body, target), - }) -} - -async function callOpenAiChat(target: ClaudeCodeProxyTarget, body: any): Promise { - if (target.apiMode !== 'chat_completions') { - const err = new Error(`Claude proxy MVP only supports chat_completions targets, got ${target.apiMode}`) - ;(err as any).status = 501 - throw err - } - return agentRunGateway.completeJson({ - url: resolveChatCompletionsUrl(target.baseUrl), - apiKey: target.apiKey, - body: anthropicToOpenAiChat(body, target), - }) -} - -async function callOpenAiResponses(target: ClaudeCodeProxyTarget, body: any): Promise { - if (target.apiMode !== 'codex_responses') { - const err = new Error(`Claude proxy responses adapter only supports codex_responses targets, got ${target.apiMode}`) - ;(err as any).status = 501 - throw err - } - return agentRunGateway.completeJson({ - url: resolveResponsesUrl(target.baseUrl), - apiKey: target.apiKey, - body: anthropicToOpenAiResponses(body, target), - }) -} - -function anthropicEventStream(events: AsyncIterable): Readable { - async function* generate() { - for await (const event of events) { - yield sseEvent(event.type, event.data) - } - } - return Readable.from(generate()) -} - -function observeResponsesEvents(target: ClaudeCodeProxyTarget, events: AsyncIterable) { - void (async () => { - try { - for await (const event of events) { - codingAgentRunManager.handleProxyUsageEvent(target.agentSessionId, event) - codingAgentRunManager.handleResponseEvent(target.agentSessionId, event) - } - } catch (err) { - loggerLikeWarn(err, '[claude-code-proxy] failed to observe provider stream') - } - })() -} - -function loggerLikeWarn(err: unknown, message: string) { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - require('../../logger').logger.warn(err, message) - } catch {} -} - -async function openAiChatToAnthropicSseStream(target: ClaudeCodeProxyTarget, body: any): Promise { - if (target.apiMode !== 'chat_completions') { - const err = new Error(`Claude proxy MVP only supports chat_completions targets, got ${target.apiMode}`) - ;(err as any).status = 501 - throw err - } - - const stream = await agentRunGateway.streamBytes({ - url: resolveChatCompletionsUrl(target.baseUrl), - apiKey: target.apiKey, - body: anthropicToOpenAiChat(body, target, true), - }) - const [clientStream, observerStream] = teeAsyncIterable(stream) - observeResponsesEvents(target, openAiChatSseToResponsesEvents(observerStream, target)) - return anthropicEventStream(openAiChatSseToAnthropicEvents(clientStream, target)) -} - -async function anthropicMessagesSseStream(target: ClaudeCodeProxyTarget, body: any): Promise { - if (target.apiMode !== 'anthropic_messages') { - const err = new Error(`Claude proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`) - ;(err as any).status = 501 - throw err - } - - const stream = await agentRunGateway.streamBytes({ - url: anthropicMessagesUrl(target), - apiKey: target.apiKey, - headers: { - 'x-api-key': target.apiKey, - 'anthropic-version': '2023-06-01', - }, - body: anthropicRequestBody(body, target), - }) - const [clientStream, observerStream] = teeAsyncIterable(stream) - observeResponsesEvents(target, anthropicMessagesSseToResponsesEvents(observerStream, target)) - return Readable.from(clientStream) -} - -async function openAiResponsesToAnthropicSseStream(target: ClaudeCodeProxyTarget, body: any): Promise { - if (target.apiMode !== 'codex_responses') { - const err = new Error(`Claude proxy responses adapter only supports codex_responses targets, got ${target.apiMode}`) - ;(err as any).status = 501 - throw err - } - - const stream = await agentRunGateway.streamBytes({ - url: resolveResponsesUrl(target.baseUrl), - apiKey: target.apiKey, - body: anthropicToOpenAiResponses(body, target, true), - }) - const [clientStream, observerStream] = teeAsyncIterable(stream) - observeResponsesEvents(target, openAiResponsesSseToResponsesEvents(observerStream)) - return anthropicEventStream(openAiResponsesSseToAnthropicEvents(clientStream, target)) -} - -export async function claudeProxyModels(ctx: Context) { - const target = requireTarget(ctx) - if (!target) return - const ids = [...new Set([...CLAUDE_PROXY_VISIBLE_MODELS, target.model])] - ctx.body = { - data: ids.map(id => ({ - type: 'model', - id, - display_name: id, - created_at: '2026-01-01T00:00:00Z', - })), - has_more: false, - first_id: ids[0], - last_id: ids[ids.length - 1], - } -} - -export async function claudeProxyMessages(ctx: Context) { - const target = requireTarget(ctx) - if (!target) return - try { - const requestBody = ctx.request.body || {} - if ((requestBody as any).stream === true) { - const stream = target.apiMode === 'anthropic_messages' - ? await anthropicMessagesSseStream(target, requestBody) - : target.apiMode === 'codex_responses' - ? await openAiResponsesToAnthropicSseStream(target, requestBody) - : await openAiChatToAnthropicSseStream(target, requestBody) - ctx.set('Content-Type', 'text/event-stream; charset=utf-8') - ctx.set('Cache-Control', 'no-cache') - ctx.body = stream - } else { - const message = target.apiMode === 'anthropic_messages' - ? await callAnthropicMessages(target, requestBody) - : target.apiMode === 'codex_responses' - ? openAiResponsesToAnthropicMessage(await callOpenAiResponses(target, requestBody), target) - : openAiToAnthropicMessage(await callOpenAiChat(target, requestBody), target) - ctx.body = message - } - } catch (err: any) { - ctx.status = err.status || 502 - ctx.body = { - type: 'error', - error: { - type: 'api_error', - message: err?.message || 'Claude proxy request failed', - provider_error: err?.providerError, - }, - } - } -} diff --git a/packages/server/src/services/agent-runner/proxies/codex-proxy.ts b/packages/server/src/services/agent-runner/proxies/codex-proxy.ts deleted file mode 100644 index 576d1aa..0000000 --- a/packages/server/src/services/agent-runner/proxies/codex-proxy.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { Readable } from 'stream' -import type { Context } from 'koa' -import { config } from '../../../config' -import { - anthropicMessagesUrl as resolveAnthropicMessagesUrl, - chatCompletionsUrl as resolveChatCompletionsUrl, - responsesUrl as resolveResponsesUrl, -} from '../endpoint-resolver' -import { sseEvent } from '../sse' -import { AgentTargetRegistry, type AgentTargetInput, type RegisteredAgentTarget } from '../target-registry' -import type { ApiMode } from '../types' -import { - anthropicMessageToResponses, - openAiChatToResponses, - responsesToAnthropicMessages, - responsesToOpenAiChat, -} from '../adapters/responses' -import { - anthropicMessagesSseToResponsesEvents, - openAiChatSseToResponsesEvents, - openAiResponsesSseToResponsesEvents, - type CanonicalResponsesEvent, -} from '../adapters/responses-stream' -import { agentRunGateway } from '../gateway' -import { codingAgentRunManager } from '../coding-agent-run-manager' - -export interface CodexProxyTargetInput extends AgentTargetInput { - profile: string -} - -type CodexProxyTarget = RegisteredAgentTarget - -const targetRegistry = new AgentTargetRegistry( - input => [input.profile.trim(), input.provider, input.model, input.apiMode, input.baseUrl, input.agentSessionId || '', input.chatSessionId || ''], -) - -function localProxyBaseUrl(routeKey: string): string { - return `http://127.0.0.1:${config.port}/api/codex-proxy/${routeKey}/v1` -} - -export function registerCodexProxyTarget(input: CodexProxyTargetInput): { baseUrl: string; token: string; routeKey: string } { - const target = targetRegistry.register({ - ...input, - profile: input.profile.trim(), - }) - - return { baseUrl: localProxyBaseUrl(target.routeKey), token: target.token, routeKey: target.routeKey } -} - -function findTarget(routeKey: string): CodexProxyTarget | null { - return targetRegistry.find(routeKey) -} - -function authToken(ctx: Context): string { - const apiKey = ctx.get('x-api-key').trim() - if (apiKey) return apiKey - const auth = ctx.get('authorization').trim() - const match = auth.match(/^Bearer\s+(.+)$/i) - return match?.[1]?.trim() || '' -} - -function requireTarget(ctx: Context): CodexProxyTarget | null { - const target = findTarget(String(ctx.params.key || '')) - if (!target) { - ctx.status = 404 - ctx.body = { error: { type: 'not_found_error', message: 'Codex proxy target not found' } } - return null - } - if (authToken(ctx) !== target.token) { - ctx.status = 401 - ctx.body = { error: { type: 'authentication_error', message: 'Invalid Codex proxy token' } } - return null - } - return target -} - -function chatCompletionsUrl(target: CodexProxyTarget): string { - return resolveChatCompletionsUrl(target.baseUrl) -} - -function anthropicMessagesUrl(target: CodexProxyTarget): string { - return resolveAnthropicMessagesUrl(target.baseUrl) -} - -async function callOpenAiChat(target: CodexProxyTarget, body: any): Promise { - if (target.apiMode !== 'chat_completions') { - const err = new Error(`Codex proxy only supports chat_completions targets, got ${target.apiMode}`) - ;(err as any).status = 501 - throw err - } - const chatBody = responsesToOpenAiChat(body, target) - return agentRunGateway.completeJson({ - url: chatCompletionsUrl(target), - apiKey: target.apiKey, - body: chatBody, - }) -} - -async function callAnthropicMessages(target: CodexProxyTarget, body: any): Promise { - if (target.apiMode !== 'anthropic_messages') { - const err = new Error(`Codex proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`) - ;(err as any).status = 501 - throw err - } - const anthropicBody = responsesToAnthropicMessages(body, target) - return agentRunGateway.completeJson({ - url: anthropicMessagesUrl(target), - apiKey: target.apiKey, - headers: { - 'x-api-key': target.apiKey, - 'anthropic-version': '2023-06-01', - }, - body: anthropicBody, - }) -} - -async function callOpenAiResponses(target: CodexProxyTarget, body: any): Promise { - if (target.apiMode !== 'codex_responses') { - const err = new Error(`Codex proxy Responses adapter only supports codex_responses targets, got ${target.apiMode}`) - ;(err as any).status = 501 - throw err - } - const responsesBody = { ...body, model: target.model } - return agentRunGateway.completeJson({ - url: resolveResponsesUrl(target.baseUrl), - apiKey: target.apiKey, - body: responsesBody, - }) -} - -function responsesEventStream(events: AsyncIterable): Readable { - async function* generate() { - for await (const event of events) { - yield sseEvent(event.type, event.data) - } - } - return Readable.from(generate()) -} - -function responseEventForCodexClient(target: CodexProxyTarget, event: CanonicalResponsesEvent): CanonicalResponsesEvent { - if (target.apiMode === 'codex_responses' || event.type !== 'response.completed') return event - const response = (event.data as any).response - if (!response?.usage) return event - const { usage: _usage, ...responseWithoutUsage } = response - return { - ...event, - data: { - ...event.data, - response: responseWithoutUsage, - }, - } -} - -function observableResponsesEvents(target: CodexProxyTarget, events: AsyncIterable): AsyncIterable { - async function* observe() { - for await (const event of events) { - codingAgentRunManager.handleProxyUsageEvent(target.agentSessionId, event) - const clientEvent = responseEventForCodexClient(target, event) - codingAgentRunManager.handleResponseEvent(target.agentSessionId, clientEvent) - yield clientEvent - } - } - return observe() -} - -async function openAiChatToResponsesSseStream(target: CodexProxyTarget, body: any): Promise { - if (target.apiMode !== 'chat_completions') { - const err = new Error(`Codex proxy only supports chat_completions targets, got ${target.apiMode}`) - ;(err as any).status = 501 - throw err - } - - const chatBody = responsesToOpenAiChat(body, target, true) - const stream = await agentRunGateway.streamBytes({ - url: chatCompletionsUrl(target), - apiKey: target.apiKey, - body: chatBody, - }) - return responsesEventStream(observableResponsesEvents(target, openAiChatSseToResponsesEvents(stream, { - ...target, - annotateMcpToolNamespaces: true, - }))) -} - -async function anthropicMessagesToResponsesSseStream(target: CodexProxyTarget, body: any): Promise { - if (target.apiMode !== 'anthropic_messages') { - const err = new Error(`Codex proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`) - ;(err as any).status = 501 - throw err - } - - const anthropicBody = responsesToAnthropicMessages(body, target, true) - const stream = await agentRunGateway.streamBytes({ - url: anthropicMessagesUrl(target), - apiKey: target.apiKey, - headers: { - 'x-api-key': target.apiKey, - 'anthropic-version': '2023-06-01', - }, - body: anthropicBody, - }) - return responsesEventStream(observableResponsesEvents(target, anthropicMessagesSseToResponsesEvents(stream, { - ...target, - annotateMcpToolNamespaces: true, - }))) -} - -async function openAiResponsesSseStream(target: CodexProxyTarget, body: any): Promise { - if (target.apiMode !== 'codex_responses') { - const err = new Error(`Codex proxy Responses adapter only supports codex_responses targets, got ${target.apiMode}`) - ;(err as any).status = 501 - throw err - } - - const responsesBody = { ...body, model: target.model, stream: true } - const stream = await agentRunGateway.streamBytes({ - url: resolveResponsesUrl(target.baseUrl), - apiKey: target.apiKey, - body: responsesBody, - }) - return responsesEventStream(observableResponsesEvents(target, openAiResponsesSseToResponsesEvents(stream))) -} - -export async function codexProxyResponses(ctx: Context) { - const target = requireTarget(ctx) - if (!target) return - try { - const requestBody = ctx.request.body || {} - if ((requestBody as any).stream === true) { - const stream = target.apiMode === 'anthropic_messages' - ? await anthropicMessagesToResponsesSseStream(target, requestBody) - : target.apiMode === 'codex_responses' - ? await openAiResponsesSseStream(target, requestBody) - : await openAiChatToResponsesSseStream(target, requestBody) - ctx.set('Content-Type', 'text/event-stream; charset=utf-8') - ctx.set('Cache-Control', 'no-cache') - ctx.body = stream - } else { - ctx.body = target.apiMode === 'anthropic_messages' - ? anthropicMessageToResponses(await callAnthropicMessages(target, requestBody), target) - : target.apiMode === 'codex_responses' - ? await callOpenAiResponses(target, requestBody) - : openAiChatToResponses(await callOpenAiChat(target, requestBody), target) - } - } catch (err: any) { - ctx.status = err.status || 502 - ctx.body = { - error: { - type: 'api_error', - message: err?.message || 'Codex proxy request failed', - provider_error: err?.providerError, - }, - } - } -} - -export async function codexProxyModels(ctx: Context) { - const target = requireTarget(ctx) - if (!target) return - ctx.body = { - object: 'list', - data: [{ - id: target.model, - object: 'model', - created: 0, - owned_by: target.provider, - }], - } -} diff --git a/packages/server/src/services/agent-runner/sse.ts b/packages/server/src/services/agent-runner/sse.ts deleted file mode 100644 index d304b7a..0000000 --- a/packages/server/src/services/agent-runner/sse.ts +++ /dev/null @@ -1,63 +0,0 @@ -export interface SseFrame { - event?: string - data: string -} - -export function parseSseFrame(raw: string): SseFrame | null { - let event: string | undefined - const data: string[] = [] - for (const line of raw.split(/\r?\n/)) { - if (!line || line.startsWith(':')) continue - if (line.startsWith('event:')) { - event = line.slice(6).trim() - continue - } - if (line.startsWith('data:')) { - data.push(line.slice(5).trimStart()) - } - } - if (!data.length) return null - return { event, data: data.join('\n') } -} - -export function readSseFrameTexts(buffer: string): { frames: string[]; rest: string } { - const frames: string[] = [] - let start = 0 - const boundary = /\r?\n\r?\n/g - let match: RegExpExecArray | null - while ((match = boundary.exec(buffer)) !== null) { - frames.push(buffer.slice(start, match.index)) - start = match.index + match[0].length - } - return { frames, rest: buffer.slice(start) } -} - -export async function* readSseFrames(stream: ReadableStream): AsyncGenerator { - const decoder = new TextDecoder() - const reader = stream.getReader() - let buffer = '' - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - const parsed = readSseFrameTexts(buffer) - buffer = parsed.rest - for (const raw of parsed.frames) { - const frame = parseSseFrame(raw) - if (frame) yield frame - } - } - - buffer += decoder.decode() - const frame = parseSseFrame(buffer) - if (frame) yield frame - } finally { - reader.releaseLock() - } -} - -export function sseEvent(event: string, data: unknown): string { - return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` -} diff --git a/packages/server/src/services/agent-runner/stream-tee.ts b/packages/server/src/services/agent-runner/stream-tee.ts deleted file mode 100644 index 5204065..0000000 --- a/packages/server/src/services/agent-runner/stream-tee.ts +++ /dev/null @@ -1,52 +0,0 @@ -export function teeAsyncIterable(source: AsyncIterable): [AsyncIterable, AsyncIterable] { - const iterator = source[Symbol.asyncIterator]() - const buffers: [T[], T[]] = [[], []] - const waiters: Array<(() => void)[]> = [[], []] - let pulling: Promise | null = null - let done = false - let error: unknown - - async function pull() { - if (pulling) return pulling - pulling = (async () => { - try { - const next = await iterator.next() - if (next.done) { - done = true - } else { - buffers[0].push(next.value) - buffers[1].push(next.value) - } - } catch (err) { - error = err - done = true - } finally { - pulling = null - for (const queue of waiters) { - for (const resolve of queue.splice(0)) resolve() - } - } - })() - return pulling - } - - function branch(index: 0 | 1): AsyncIterable { - return { - async *[Symbol.asyncIterator]() { - while (true) { - if (buffers[index].length > 0) { - yield buffers[index].shift() as T - continue - } - if (error) throw error - if (done) return - const waiting = new Promise(resolve => waiters[index].push(resolve)) - void pull() - await waiting - } - }, - } - } - - return [branch(0), branch(1)] -} diff --git a/packages/server/src/services/agent-runner/target-registry.ts b/packages/server/src/services/agent-runner/target-registry.ts deleted file mode 100644 index 301e341..0000000 --- a/packages/server/src/services/agent-runner/target-registry.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { randomBytes } from 'crypto' -import type { AgentApiMode } from './types' - -export interface AgentTargetInput { - provider: string - model: string - baseUrl: string - apiKey: string - apiMode?: AgentApiMode - reasoningEffort?: string - agentId?: string - agentSessionId?: string - chatSessionId?: string -} - -export type NormalizedAgentTargetInput = Omit & { - provider: string - model: string - baseUrl: string - apiKey: string - apiMode: AgentApiMode -} - -export type RegisteredAgentTarget = NormalizedAgentTargetInput & { - key: string - routeKey: string - token: string - updatedAt: number -} - -export class AgentTargetRegistry { - private targets = new Map>() - - constructor(private readonly keyParts: (input: NormalizedAgentTargetInput) => string[]) {} - - register(input: T): RegisteredAgentTarget { - const normalized = { - ...input, - provider: input.provider.trim(), - model: input.model.trim(), - baseUrl: input.baseUrl.trim().replace(/\/+$/, ''), - apiMode: input.apiMode || 'chat_completions', - } as NormalizedAgentTargetInput - const key = this.keyParts(normalized).join('\0') - const existing = this.targets.get(key) - const routeKey = existing?.routeKey || Buffer.from(key, 'utf-8').toString('base64url') - const token = existing?.token || `hwui_${randomBytes(24).toString('base64url')}` - const target = { - ...normalized, - key, - routeKey, - token, - updatedAt: Date.now(), - } as RegisteredAgentTarget - - this.targets.set(key, target) - return target - } - - find(routeKey: string): RegisteredAgentTarget | null { - for (const target of this.targets.values()) { - if (target.routeKey === routeKey) return target - } - return null - } -} diff --git a/packages/server/src/services/agent-runner/types.ts b/packages/server/src/services/agent-runner/types.ts deleted file mode 100644 index c200c20..0000000 --- a/packages/server/src/services/agent-runner/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type AgentApiMode = - | 'chat_completions' - | 'codex_responses' - | 'anthropic_messages' - | 'bedrock_converse' - | 'codex_app_server' - -export type ApiMode = AgentApiMode diff --git a/packages/server/src/services/coding-agents.ts b/packages/server/src/services/coding-agents.ts deleted file mode 100644 index 336807e..0000000 --- a/packages/server/src/services/coding-agents.ts +++ /dev/null @@ -1,1952 +0,0 @@ -import { execFile } from 'child_process' -import { randomUUID } from 'crypto' -import { existsSync, readdirSync, realpathSync } from 'fs' -import { chmod, mkdir, readFile, stat, writeFile } from 'fs/promises' -import { homedir } from 'os' -import { delimiter, dirname, join } from 'path' -import { promisify } from 'util' -import { getWebUiHome } from '../config' -import { PROVIDER_ENV_MAP, readConfigYamlForProfile, safeReadFile } from './config-helpers' -import { getCompatibleCustomProviders } from './hermes/custom-providers-compat' -import { registerClaudeCodeProxyTarget } from './agent-runner/proxies/claude-code-proxy' -import { registerCodexProxyTarget } from './agent-runner/proxies/codex-proxy' -import type { ApiMode } from './agent-runner/types' -import { PROVIDER_PRESETS } from '../shared/providers' -import { getModelContextLength } from './hermes/model-context' -import { getProfileDir } from './hermes/hermes-profile' -import { getSystemPrompt } from '../lib/llm-prompt' -import { codingAgentRunManager } from './agent-runner/coding-agent-run-manager' -import { getSession, updateSession, type HermesSessionRow } from '../db/hermes/session-store' -import type { SessionState } from './hermes/run-chat/types' -import { normalizeWindowsCommandPath, windowsCmdShimExecution, windowsCommandNeedsShell, type WindowsCommandExecution } from './windows-command' - -const execFileAsync = promisify(execFile) -const LAUNCH_API_MODES = new Set(['chat_completions', 'codex_responses', 'anthropic_messages']) -const CODING_AGENT_HOME_DIR = 'coding-agent' -const CODEX_MODEL_CATALOG_FILE = 'codex-model-catalog.json' -const CODEX_CATALOG_BASE_INSTRUCTIONS = 'You are Codex, a coding agent. Be precise, safe, and helpful.' -const NODE_ENVIRONMENT_MISSING_CODE = 'node_environment_missing' -const POSIX_LAUNCHER_FILE = 'launch.sh' -const WINDOWS_LAUNCHER_FILE = 'launch.ps1' -const CODING_AGENT_SCOPED_AUTH_PROVIDERS = new Set(['openai-codex', 'copilot', 'xai-oauth', 'nous', 'google-gemini-cli', 'claude-oauth']) -const CLAUDE_CODE_SKIP_PERMISSIONS_ARGS = ['--dangerously-skip-permissions'] -const CLAUDE_CODE_ROOT_PERMISSION_ARGS = ['--permission-mode', 'auto'] -const HERMES_MCP_SERVERS = [ - { name: 'hermes-studio-api', toolset: 'api' }, - { name: 'hermes-studio-devices', toolset: 'devices' }, - { name: 'hermes-studio-use', toolset: 'use' }, -] as const -const HERMES_MCP_SERVER_NAMES: Set = new Set(HERMES_MCP_SERVERS.map(server => server.name)) -const LEGACY_HERMES_MCP_SERVER_NAMES = new Set(['hermes-studio', 'hermes-studio-mcp', 'hermes-web-ui-mcp']) -const LEGACY_HERMES_MCP_COMMANDS = new Set([ - 'hermes-lan-peer-mcp', - 'hermes-devices-mcp', - 'hermes-web-ui-mcp', - 'hermes-studio-mcp', -]) -const HERMES_MCP_MANAGED_ENV_KEY = 'HERMES_WEB_UI_MANAGED_MCP' -const HERMES_PROMPT_BLOCK_BEGIN = '' -const HERMES_PROMPT_BLOCK_END = '' - -interface CommandExecution { - command: string - args: string[] - windowsVerbatimArguments?: WindowsCommandExecution['windowsVerbatimArguments'] -} - -export type CodingAgentId = 'claude-code' | 'codex' - -export interface CodingAgentDefinition { - id: CodingAgentId - name: string - provider: string - command: string - packageName: string -} - -export interface CodingAgentToolStatus extends CodingAgentDefinition { - installed: boolean - version: string - rawVersion: string - error?: string -} - -export interface CodingAgentsStatus { - tools: CodingAgentToolStatus[] -} - -export interface CodingAgentMutationResult extends CodingAgentsStatus { - success: boolean - tool: CodingAgentToolStatus - message?: string - code?: string -} - -export interface CodingAgentConfigFileDefinition { - key: string - path: string - absolutePath: string - language: string -} - -export interface CodingAgentConfigScope { - profile?: string - provider?: string -} - -export interface CodingAgentConfigFileContent extends CodingAgentConfigFileDefinition { - content: string - exists: boolean - size: number - profile: string - provider: string - rootDir: string -} - -export interface CodingAgentLaunchInput extends CodingAgentConfigScope { - mode?: 'scoped' | 'global' - model?: string - workspace?: string | null - baseUrl?: string - apiKey?: string - apiMode?: ApiMode - reasoningEffort?: string - sessionId?: string - agentSessionId?: string - agentNativeSessionId?: string - isolateSettings?: boolean - sessionSource?: 'global_agent' | 'workflow' -} - -export interface CodingAgentLaunchResult { - agentId: CodingAgentId - mode: 'scoped' | 'global' - profile: string - provider: string - model: string - apiMode?: ApiMode - rootDir: string - workspaceDir: string - command: string - args: string[] - env: Record - shellCommand: string - files: Array<{ key: string; path: string; absolutePath: string }> - reasoningEffort?: string -} - -export interface CodingAgentNativeLaunchResult extends CodingAgentLaunchResult { - nativeTerminal: true - terminal: string -} - -export interface CodingAgentRunStartResult extends CodingAgentLaunchResult { - agentSessionId: string - sessionId: string - pid: number -} - -const TOOL_DEFINITIONS: CodingAgentDefinition[] = [ - { - id: 'claude-code', - name: 'Claude Code', - provider: 'Anthropic', - command: 'claude', - packageName: '@anthropic-ai/claude-code', - }, - { - id: 'codex', - name: 'Codex', - provider: 'OpenAI', - command: 'codex', - packageName: '@openai/codex', - }, -] - -const CONFIG_FILE_DEFINITIONS: Record & { scopedPath: string }>> = { - 'claude-code': [ - { key: 'settings', path: '~/.claude/settings.json', scopedPath: 'settings.json', language: 'json' }, - { key: 'mcp', path: '~/.claude/mcp.json', scopedPath: 'mcp.json', language: 'json' }, - { key: 'prompt', path: '~/.claude/hermes-rules.md', scopedPath: 'hermes-rules.md', language: 'markdown' }, - ], - codex: [ - { key: 'auth', path: '~/.codex/auth.json', scopedPath: 'auth.json', language: 'json' }, - { key: 'config', path: '~/.codex/config.toml', scopedPath: 'config.toml', language: 'ini' }, - { key: 'agents', path: '~/.codex/AGENTS.md', scopedPath: 'AGENTS.md', language: 'markdown' }, - ], -} - -const installingTools = new Set() -const deletingTools = new Set() -let cachedGlobalNpmBin: string | null | undefined -let cachedLoginShellPath: string | null | undefined -const MAX_CONFIG_FILE_SIZE = parseInt(process.env.MAX_EDIT_SIZE || '', 10) || 10 * 1024 * 1024 - -function getNodeBinDir() { - return dirname(process.execPath) -} - -function getNodePrefix() { - return process.platform === 'win32' ? getNodeBinDir() : dirname(getNodeBinDir()) -} - -function getHomebrewPrefix() { - const match = process.execPath.match(/^(.*)\/Cellar\/[^/]+\/[^/]+\/bin\/node$/) - return match?.[1] || null -} - -function getNpmCliCandidates() { - const prefix = getNodePrefix() - const homebrewPrefix = getHomebrewPrefix() - - return process.platform === 'win32' - ? [ - join(prefix, 'node_modules', 'npm', 'bin', 'npm-cli.js'), - join(getNodeBinDir(), 'node_modules', 'npm', 'bin', 'npm-cli.js'), - ] - : [ - join(prefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), - ...(homebrewPrefix ? [join(homebrewPrefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')] : []), - ] -} - -function getNpmCliPath() { - return getNpmCliCandidates().find(existsSync) || null -} - -function getNpmBin() { - return process.platform === 'win32' ? 'npm.cmd' : 'npm' -} - -function getGlobalConfigHome() { - return process.env.HERMES_CODING_AGENT_GLOBAL_HOME?.trim() || homedir() -} - -function compareNodeVersionDesc(left: string, right: string): number { - const leftParts = left.replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10) || 0) - const rightParts = right.replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10) || 0) - for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) { - const diff = (rightParts[index] || 0) - (leftParts[index] || 0) - if (diff !== 0) return diff - } - return right.localeCompare(left) -} - -function getNvmNodeBinPaths(): string { - if (process.env.HERMES_DESKTOP !== 'true' || process.platform === 'win32') return '' - - const nvmDir = process.env.NVM_DIR?.trim() || join(homedir(), '.nvm') - const versionsDir = join(nvmDir, 'versions', 'node') - if (!existsSync(versionsDir)) return '' - - try { - return readdirSync(versionsDir, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => entry.name) - .sort(compareNodeVersionDesc) - .map(version => join(versionsDir, version, 'bin')) - .filter(binDir => existsSync(binDir)) - .join(delimiter) - } catch { - return '' - } -} - -function getLoginShellCandidates(): string[] { - if (process.platform === 'win32') return [] - return [ - process.env.SHELL || '', - '/bin/zsh', - '/bin/bash', - '/usr/bin/zsh', - '/usr/bin/bash', - ].filter(Boolean) -} - -function getLoginShell(): string | null { - for (const shell of [...new Set(getLoginShellCandidates())]) { - if (shell.startsWith('/') && existsSync(shell)) return shell - } - return null -} - -async function getLoginShellPath(): Promise { - if (process.env.HERMES_DESKTOP !== 'true' || process.platform === 'win32') return null - if (typeof cachedLoginShellPath !== 'undefined') return cachedLoginShellPath - - const shell = getLoginShell() - if (!shell) { - cachedLoginShellPath = null - return cachedLoginShellPath - } - - try { - const { stdout } = await execFileAsync(shell, ['-lc', 'printf %s "$PATH"'], { - encoding: 'utf-8', - timeout: 3000, - windowsHide: true, - }) - cachedLoginShellPath = stdout.trim() || null - } catch { - cachedLoginShellPath = null - } - return cachedLoginShellPath -} - -function getDesktopCommonBinPaths(): string[] { - if (process.env.HERMES_DESKTOP !== 'true' || process.platform === 'win32') return [] - const home = homedir() - return [ - join(home, '.npm-global', 'bin'), - join(home, '.local', 'bin'), - join(home, '.yarn', 'bin'), - join(home, '.config', 'yarn', 'global', 'node_modules', '.bin'), - join(home, '.pnpm'), - join(home, 'Library', 'pnpm'), - '/opt/homebrew/bin', - '/usr/local/bin', - ] -} - -function prependPathEntries(env: NodeJS.ProcessEnv, entries: Array) { - const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path') || 'PATH' - const currentPath = env[pathKey] || '' - const existing = new Set(currentPath.split(delimiter).filter(Boolean)) - const prepended: string[] = [] - - for (const entry of entries) { - if (!entry) continue - for (const segment of entry.split(delimiter).map(item => item.trim()).filter(Boolean)) { - if (existing.has(segment) || prepended.includes(segment)) continue - prepended.push(segment) - } - } - - if (prepended.length > 0) { - env[pathKey] = currentPath ? `${prepended.join(delimiter)}${delimiter}${currentPath}` : prepended.join(delimiter) - } -} - -function nodeEnvironmentMissingError(): Error { - const err = new Error('Node/npm environment was not detected. Please install Node.js and try again.') - ;(err as any).code = NODE_ENVIRONMENT_MISSING_CODE - return err -} - -function isNodeEnvironmentMissingError(err: any): boolean { - const text = [ - err?.code, - err?.message, - typeof err?.stderr === 'string' ? err.stderr : '', - typeof err?.stdout === 'string' ? err.stdout : '', - ].filter(Boolean).join('\n').toLowerCase() - return text.includes('enoent') || - text.includes('spawn npm') || - text.includes('npm: command not found') || - text.includes('npm not found') || - text.includes('node: command not found') || - text.includes('node not found') -} - -function npmCliFromNpmBin(npmBin: string): { node: string; npmCli: string } | null { - const binDir = dirname(npmBin) - if (process.platform === 'win32') { - const node = join(binDir, 'node.exe') - const npmCli = join(binDir, 'node_modules', 'npm', 'bin', 'npm-cli.js') - return existsSync(node) && existsSync(npmCli) ? { node, npmCli } : null - } - - const node = join(binDir, 'node') - const npmCli = join(dirname(binDir), 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js') - return existsSync(node) && existsSync(npmCli) ? { node, npmCli } : null -} - -function normalizeScopeSegment(value: string | undefined, fallback: string, label: string): string { - // Replace invalid filename characters with underscores - // Windows invalid chars: < > : " / \ | ? * - // Additional problematic chars: control characters - const sanitizedValue = String(value || '').trim().replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') - const segment = sanitizedValue || fallback - - if ( - segment === '.' || - segment === '..' || - segment.includes('\0') - ) { - const err = new Error(`Invalid ${label}`) - ;(err as any).status = 400 - throw err - } - if (segment.length > 128) { - const err = new Error(`${label} is too long`) - ;(err as any).status = 400 - throw err - } - return segment -} - -function normalizeConfigScope(scope: CodingAgentConfigScope = {}): Required { - return { - profile: normalizeScopeSegment(scope.profile, 'default', 'profile'), - provider: normalizeScopeSegment(scope.provider, 'default', 'provider'), - } -} - -function slugProviderName(value: string): string { - return String(value || '').trim().toLowerCase().replace(/ /g, '-') -} - -function providerKeyWithoutCustomPrefix(providerKey: string): string { - if (providerKey.startsWith('custom:')) return providerKey.slice('custom:'.length) - if (providerKey.startsWith('custom_')) return providerKey.slice('custom_'.length) - return providerKey -} - -function providerLookupCandidates(provider: string): string[] { - const trimmed = String(provider || '').trim() - const withoutCustom = providerKeyWithoutCustomPrefix(trimmed) - return [...new Set([ - trimmed, - withoutCustom, - withoutCustom ? `custom:${withoutCustom}` : '', - withoutCustom ? `custom_${withoutCustom}` : '', - ].filter(Boolean))] -} - -function parseEnvValue(envContent: string, key: string): string { - if (!key) return '' - const lines = envContent.split(/\r?\n/) - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith('#')) continue - const eqIndex = trimmed.indexOf('=') - if (eqIndex === -1) continue - if (trimmed.slice(0, eqIndex).trim() !== key) continue - const raw = trimmed.slice(eqIndex + 1).trim() - if ( - (raw.startsWith('"') && raw.endsWith('"')) || - (raw.startsWith("'") && raw.endsWith("'")) - ) { - return raw.slice(1, -1) - } - return raw - } - return '' -} - -function inferLaunchApiMode(provider: string, baseUrl: string, fallback: ApiMode = 'chat_completions'): ApiMode { - const providerKey = String(provider || '').toLowerCase() - const normalizedBaseUrl = String(baseUrl || '').toLowerCase() - if ( - providerKey.includes('claude') || - providerKey === 'anthropic' || - normalizedBaseUrl.includes('anthropic') || - normalizedBaseUrl.includes('/anthropic') - ) { - return 'anthropic_messages' - } - if ( - providerKey === 'deepseek' || - providerKey === 'lmstudio' || - normalizedBaseUrl.includes('deepseek') || - normalizedBaseUrl.includes('127.0.0.1') || - normalizedBaseUrl.includes('localhost') - ) { - return 'chat_completions' - } - return fallback -} - -function providerPresetHost(value?: string): string { - const url = String(value || '').trim() - if (!url) return '' - try { - return new URL(url).hostname.toLowerCase() - } catch { - return '' - } -} - -function belongsToDifferentBuiltinProvider(provider: string, baseUrl: string): boolean { - const providerKey = providerKeyWithoutCustomPrefix(String(provider || '').trim().toLowerCase()) - if (!providerKey || provider !== providerKey) return false - const currentPreset = PROVIDER_PRESETS.find(item => item.value === providerKey) - if (!currentPreset) return false - const inputHost = providerPresetHost(baseUrl) - const currentHost = providerPresetHost(currentPreset.base_url) - if (!inputHost || !currentHost || inputHost === currentHost) return false - return PROVIDER_PRESETS.some((item) => ( - item.value !== providerKey && - providerPresetHost(item.base_url) === inputHost - )) -} - -function isScopedCodingAgentAuthProvider(provider: string, apiKey = ''): boolean { - const providerKey = String(provider || '').trim().toLowerCase() - return CODING_AGENT_SCOPED_AUTH_PROVIDERS.has(providerKey) -} - -function assertScopedCodingAgentProviderAllowed(mode: CodingAgentLaunchResult['mode'], provider: string, apiKey = ''): void { - if (mode === 'global') return - if (!isScopedCodingAgentAuthProvider(provider, apiKey)) return - const err = new Error('Coding agent scoped mode does not support OAuth/subscription providers. Use global mode or select an API-key provider.') - ;(err as any).status = 400 - throw err -} - -async function resolveStoredProviderLaunchInput( - input: CodingAgentLaunchInput & { sessionId: string }, - existingSession: HermesSessionRow | null, -): Promise { - if (input.mode === 'global') return input - - const profile = String(input.profile || existingSession?.profile || 'default').trim() || 'default' - const inputProvider = String(input.provider || '').trim() - const storedProvider = String(existingSession?.provider || '').trim() - const provider = String(inputProvider || storedProvider).trim() - const model = String(input.model || existingSession?.model || '').trim() - const workspace = input.workspace || existingSession?.workspace || undefined - let baseUrl = String(input.baseUrl || '').trim() - let apiKey = String(input.apiKey || '').trim() - const storedApiMode = !inputProvider || inputProvider === storedProvider - ? normalizeStoredLaunchApiMode(existingSession?.api_mode) - : undefined - let apiMode = input.apiMode || storedApiMode - let canonicalProvider = provider - const ignoredStaleProviderRuntime = belongsToDifferentBuiltinProvider(provider, baseUrl) - if (ignoredStaleProviderRuntime) { - baseUrl = '' - apiKey = '' - } - - if (!provider || (baseUrl && apiKey && apiMode)) { - return { ...input, profile, provider: provider || input.provider, model: model || input.model, workspace, baseUrl, apiKey, apiMode } - } - - let config: Record = {} - try { - config = await readConfigYamlForProfile(profile) - } catch {} - const envContent = await safeReadFile(join(getProfileDir(profile), '.env')) || '' - const normalizedProvider = providerKeyWithoutCustomPrefix(provider) - const preset = PROVIDER_PRESETS.find(item => item.value === normalizedProvider) - const candidates = providerLookupCandidates(provider) - - const customProviders = getCompatibleCustomProviders(config) - const customEntry = customProviders.find((entry) => { - const name = slugProviderName(String(entry?.name || '')) - return candidates.includes(`custom:${name}`) || candidates.includes(`custom_${name}`) || candidates.includes(name) - }) - if (customEntry) { - canonicalProvider = `custom:${slugProviderName(String(customEntry.name || normalizedProvider))}` - if (!baseUrl) baseUrl = String(customEntry.base_url || '').trim() - if (!apiKey) apiKey = String(customEntry.api_key || '').trim() - if (!apiKey) { - const keyEnv = String(customEntry.key_env || '').trim() - if (keyEnv) apiKey = parseEnvValue(envContent, keyEnv) - } - if (!apiMode) { - apiMode = normalizeLaunchApiMode( - customEntry.api_mode, - preset?.api_mode || inferLaunchApiMode(canonicalProvider, baseUrl, 'chat_completions'), - ) - } - } - - const canonicalProviderKey = providerKeyWithoutCustomPrefix(canonicalProvider) - const canonicalPreset = PROVIDER_PRESETS.find(item => item.value === canonicalProviderKey) || preset - const envMapping = PROVIDER_ENV_MAP[canonicalProviderKey] - if (!baseUrl) { - baseUrl = envMapping?.base_url_env - ? parseEnvValue(envContent, envMapping.base_url_env) || canonicalPreset?.base_url || '' - : canonicalPreset?.base_url || '' - } - if (!apiKey && envMapping?.api_key_env) { - apiKey = parseEnvValue(envContent, envMapping.api_key_env) - } - if (!apiMode) { - apiMode = normalizeLaunchApiMode( - canonicalPreset?.api_mode, - inferLaunchApiMode(canonicalProvider, baseUrl, 'chat_completions'), - ) - } - - return { - ...input, - profile, - provider: canonicalProvider, - model: model || input.model, - workspace, - baseUrl: baseUrl || (ignoredStaleProviderRuntime ? '' : input.baseUrl), - apiKey: apiKey || (ignoredStaleProviderRuntime ? '' : input.apiKey), - apiMode, - } -} - -function normalizeStoredLaunchApiMode(value: unknown): ApiMode | undefined { - if (!value) return undefined - try { - return normalizeLaunchApiMode(value, 'chat_completions') - } catch { - return undefined - } -} - -function normalizeLaunchApiMode(value: unknown, fallback: ApiMode): ApiMode { - if (!value) return fallback - const mode = String(value).trim() as ApiMode - if (LAUNCH_API_MODES.has(mode)) return mode - if (mode === 'codex_app_server') return 'codex_responses' - - const err = new Error('Invalid API protocol') - ;(err as any).status = 400 - throw err -} - -function storedCodingAgentMode(session: HermesSessionRow | null): 'scoped' | 'global' { - if (session?.agent_mode === 'global' || session?.agent_mode === 'scoped') return session.agent_mode - return session?.provider === 'global' ? 'global' : 'scoped' -} - -function makeAgentSessionId(): string { - return `coding_agent_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` -} - -function getScopedConfigRoot(id: CodingAgentId, scope: Required): string { - return join(getWebUiHome(), CODING_AGENT_HOME_DIR, 'model', scope.profile, scope.provider, id) -} - -function getScopedWorkspaceRoot(scope: Required): string { - return join(getWebUiHome(), CODING_AGENT_HOME_DIR, 'workspace', scope.profile, scope.provider) -} - -function resolveLaunchWorkspaceRoot(scope: Required, workspace?: string | null): string { - const customWorkspace = String(workspace || '').trim() - if (customWorkspace) { - if (customWorkspace.includes('\0')) { - const err = new Error('Invalid workspace') - ;(err as any).status = 400 - throw err - } - return customWorkspace - } - return getScopedWorkspaceRoot(scope) -} - -function displayNameForModel(model: string): string { - const trimmed = model.trim() - if (!trimmed) return 'Model' - const leaf = trimmed.split('/').filter(Boolean).pop() || trimmed - return leaf - .replace(/[-_]+/g, ' ') - .replace(/\b\w/g, char => char.toUpperCase()) -} - -function codexCatalogEntry(input: { - model: string - displayName: string - contextWindow: number - priority: number -}) { - return { - slug: input.model, - display_name: input.displayName, - description: input.displayName, - default_reasoning_level: 'medium', - supported_reasoning_levels: [ - { effort: 'none', description: 'Disable provider-side reasoning when supported' }, - { effort: 'minimal', description: 'Use the smallest provider-side reasoning budget when supported' }, - { effort: 'low', description: 'Fast responses with lighter reasoning' }, - { effort: 'medium', description: 'Balances speed and reasoning depth for everyday tasks' }, - { effort: 'high', description: 'Greater reasoning depth for complex problems' }, - { effort: 'xhigh', description: 'Extra high reasoning depth for complex problems' }, - ], - shell_type: 'shell_command', - visibility: 'list', - supported_in_api: true, - priority: 1000 + input.priority, - additional_speed_tiers: [], - service_tiers: [], - default_service_tier: null, - availability_nux: null, - upgrade: null, - base_instructions: CODEX_CATALOG_BASE_INSTRUCTIONS, - model_messages: { - instructions_template: '{{ base_instructions }}\n\n{{ personality }}', - instructions_variables: { - base_instructions: CODEX_CATALOG_BASE_INSTRUCTIONS, - personality: '', - personality_default: '', - personality_friendly: '', - personality_pragmatic: '', - }, - }, - supports_reasoning_summaries: true, - default_reasoning_summary: 'auto', - support_verbosity: true, - default_verbosity: 'low', - apply_patch_tool_type: 'freeform', - web_search_tool_type: 'text_and_image', - truncation_policy: { mode: 'tokens', limit: 10_000 }, - supports_parallel_tool_calls: true, - supports_image_detail_original: true, - context_window: input.contextWindow, - max_context_window: input.contextWindow, - effective_context_window_percent: 95, - experimental_supported_tools: [], - input_modalities: ['text'], - supports_search_tool: true, - } -} - -function buildCodexModelCatalog(input: { - profile: string - provider: string - model: string - presetModels: string[] -}) { - const models = [...new Set([input.model, ...input.presetModels].map(item => item.trim()).filter(Boolean))] - return { - models: models.map((model, index) => codexCatalogEntry({ - model, - displayName: displayNameForModel(model), - contextWindow: getModelContextLength({ profile: input.profile, provider: input.provider, model }), - priority: index, - })), - } -} - -function hasRootPrivileges(): boolean { - if (process.platform === 'win32') return false - const uid = typeof process.getuid === 'function' ? process.getuid() : null - const euid = typeof process.geteuid === 'function' ? process.geteuid() : null - return uid === 0 || euid === 0 -} - -function claudeCodePermissionArgs(): string[] { - return hasRootPrivileges() ? CLAUDE_CODE_ROOT_PERMISSION_ARGS : CLAUDE_CODE_SKIP_PERMISSIONS_ARGS -} - -function expandHomePath(path: string): string { - if (path === '~') return getGlobalConfigHome() - if (path.startsWith('~/')) return join(getGlobalConfigHome(), path.slice(2)) - return path -} - -function hermesPromptDocument(): string { - return [ - HERMES_PROMPT_BLOCK_BEGIN, - getSystemPrompt().trim(), - HERMES_PROMPT_BLOCK_END, - '', - ].join('\n') -} - -function upsertManagedMarkdownBlock(existing: string, block: string): string { - const normalizedBlock = block.endsWith('\n') ? block : `${block}\n` - const start = existing.indexOf(HERMES_PROMPT_BLOCK_BEGIN) - const end = existing.indexOf(HERMES_PROMPT_BLOCK_END) - if (start >= 0 && end >= start) { - const afterEnd = end + HERMES_PROMPT_BLOCK_END.length - const before = existing.slice(0, start).replace(/\s*$/, '') - const after = existing.slice(afterEnd).replace(/^\s*/, '') - return [before, normalizedBlock.trimEnd(), after].filter(Boolean).join('\n\n') + '\n' - } - const trimmedExisting = existing.replace(/\s*$/, '') - if (!trimmedExisting) return normalizedBlock - return `${trimmedExisting}\n\n${normalizedBlock}` -} - -async function writeManagedPromptFile(definition: CodingAgentConfigFileDefinition): Promise<{ key: string; path: string; absolutePath: string }> { - let existing = '' - try { - existing = await readFile(definition.absolutePath, 'utf-8') - } catch (err: any) { - if (err?.code !== 'ENOENT') throw err - } - const next = upsertManagedMarkdownBlock(existing, hermesPromptDocument()) - if (next !== existing) { - await mkdir(dirname(definition.absolutePath), { recursive: true }) - await writeFile(definition.absolutePath, next, 'utf-8') - } - return { - key: definition.key, - path: definition.path, - absolutePath: definition.absolutePath, - } -} - -async function ensureGlobalCodingAgentPromptFile(id: CodingAgentId): Promise> { - if (id !== 'claude-code') return [] - const definition = getLiveConfigFileDefinition(id, 'prompt') - if (!definition) return [] - return [await writeManagedPromptFile(definition)] -} - -function shellQuote(value: string): string { - if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) return value - return `'${value.replace(/'/g, `'\\''`)}'` -} - -function powerShellQuote(value: string): string { - return `'${value.replace(/'/g, "''")}'` -} - -function tomlString(value: string): string { - return JSON.stringify(value) -} - -function tomlMultilineString(value: string): string { - const normalized = String(value || '') - .replace(/\r\n/g, '\n') - .replace(/\r/g, '\n') - .replace(/\\/g, '\\\\') - .replace(/"""/g, '\\"\\"\\"') - return `"""\n${normalized}\n"""` -} - -function tomlStringArray(values: string[]): string { - return `[${values.map(tomlString).join(', ')}]` -} - -function tomlInlineStringTable(values: Record): string { - return `{ ${Object.entries(values).map(([key, value]) => `${key} = ${tomlString(value)}`).join(', ')} }` -} - -function isDesktopRuntime(): boolean { - return String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true' -} - -function candidateBundledMcpScripts(): string[] { - return [ - process.env.HERMES_WEB_UI_MCP_BIN, - join(process.cwd(), 'bin/hermes-ui-mcp.mjs'), - join(__dirname, '../../bin/hermes-ui-mcp.mjs'), - join(__dirname, '../../../../../bin/hermes-ui-mcp.mjs'), - join(process.cwd(), 'bin/hermes-web-ui-mcp.mjs'), - join(__dirname, '../../bin/hermes-web-ui-mcp.mjs'), - join(__dirname, '../../../../../bin/hermes-web-ui-mcp.mjs'), - ].filter((value): value is string => !!value) -} - -function bundledMcpScriptPath(): string | null { - return candidateBundledMcpScripts().find(candidate => existsSync(candidate)) || null -} - -function runtimeNodePath(): string | null { - const node = process.env.HERMES_AGENT_NODE?.trim() - return node || null -} - -function hermesMcpCommandConfig(toolset: string): { command: string; args?: string[] } { - const script = bundledMcpScriptPath() - if (script) return { command: runtimeNodePath() || process.execPath, args: [script, toolset] } - if (isDesktopRuntime()) return { command: 'hermes-studio-mcp', args: [toolset] } - return { command: 'hermes-studio-mcp', args: [toolset] } -} - -function hermesMcpServerConfig(profile: string, serverName: string, toolset: string): { command: string; args?: string[]; env: Record } { - const appHome = getWebUiHome() - return { - ...hermesMcpCommandConfig(toolset), - env: { - HERMES_WEB_UI_URL: `http://127.0.0.1:${process.env.PORT || '8648'}`, - HERMES_WEB_UI_HOME: appHome, - HERMES_WEBUI_STATE_DIR: appHome, - HERMES_WEB_UI_PROFILE: profile, - HERMES_MCP_SERVER_NAME: serverName, - HERMES_MCP_TOOLSET: toolset, - [HERMES_MCP_MANAGED_ENV_KEY]: '1', - }, - } -} - -function isManagedHermesMcpServer(value: unknown): boolean { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false - const server = value as Record - if (server.env && typeof server.env === 'object' && server.env[HERMES_MCP_MANAGED_ENV_KEY] === '1') return true - return typeof server.command === 'string' && LEGACY_HERMES_MCP_COMMANDS.has(server.command) -} - -function normalizeClaudeMcpServer(server: unknown): unknown { - if (!server || typeof server !== 'object' || Array.isArray(server)) return server - const normalized = { ...(server as Record) } - if (normalized.type === 'streamableHttp') normalized.type = 'http' - return normalized -} - -function parseClaudeMcpServers(existingContent: string | null | undefined = ''): Record { - if (!existingContent?.trim()) return {} - try { - const parsed = JSON.parse(existingContent) - if (!parsed?.mcpServers || typeof parsed.mcpServers !== 'object' || Array.isArray(parsed.mcpServers)) return {} - return Object.fromEntries(Object.entries(parsed.mcpServers) - .filter(([name, server]) => { - if (HERMES_MCP_SERVER_NAMES.has(name)) return false - if (LEGACY_HERMES_MCP_SERVER_NAMES.has(name)) return false - return !isManagedHermesMcpServer(server) - }) - .map(([name, server]) => [name, normalizeClaudeMcpServer(server)])) - } catch { - return {} - } -} - -function inheritClaudeSettings(existingContent: string | null | undefined = ''): Record { - if (!existingContent?.trim()) return {} - try { - const parsed = JSON.parse(existingContent) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} - const inherited: Record = {} - const enabledServers = (parsed as any).enabledMcpjsonServers - if (Array.isArray(enabledServers)) inherited.enabledMcpjsonServers = enabledServers.map(String).filter(Boolean) - const plugins = (parsed as any).plugins - if (plugins && typeof plugins === 'object' && !Array.isArray(plugins)) inherited.plugins = plugins - const enabledPlugins = (parsed as any).enabledPlugins - if (enabledPlugins && typeof enabledPlugins === 'object' && !Array.isArray(enabledPlugins)) inherited.enabledPlugins = enabledPlugins - return inherited - } catch { - return {} - } -} - -function claudeMcpConfigJson(profile: string, ...existingContents: Array): string { - const mcpServers: Record = {} - for (const content of existingContents) { - Object.assign(mcpServers, parseClaudeMcpServers(content)) - } - for (const server of HERMES_MCP_SERVERS) { - mcpServers[server.name] = hermesMcpServerConfig(profile, server.name, server.toolset) - } - return `${JSON.stringify({ mcpServers }, null, 2)}\n` -} - -function parseCodexExternalMcpBlocks(...contents: Array): string[] { - const blockByServer = new Map() - - for (const content of contents) { - if (!content?.trim()) continue - let currentServer = '' - let currentLines: string[] = [] - const flush = () => { - if (!currentServer || currentLines.length === 0) return - const block = currentLines.join('\n').trim() - const isManaged = block.includes(`${HERMES_MCP_MANAGED_ENV_KEY}`) - if (!HERMES_MCP_SERVER_NAMES.has(currentServer) && !LEGACY_HERMES_MCP_SERVER_NAMES.has(currentServer) && !isManaged) { - blockByServer.set(currentServer, block) - } - } - - for (const line of content.split(/\r?\n/)) { - const mcpMatch = line.match(/^\s*\[mcp_servers\.([^\].]+)(\.[^\]]+)?\]\s*$/) - if (mcpMatch) { - const nextServer = mcpMatch[1] - const isSubtable = Boolean(mcpMatch[2]) - if (currentServer && nextServer === currentServer && isSubtable) { - currentLines.push(line) - continue - } - flush() - currentServer = nextServer - currentLines = [line] - continue - } - if (/^\s*\[/.test(line)) { - flush() - currentServer = '' - currentLines = [] - continue - } - if (currentServer) currentLines.push(line) - } - flush() - } - - return Array.from(blockByServer.values()).filter(Boolean) -} - -function codexMcpConfigToml(profile: string, ...externalContents: Array): string { - const blocks: string[] = [...parseCodexExternalMcpBlocks(...externalContents)] - for (const item of HERMES_MCP_SERVERS) { - const server = hermesMcpServerConfig(profile, item.name, item.toolset) - const lines = [ - `[mcp_servers.${item.name}]`, - `command = ${tomlString(server.command)}`, - ] - if (server.args?.length) lines.push(`args = ${tomlStringArray(server.args)}`) - lines.push('startup_timeout_sec = 120') - lines.push(`env = ${tomlInlineStringTable(server.env)}`) - lines.push('') - blocks.push(lines.join('\n')) - } - return blocks.join('\n') -} - -function buildLaunchShellCommand(input: { - workspaceDir: string - env: Record - command: string - args: string[] -}): string { - if (process.platform === 'win32') { - const envAssignments = Object.entries(input.env) - .map(([key, value]) => `$env:${key} = ${powerShellQuote(value)}`) - return [ - `Set-Location -LiteralPath ${powerShellQuote(input.workspaceDir)}`, - ...envAssignments, - `& ${powerShellQuote(input.command)} ${input.args.map(powerShellQuote).join(' ')}`.trim(), - ].join('; ') - } - - const envPrefix = Object.entries(input.env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(' ') - const runCommand = [ - envPrefix, - shellQuote(input.command), - ...input.args.map(shellQuote), - ].filter(Boolean).join(' ') - return `cd ${shellQuote(input.workspaceDir)} && ${runCommand}` -} - -function buildPosixLauncherScript(input: { - workspaceDir: string - env: Record - command: string - args: string[] -}): string { - const exports = Object.entries(input.env) - .map(([key, value]) => `export ${key}=${shellQuote(value)}`) - const command = [ - shellQuote(input.command), - ...input.args.map(shellQuote), - ].join(' ') - return [ - '#!/usr/bin/env bash', - 'set -e', - `cd ${shellQuote(input.workspaceDir)}`, - ...exports, - `exec ${command}`, - '', - ].join('\n') -} - -function buildPowerShellLauncherScript(input: { - workspaceDir: string - env: Record - command: string - args: string[] -}): string { - const envAssignments = Object.entries(input.env) - .map(([key, value]) => `$env:${key} = ${powerShellQuote(value)}`) - const command = [ - `& ${powerShellQuote(input.command)}`, - ...input.args.map(powerShellQuote), - ].join(' ') - return [ - '$ErrorActionPreference = "Stop"', - `Set-Location -LiteralPath ${powerShellQuote(input.workspaceDir)}`, - ...envAssignments, - command, - 'exit $LASTEXITCODE', - '', - ].join('\r\n') -} - -async function writeLauncherScript(input: { - rootDir: string - workspaceDir: string - env: Record - command: string - args: string[] -}): Promise { - const isWindows = process.platform === 'win32' - const launcherPath = join(input.rootDir, isWindows ? WINDOWS_LAUNCHER_FILE : POSIX_LAUNCHER_FILE) - await writeFile( - launcherPath, - isWindows ? buildPowerShellLauncherScript(input) : buildPosixLauncherScript(input), - 'utf-8', - ) - if (!isWindows) await chmod(launcherPath, 0o700) - return launcherPath -} - -function buildLauncherShellCommand(workspaceDir: string, launcherPath: string): string { - return process.platform === 'win32' - ? buildLaunchShellCommand({ - workspaceDir, - env: {}, - command: 'powershell.exe', - args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', launcherPath], - }) - : buildLaunchShellCommand({ - workspaceDir, - env: {}, - command: launcherPath, - args: [], - }) -} - -function appleScriptString(value: string): string { - return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` -} - -async function commandExists(command: string): Promise { - try { - await execFileAsync(process.platform === 'win32' ? 'where' : 'which', [command], { - encoding: 'utf-8', - timeout: 3000, - windowsHide: true, - }) - return true - } catch { - return false - } -} - -function isDockerRuntime(): boolean { - return existsSync('/.dockerenv') || process.env.container === 'docker' -} - -async function openNativeTerminal(shellCommand: string): Promise { - if (process.platform === 'win32') { - const escapedCommand = shellCommand.replace(/"/g, '""').replace(/\$/g, '`$') - await execFileAsync('powershell.exe', [ - '-NoProfile', - '-Command', - `Start-Process -FilePath powershell.exe -ArgumentList @('-NoExit', '-Command', "${escapedCommand}")`, - ], { - encoding: 'utf-8', - timeout: 8000, - windowsHide: true, - }) - return 'PowerShell' - } - - if (process.platform === 'darwin') { - await execFileAsync('osascript', [ - '-e', - `tell application "Terminal" to do script ${appleScriptString(shellCommand)}`, - '-e', - 'tell application "Terminal" to activate', - ], { - encoding: 'utf-8', - timeout: 8000, - windowsHide: true, - }) - return 'Terminal.app' - } - - if (process.platform === 'linux') { - if (isDockerRuntime()) { - const err = new Error('Native terminal is not available inside Docker') - ;(err as any).status = 400 - throw err - } - if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { - const err = new Error('Native terminal requires a Linux desktop session') - ;(err as any).status = 400 - throw err - } - - const candidates: Array<{ command: string; args: string[] }> = [ - { command: 'xdg-terminal-exec', args: ['bash', '-lc', shellCommand] }, - { command: 'gnome-terminal', args: ['--', 'bash', '-lc', shellCommand] }, - { command: 'konsole', args: ['-e', 'bash', '-lc', shellCommand] }, - { command: 'xfce4-terminal', args: ['--command', `bash -lc ${shellQuote(shellCommand)}`] }, - { command: 'kitty', args: ['bash', '-lc', shellCommand] }, - { command: 'alacritty', args: ['-e', 'bash', '-lc', shellCommand] }, - { command: 'xterm', args: ['-e', 'bash', '-lc', shellCommand] }, - ] - - const errors: string[] = [] - for (const candidate of candidates) { - if (!(await commandExists(candidate.command))) continue - try { - await execFileAsync(candidate.command, candidate.args, { - encoding: 'utf-8', - timeout: 8000, - windowsHide: true, - }) - return candidate.command - } catch (err: any) { - errors.push(`${candidate.command}: ${normalizeError(err)}`) - } - } - - const err = new Error(errors[0] || 'No supported Linux terminal command was found') - ;(err as any).status = 400 - throw err - } - - const err = new Error('Native terminal launch is not supported on this platform') - ;(err as any).status = 400 - throw err -} - -function getLiveConfigFileDefinition(id: string, key: string): CodingAgentConfigFileDefinition | null { - const tool = getCodingAgentDefinition(id) - if (!tool) return null - const definition = CONFIG_FILE_DEFINITIONS[tool.id].find(file => file.key === key) - if (!definition) return null - return { - key: definition.key, - path: definition.path, - language: definition.language, - absolutePath: expandHomePath(definition.path), - } -} - -function getScopedConfigFileDefinition(id: string, key: string, scopeInput: CodingAgentConfigScope = {}): (CodingAgentConfigFileDefinition & Required & { rootDir: string }) | null { - const tool = getCodingAgentDefinition(id) - if (!tool) return null - const definition = CONFIG_FILE_DEFINITIONS[tool.id].find(file => file.key === key) - if (!definition) return null - const scope = normalizeConfigScope(scopeInput) - const rootDir = getScopedConfigRoot(tool.id, scope) - return { - key: definition.key, - path: definition.path, - language: definition.language, - ...scope, - rootDir, - absolutePath: join(rootDir, definition.scopedPath), - } -} - -function getCurrentNodeEnv(): NodeJS.ProcessEnv { - return { - ...process.env, - PATH: [getNodeBinDir(), getNvmNodeBinPaths(), process.env.PATH].filter(Boolean).join(delimiter), - npm_node_execpath: process.execPath, - } -} - -async function npmExecution(args: string[], env: NodeJS.ProcessEnv): Promise { - const bundledNpmCli = getNpmCliPath() - if (bundledNpmCli) return { command: process.execPath, args: [bundledNpmCli, ...args] } - - let npmBin: string | null = null - for (const command of [...new Set([getNpmBin(), 'npm'])]) { - const paths = await findCommandPaths(command, env) - if (paths[0]) { - npmBin = paths[0] - break - } - } - if (!npmBin) throw nodeEnvironmentMissingError() - - const npmCli = npmCliFromNpmBin(npmBin) - if (npmCli) return { command: npmCli.node, args: [npmCli.npmCli, ...args] } - - let nodeBin: string | null = null - for (const command of [...new Set([process.platform === 'win32' ? 'node.exe' : 'node', 'node'])]) { - const paths = await findCommandPaths(command, env) - if (paths[0]) { - nodeBin = paths[0] - break - } - } - if (!nodeBin) throw nodeEnvironmentMissingError() - - return commandExecution(npmBin, args) -} - -async function runNpm(args: string[], options: { timeout?: number; env?: NodeJS.ProcessEnv } = {}) { - const env = { - ...getCurrentNodeEnv(), - ...options.env, - } - const execution = await npmExecution(args, env) - return execFileAsync(execution.command, execution.args, { - encoding: 'utf-8', - timeout: options.timeout, - windowsHide: true, - windowsVerbatimArguments: execution.windowsVerbatimArguments, - maxBuffer: 10 * 1024 * 1024, - env, - }) -} - -function normalizeError(err: any): string { - if (isNodeEnvironmentMissingError(err)) return nodeEnvironmentMissingError().message - const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : '' - const stdout = typeof err?.stdout === 'string' ? err.stdout.trim() : '' - const message = stderr || stdout || err?.message || String(err) - return message.split(/\r?\n/).filter(Boolean).slice(0, 4).join('\n') -} - -function normalizeErrorCode(err: any): string | undefined { - return isNodeEnvironmentMissingError(err) ? NODE_ENVIRONMENT_MISSING_CODE : undefined -} - -async function findCommandPaths(command: string, env: NodeJS.ProcessEnv): Promise { - try { - const lookupCommand = process.platform === 'win32' ? 'where' : 'which' - const lookupArgs = process.platform === 'win32' ? [command] : ['-a', command] - const { stdout } = await execFileAsync(lookupCommand, lookupArgs, { - encoding: 'utf-8', - timeout: 5000, - windowsHide: true, - env, - }) - return stdout.split(/\r?\n/).map(line => normalizeWindowsCommandPath(line.trim())).filter(Boolean) - } catch { - return [] - } -} - -async function resolveCommandForExecution(command: string, env: NodeJS.ProcessEnv): Promise { - if (process.platform !== 'win32') return command - const paths = await findCommandPaths(command, env) - // On Windows, prioritize paths with .cmd or .bat extensions since where may return - // both the unix-style script (without extension) and the Windows shim (.cmd) - const windowsPath = paths.find(path => windowsCommandNeedsShell(path)) - return windowsPath || paths[0] || command -} - -function commandExecution(command: string, args: string[]): CommandExecution { - const normalizedCommand = normalizeWindowsCommandPath(command) - if (process.platform === 'win32' && windowsCommandNeedsShell(normalizedCommand)) { - return windowsCmdShimExecution(normalizedCommand, args) - } - return { command: normalizedCommand, args } -} - -function packageParts(packageName: string): string[] { - return packageName.split('/').filter(Boolean) -} - -function getPrefixFromPackagePath(path: string, packageName: string): string | null { - const normalized = path.replace(/\\/g, '/') - const parts = normalized.split('/').filter(Boolean) - const nodeModulesIndex = parts.lastIndexOf('node_modules') - const packageNameParts = packageParts(packageName) - - if (nodeModulesIndex <= 0) return null - for (let i = 0; i < packageNameParts.length; i += 1) { - if (parts[nodeModulesIndex + 1 + i] !== packageNameParts[i]) return null - } - - const libIndex = nodeModulesIndex - 1 - if (parts[libIndex] !== 'lib') return null - const prefixParts = parts.slice(0, libIndex) - if (prefixParts.length === 0) return process.platform === 'win32' ? null : '/' - return `${normalized.startsWith('/') ? '/' : ''}${prefixParts.join('/')}` -} - -async function getCommandPackagePrefixes(definition: CodingAgentDefinition, env: NodeJS.ProcessEnv): Promise { - const commandPaths = await findCommandPaths(definition.command, env) - const prefixes = new Set() - - for (const commandPath of commandPaths) { - const candidates = [commandPath] - try { - candidates.push(realpathSync(commandPath)) - } catch { - // Keep the unresolved command path as the fallback candidate. - } - - for (const candidate of candidates) { - const prefix = getPrefixFromPackagePath(candidate, definition.packageName) - if (prefix) prefixes.add(prefix) - } - } - return [...prefixes] -} - -function extractVersion(raw: string): string { - const trimmed = raw.trim() - return trimmed.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/)?.[0] || trimmed.split(/\s+/)[0] || '' -} - -async function getGlobalNpmBin(): Promise { - if (typeof cachedGlobalNpmBin !== 'undefined') return cachedGlobalNpmBin - try { - const { stdout } = await runNpm(['prefix', '-g'], { timeout: 5000 }) - const prefix = stdout.trim() - cachedGlobalNpmBin = prefix ? (process.platform === 'win32' ? prefix : join(prefix, 'bin')) : null - } catch { - cachedGlobalNpmBin = null - } - return cachedGlobalNpmBin -} - -async function commandEnv(): Promise { - const env = getCurrentNodeEnv() - const npmBin = await getGlobalNpmBin() - const loginShellPath = await getLoginShellPath() - prependPathEntries(env, [ - npmBin, - loginShellPath, - ...getDesktopCommonBinPaths(), - ]) - return env -} - -export function getCodingAgentDefinitions(): CodingAgentDefinition[] { - return TOOL_DEFINITIONS.map(tool => ({ ...tool })) -} - -export function getCodingAgentDefinition(id: string): CodingAgentDefinition | null { - return TOOL_DEFINITIONS.find(tool => tool.id === id) || null -} - -export function getCodingAgentConfigFileDefinitions(id: string): CodingAgentConfigFileDefinition[] { - const tool = getCodingAgentDefinition(id) - if (!tool) return [] - return CONFIG_FILE_DEFINITIONS[tool.id].map(file => ({ - key: file.key, - path: file.path, - language: file.language, - absolutePath: expandHomePath(file.path), - })) -} - -export async function getCodingAgentStatus(definition: CodingAgentDefinition): Promise { - try { - const env = await commandEnv() - const resolvedCommand = await resolveCommandForExecution(definition.command, env) - const execution = commandExecution(resolvedCommand, ['--version']) - const { stdout, stderr } = await execFileAsync(execution.command, execution.args, { - encoding: 'utf-8', - timeout: 8000, - windowsHide: true, - windowsVerbatimArguments: execution.windowsVerbatimArguments, - env, - }) - const rawVersion = `${stdout || ''}${stderr || ''}`.trim() - return { - ...definition, - installed: true, - version: extractVersion(rawVersion), - rawVersion, - } - } catch (err: any) { - return { - ...definition, - installed: false, - version: '', - rawVersion: '', - error: normalizeError(err), - } - } -} - -export async function getCodingAgentsStatus(): Promise { - return { - tools: await Promise.all(TOOL_DEFINITIONS.map(tool => getCodingAgentStatus(tool))), - } -} - -export async function installCodingAgent(id: string): Promise { - const tool = getCodingAgentDefinition(id) - if (!tool) { - const err = new Error('Unknown coding agent') - ;(err as any).status = 400 - throw err - } - if (installingTools.has(tool.id)) { - const err = new Error('Install is already running') - ;(err as any).status = 409 - throw err - } - - installingTools.add(tool.id) - try { - const env = await commandEnv() - await runNpm(['install', '-g', tool.packageName], { - timeout: 10 * 60 * 1000, - env, - }) - cachedGlobalNpmBin = undefined - const status = await getCodingAgentStatus(tool) - const allStatus = await getCodingAgentsStatus() - return { - success: status.installed, - tool: status, - tools: allStatus.tools, - message: status.installed ? 'Installed' : status.error || 'Install completed but the command was not found', - } - } catch (err: any) { - const status = await getCodingAgentStatus(tool) - const allStatus = await getCodingAgentsStatus() - return { - success: false, - tool: status, - tools: allStatus.tools, - message: normalizeError(err), - code: normalizeErrorCode(err), - } - } finally { - installingTools.delete(tool.id) - } -} - -export async function deleteCodingAgent(id: string): Promise { - const tool = getCodingAgentDefinition(id) - if (!tool) { - const err = new Error('Unknown coding agent') - ;(err as any).status = 400 - throw err - } - if (deletingTools.has(tool.id)) { - const err = new Error('Delete is already running') - ;(err as any).status = 409 - throw err - } - - deletingTools.add(tool.id) - try { - const env = await commandEnv() - const packagePrefixes = await getCommandPackagePrefixes(tool, env) - const uninstallArgsList = packagePrefixes.length > 0 - ? packagePrefixes.map(prefix => ['uninstall', '-g', '--prefix', prefix, tool.packageName]) - : [['uninstall', '-g', tool.packageName]] - for (const uninstallArgs of uninstallArgsList) { - await runNpm(uninstallArgs, { - timeout: 10 * 60 * 1000, - env, - }) - } - cachedGlobalNpmBin = undefined - const status = await getCodingAgentStatus(tool) - const allStatus = await getCodingAgentsStatus() - return { - success: !status.installed, - tool: status, - tools: allStatus.tools, - message: !status.installed ? 'Deleted' : 'Delete completed but the command is still available', - } - } catch (err: any) { - const status = await getCodingAgentStatus(tool) - const allStatus = await getCodingAgentsStatus() - return { - success: false, - tool: status, - tools: allStatus.tools, - message: normalizeError(err), - code: normalizeErrorCode(err), - } - } finally { - deletingTools.delete(tool.id) - } -} - -export async function readCodingAgentConfigFile(id: string, key: string, scope: CodingAgentConfigScope = {}): Promise { - const definition = getLiveConfigFileDefinition(id, key) - if (!definition) { - const err = new Error('Unknown coding agent config file') - ;(err as any).status = 404 - throw err - } - const normalizedScope = normalizeConfigScope(scope) - - try { - const info = await stat(definition.absolutePath) - if (!info.isFile()) { - const err = new Error('Config path is not a file') - ;(err as any).status = 400 - throw err - } - if (info.size > MAX_CONFIG_FILE_SIZE) { - const err = new Error('Config file is too large to edit') - ;(err as any).status = 413 - throw err - } - return { - ...definition, - ...normalizedScope, - rootDir: dirname(definition.absolutePath), - content: await readFile(definition.absolutePath, 'utf-8'), - exists: true, - size: info.size, - } - } catch (err: any) { - if (err?.code !== 'ENOENT') throw err - return { - ...definition, - ...normalizedScope, - rootDir: dirname(definition.absolutePath), - content: '', - exists: false, - size: 0, - } - } -} - -export async function writeCodingAgentConfigFile(id: string, key: string, content: string, scope: CodingAgentConfigScope = {}): Promise { - const definition = getLiveConfigFileDefinition(id, key) - if (!definition) { - const err = new Error('Unknown coding agent config file') - ;(err as any).status = 404 - throw err - } - const normalizedScope = normalizeConfigScope(scope) - - const buffer = Buffer.from(content || '', 'utf-8') - if (buffer.length > MAX_CONFIG_FILE_SIZE) { - const err = new Error('Config file content is too large') - ;(err as any).status = 413 - throw err - } - - await mkdir(dirname(definition.absolutePath), { recursive: true }) - await writeFile(definition.absolutePath, buffer) - return { - ...definition, - ...normalizedScope, - rootDir: dirname(definition.absolutePath), - content, - exists: true, - size: buffer.length, - } -} - -export async function prepareCodingAgentLaunch(id: string, input: CodingAgentLaunchInput): Promise { - const tool = getCodingAgentDefinition(id) - if (!tool) { - const err = new Error('Unknown coding agent') - ;(err as any).status = 400 - throw err - } - - const mode = input.mode === 'global' ? 'global' : 'scoped' - if (mode === 'global') { - const scope = normalizeConfigScope({ profile: input.profile, provider: 'global' }) - const workspaceDir = resolveLaunchWorkspaceRoot(scope, input.workspace) - await mkdir(workspaceDir, { recursive: true }) - const files = await ensureGlobalCodingAgentPromptFile(tool.id) - const promptFile = files.find(file => file.key === 'prompt')?.absolutePath || '' - const args = tool.id === 'claude-code' - ? [ - ...(promptFile ? ['--append-system-prompt-file', promptFile] : []), - ...claudeCodePermissionArgs(), - ] - : [] - const shellCommand = buildLaunchShellCommand({ - workspaceDir, - env: {}, - command: tool.command, - args, - }) - return { - agentId: tool.id, - mode, - profile: scope.profile, - provider: scope.provider, - model: '', - rootDir: workspaceDir, - workspaceDir, - command: tool.command, - args, - env: {}, - shellCommand, - files, - } - } - - const provider = normalizeScopeSegment(input.provider, 'default', 'provider') - const scope = normalizeConfigScope({ profile: input.profile, provider }) - const model = String(input.model || '').trim() - const apiKey = String(input.apiKey || '').trim() - assertScopedCodingAgentProviderAllowed(mode, provider, apiKey) - if (!model) { - const err = new Error('Model is required') - ;(err as any).status = 400 - throw err - } - - const baseUrl = String(input.baseUrl || '').trim() - const preset = PROVIDER_PRESETS.find(item => item.value === provider) - const apiMode = normalizeLaunchApiMode(input.apiMode, preset?.api_mode || 'chat_completions') - const reasoningEffort = String(input.reasoningEffort || '').trim() - const rootDir = getScopedConfigRoot(tool.id, scope) - const workspaceDir = resolveLaunchWorkspaceRoot(scope, input.workspace) - await mkdir(rootDir, { recursive: true }) - await mkdir(workspaceDir, { recursive: true }) - - const files: Array<{ key: string; path: string; absolutePath: string }> = [] - const writeScopedFile = async (key: string, content: string) => { - const definition = getScopedConfigFileDefinition(tool.id, key, scope) - if (!definition) return - await mkdir(dirname(definition.absolutePath), { recursive: true }) - await writeFile(definition.absolutePath, content, 'utf-8') - files.push({ key, path: definition.path, absolutePath: definition.absolutePath }) - } - - let args: string[] = [] - let env: Record = {} - - if (tool.id === 'claude-code') { - const proxyTarget = baseUrl && apiKey - ? registerClaudeCodeProxyTarget({ - provider, - model, - baseUrl, - apiKey, - apiMode, - reasoningEffort, - agentId: tool.id, - agentSessionId: input.agentSessionId, - chatSessionId: input.sessionId, - }) - : null - const claudeBaseUrl = proxyTarget?.baseUrl || baseUrl - const claudeApiKey = proxyTarget?.token || apiKey - const modelName = displayNameForModel(model) - const globalSettingsPath = getLiveConfigFileDefinition(tool.id, 'settings')?.absolutePath - const inheritedSettings = inheritClaudeSettings(globalSettingsPath ? await safeReadFile(globalSettingsPath) : '') - const settings = { - ...inheritedSettings, - model, - env: { - ...(claudeApiKey ? { ANTHROPIC_API_KEY: claudeApiKey } : {}), - ...(claudeBaseUrl ? { ANTHROPIC_BASE_URL: claudeBaseUrl } : {}), - ANTHROPIC_MODEL: model, - ANTHROPIC_CUSTOM_MODEL_OPTION: model, - ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: modelName, - ANTHROPIC_DEFAULT_HAIKU_MODEL: model, - ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME: modelName, - ANTHROPIC_DEFAULT_SONNET_MODEL: model, - ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: modelName, - ANTHROPIC_DEFAULT_OPUS_MODEL: model, - ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: modelName, - }, - } - env = settings.env - await writeScopedFile('settings', `${JSON.stringify(settings, null, 2)}\n`) - const globalMcpPath = getLiveConfigFileDefinition(tool.id, 'mcp')?.absolutePath - const existingMcpPath = getScopedConfigFileDefinition(tool.id, 'mcp', scope)?.absolutePath - const globalMcpConfig = globalMcpPath ? await safeReadFile(globalMcpPath) : '' - const existingMcpConfig = existingMcpPath ? await safeReadFile(existingMcpPath) : '' - await writeScopedFile('mcp', claudeMcpConfigJson(scope.profile, globalMcpConfig, existingMcpConfig)) - await writeScopedFile('prompt', hermesPromptDocument()) - - const settingsPath = join(rootDir, 'settings.json') - const mcpPath = join(rootDir, 'mcp.json') - const promptPath = join(rootDir, 'hermes-rules.md') - args = [ - '--settings', - settingsPath, - ...(input.isolateSettings ? ['--setting-sources', 'local'] : []), - '--mcp-config', - mcpPath, - '--append-system-prompt-file', - promptPath, - ...claudeCodePermissionArgs(), - ] - } else { - if (apiMode !== 'chat_completions' && apiMode !== 'codex_responses' && apiMode !== 'anthropic_messages') { - const err = new Error('Codex launch only supports OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages providers') - ;(err as any).status = 400 - throw err - } - const proxyTarget = baseUrl && apiKey - ? registerCodexProxyTarget({ - profile: scope.profile, - provider, - model, - baseUrl, - apiKey, - apiMode, - reasoningEffort, - agentId: tool.id, - agentSessionId: input.agentSessionId, - chatSessionId: input.sessionId, - }) - : null - const codexBaseUrl = proxyTarget?.baseUrl || baseUrl - const codexApiKey = proxyTarget?.token || apiKey - const providerId = 'custom' - const catalogPath = join(rootDir, CODEX_MODEL_CATALOG_FILE) - const configToml = [ - `model_catalog_json = ${JSON.stringify(catalogPath)}`, - `model_provider = ${JSON.stringify(providerId)}`, - `model = ${JSON.stringify(model)}`, - 'model_reasoning_summary = "auto"', - ...(reasoningEffort ? [`model_reasoning_effort = ${JSON.stringify(reasoningEffort)}`] : []), - `developer_instructions = ${tomlMultilineString(getSystemPrompt().trim())}`, - 'disable_response_storage = true', - '', - `[model_providers.${providerId}]`, - `name = ${JSON.stringify(provider)}`, - ...(codexBaseUrl ? [`base_url = ${JSON.stringify(codexBaseUrl)}`] : []), - 'wire_api = "responses"', - 'requires_openai_auth = false', - ...(codexApiKey ? [`experimental_bearer_token = ${JSON.stringify(codexApiKey)}`] : []), - '', - codexMcpConfigToml( - scope.profile, - await safeReadFile(getLiveConfigFileDefinition(tool.id, 'config')?.absolutePath || ''), - await safeReadFile(getScopedConfigFileDefinition(tool.id, 'config', scope)?.absolutePath || ''), - ), - ].join('\n') - const catalog = buildCodexModelCatalog({ - profile: scope.profile, - provider, - model, - presetModels: Array.isArray(preset?.models) ? preset.models : [], - }) - await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf-8') - files.push({ key: 'model_catalog', path: CODEX_MODEL_CATALOG_FILE, absolutePath: catalogPath }) - await writeScopedFile('config', configToml) - await writeScopedFile('auth', `${JSON.stringify({}, null, 2)}\n`) - - env = { CODEX_HOME: rootDir } - args = [ - '--model', model, - ...(reasoningEffort ? ['-c', `model_reasoning_effort=${JSON.stringify(reasoningEffort)}`] : []), - ] - } - - let shellCommand = buildLaunchShellCommand({ - workspaceDir, - env, - command: tool.command, - args, - }) - const launcherPath = await writeLauncherScript({ - rootDir, - workspaceDir, - env, - command: tool.command, - args, - }) - files.push({ - key: 'launcher', - path: process.platform === 'win32' ? WINDOWS_LAUNCHER_FILE : POSIX_LAUNCHER_FILE, - absolutePath: launcherPath, - }) - shellCommand = buildLauncherShellCommand(workspaceDir, launcherPath) - - return { - agentId: tool.id, - mode, - profile: scope.profile, - provider: scope.provider, - model, - apiMode, - rootDir, - workspaceDir, - command: tool.command, - args, - env, - shellCommand, - files, - reasoningEffort, - } -} - -export async function startCodingAgentRun( - id: string, - input: CodingAgentLaunchInput & { sessionId: string }, - state?: SessionState, -): Promise { - const sessionId = String(input.sessionId || '').trim() - if (!sessionId) { - const err = new Error('sessionId is required') - ;(err as any).status = 400 - throw err - } - const existingSession = getSession(sessionId) - const sessionSource = input.sessionSource === 'global_agent' - ? 'global_agent' - : input.sessionSource === 'workflow' - ? 'workflow' - : 'coding_agent' - const existingAgentSessionId = existingSession?.agent_session_id || '' - const resolvedInput = await resolveStoredProviderLaunchInput(input, existingSession) - const requestedMode = resolvedInput.mode === 'global' ? 'global' : 'scoped' - const requestedProvider = String(resolvedInput.provider || '').trim().toLowerCase() - if (requestedMode !== 'global' && CODING_AGENT_SCOPED_AUTH_PROVIDERS.has(requestedProvider)) { - const err = new Error('Coding agent scoped mode does not support OAuth/subscription providers. Use global mode or select an API-key provider.') - ;(err as any).status = 400 - throw err - } - if (requestedMode !== 'global' && (!String(resolvedInput.baseUrl || '').trim() || !String(resolvedInput.apiKey || '').trim())) { - const err = new Error('Coding agent provider credentials are missing. Re-select the provider/model or update the provider API key before continuing this session.') - ;(err as any).status = 400 - throw err - } - const agentSessionId = resolvedInput.agentSessionId || existingAgentSessionId || makeAgentSessionId() - const canResumeNativeSession = existingSession - ? storedCodingAgentMode(existingSession) === requestedMode && - (existingSession.agent === (id === 'codex' ? 'codex' : 'claude') || !existingSession.agent) && - String(existingSession.provider || '').trim() === String(resolvedInput.provider || '').trim() && - String(existingSession.model || '').trim() === String(resolvedInput.model || '').trim() && - (!String(existingSession.api_mode || '').trim() || String(existingSession.api_mode || '').trim() === String(resolvedInput.apiMode || '').trim()) - : false - const existingNativeSessionId = canResumeNativeSession ? existingSession?.agent_native_session_id || '' : '' - const agentNativeSessionId = resolvedInput.agentNativeSessionId || existingNativeSessionId || (id === 'claude-code' ? randomUUID() : '') - const launch = await prepareCodingAgentLaunch(id, { - ...resolvedInput, - sessionId, - agentSessionId, - isolateSettings: true, - }) - const runtimeEnv = process.platform === 'win32' - ? { - ...(await commandEnv()), - ...launch.env, - } - : launch.env - const runtimeCommand = process.platform === 'win32' - ? await resolveCommandForExecution(launch.command, runtimeEnv) - : launch.command - const persistedProvider = String(resolvedInput.provider || launch.provider || '').trim() || launch.provider - const started = codingAgentRunManager.start({ - agentSessionId, - agentId: launch.agentId, - mode: launch.mode, - profile: launch.profile, - provider: persistedProvider, - model: launch.model, - apiMode: launch.apiMode, - sessionId, - agentNativeSessionId, - nativeResume: Boolean(existingNativeSessionId), - command: runtimeCommand, - args: launch.args, - shellCommand: launch.shellCommand, - workspaceDir: launch.workspaceDir, - env: runtimeEnv, - state, - reasoningEffort: launch.reasoningEffort, - sessionSource: sessionSource === 'global_agent' || sessionSource === 'workflow' ? sessionSource : undefined, - }) - updateSession(sessionId, { - source: sessionSource, - agent: launch.agentId === 'codex' ? 'codex' : 'claude', - agent_mode: launch.mode, - agent_session_id: agentSessionId, - agent_native_session_id: agentNativeSessionId, - model: launch.model, - provider: persistedProvider, - api_mode: launch.apiMode || '', - workspace: launch.workspaceDir, - }) - return { - ...launch, - provider: persistedProvider, - agentSessionId, - sessionId, - pid: started.pid, - } -} - -export function sendCodingAgentRunInput(sessionId: string, input: string, systemPrompt?: string): { runId: string } { - return codingAgentRunManager.send(sessionId, input, { systemPrompt }) -} - -export function stopCodingAgentRun(sessionId: string): { stopped: boolean } { - return { stopped: codingAgentRunManager.stop(sessionId) } -} - -export async function openCodingAgentNativeTerminal(id: string, input: CodingAgentLaunchInput): Promise { - const launch = await prepareCodingAgentLaunch(id, input) - const terminal = await openNativeTerminal(launch.shellCommand) - return { - ...launch, - nativeTerminal: true, - terminal, - } -} diff --git a/packages/server/src/services/ekko-agent/manager.ts b/packages/server/src/services/ekko-agent/manager.ts deleted file mode 100644 index 1bbc622..0000000 --- a/packages/server/src/services/ekko-agent/manager.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - AgentRuntime, - type AgentRuntimeRunInput, - type AgentRuntimeRunResult, -} from '../../../../ekko-agent/src' - -export class GlobalEkkoAgent { - readonly createdAt = Date.now() - lastUsedAt = this.createdAt - runCount = 0 - private readonly runtime = new AgentRuntime({}) - - async run(input: AgentRuntimeRunInput): Promise { - this.lastUsedAt = Date.now() - this.runCount += 1 - return this.runtime.run(input) - } - - status() { - return { - createdAt: this.createdAt, - lastUsedAt: this.lastUsedAt, - runCount: this.runCount, - } - } -} - -const globalEkkoAgent = new GlobalEkkoAgent() - -export function getGlobalEkkoAgent(): GlobalEkkoAgent { - return globalEkkoAgent -} diff --git a/packages/server/src/services/ekko-agent/mcp.ts b/packages/server/src/services/ekko-agent/mcp.ts deleted file mode 100644 index 1c1f99f..0000000 --- a/packages/server/src/services/ekko-agent/mcp.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { existsSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { config } from '../../config' - -const MANAGED_ENV_KEY = 'HERMES_WEB_UI_MANAGED_MCP' -const MANAGED_SERVERS = [ - { name: 'hermes-studio-api', toolset: 'api' }, - { name: 'hermes-studio-devices', toolset: 'devices' }, - { name: 'hermes-studio-use', toolset: 'use' }, -] as const - -export type EkkoMcpServers = Record - -function isEnabledEnv(value: string | undefined): boolean { - return ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase()) -} - -function isDesktopRuntime(): boolean { - return String(process.env.HERMES_DESKTOP || '').trim().toLowerCase() === 'true' -} - -function allowTransientAutoinject(): boolean { - return isEnabledEnv(process.env.HERMES_WEB_UI_ALLOW_TRANSIENT_MCP_AUTOINJECT) -} - -function normalizedPathPrefix(pathname: string): string { - return pathname.replace(/\/+$/, '') + '/' -} - -function isTransientAppHome(appHome: string): boolean { - const normalized = normalizedPathPrefix(appHome) - const transientRoots = [tmpdir(), '/tmp', '/private/tmp'] - .filter(Boolean) - .map(root => normalizedPathPrefix(root)) - return transientRoots.some(root => normalized.startsWith(root)) -} - -function shouldIncludeManagedMcpServers(): boolean { - if (isEnabledEnv(process.env.HERMES_WEB_UI_DISABLE_MCP_AUTOINJECT)) return false - return !isTransientAppHome(config.appHome) || allowTransientAutoinject() -} - -function candidateBundledMcpScripts(): string[] { - return [ - process.env.HERMES_WEB_UI_MCP_BIN, - join(process.cwd(), 'bin/hermes-ui-mcp.mjs'), - join(__dirname, '../../bin/hermes-ui-mcp.mjs'), - join(__dirname, '../../../../../bin/hermes-ui-mcp.mjs'), - join(process.cwd(), 'bin/hermes-studio-mcp.mjs'), - join(__dirname, '../../bin/hermes-studio-mcp.mjs'), - join(__dirname, '../../../../../bin/hermes-studio-mcp.mjs'), - join(process.cwd(), 'bin/hermes-web-ui-mcp.mjs'), - join(__dirname, '../../bin/hermes-web-ui-mcp.mjs'), - join(__dirname, '../../../../../bin/hermes-web-ui-mcp.mjs'), - ].filter((value): value is string => !!value) -} - -function bundledMcpScriptPath(): string | null { - return candidateBundledMcpScripts().find(candidate => existsSync(candidate)) || null -} - -function runtimeNodePath(): string | null { - const node = process.env.HERMES_AGENT_NODE?.trim() - return node || null -} - -function managedCommandConfig(toolset: string): Record { - const bundledScript = bundledMcpScriptPath() - if (bundledScript) { - return { command: runtimeNodePath() || process.execPath, args: [bundledScript, toolset] } - } - - if (isDesktopRuntime()) { - return { command: 'hermes-studio-mcp', args: [toolset] } - } - - return { command: 'hermes-studio-mcp', args: [toolset] } -} - -function managedMcpServerConfig(profile: string, serverName: string, toolset: string): Record { - return { - ...managedCommandConfig(toolset), - env: { - HERMES_WEB_UI_URL: `http://127.0.0.1:${config.port}`, - HERMES_WEB_UI_HOME: config.appHome, - HERMES_WEBUI_STATE_DIR: config.appHome, - HERMES_WEB_UI_PROFILE: profile, - HERMES_MCP_SERVER_NAME: serverName, - HERMES_MCP_TOOLSET: toolset, - [MANAGED_ENV_KEY]: '1', - }, - enabled: true, - } -} - -export function buildManagedEkkoMcpServers(profile: string): EkkoMcpServers { - if (!shouldIncludeManagedMcpServers()) return {} - - return Object.fromEntries( - MANAGED_SERVERS.map(server => [ - server.name, - managedMcpServerConfig(profile, server.name, server.toolset), - ]), - ) -} - -export function resolveEkkoMcpServers(profile: string, provided?: EkkoMcpServers): EkkoMcpServers | undefined { - const managed = buildManagedEkkoMcpServers(profile) - const merged = { - ...managed, - ...(provided || {}), - } - - return Object.keys(merged).length > 0 ? merged : undefined -} diff --git a/packages/server/src/services/hermes/run-chat/abort.ts b/packages/server/src/services/hermes/run-chat/abort.ts index 45e125e..17d34f1 100644 --- a/packages/server/src/services/hermes/run-chat/abort.ts +++ b/packages/server/src/services/hermes/run-chat/abort.ts @@ -5,19 +5,13 @@ import type { Server, Socket } from 'socket.io' import { updateSession, updateSessionStats } from '../../../db/hermes/session-store' import { logger } from '../../logger' -import { codingAgentRunManager } from '../../agent-runner/coding-agent-run-manager' import { flushBridgePendingToDb } from './bridge-message' -import { flushResponseRunToDb } from './response-stream' import { replaceState } from './compression' import { calcAndUpdateUsage } from './usage' import type { QueuedRun, SessionState } from './types' const ABORT_BRIDGE_SYNC_TIMEOUT_MESSAGE = 'Hermes Agent did not confirm stop before timeout. Local run state was released so you can continue.' -function isBridgeRunSource(source?: string): boolean { - return source === 'cli' || source === 'global_agent' || source === 'workflow' -} - export async function handleAbort( nsp: ReturnType, socket: Socket, @@ -26,14 +20,8 @@ export async function handleAbort( bridge: any, runQueuedItem: (socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile?: string) => void, ) { - let state = sessionMap.get(sessionId) - const hasCodingAgentRun = codingAgentRunManager.hasSession(sessionId) - if (!state && hasCodingAgentRun) { - state = { messages: [], isWorking: true, events: [], queue: [], source: 'coding_agent' } - sessionMap.set(sessionId, state) - } - const isCodingAgentRun = state?.source === 'coding_agent' || hasCodingAgentRun - if ((!state?.isWorking && !hasCodingAgentRun) || (state && !isCodingAgentRun && !state.runId && !state.abortController)) { + const state = sessionMap.get(sessionId) + if (!state || (!state.isWorking && !state.runId && !state.abortController)) { logger.info({ sessionId }, '[chat-run-socket][abort] ignored: no active run') if (state) { state.isWorking = false @@ -50,11 +38,8 @@ export async function handleAbort( return } - const activeState = state - if (!activeState) return - - const runId = activeState.runId - activeState.isAborting = true + const runId = state.runId + state.isAborting = true replaceState(sessionMap, sessionId, 'abort.started', { event: 'abort.started', run_id: runId, @@ -67,60 +52,44 @@ export async function handleAbort( }) logger.info({ sessionId, runId }, '[chat-run-socket][abort] started') - // Workflow sessions can be backed either by Hermes bridge runs or by scoped - // coding-agent runners. Prefer the coding-agent runtime when it owns the - // session; otherwise source='workflow' is misclassified as a bridge run and - // bridge.interrupt returns "unknown session" while the coding agent keeps - // running. - const shouldAbortThroughBridge = isBridgeRunSource(activeState.source) && !isCodingAgentRun + // Every run is bridge-driven now: resolveRunSource normalizes the legacy + // "coding_agent" source to "cli", so aborting always goes through the bridge. + flushBridgePendingToDb(state, sessionId) + state.abortController?.abort() - // Flush in-memory assistant text to DB before aborting the stream. - if (shouldAbortThroughBridge) { - flushBridgePendingToDb(activeState, sessionId) - } else { - flushResponseRunToDb(activeState, sessionId) + let interruptResult: any = null + try { + interruptResult = await bridge.interrupt(sessionId, 'Aborted by user', state.profile) + } catch (err) { + logger.warn(err, '[chat-run-socket][abort] failed to interrupt CLI bridge for session %s', sessionId) } - - if (shouldAbortThroughBridge) { - let interruptResult: any = null + try { + await bridge.goalPause?.(sessionId, 'user-interrupted', state.profile) + state.queue = state.queue.filter(item => !item.goalContinuation) + } catch (err) { + logger.debug(err, '[chat-run-socket][abort] goal pause-on-interrupt skipped for session %s', sessionId) + } + if (interruptResult?.synced === false) { + replaceState(sessionMap, sessionId, 'abort.timeout', { + event: 'abort.timeout', + run_id: runId, + synced: false, + message: ABORT_BRIDGE_SYNC_TIMEOUT_MESSAGE, + }) + emitToSession(nsp, socket, sessionId, 'abort.timeout', { + event: 'abort.timeout', + run_id: runId, + synced: false, + message: ABORT_BRIDGE_SYNC_TIMEOUT_MESSAGE, + }) + logger.warn({ sessionId, runId }, '[chat-run-socket][abort] CLI bridge interrupt did not sync before timeout') try { - interruptResult = await bridge.interrupt(sessionId, 'Aborted by user', activeState.profile) + await bridge.destroy?.(sessionId, state.profile) } catch (err) { - logger.warn(err, '[chat-run-socket][abort] failed to interrupt CLI bridge for session %s', sessionId) + logger.warn(err, '[chat-run-socket][abort] failed to destroy timed-out CLI bridge session %s', sessionId) } - try { - await bridge.goalPause?.(sessionId, 'user-interrupted', activeState.profile) - activeState.queue = activeState.queue.filter(item => !item.goalContinuation) - } catch (err) { - logger.debug(err, '[chat-run-socket][abort] goal pause-on-interrupt skipped for session %s', sessionId) - } - if (interruptResult?.synced === false) { - replaceState(sessionMap, sessionId, 'abort.timeout', { - event: 'abort.timeout', - run_id: runId, - synced: false, - message: ABORT_BRIDGE_SYNC_TIMEOUT_MESSAGE, - }) - emitToSession(nsp, socket, sessionId, 'abort.timeout', { - event: 'abort.timeout', - run_id: runId, - synced: false, - message: ABORT_BRIDGE_SYNC_TIMEOUT_MESSAGE, - }) - logger.warn({ sessionId, runId }, '[chat-run-socket][abort] CLI bridge interrupt did not sync before timeout') - try { - await bridge.destroy?.(sessionId, activeState.profile) - } catch (err) { - logger.warn(err, '[chat-run-socket][abort] failed to destroy timed-out CLI bridge session %s', sessionId) - } - await markAbortCompleted(nsp, socket, sessionId, runId || 'bridge_abort_timeout', sessionMap, runQueuedItem, false) - return - } - } else if (isCodingAgentRun) { - activeState.abortController?.abort() - codingAgentRunManager.stop(sessionId, { reportClosed: false }) - } else if (activeState.abortController) { - activeState.abortController.abort() + await markAbortCompleted(nsp, socket, sessionId, runId || 'bridge_abort_timeout', sessionMap, runQueuedItem, false) + return } await markAbortCompleted(nsp, socket, sessionId, runId || 'response_stream', sessionMap, runQueuedItem) diff --git a/packages/server/src/services/hermes/run-chat/handle-coding-agent-run.ts b/packages/server/src/services/hermes/run-chat/handle-coding-agent-run.ts deleted file mode 100644 index 167ba22..0000000 --- a/packages/server/src/services/hermes/run-chat/handle-coding-agent-run.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { Server, Socket } from 'socket.io' -import { codingAgentRunManager } from '../../agent-runner/coding-agent-run-manager' -import { - sendCodingAgentRunInput, - startCodingAgentRun, - type CodingAgentId as ExternalCodingAgentId, -} from '../../coding-agents' -import { getOrCreateSession } from './compression' -import { contentBlocksToString } from './content-blocks' -import type { ContentBlock, SessionState } from './types' -import type { ChatCodingAgentId } from './types' -import { writeModelRunProfileToken } from './model-run-prompt' -import type { AuthenticatedUser } from '../../../middleware/user-auth' -import { getSystemPrompt } from '../../../lib/llm-prompt' -import { getSession, updateSession } from '../../../db/hermes/session-store' -import { logger } from '../../logger' - -export interface CodingAgentRunSocketData { - input: string | ContentBlock[] - session_id?: string - profile?: string - provider?: string - model?: string - coding_agent_id?: ChatCodingAgentId - agent_id?: ChatCodingAgentId - mode?: 'scoped' | 'global' - workspace?: string | null - source?: string - baseUrl?: string - base_url?: string - apiKey?: string - api_key?: string - apiMode?: any - api_mode?: any - reasoning_effort?: string - session_source?: 'global_agent' | 'workflow' -} - -function codingAgentId(data: CodingAgentRunSocketData): ExternalCodingAgentId { - const value = data.coding_agent_id || data.agent_id || 'claude-code' - return value === 'codex' ? 'codex' : 'claude-code' -} - -export async function handleCodingAgentRun( - nsp: ReturnType, - socket: Socket, - data: CodingAgentRunSocketData, - profile: string, - sessionMap: Map, -) { - const sessionId = String(data.session_id || '').trim() - if (!sessionId) { - socket.emit('run.failed', { event: 'run.failed', error: 'session_id is required for coding agent runs' }) - return - } - - socket.join(`session:${sessionId}`) - const agentId = codingAgentId(data) - const state = getOrCreateSession(sessionMap, sessionId) - state.profile = profile - state.source = data.session_source === 'workflow' || data.source === 'workflow' ? 'workflow' : 'coding_agent' - - let runId = codingAgentRunManager.runIdForSession(sessionId) - const mode = data.mode === 'global' ? 'global' : 'scoped' - const storedSession = getSession(sessionId) - const launchProvider = data.provider || (mode === 'scoped' ? storedSession?.provider || undefined : undefined) - const launchModel = data.model || (mode === 'scoped' ? storedSession?.model || undefined : undefined) - const launchApiMode = data.apiMode || data.api_mode || (mode === 'scoped' ? storedSession?.api_mode || undefined : undefined) - if (runId && !codingAgentRunManager.isSessionLaunchCompatible(sessionId, { - agentId, - mode, - provider: launchProvider, - model: launchModel, - apiMode: launchApiMode, - reasoningEffort: data.reasoning_effort, - })) { - codingAgentRunManager.stop(sessionId, { reportClosed: false }) - runId = undefined - } - if (!runId) { - const started = await startCodingAgentRun(agentId, { - sessionId, - mode, - profile, - provider: launchProvider, - model: launchModel, - workspace: data.workspace, - baseUrl: data.baseUrl || data.base_url, - apiKey: data.apiKey || data.api_key, - apiMode: launchApiMode, - reasoningEffort: data.reasoning_effort, - sessionSource: data.session_source, - }, state) - runId = started.agentSessionId - } - - state.isWorking = true - state.runId = runId - try { - updateSession(sessionId, { - ended_at: null, - end_reason: null, - last_active: Math.floor(Date.now() / 1000), - }) - } catch (err) { - logger.warn(err, '[chat-run-socket] failed to reopen coding-agent session %s', sessionId) - } - - try { - const inputText = contentBlocksToString(data.input) - const socketUser = socket.data?.user as AuthenticatedUser | undefined - await writeModelRunProfileToken(socketUser, profile) - const includeBaseSystemPrompt = agentId === 'claude-code' || agentId === 'codex' - const runPrompt = [ - includeBaseSystemPrompt ? getSystemPrompt(undefined, { source: data.session_source || data.source }) : '', - ].filter(Boolean).join('\n') - await sendCodingAgentRunInput(sessionId, inputText, runPrompt) - } catch (err) { - if (!codingAgentRunManager.isSessionProcessing(sessionId)) { - state.isWorking = false - state.isAborting = false - state.runId = undefined - state.abortController = undefined - state.activeRunMarker = undefined - state.events = [] - state.responseRun = undefined - try { - updateSession(sessionId, { - ended_at: Math.floor(Date.now() / 1000), - end_reason: 'error', - }) - } catch (updateErr) { - logger.warn(updateErr, '[chat-run-socket] failed to write coding-agent send error end marker for %s', sessionId) - } - } - throw err - } -} diff --git a/packages/server/src/services/hermes/run-chat/handle-ekko-agent-run.ts b/packages/server/src/services/hermes/run-chat/handle-ekko-agent-run.ts deleted file mode 100644 index a4fa821..0000000 --- a/packages/server/src/services/hermes/run-chat/handle-ekko-agent-run.ts +++ /dev/null @@ -1,834 +0,0 @@ -import type { Server, Socket } from 'socket.io' -import { createHash } from 'crypto' -import { inspect } from 'util' -import { - createModelClient, - resolveModelProviderConfigs, - type AgentMessage, - type AgentToolCall, - type ModelClient, - type ModelEvent, - type AgentRuntimeEvent, - type ModelProviderConfig, - type ModelRequest, - type ModelResponse, -} from '../../../../../ekko-agent/src' -import { getGlobalEkkoAgent } from '../../ekko-agent/manager' -import { resolveEkkoMcpServers } from '../../ekko-agent/mcp' -import { createSession, addMessage, getSession, updateSession, updateSessionStats } from '../../../db/hermes/session-store' -import { logger } from '../../logger' -import { recordSessionUsage } from '../../usage-recorder' -import { getProfileDir } from '../hermes-profile' -import { observeRunChatPetEvent } from '../pet-state-socket' -import { contentBlocksToString, extractTextForPreview } from './content-blocks' -import { getOrCreateSession } from './compression' -import { resolveBridgeRunModelConfig, type RunModelGroup } from './model-config' -import { estimateUsageTokensFromMessages } from './usage' -import type { ChatCodingAgentId, ContentBlock, SessionState } from './types' - -export interface EkkoAgentRunSocketData { - input: string | ContentBlock[] - display_input?: string | ContentBlock[] | null - display_role?: 'user' | 'command' - storage_message?: string - session_id?: string - profile?: string - provider?: string - model?: string - model_groups?: RunModelGroup[] - coding_agent_id?: ChatCodingAgentId - agent_id?: ChatCodingAgentId - mode?: 'scoped' | 'global' - workspace?: string | null - source?: string - baseUrl?: string - base_url?: string - apiKey?: string - api_key?: string - apiMode?: string - api_mode?: string - mcpServers?: Record - mcp_servers?: Record - peerExcludeSocketId?: string - queue_id?: string - onEvent?: (event: string, payload: any) => void -} - -function isEkkoAgentId(data: EkkoAgentRunSocketData): boolean { - return data.coding_agent_id === 'ekko-agent' || data.agent_id === 'ekko-agent' -} - -function parseJsonRecord(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null - return value as Record -} - -function parseToolArguments(raw: unknown): { arguments: Record; rawArguments?: string } { - if (raw && typeof raw === 'object' && !Array.isArray(raw)) return { arguments: raw as Record } - const rawArguments = typeof raw === 'string' ? raw : JSON.stringify(raw ?? {}) - try { - const parsed = JSON.parse(rawArguments) - return { - arguments: parseJsonRecord(parsed) || {}, - rawArguments, - } - } catch { - return { arguments: {}, rawArguments } - } -} - -function normalizeStoredToolCall(raw: unknown): AgentToolCall | null { - const record = parseJsonRecord(raw) - if (!record) return null - const functionRecord = parseJsonRecord(record.function) - const id = String(record.id || record.call_id || record.tool_call_id || '').trim() - const name = String(record.name || functionRecord?.name || '').trim() - if (!id || !name) return null - const parsed = parseToolArguments(record.arguments ?? functionRecord?.arguments) - return { - id, - name, - arguments: parsed.arguments, - rawArguments: parsed.rawArguments, - } -} - -function normalizeStoredToolCalls(value: unknown): AgentToolCall[] | undefined { - const rawCalls = Array.isArray(value) - ? value - : typeof value === 'string' && value.trim() - ? JSON.parse(value) - : [] - if (!Array.isArray(rawCalls)) return undefined - const calls = rawCalls - .map(normalizeStoredToolCall) - .filter((call): call is AgentToolCall => !!call) - return calls.length ? calls : undefined -} - -function toAgentMessages(messages: SessionState['messages']): AgentMessage[] { - const toolCallIds = new Set() - const result: AgentMessage[] = [] - - for (const message of messages) { - if (message.role === 'user' || message.role === 'command' || message.role === 'system') { - const content = contentBlocksToString(message.content as any) - if (content.trim()) { - result.push({ - role: message.role === 'system' ? 'system' : 'user', - content, - }) - } - continue - } - - if (message.role === 'assistant') { - let toolCalls: AgentToolCall[] | undefined - try { - toolCalls = normalizeStoredToolCalls(message.tool_calls) - } catch { - toolCalls = undefined - } - for (const call of toolCalls || []) toolCallIds.add(call.id) - const agentMessage: AgentMessage = { - role: 'assistant', - content: contentBlocksToString(message.content as any), - reasoning: message.reasoning || message.reasoning_content || undefined, - toolCalls, - } - if (agentMessage.content.trim() || (agentMessage.reasoning?.trim().length ?? 0) > 0 || toolCalls?.length) { - result.push(agentMessage) - } - continue - } - - if (message.role === 'tool') { - const toolCallId = String(message.tool_call_id || '').trim() - if (!toolCallId || !toolCallIds.has(toolCallId)) continue - const content = contentBlocksToString(message.content as any) - if (!content.trim()) continue - result.push({ - role: 'tool', - content, - toolCallId, - name: message.tool_name || undefined, - }) - toolCallIds.delete(toolCallId) - } - } - - return result -} - -function appendStateEvent(state: SessionState, event: string, payload: any): void { - if (!state.isWorking) return - state.events.push({ event, data: payload }) - if (state.events.length > 200) state.events.splice(0, state.events.length - 200) -} - -function redactProviderConfig(config: ModelProviderConfig): ModelProviderConfig { - return { - ...config, - apiKey: config.apiKey ? apiKeyDebugInfo(config.apiKey) : undefined, - headers: config.headers - ? Object.fromEntries(Object.entries(config.headers).map(([key, value]) => [ - key, - /authorization|api[-_]?key|token/i.test(key) ? headerSecretDebugInfo(value) : value, - ])) - : undefined, - } -} - -function apiKeyDebugInfo(apiKey: string): string { - return `[present length=${apiKey.length} last4=${apiKey.slice(-4)} sha256=${createHash('sha256').update(apiKey).digest('hex').slice(0, 12)}]` -} - -function headerSecretDebugInfo(value: unknown): string { - const raw = String(value ?? '') - if (!raw) return '[empty]' - const token = raw.replace(/^Bearer\s+/i, '') - return raw.startsWith('Bearer ') - ? `Bearer ${apiKeyDebugInfo(token)}` - : apiKeyDebugInfo(raw) -} - -function consolePayload(value: unknown): string { - return inspect(value, { - depth: null, - colors: false, - maxArrayLength: null, - maxStringLength: null, - breakLength: 120, - compact: false, - }) -} - -function errorPayload(err: unknown): unknown { - if (!(err instanceof Error)) return err - const withDetails = err as Error & { - provider?: string - statusCode?: number - retryable?: boolean - details?: unknown - } - return { - name: err.name, - message: err.message, - provider: withDetails.provider, - statusCode: withDetails.statusCode, - retryable: withDetails.retryable, - details: withDetails.details, - stack: err.stack, - } -} - -function shouldUsePlainChatRequest(config: ModelProviderConfig): boolean { - const provider = String(config.id || '').toLowerCase() - const baseUrl = String(config.baseUrl || '').toLowerCase() - return provider.includes('glm') || baseUrl.includes('bigmodel.cn') -} - -function requestForProvider(request: ModelRequest, config: ModelProviderConfig): ModelRequest { - if (!shouldUsePlainChatRequest(config)) return request - return { - ...request, - metadata: undefined, - } -} - -function shouldFallbackProtocol(err: unknown): boolean { - const statusCode = (err as { statusCode?: number } | null)?.statusCode - return statusCode === 400 || statusCode === 404 || statusCode === 405 || statusCode === 415 || statusCode === 422 -} - -function toStoredToolCall(toolCall: AgentToolCall) { - return { - id: toolCall.id, - type: 'function', - function: { - name: toolCall.name, - arguments: toolCall.rawArguments || JSON.stringify(toolCall.arguments || {}), - }, - } -} - -function createConsoleModelClient( - client: ModelClient, - context: { - sessionId: string - providerConfig: ModelProviderConfig - fallback?: { - client: ModelClient - providerConfig: ModelProviderConfig - } - }, -): ModelClient { - return { - ...client, - provider: client.provider, - requestStyle: client.requestStyle, - capabilities: client.capabilities, - async create(request: ModelRequest): Promise { - const providerRequest = requestForProvider(request, context.providerConfig) - console.log('[ekko-agent] model request', consolePayload({ - session_id: context.sessionId, - provider_config: redactProviderConfig(context.providerConfig), - request: providerRequest, - })) - try { - const response = await client.create(providerRequest) - console.log('[ekko-agent] model request success', consolePayload({ - session_id: context.sessionId, - provider: client.provider, - request_style: client.requestStyle, - response, - })) - return response - } catch (err) { - console.error('[ekko-agent] model request failed', consolePayload({ - session_id: context.sessionId, - provider: client.provider, - request_style: client.requestStyle, - error: errorPayload(err), - })) - if (context.fallback && context.fallback.providerConfig.requestStyle !== context.providerConfig.requestStyle && shouldFallbackProtocol(err)) { - const fallbackRequest = requestForProvider(request, context.fallback.providerConfig) - console.warn('[ekko-agent] model request protocol fallback', consolePayload({ - session_id: context.sessionId, - from_request_style: context.providerConfig.requestStyle, - to_request_style: context.fallback.providerConfig.requestStyle, - provider_config: redactProviderConfig(context.fallback.providerConfig), - request: fallbackRequest, - })) - try { - const response = await context.fallback.client.create(fallbackRequest) - console.log('[ekko-agent] model request fallback success', consolePayload({ - session_id: context.sessionId, - provider: context.fallback.client.provider, - request_style: context.fallback.client.requestStyle, - response, - })) - return response - } catch (fallbackErr) { - console.error('[ekko-agent] model request fallback failed', consolePayload({ - session_id: context.sessionId, - provider: context.fallback.client.provider, - request_style: context.fallback.client.requestStyle, - error: errorPayload(fallbackErr), - })) - } - } - throw err - } - }, - async *stream(request: ModelRequest): AsyncIterable { - const providerRequest = requestForProvider(request, context.providerConfig) - console.log('[ekko-agent] model stream request', consolePayload({ - session_id: context.sessionId, - provider_config: redactProviderConfig(context.providerConfig), - request: providerRequest, - })) - try { - for await (const event of client.stream(providerRequest)) { - yield event - } - console.log('[ekko-agent] model stream success', consolePayload({ - session_id: context.sessionId, - provider: client.provider, - request_style: client.requestStyle, - })) - } catch (err) { - console.error('[ekko-agent] model stream failed', consolePayload({ - session_id: context.sessionId, - provider: client.provider, - request_style: client.requestStyle, - error: errorPayload(err), - })) - if (context.fallback && context.fallback.providerConfig.requestStyle !== context.providerConfig.requestStyle && shouldFallbackProtocol(err)) { - const fallbackRequest = requestForProvider(request, context.fallback.providerConfig) - console.warn('[ekko-agent] model stream protocol fallback', consolePayload({ - session_id: context.sessionId, - from_request_style: context.providerConfig.requestStyle, - to_request_style: context.fallback.providerConfig.requestStyle, - provider_config: redactProviderConfig(context.fallback.providerConfig), - request: fallbackRequest, - })) - try { - for await (const event of context.fallback.client.stream(fallbackRequest)) { - yield event - } - console.log('[ekko-agent] model stream fallback success', consolePayload({ - session_id: context.sessionId, - provider: context.fallback.client.provider, - request_style: context.fallback.client.requestStyle, - })) - return - } catch (fallbackErr) { - console.error('[ekko-agent] model stream fallback failed', consolePayload({ - session_id: context.sessionId, - provider: context.fallback.client.provider, - request_style: context.fallback.client.requestStyle, - error: errorPayload(fallbackErr), - })) - } - } - throw err - } - }, - } -} - -export async function handleEkkoAgentRun( - nsp: ReturnType, - socket: Socket, - data: EkkoAgentRunSocketData, - profile: string, - sessionMap: Map, - dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => boolean, - skipUserMessage = false, -) { - const sessionId = String(data.session_id || '').trim() - if (!sessionId) { - socket.emit('run.failed', { event: 'run.failed', error: 'session_id is required for ekko-agent runs' }) - return - } - if (!isEkkoAgentId(data)) { - socket.emit('run.failed', { event: 'run.failed', session_id: sessionId, error: 'ekko-agent run requires coding_agent_id=ekko-agent' }) - return - } - - socket.join(`session:${sessionId}`) - const state = getOrCreateSession(sessionMap, sessionId) - state.isWorking = true - state.isAborting = false - state.profile = profile - state.source = data.source === 'workflow' ? 'workflow' : 'coding_agent' - state.events = [] - const abortController = new AbortController() - state.abortController = abortController - - const storedSession = getSession(sessionId) - const modelConfig = await resolveBridgeRunModelConfig({ - profile, - sessionModel: storedSession?.model, - sessionProvider: storedSession?.provider, - requestedModel: data.model, - requestedProvider: data.provider, - modelGroups: data.model_groups, - preferRequested: true, - }) - const workspace = data.workspace || storedSession?.workspace || getProfileDir(profile) - const displayInput = data.display_input === undefined ? data.input : data.display_input - const inputText = contentBlocksToString(data.input) - const displayText = displayInput == null ? '' : contentBlocksToString(displayInput) - const storageText = data.storage_message !== undefined ? data.storage_message : displayText - const shouldPersistUserMessage = !skipUserMessage && displayInput !== null - const now = Math.floor(Date.now() / 1000) - const emit = (event: string, payload: any) => { - const tagged = { ...payload, session_id: sessionId } - observeRunChatPetEvent(profile, event, tagged) - data.onEvent?.(event, tagged) - appendStateEvent(state, event, tagged) - nsp.to(`session:${sessionId}`).emit(event, tagged) - if (!data.onEvent && !nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) { - socket.emit(event, tagged) - } - } - - if (!storedSession) { - const previewText = extractTextForPreview(displayInput === null ? data.input : displayInput || data.input) - const title = previewText.replace(/[\r\n]/g, ' ').substring(0, 100) - createSession({ - id: sessionId, - profile, - source: 'coding_agent', - agent: 'ekko-agent', - agent_mode: 'scoped', - model: modelConfig.model, - provider: modelConfig.provider, - title, - workspace, - }) - } - try { - updateSession(sessionId, { ended_at: null, end_reason: null, last_active: now }) - } catch (err) { - logger.warn(err, '[chat-run-socket] failed to reopen ekko-agent session %s', sessionId) - } - - if (shouldPersistUserMessage) { - const role = data.display_role === 'command' ? 'command' : 'user' - const messageId = addMessage({ - session_id: sessionId, - role, - content: storageText, - timestamp: now, - }) - state.messages.push({ - id: data.queue_id || messageId || state.messages.length + 1, - session_id: sessionId, - role, - content: storageText, - timestamp: now, - }) - const peerTarget = data.peerExcludeSocketId - ? nsp.to(`session:${sessionId}`).except(data.peerExcludeSocketId) - : socket.to(`session:${sessionId}`) - peerTarget.emit('run.peer_user_message', { - event: 'run.peer_user_message', - session_id: sessionId, - message: { - id: data.queue_id || messageId, - role, - content: storageText, - timestamp: now, - }, - }) - } - - const baseUrl = data.baseUrl || data.base_url || '' - const apiMode = data.apiMode || data.api_mode - const apiKey = data.apiKey || data.api_key || undefined - const { providerConfig, fallbackProviderConfig } = resolveModelProviderConfigs({ - provider: modelConfig.provider, - baseUrl, - apiKey, - model: modelConfig.model, - apiMode, - timeoutMs: 120_000, - }) - const mcpServers = resolveEkkoMcpServers(profile, data.mcpServers || data.mcp_servers) - const modelClient = createConsoleModelClient(createModelClient(providerConfig), { - sessionId, - providerConfig, - fallback: fallbackProviderConfig - ? { - client: createModelClient(fallbackProviderConfig), - providerConfig: fallbackProviderConfig, - } - : undefined, - }) - const agent = getGlobalEkkoAgent() - - let assistantText = '' - let assistantReasoning = '' - let runId = '' - let usageInput = 0 - let usageOutput = 0 - let usageCallIndex = 0 - let contextEstimate: any - const handleRuntimeEvent = (event: AgentRuntimeEvent) => { - if ('runId' in event) runId = event.runId - if (event.type === 'run.started') { - state.runId = event.runId - emit('run.started', { - event: 'run.started', - run_id: event.runId, - model: modelConfig.model, - provider: modelConfig.provider, - }) - } else if (event.type === 'context.estimated') { - contextEstimate = event.estimate - emit('context.estimated', { - event: 'context.estimated', - run_id: event.runId, - contextTokens: event.estimate.contextTokens, - context_tokens: event.estimate.contextTokens, - systemPromptTokens: event.estimate.systemPromptTokens, - toolTokens: event.estimate.toolTokens, - messageTokens: event.estimate.messageTokens, - toolCount: event.estimate.toolCount, - }) - } else if (event.type === 'model.message') { - const text = event.message.content || '' - if (text && !event.message.toolCalls?.length) { - const shouldEmitFullMessage = assistantText.length === 0 - assistantText = text - if (shouldEmitFullMessage) { - emit('message.delta', { - event: 'message.delta', - run_id: event.runId, - delta: text, - }) - } - } - } else if (event.type === 'model.delta') { - assistantText += event.text - emit('message.delta', { - event: 'message.delta', - run_id: event.runId, - delta: event.text, - }) - } else if (event.type === 'model.usage') { - usageInput += event.usage.inputTokens || 0 - usageOutput += event.usage.outputTokens || 0 - usageCallIndex += 1 - recordSessionUsage({ - sessionId, - runId: `${event.runId}:step:${event.step}:call:${usageCallIndex}`, - source: 'ekko_agent', - agent: 'ekko_agent', - usageScope: 'model_call', - apiCalls: 1, - usage: event.usage, - profile, - model: modelConfig.model, - provider: modelConfig.provider, - isEstimated: false, - }) - } else if (event.type === 'model.context') { - emit('context.updated', { - event: 'context.updated', - run_id: event.runId, - context: event.context, - }) - } else if (event.type === 'model.reasoning') { - if (event.text) { - assistantReasoning += event.text - emit('reasoning.delta', { - event: 'reasoning.delta', - run_id: event.runId, - delta: event.text, - }) - } - } else if (event.type === 'tool.started') { - emit('tool.started', { - event: 'tool.started', - run_id: event.runId, - tool: event.toolName, - name: event.toolName, - arguments: event.arguments, - preview: JSON.stringify(event.arguments || {}), - tool_call_id: event.toolCallId, - }) - } else if (event.type === 'tool.completed' || event.type === 'tool.failed') { - emit(event.type, { - event: event.type, - run_id: event.runId, - tool: event.toolName, - name: event.toolName, - output: event.result.content, - preview: event.result.content, - tool_call_id: event.toolCallId, - duration: Math.round(event.durationMs / 10) / 100, - error: event.result.error, - }) - } - } - - try { - logger.info('[chat-run-socket] starting ekko-agent run for session %s', sessionId) - const result = await agent.run({ - modelClient, - model: modelConfig.model, - modelDefaults: { - model: modelConfig.model, - }, - messages: toAgentMessages(state.messages), - signal: abortController.signal, - onEvent: handleRuntimeEvent, - toolContext: { - cwd: workspace, - workspaceRoot: workspace, - sessionId, - browserSessionId: sessionId, - mcpServers, - timeoutMs: 120_000, - signal: abortController.signal, - }, - metadata: { - session_id: sessionId, - profile, - }, - }) - assistantText = result.output.content || assistantText - const outputUsage = result.output.usage - if (outputUsage && !usageInput && !usageOutput) { - usageInput += outputUsage.inputTokens || 0 - usageOutput += outputUsage.outputTokens || 0 - } - for (const step of result.steps) { - if (step.type === 'model' && step.message.toolCalls?.length) { - const toolCalls = step.message.toolCalls.map(toStoredToolCall) - const timestamp = Math.floor(Date.now() / 1000) - const assistantId = addMessage({ - session_id: sessionId, - role: 'assistant', - content: step.message.content || '', - tool_calls: toolCalls, - timestamp, - finish_reason: 'tool_calls', - reasoning: step.message.reasoning || null, - reasoning_content: step.message.reasoning || null, - }) - state.messages.push({ - id: assistantId || state.messages.length + 1, - session_id: sessionId, - role: 'assistant', - content: step.message.content || '', - tool_calls: toolCalls, - timestamp, - finish_reason: 'tool_calls', - reasoning: step.message.reasoning || null, - reasoning_content: step.message.reasoning || null, - }) - } else if (step.type === 'tool') { - const timestamp = Math.floor(Date.now() / 1000) - const toolId = addMessage({ - session_id: sessionId, - role: 'tool', - content: step.result.content, - tool_call_id: step.toolCallId, - tool_name: step.toolName, - timestamp, - finish_reason: step.result.ok ? null : 'error', - }) - state.messages.push({ - id: toolId || state.messages.length + 1, - session_id: sessionId, - role: 'tool', - content: step.result.content, - tool_call_id: step.toolCallId, - tool_name: step.toolName, - timestamp, - finish_reason: step.result.ok ? null : 'error', - }) - } - } - assistantReasoning = result.output.reasoning || assistantReasoning - const hadToolActivity = result.steps.some(step => step.type === 'tool') - if (!assistantText.trim() && !assistantReasoning.trim() && !hadToolActivity) { - const error = 'Model provider returned an empty response after streaming and non-streaming attempts.' - logger.warn({ - session_id: sessionId, - provider_config: redactProviderConfig(providerConfig), - response: result.output, - }, '[chat-run-socket] ekko-agent model returned empty output') - if (state.queue.length === 0) { - try { - updateSession(sessionId, { - ended_at: Math.floor(Date.now() / 1000), - end_reason: 'error', - }) - } catch (err) { - logger.warn(err, '[chat-run-socket] failed to write ekko-agent empty-response end marker for %s', sessionId) - } - } - emit('run.failed', { - event: 'run.failed', - run_id: runId || result.runId, - error, - queue_remaining: state.queue.length, - }) - return - } - if (assistantText.trim() || assistantReasoning.trim()) { - const assistantId = addMessage({ - session_id: sessionId, - role: 'assistant', - content: assistantText, - timestamp: Math.floor(Date.now() / 1000), - finish_reason: result.output.finishReason || null, - reasoning: assistantReasoning || null, - reasoning_content: assistantReasoning || null, - }) - state.messages.push({ - id: assistantId || state.messages.length + 1, - session_id: sessionId, - role: 'assistant', - content: assistantText, - timestamp: Math.floor(Date.now() / 1000), - finish_reason: result.output.finishReason || null, - reasoning: assistantReasoning || null, - reasoning_content: assistantReasoning || null, - }) - } - if (!usageInput && !usageOutput) { - const usage = estimateUsageTokensFromMessages([ - { role: 'user', content: inputText }, - { role: 'assistant', content: assistantText }, - ]) - usageInput = usage.inputTokens - usageOutput = usage.outputTokens - } - state.inputTokens = (state.inputTokens || 0) + usageInput - state.outputTokens = (state.outputTokens || 0) + usageOutput - if (contextEstimate?.contextTokens != null) state.contextTokens = contextEstimate.contextTokens - updateSessionStats(sessionId) - if (state.queue.length === 0) { - try { - updateSession(sessionId, { - ended_at: Math.floor(Date.now() / 1000), - end_reason: 'complete', - }) - } catch (err) { - logger.warn(err, '[chat-run-socket] failed to write ekko-agent session end marker for %s', sessionId) - } - } - emit('usage.updated', { - event: 'usage.updated', - run_id: runId || result.runId, - input_tokens: state.inputTokens || 0, - output_tokens: state.outputTokens || 0, - total_tokens: (state.inputTokens || 0) + (state.outputTokens || 0), - contextTokens: contextEstimate?.contextTokens ?? state.contextTokens, - context_tokens: contextEstimate?.contextTokens ?? state.contextTokens, - }) - emit('run.completed', { - event: 'run.completed', - run_id: runId || result.runId, - output: assistantText, - context: result.context, - contextTokens: contextEstimate?.contextTokens, - context_tokens: contextEstimate?.contextTokens, - contextEstimate, - usage: { - input_tokens: usageInput, - output_tokens: usageOutput, - total_tokens: usageInput + usageOutput, - }, - queue_remaining: state.queue.length, - }) - } catch (err) { - if (abortController.signal.aborted || isAbortError(err)) { - logger.info('[chat-run-socket] ekko-agent run aborted for session %s', sessionId) - return - } - const error = err instanceof Error ? err.message : String(err) - logger.warn(err, '[chat-run-socket] ekko-agent run failed for session %s', sessionId) - if (state.queue.length === 0) { - try { - updateSession(sessionId, { - ended_at: Math.floor(Date.now() / 1000), - end_reason: 'error', - }) - } catch (updateErr) { - logger.warn(updateErr, '[chat-run-socket] failed to write ekko-agent error end marker for %s', sessionId) - } - } - emit('run.failed', { - event: 'run.failed', - run_id: runId, - error, - queue_remaining: state.queue.length, - }) - } finally { - if (!abortController.signal.aborted || state.abortController === abortController) { - state.isWorking = false - state.isAborting = false - state.runId = undefined - state.abortController = undefined - state.activeRunMarker = undefined - state.responseRun = undefined - state.profile = undefined - state.events = [] - if (state.queue.length > 0) { - dequeueNextQueuedRun(socket, sessionId, profile) - } - } - } -} - -function isAbortError(error: unknown): boolean { - return error instanceof Error && (error.name === 'AbortError' || error.message === 'Run aborted.') -} diff --git a/packages/server/src/services/hermes/run-chat/index.ts b/packages/server/src/services/hermes/run-chat/index.ts index f8648f2..79e5c77 100644 --- a/packages/server/src/services/hermes/run-chat/index.ts +++ b/packages/server/src/services/hermes/run-chat/index.ts @@ -16,14 +16,12 @@ import { AgentBridgeClient } from '../agent-bridge' import { getAgentBridgeManager } from '../agent-bridge/manager' import { redactAgentBridgeError } from '../agent-bridge/redact' import { handleBridgeRun, resumeBridgeRun } from './handle-bridge-run' -import { handleCodingAgentRun } from './handle-coding-agent-run' -import { handleEkkoAgentRun } from './handle-ekko-agent-run' import { handleAbort } from './abort' import { getOrCreateSession } from './compression' import { loadSessionStateFromDb, resolveRunSource } from './load-state' import { handleSessionCommand, isSessionCommand, parseSessionCommand } from './session-command' import { contentBlocksToString } from './content-blocks' -import type { ChatCodingAgentId, ContentBlock, QueuedRun, SessionState } from './types' +import type { ChatRunSource, ContentBlock, QueuedRun, SessionState } from './types' import { authenticateUserToken, isAuthEnabled, type AuthenticatedUser } from '../../../middleware/user-auth' import { userCanAccessProfile } from '../../../db/hermes/users-store' import { observeRunChatPetEvent } from '../pet-state-socket' @@ -47,18 +45,11 @@ function isBridgeStatusLookupTimeout(error: unknown): boolean { return /^Agent bridge request timed out after \d+ms$/.test(message.trim()) } -function isHermesWorkerBackedSession(session?: { source?: string | null; agent?: string | null; agent_session_id?: string | null }): boolean { - const source = session?.source || undefined - // "api_server" is a legacy/default source value; Hermes sessions still use worker-backed runtime. - // coding_agent runs have a separate lifecycle. - if (!source || source === 'cli' || source === 'api_server') return true - if (source === 'workflow') { - const agent = String(session?.agent || '').trim() - return agent !== 'claude' && agent !== 'codex' && agent !== 'ekko-agent' && !session?.agent_session_id - } - if (source !== 'global_agent') return false - const agent = String(session?.agent || '').trim() - return agent !== 'claude' && agent !== 'codex' && agent !== 'ekko-agent' && !session?.agent_session_id +function isHermesWorkerBackedSession(source: ChatRunSource, agentSessionId?: string | null): boolean { + if (source !== 'workflow' && source !== 'global_agent') return true + // A session with an agent binding is driven by that agent rather than the + // Hermes worker, so it is not worker-backed. + return !agentSessionId } function isBridgeRunSource(source?: string): boolean { @@ -83,14 +74,6 @@ export async function ensureBridgeReadyForChatRun(): Promise<{ ok: true } | { ok } } -function isCodingAgentExecution(source: string | undefined, data?: { coding_agent_id?: string; agent_id?: string }): boolean { - return source === 'coding_agent' || (source === 'workflow' && Boolean(data?.coding_agent_id || data?.agent_id)) -} - -function isEkkoAgentExecution(data?: { coding_agent_id?: string; agent_id?: string }): boolean { - return data?.coding_agent_id === 'ekko-agent' || data?.agent_id === 'ekko-agent' -} - export interface ChatRunAndWaitResult { ok: boolean event: 'run.completed' | 'run.failed' @@ -190,8 +173,6 @@ export class ChatRunSocket { workspace?: string | null source?: string session_source?: 'global_agent' | 'workflow' - coding_agent_id?: ChatCodingAgentId - agent_id?: ChatCodingAgentId mode?: 'scoped' | 'global' baseUrl?: string base_url?: string @@ -262,8 +243,6 @@ export class ChatRunSocket { workspace: data.workspace, source, sessionSource: data.session_source, - codingAgentId: data.coding_agent_id, - agentId: data.agent_id, mode: data.mode, baseUrl: data.baseUrl, base_url: data.base_url, @@ -286,7 +265,7 @@ export class ChatRunSocket { return } state.events = [] - state.isWorking = !isCodingAgentExecution(source, data) + state.isWorking = true state.profile = runProfile state.source = source } @@ -399,8 +378,6 @@ export class ChatRunSocket { session_source?: 'global_agent' | 'workflow' queue_id?: string peerExcludeSocketId?: string - coding_agent_id?: ChatCodingAgentId - agent_id?: ChatCodingAgentId mode?: 'scoped' | 'global' baseUrl?: string base_url?: string @@ -420,79 +397,55 @@ export class ChatRunSocket { const source = resolveRunSource(data.source, data.session_id) if (data.session_id && isBridgeRunSource(source) && isSessionCommand(data.input) && data.allow_command_passthrough !== true) return - if (!isCodingAgentExecution(source, data)) { - const bridgeReady = await ensureBridgeReadyForChatRun() - if (!bridgeReady.ok) { - let shouldDequeueNext = false - let queueRemaining = 0 - if (data.session_id) { - const state = this.sessionMap.get(data.session_id) - queueRemaining = state?.queue?.length ?? 0 - const canReleaseCurrentRun = state && !state.runId && !state.abortController && !state.activeRunMarker - if (canReleaseCurrentRun) { - if (queueRemaining > 0) { - const nextQueuedRun = state.queue[0] - state.isWorking = true - state.profile = nextQueuedRun?.profile || profile - state.source = nextQueuedRun?.source - shouldDequeueNext = true - } else { - state.isWorking = false - state.profile = undefined - } + const bridgeReady = await ensureBridgeReadyForChatRun() + if (!bridgeReady.ok) { + let shouldDequeueNext = false + let queueRemaining = 0 + if (data.session_id) { + const state = this.sessionMap.get(data.session_id) + queueRemaining = state?.queue?.length ?? 0 + const canReleaseCurrentRun = state && !state.runId && !state.abortController && !state.activeRunMarker + if (canReleaseCurrentRun) { + if (queueRemaining > 0) { + const nextQueuedRun = state.queue[0] + state.isWorking = true + state.profile = nextQueuedRun?.profile || profile + state.source = nextQueuedRun?.source + shouldDequeueNext = true + } else { + state.isWorking = false + state.profile = undefined } } - const payload: { - event: 'run.failed' - session_id?: string - error: string - queue_remaining?: number - } = { - event: 'run.failed', - session_id: data.session_id, - error: `Agent Bridge is not reachable: ${bridgeReady.error}`, - } - if (queueRemaining > 0) payload.queue_remaining = queueRemaining - socket.emit('run.failed', payload) - if (data.session_id && shouldDequeueNext) { - this.dequeueNextQueuedRun(socket, data.session_id, profile) - } - return } - - let fullInstructions = data.instructions - ? `${getSystemPrompt(undefined, { source })}\n${data.instructions}` - : getSystemPrompt(undefined, { source }) - - await handleBridgeRun( - this.nsp, socket, { ...data, instructions: fullInstructions }, profile, - this.sessionMap, this.bridge, - skipUserMessage, - loadSessionStateFromDb, - this.dequeueNextQueuedRun.bind(this), - ) + const payload: { + event: 'run.failed' + session_id?: string + error: string + queue_remaining?: number + } = { + event: 'run.failed', + session_id: data.session_id, + error: `Agent Bridge is not reachable: ${bridgeReady.error}`, + } + if (queueRemaining > 0) payload.queue_remaining = queueRemaining + socket.emit('run.failed', payload) + if (data.session_id && shouldDequeueNext) { + this.dequeueNextQueuedRun(socket, data.session_id, profile) + } return } - if (isEkkoAgentExecution(data)) { - await handleEkkoAgentRun( - this.nsp, - socket, - data, - profile, - this.sessionMap, - this.dequeueNextQueuedRun.bind(this), - skipUserMessage, - ) - return - } + const fullInstructions = data.instructions + ? `${getSystemPrompt(undefined, { source })}\n${data.instructions}` + : getSystemPrompt(undefined, { source }) - await handleCodingAgentRun( - this.nsp, - socket, - data, - profile, - this.sessionMap, + await handleBridgeRun( + this.nsp, socket, { ...data, instructions: fullInstructions }, profile, + this.sessionMap, this.bridge, + skipUserMessage, + loadSessionStateFromDb, + this.dequeueNextQueuedRun.bind(this), ) } @@ -539,8 +492,11 @@ export class ChatRunSocket { private async reattachBridgeRun(socket: Socket, sid: string, state: SessionState) { if (state.runId && state.isWorking) return const session = getSession(sid) - const source = state.source || session?.source - if (!isHermesWorkerBackedSession({ source, agent: session?.agent, agent_session_id: session?.agent_session_id })) return + // Sessions stored before Phase 003 can still carry the removed + // "coding_agent" source; they run through the bridge like every other + // session, so normalize before deciding how to reattach. + const source = resolveRunSource(state.source || session?.source) + if (!isHermesWorkerBackedSession(source, session?.agent_session_id)) return const profile = session?.profile || currentProfileFromSocket(socket) let pollKey: string | undefined try { @@ -649,8 +605,6 @@ export class ChatRunSocket { session_source: next.sessionSource, queue_id: next.queue_id, peerExcludeSocketId: next.originSocketId, - coding_agent_id: next.codingAgentId, - agent_id: next.agentId, mode: next.mode, baseUrl: next.baseUrl, base_url: next.base_url, @@ -682,8 +636,6 @@ export class ChatRunSocket { source?: string session_source?: 'global_agent' | 'workflow' queue_id?: string - coding_agent_id?: ChatCodingAgentId - agent_id?: ChatCodingAgentId mode?: 'scoped' | 'global' baseUrl?: string base_url?: string @@ -704,7 +656,7 @@ export class ChatRunSocket { const source = resolveRunSource(data.source, sessionId) const state = getOrCreateSession(this.sessionMap, sessionId) state.events = [] - state.isWorking = !isCodingAgentExecution(source, data) + state.isWorking = true state.profile = profile state.source = source diff --git a/packages/server/src/services/hermes/run-chat/load-state.ts b/packages/server/src/services/hermes/run-chat/load-state.ts index 7b43e26..c4d38fa 100644 --- a/packages/server/src/services/hermes/run-chat/load-state.ts +++ b/packages/server/src/services/hermes/run-chat/load-state.ts @@ -11,10 +11,13 @@ import { estimateUsageTokensFromMessages } from './usage' import type { ChatRunSource, SessionState } from './types' export function resolveRunSource(source?: string, sessionId?: string): ChatRunSource { - if (source === 'coding_agent' || source === 'global_agent' || source === 'workflow' || source === 'cli') return source + // Sessions stored before the coding-agent removal can still carry the old + // "coding_agent" source. They now run through the bridge like any other + // session, so they deliberately fall through to the default below. + if (source === 'global_agent' || source === 'workflow' || source === 'cli') return source if (sessionId) { const stored = getSession(sessionId)?.source - if (stored === 'coding_agent' || stored === 'global_agent' || stored === 'workflow' || stored === 'cli') return stored + if (stored === 'global_agent' || stored === 'workflow' || stored === 'cli') return stored } return 'cli' } diff --git a/packages/server/src/services/hermes/run-chat/session-command.ts b/packages/server/src/services/hermes/run-chat/session-command.ts index 795c7da..383c773 100644 --- a/packages/server/src/services/hermes/run-chat/session-command.ts +++ b/packages/server/src/services/hermes/run-chat/session-command.ts @@ -722,17 +722,6 @@ export async function handleSessionCommand( return } - const parent = getSession(sessionId) - if (isCodingAgentBranchSource(parent)) { - emitCommand({ - ok: false, - action: 'branch', - terminal: true, - message: 'Cannot branch coding agent sessions.', - }) - return - } - const fork = createBranchSession(sessionId, command.args, ctx) if (!fork) { emitCommand({ @@ -1095,7 +1084,7 @@ function persistCommandMessage(sessionId: string, state: SessionState, content: function createBranchSession(parentSessionId: string, requestedTitle: string, ctx: SessionCommandContext): BranchSessionSummary | null { const parent = getSession(parentSessionId) - if (!parent || isCodingAgentBranchSource(parent)) return null + if (!parent) return null const detail = getSessionDetail(parentSessionId) const sourceMessages = detail?.messages || [] @@ -1161,10 +1150,6 @@ function createBranchSession(parentSessionId: string, requestedTitle: string, ct } -function isCodingAgentBranchSource(session: { source?: string | null; agent?: string | null } | null | undefined): boolean { - return session?.source === 'coding_agent' || session?.agent === 'claude' || session?.agent === 'codex' || session?.agent === 'ekko-agent' -} - function generateBranchSessionId(): string { const now = new Date() const ts = [ diff --git a/packages/server/src/services/hermes/run-chat/sse-utils.ts b/packages/server/src/services/hermes/run-chat/sse-utils.ts deleted file mode 100644 index 27e97f0..0000000 --- a/packages/server/src/services/hermes/run-chat/sse-utils.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * SSE frame reading utilities for parsing upstream streaming responses. - */ - -export { parseSseFrame, readSseFrames } from '../../agent-runner/sse' diff --git a/packages/server/src/services/hermes/run-chat/types.ts b/packages/server/src/services/hermes/run-chat/types.ts index 76aa53f..9c137ad 100644 --- a/packages/server/src/services/hermes/run-chat/types.ts +++ b/packages/server/src/services/hermes/run-chat/types.ts @@ -41,8 +41,6 @@ export interface QueuedRun { workspace?: string | null source?: ChatRunSource sessionSource?: 'global_agent' | 'workflow' - codingAgentId?: ChatCodingAgentId - agentId?: ChatCodingAgentId mode?: 'scoped' | 'global' baseUrl?: string base_url?: string @@ -114,8 +112,7 @@ export interface BridgeContextState { workspace?: string } -export type ChatRunSource = 'api_server' | 'cli' | 'coding_agent' | 'global_agent' | 'workflow' -export type ChatCodingAgentId = 'claude-code' | 'codex' | 'ekko-agent' +export type ChatRunSource = 'api_server' | 'cli' | 'global_agent' | 'workflow' export interface BridgeCompressionResult { messages: ChatMessage[] diff --git a/packages/server/src/services/shutdown.ts b/packages/server/src/services/shutdown.ts index 4b60ce7..f9ad6e0 100644 --- a/packages/server/src/services/shutdown.ts +++ b/packages/server/src/services/shutdown.ts @@ -1,7 +1,6 @@ import { logger } from './logger' import { closeDb } from '../db' import { stopPreviewRuntime } from '../controllers/update' -import { codingAgentRunManager } from './agent-runner/coding-agent-run-manager' import { shutdownManagedGateways } from './hermes/gateway-runner' import { stopOutboundRelayClient } from './global-agent/outbound-relay-client' @@ -94,9 +93,6 @@ export function createShutdownHandler(server: any, groupChatServer?: any, chatRu stopOutboundRelayClient() logger.info('Outbound relay clients closed') - codingAgentRunManager.shutdown() - logger.info('Coding agent hidden sessions closed') - // Disconnect Socket.IO before HTTP server to prevent hanging if (groupChatServer) { groupChatServer.agentClients.disconnectAll() diff --git a/packages/server/src/services/usage-recorder.ts b/packages/server/src/services/usage-recorder.ts index 5121598..4a9290d 100644 --- a/packages/server/src/services/usage-recorder.ts +++ b/packages/server/src/services/usage-recorder.ts @@ -12,8 +12,8 @@ export interface NormalizedTokenUsage { export interface RecordSessionUsageInput { sessionId: string runId?: string | null - source: 'hermes' | 'coding_agent' | 'ekko_agent' - agent: 'hermes' | 'claude_code' | 'codex' | 'ekko_agent' + source: 'hermes' + agent: 'hermes' profile?: string | null model?: string | null provider?: string | null diff --git a/packages/server/src/services/workflow-manager.ts b/packages/server/src/services/workflow-manager.ts index 0a721d5..9cac5df 100644 --- a/packages/server/src/services/workflow-manager.ts +++ b/packages/server/src/services/workflow-manager.ts @@ -29,7 +29,6 @@ import { getChatRunServer } from '../routes/hermes/chat-run' import type { ContentBlock } from './hermes/run-chat' import type { AuthenticatedUser } from '../middleware/user-auth' import { resolveWorkflowSkillContent } from './workflow-skill-resolver' -import { codingAgentRunManager } from './agent-runner/coding-agent-run-manager' import { deleteSessionForProfile } from './hermes/hermes-cli' import { listProfileNamesFromDisk } from './hermes/hermes-profile' import { logger } from './logger' @@ -38,13 +37,12 @@ export type { WorkflowCreateInput, WorkflowRecord, WorkflowUpdateInput } export type WorkflowRuntimeState = 'idle' | 'queued' | 'running' | 'pending_approval' | 'completed' | 'failed' | 'approval_rejected' | 'canceled' export type WorkflowRunType = 'workflow' -export type WorkflowNodeAgent = 'hermes' | 'claude-code' | 'codex' +export type WorkflowNodeAgent = 'hermes' export interface WorkflowNodeRunTarget { type: WorkflowRunType source: 'workflow' - agent: 'hermes' | 'claude' | 'codex' - codingAgentId?: 'claude-code' | 'codex' + agent: 'hermes' } export interface WorkflowRuntimeStatus { @@ -127,22 +125,8 @@ function idleStatus(workflowId: string): WorkflowRuntimeStatus { } export function resolveWorkflowNodeRunTarget(agent?: string | null): WorkflowNodeRunTarget { - if (agent === 'claude-code') { - return { - type: 'workflow', - source: 'workflow', - agent: 'claude', - codingAgentId: 'claude-code', - } - } - if (agent === 'codex') { - return { - type: 'workflow', - source: 'workflow', - agent: 'codex', - codingAgentId: 'codex', - } - } + // Workflow nodes always run through the Hermes bridge; the coding-agent + // runtimes they used to dispatch to were removed in Phase 003. return { type: 'workflow', source: 'workflow', @@ -214,11 +198,6 @@ function lastAssistantOutput(sessionId: string, fallback?: string | null): strin return String(fallback || '') } -function isWorkflowCodingAgentSession(session?: { source?: string | null; agent?: string | null; agent_session_id?: string | null } | null): boolean { - const agent = String(session?.agent || '').trim() - return agent === 'claude' || agent === 'codex' || Boolean(session?.agent_session_id) -} - async function deleteHermesSessionIfPresent(sessionId: string, profile: string): Promise { const targetProfile = profile || 'default' if (!listProfileNamesFromDisk().includes(targetProfile)) return @@ -345,9 +324,7 @@ export class WorkflowManager extends EventEmitter { private async deleteNodeSessionArtifacts(sessionId: string, profile: string, agent: string): Promise { if (!sessionId) return const existing = getSession(sessionId) - if (isWorkflowCodingAgentSession(existing)) { - codingAgentRunManager.stop(sessionId, { reportClosed: false }) - } else if (agent === 'hermes') { + if (agent === 'hermes') { await deleteHermesSessionIfPresent(sessionId, profile || existing?.profile || 'default') } if (existing) { @@ -599,9 +576,6 @@ export class WorkflowManager extends EventEmitter { workspace: workflow.workspace, model: node.data.model || undefined, provider: node.data.provider || undefined, - mode: node.data.agent === 'hermes' ? undefined : 'scoped', - coding_agent_id: target.codingAgentId, - agent_id: target.codingAgentId, apiMode: node.data.apiMode || undefined, }, { profile, @@ -936,9 +910,6 @@ export class WorkflowManager extends EventEmitter { workspace: run.workspace, model: node.data.model || undefined, provider: node.data.provider || undefined, - mode: node.data.agent === 'hermes' ? undefined : 'scoped', - coding_agent_id: target.codingAgentId, - agent_id: target.codingAgentId, apiMode: node.data.apiMode || undefined, }, { profile, diff --git a/scripts/generate-openapi.mjs b/scripts/generate-openapi.mjs index 20776de..e13539f 100644 --- a/scripts/generate-openapi.mjs +++ b/scripts/generate-openapi.mjs @@ -83,7 +83,6 @@ const tagMappings = { 'routes/webhook.ts': { name: 'Webhook', description: 'Incoming webhooks' }, 'routes/auth.ts': { name: 'Auth', description: 'Authentication management' }, 'routes/devices.ts': { name: 'Devices', description: 'Device pairing and LAN peer operations' }, - 'routes/coding-agents.ts': { name: 'Coding Agents', description: 'Coding agent installation, config, and runs' }, 'routes/api-docs.ts': { name: 'API Docs', description: 'OpenAPI route catalog' }, } @@ -213,8 +212,6 @@ function findMatchingBrace(content, openBrace) { } function addEndpoint(paths, method, path, controllerMethod, tagInfo, content, matchIndex, controllerSource = '') { - if (isInternalProxyRoute(path)) return - // Clean path parameters const openapiPath = path .replace(/:([^/]+)/g, '{$1}') @@ -250,10 +247,6 @@ function addEndpoint(paths, method, path, controllerMethod, tagInfo, content, ma paths[openapiPath][method] = operation } -function isInternalProxyRoute(path) { - return path.startsWith('/api/codex-proxy/') || path.startsWith('/api/claude-code-proxy/') -} - function generateParameters(openapiPath, source) { const params = [] const seen = new Set() @@ -608,7 +601,7 @@ function schemaFromType(type) { const schema = {} if (/\bnull\b/.test(normalized)) schema.nullable = true - if (/SessionProviderApiMode|CodingAgentApiMode/.test(normalized)) { + if (/SessionProviderApiMode/.test(normalized)) { return { ...schema, type: 'string', enum: ['chat_completions', 'codex_responses', 'anthropic_messages'] } } if (/string\[\]|Array/.test(normalized)) { @@ -877,13 +870,13 @@ openapi.paths['/api/chat-run/runs'] = { }, source: { type: 'string', - enum: ['cli', 'coding_agent', 'global_agent'], - description: 'Run backend source. Use cli for Hermes bridge runs, coding_agent for Claude Code/Codex, or global_agent for global-agent sessions. Omit source for normal Hermes chat runs; do not use the legacy api_server source.', + enum: ['cli', 'global_agent', 'workflow'], + description: 'Run backend source. Use cli for Hermes bridge runs or global_agent for global-agent sessions. Omit source for normal Hermes chat runs; do not use the legacy api_server source.', }, session_source: { type: 'string', - enum: ['global_agent'], - description: 'Marks a coding-agent or bridge session as launched from the global agent.', + enum: ['global_agent', 'workflow'], + description: 'Marks the session as launched from the global agent or from a workflow run.', }, instructions: { type: 'string', @@ -898,34 +891,6 @@ openapi.paths['/api/chat-run/runs'] = { type: 'string', description: 'Optional per-run reasoning effort override.', }, - coding_agent_id: { - type: 'string', - enum: ['claude-code', 'codex'], - description: 'Coding agent id when source is coding_agent.', - }, - agent_id: { - type: 'string', - enum: ['claude-code', 'codex'], - description: 'Alias for coding_agent_id.', - }, - mode: { - type: 'string', - enum: ['scoped', 'global'], - description: 'Coding-agent launch mode.', - }, - baseUrl: { - type: 'string', - description: 'Optional provider base URL for coding-agent runs.', - }, - apiKey: { - type: 'string', - description: 'Optional provider API key for coding-agent runs.', - }, - apiMode: { - type: 'string', - enum: ['chat_completions', 'codex_responses', 'anthropic_messages'], - description: 'Optional provider wire API mode for coding-agent runs.', - }, timeout_ms: { type: 'integer', minimum: 1, diff --git a/scripts/harness-check.mjs b/scripts/harness-check.mjs index 90c409a..1c47913 100644 --- a/scripts/harness-check.mjs +++ b/scripts/harness-check.mjs @@ -279,10 +279,6 @@ const ALLOWED_LEGACY_BRAND = [ file: 'packages/server/src/services/hermes/skill-injector.ts', reason: 'legacy manifest owner must stay recognized so managed skills are not re-injected', }, - { - file: 'packages/server/src/services/coding-agents.ts', - reason: 'legacy MCP server/command names are detected to clean up old on-disk config', - }, { file: 'packages/server/src/services/hermes/studio-mcp-autoinject.ts', reason: 'legacy MCP server/command names are detected to clean up old on-disk config', @@ -291,10 +287,6 @@ const ALLOWED_LEGACY_BRAND = [ file: 'tests/server/studio-mcp-autoinject.test.ts', reason: 'asserts legacy MCP entries are cleaned from existing on-disk config', }, - { - file: 'tests/server/coding-agents-launch.test.ts', - reason: 'asserts legacy MCP entries are cleaned from existing on-disk config', - }, { file: 'tests/client/markdown-rendering.test.ts', reason: 'fixture URL is arbitrary sample data, not project branding', @@ -307,40 +299,20 @@ const ALLOWED_LEGACY_BRAND = [ file: 'tests/server/agent-bridge-python-concurrency.test.ts', reason: 'asserts preview socket path derived from the preview endpoint env var', }, + { + file: 'packages/server/src/services/runtime-version-manager.ts', + reason: 'release download host and repo slug are the runtime-version lookup contract; replaced in Phase 008', + }, + { + file: 'packages/server/src/controllers/hermes/sessions.ts', + reason: 'the visible-session-source check still lists coding_agent so sessions stored before Phase 003 stay listed', + }, + { + file: 'packages/client/src/shared/session-display.ts', + reason: 'SOURCE_LABELS still maps the pre-Phase-003 coding_agent source so stored sessions render a label', + }, ] -// Files whose remaining 'ekko' hits are the in-house agent runtime that Phase -// 003 deletes outright. They are allow-listed here only until then; Phase 003 -// must delete these entries together with the files. -const PHASE_003_EKKO_AGENT_FILES = [ - 'packages/server/src/services/ekko-agent/manager.ts', - 'packages/server/src/services/ekko-agent/mcp.ts', - 'packages/server/src/services/hermes/run-chat/handle-ekko-agent-run.ts', - 'packages/server/src/services/hermes/run-chat/index.ts', - 'packages/server/src/services/hermes/run-chat/session-command.ts', - 'packages/server/src/services/hermes/run-chat/types.ts', - 'packages/client/src/api/coding-agents.ts', - 'packages/client/src/api/hermes/chat.ts', - 'packages/client/src/components/hermes/chat/ChatPanel.vue', - 'packages/client/src/components/hermes/chat/MessageList.vue', - 'packages/client/src/components/hermes/chat/SessionListItem.vue', - 'packages/client/src/stores/hermes/chat.ts', - 'packages/client/src/i18n/locales/en.ts', - 'packages/client/src/i18n/locales/zh.ts', - 'tests/ekko-agent', - 'tests/server/ekko-agent-manager.test.ts', - 'tests/server/ekko-agent-mcp.test.ts', - 'tests/server/run-chat-ekko-context.test.ts', - 'AGENTS.md', - 'ARCHITECTURE.md', -] -for (const target of PHASE_003_EKKO_AGENT_FILES) { - ALLOWED_LEGACY_BRAND.push({ - file: target, - reason: 'ekko-agent runtime is removed in Phase 003; this entry must be deleted then', - }) -} - // Legacy identifiers that stay valid everywhere, because they are public // deployment contracts rather than project branding. const GLOBAL_ALLOWED_PATTERNS = [ @@ -398,6 +370,59 @@ for (const hit of brandHits) { fail(`Legacy brand string must be removed (ekko / hermes-web-ui): ${hit}`) } +// --- Retired runtime scan ---------------------------------------------- +// Phase 003 removed the coding-agent and in-house agent runtimes. Every chat +// session now runs through the Hermes bridge. A few identifiers survive only +// so sessions, workflows, and history rows stored before Phase 003 keep +// loading; RETIRED_RUNTIME_ALLOWED records why each one stays. +const RETIRED_RUNTIME_PATTERNS = [ + /\bcoding_agent\b/, + /\bcodingAgent[A-Za-z]/, + /\bCodingAgent[A-Za-z]/, + /\bekko-agent\b/, + /\bekkoAgent[A-Za-z]/, + /\bEkkoAgent[A-Za-z]/, +] + +const RETIRED_RUNTIME_ALLOWED = [ + { + file: 'packages/server/src/controllers/hermes/sessions.ts', + reason: 'isVisibleWebUiSessionSource still accepts the retired source so sessions stored before Phase 003 stay listed', + }, + { + file: 'packages/client/src/shared/session-display.ts', + reason: 'SOURCE_LABELS still maps the retired source so stored sessions render a label', + }, +] + +function isAllowedRetiredRuntime(relativePath, line) { + // Comments explain why the retired identifier still exists; only code hits matter. + const trimmed = line.trim() + if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return true + const normalized = relativePath.split(path.sep).join('/') + return RETIRED_RUNTIME_ALLOWED.some( + entry => normalized === entry.file || normalized.startsWith(`${entry.file}/`), + ) +} + +const retiredHits = [] +for (const relativePath of scanFiles) { + const normalized = relativePath.split(path.sep).join('/') + if (SCAN_SKIP_FILES.has(normalized)) continue + if (normalized.startsWith('docs/')) continue + const text = await readFile(path.join(root, relativePath), 'utf8') + const lines = text.split(/\r?\n/) + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] + if (!RETIRED_RUNTIME_PATTERNS.some(pattern => pattern.test(line))) continue + if (isAllowedRetiredRuntime(relativePath, line)) continue + retiredHits.push(`${relativePath}:${index + 1}: ${line.trim()}`) + } +} +for (const hit of retiredHits) { + fail(`Retired runtime identifier must be removed (coding-agent / ekko-agent): ${hit}`) +} + if (failures.length > 0) { console.error('Harness check failed:') for (const failure of failures) { diff --git a/tests/client/api.test.ts b/tests/client/api.test.ts index 61703a5..e1c2f05 100644 --- a/tests/client/api.test.ts +++ b/tests/client/api.test.ts @@ -173,13 +173,13 @@ describe('API Client', () => { status: 500, text: () => Promise.resolve(JSON.stringify({ error: { - message: 'spawn claude ENOENT', + message: 'bridge connect ENOENT', code: 'ENOENT', }, })), }) - await expect(request('/api/coding-agents/runs/session-1/input')).rejects.toThrow('API Error 500: spawn claude ENOENT') + await expect(request('/api/hermes/sessions/session-1/input')).rejects.toThrow('API Error 500: bridge connect ENOENT') }) it('returns parsed JSON on success', async () => { diff --git a/tests/client/chat-input-draft.test.ts b/tests/client/chat-input-draft.test.ts index 8e868e1..759e9e9 100644 --- a/tests/client/chat-input-draft.test.ts +++ b/tests/client/chat-input-draft.test.ts @@ -154,11 +154,8 @@ describe('ChatInput draft persistence', () => { expect((wrapper.get('textarea').element as HTMLTextAreaElement).style.height).not.toBe('180px') }) - it('shows context usage for coding-agent sessions', async () => { - const wrapper = mountForSession('session-codex', { - source: 'coding_agent', - agent: 'codex', - codingAgentId: 'codex', + it('shows context usage for the active session', async () => { + const wrapper = mountForSession('session-context', { inputTokens: 1200, outputTokens: 800, contextTokens: 2000, @@ -170,12 +167,8 @@ describe('ChatInput draft persistence', () => { expect(wrapper.find('.context-bar').exists()).toBe(true) }) - it('shows reasoning effort selector for coding-agent sessions', async () => { - const wrapper = mountForSession('session-codex', { - source: 'coding_agent', - agent: 'codex', - codingAgentId: 'codex', - }) + it('shows the reasoning effort selector for the active session', async () => { + const wrapper = mountForSession('session-reasoning-effort') await nextTick() expect(wrapper.find('.n-popselect-stub').exists()).toBe(true) diff --git a/tests/client/chat-panel-session-click.test.ts b/tests/client/chat-panel-session-click.test.ts index 51e4758..29d0a00 100644 --- a/tests/client/chat-panel-session-click.test.ts +++ b/tests/client/chat-panel-session-click.test.ts @@ -9,27 +9,20 @@ describe('ChatPanel session clicks', () => { expect(source).toContain('await chatStore.switchSession(sessionId)') }) - it('allows session model switching for coding agent sessions', () => { + it('switches the session model without a protocol mode modal', () => { const source = readFileSync('packages/client/src/components/hermes/chat/ChatPanel.vue', 'utf8') - expect(source).toContain('contextSession.value?.source === "coding_agent"') - expect(source).toContain('isSessionModelScopedCodingAgent') - expect(source).toContain('!isCodingAgentAuthProvider(group.provider)') - expect(source).toContain('showSessionModelModeModal') - expect(source).toContain('pendingSessionModelSwitch') - expect(source).toContain('chatStore.switchSessionModel(model, provider, sessionModelSessionId.value, apiMode)') + expect(source).toContain('chatStore.switchSessionModel(model, provider, sessionModelSessionId.value)') + expect(source).not.toContain('showSessionModelModeModal') + expect(source).not.toContain('pendingSessionModelSwitch') expect(source).not.toContain('header-model-button--readonly') - expect(source).not.toContain('if (isActiveSessionCodingAgent.value) return') }) - it('uses codingAgentId when deciding whether session model switches need an API mode', () => { + it('does not gate session model switching on a coding agent id', () => { const source = readFileSync('packages/client/src/components/hermes/chat/ChatPanel.vue', 'utf8') - expect(source).toContain('const sessionModelCodingAgentId = computed') - expect(source).toContain('sessionModelSession.value?.codingAgentId ||') - expect(source).toContain('sessionModelSession.value?.agent === "claude"') - expect(source).toContain('sessionModelCodingAgentId.value === "claude-code"') - expect(source).not.toContain('sessionModelSession.value?.agent === "claude-code"') + expect(source).not.toContain('sessionModelCodingAgentId') + expect(source).not.toContain('isSessionModelScopedCodingAgent') }) it('uses the active sidebar model as the new chat default for the active profile', () => { diff --git a/tests/client/chat-store-reasoning-tool-boundary.test.ts b/tests/client/chat-store-reasoning-tool-boundary.test.ts index 1b1240d..94ecfcf 100644 --- a/tests/client/chat-store-reasoning-tool-boundary.test.ts +++ b/tests/client/chat-store-reasoning-tool-boundary.test.ts @@ -294,12 +294,9 @@ describe('chat store reasoning/tool boundaries', () => { ]) }) - it('settles running coding-agent tools when the run completes without a tool.completed event', async () => { + it('settles running tools when the run completes without a tool.completed event', async () => { const store = useChatStore() const session = makeSession() - session.source = 'coding_agent' - session.agent = 'claude' - session.codingAgentId = 'claude-code' store.sessions = [session] store.activeSessionId = 'session-1' store.activeSession = session @@ -351,49 +348,6 @@ describe('chat store reasoning/tool boundaries', () => { ].join('\n')) }) - it('queues active coding agent follow-up messages without command styling', async () => { - const store = useChatStore() - const session = makeSession() - session.source = 'coding_agent' - session.agent = 'claude' - session.codingAgentId = 'claude-code' - store.sessions = [session] - store.activeSessionId = 'session-1' - store.activeSession = session - - await store.sendMessage('first input') - const onEvent = chatApi.startRunViaSocket.mock.calls[0][1] as (event: RunEvent) => void - onEvent({ - event: 'agent.event', - session_id: 'session-1', - source: 'coding_agent', - kind: 'status', - text: 'Input sent to coding agent.', - }) - expect(store.messages).toHaveLength(1) - - onEvent({ event: 'run.started', session_id: 'session-1' }) - - await store.sendMessage('/not-a-hermes-command') - - expect(chatApi.startRunViaSocket).toHaveBeenCalledTimes(2) - expect(store.queuedUserMessages.get('session-1')).toEqual([ - expect.objectContaining({ - role: 'user', - content: '/not-a-hermes-command', - queued: true, - systemType: undefined, - }), - ]) - expect(store.messages).toEqual([ - expect.objectContaining({ - role: 'user', - content: 'first input', - queued: false, - }), - ]) - }) - it('queues unknown slash commands in bridge sessions as normal user input', async () => { const store = useChatStore() const session = makeSession() @@ -484,111 +438,14 @@ describe('chat store reasoning/tool boundaries', () => { ]) }) - it('starts global coding-agent runs without provider credentials', async () => { + it('sends the selected workspace and reasoning effort when starting a bridge run', async () => { const store = useChatStore() const session = makeSession() - session.source = 'coding_agent' - session.agent = 'codex' - session.codingAgentId = 'codex' - session.codingAgentMode = 'global' - session.provider = 'should-not-send' - session.model = 'should-not-send' - session.baseUrl = 'http://example.invalid' - session.apiKey = 'secret' - session.apiMode = 'chat_completions' - session.reasoningEffort = 'high' - store.sessions = [session] - store.activeSessionId = 'session-1' - store.activeSession = session - - await store.sendMessage('use global codex auth') - - const body = chatApi.startRunViaSocket.mock.calls[0][0] - expect(body).toEqual(expect.objectContaining({ - source: 'coding_agent', - coding_agent_id: 'codex', - mode: 'global', - })) - expect(body.provider).toBeUndefined() - expect(body.model).toBeUndefined() - expect(body.baseUrl).toBeUndefined() - expect(body.apiKey).toBeUndefined() - expect(body.apiMode).toBeUndefined() - expect(body.reasoning_effort).toBeUndefined() - }) - - it('clears stale coding-agent runtime credentials when switching providers', async () => { - const store = useChatStore() - const session = makeSession() - session.source = 'coding_agent' - session.agent = 'codex' - session.codingAgentId = 'codex' - session.codingAgentMode = 'scoped' - session.provider = 'xiaomi' - session.model = 'mimo-v2.5-pro' - session.baseUrl = 'https://api.xiaomimimo.com/v1' - session.apiKey = 'sk-xiaomi' - session.apiMode = 'chat_completions' - store.sessions = [session] - store.activeSessionId = 'session-1' - store.activeSession = session - - const ok = await store.switchSessionModel('deepseek-v4-pro', 'deepseek', 'session-1', 'chat_completions') - - expect(ok).toBe(true) - expect(sessionsApi.setSessionModel).toHaveBeenCalledWith( - 'session-1', - 'deepseek-v4-pro', - 'deepseek', - 'chat_completions', - ) - expect(session.provider).toBe('deepseek') - expect(session.model).toBe('deepseek-v4-pro') - expect(session.baseUrl).toBeUndefined() - expect(session.apiKey).toBeUndefined() - expect(session.apiMode).toBe('chat_completions') - }) - - - it('preserves a scoped coding-agent API mode when reselecting the same provider', async () => { - const store = useChatStore() - const session = makeSession() - session.source = 'coding_agent' - session.agent = 'codex' - session.codingAgentId = 'codex' - session.codingAgentMode = 'scoped' - session.provider = 'fun-codex' - session.model = 'gpt-5.4' - session.apiMode = 'chat_completions' - store.sessions = [session] - store.activeSessionId = 'session-1' - store.activeSession = session - - const ok = await store.switchSessionModel('gpt-5.5', 'fun-codex', 'session-1') - - expect(ok).toBe(true) - expect(sessionsApi.setSessionModel).toHaveBeenCalledWith( - 'session-1', - 'gpt-5.5', - 'fun-codex', - 'chat_completions', - ) - expect(session.apiMode).toBe('chat_completions') - }) - - it('sends the selected workspace when starting a coding-agent run', async () => { - const store = useChatStore() - const session = makeSession() - session.source = 'coding_agent' - session.agent = 'claude' - session.codingAgentId = 'claude-code' - session.codingAgentMode = 'scoped' + session.source = 'cli' session.provider = 'openrouter' session.model = 'anthropic/claude-sonnet-4.6' - session.baseUrl = 'https://openrouter.ai/api/v1' - session.apiKey = 'sk-test' - session.apiMode = 'anthropic_messages' session.workspace = '/workspace/project-a' + session.reasoningEffort = 'high' store.sessions = [session] store.activeSessionId = 'session-1' store.activeSession = session @@ -596,21 +453,62 @@ describe('chat store reasoning/tool boundaries', () => { await store.sendMessage('use this project') expect(chatApi.startRunViaSocket.mock.calls[0][0]).toEqual(expect.objectContaining({ - source: 'coding_agent', - coding_agent_id: 'claude-code', - mode: 'scoped', + source: 'cli', workspace: '/workspace/project-a', + reasoning_effort: 'high', })) }) - it('keeps a local stream controller for active coding-agent runs', async () => { + it('sends only the bridge run fields on the run payload', async () => { + const store = useChatStore() + const session = makeSession() + store.sessions = [session] + store.activeSessionId = 'session-1' + store.activeSession = session + + await store.sendMessage('hello') + + expect(Object.keys(chatApi.startRunViaSocket.mock.calls[0][0] as Record).sort()) + .toEqual([ + 'input', + 'model', + 'model_groups', + 'profile', + 'provider', + 'queue_id', + 'reasoning_effort', + 'session_id', + 'source', + 'workspace', + ]) + }) + + it('switches the session model without touching runtime credentials', async () => { + const store = useChatStore() + const session = makeSession() + session.provider = 'xiaomi' + session.model = 'mimo-v2.5-pro' + store.sessions = [session] + store.activeSessionId = 'session-1' + store.activeSession = session + + const ok = await store.switchSessionModel('deepseek-v4-pro', 'deepseek', 'session-1') + + expect(ok).toBe(true) + expect(sessionsApi.setSessionModel).toHaveBeenCalledWith( + 'session-1', + 'deepseek-v4-pro', + 'deepseek', + ) + expect(session.provider).toBe('deepseek') + expect(session.model).toBe('deepseek-v4-pro') + }) + + it('keeps a local stream controller for active runs', async () => { const abort = vi.fn() chatApi.startRunViaSocket.mockReturnValue({ abort }) const store = useChatStore() const session = makeSession() - session.source = 'coding_agent' - session.agent = 'claude' - session.codingAgentId = 'claude-code' store.sessions = [session] store.activeSessionId = 'session-1' store.activeSession = session diff --git a/tests/client/coding-agent-api-modes.test.ts b/tests/client/coding-agent-api-modes.test.ts deleted file mode 100644 index f0b9737..0000000 --- a/tests/client/coding-agent-api-modes.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -// @vitest-environment jsdom -import { describe, expect, it } from 'vitest' -import { - CODING_AGENT_API_MODES, - inferCodingAgentApiMode, - normalizeCodingAgentApiMode, -} from '@/api/coding-agents' - -describe('coding agent api modes', () => { - it('only exposes scoped coding-agent protocols supported by launch flows', () => { - expect(CODING_AGENT_API_MODES).toEqual([ - 'chat_completions', - 'codex_responses', - 'anthropic_messages', - ]) - }) - - it('keeps supported provider api modes and downgrades unsupported ones', () => { - expect(normalizeCodingAgentApiMode('anthropic_messages', 'codex_responses')).toBe('anthropic_messages') - expect(normalizeCodingAgentApiMode('bedrock_converse', 'chat_completions')).toBe('chat_completions') - expect(normalizeCodingAgentApiMode('codex_app_server', 'codex_responses')).toBe('codex_responses') - }) - - it('infers a safe fallback protocol from provider identity and base URL', () => { - expect(inferCodingAgentApiMode('anthropic', 'https://api.anthropic.com')).toBe('anthropic_messages') - expect(inferCodingAgentApiMode('deepseek', 'https://api.deepseek.com')).toBe('chat_completions') - expect(inferCodingAgentApiMode('xiaomi', 'https://api.xiaomimimo.com/v1')).toBe('chat_completions') - expect(inferCodingAgentApiMode('openrouter', 'https://openrouter.ai/api/v1')).toBe('chat_completions') - }) -}) diff --git a/tests/client/session-list-item.test.ts b/tests/client/session-list-item.test.ts index 5ed1a64..8a6b33e 100644 --- a/tests/client/session-list-item.test.ts +++ b/tests/client/session-list-item.test.ts @@ -151,7 +151,7 @@ describe('SessionListItem', () => { }) const logo = wrapper.get('.session-item-agent-logo') - expect(logo.attributes('src')).toBe('/coding-agents/hermes.png') + expect(logo.attributes('src')).toBe('/logo.png') expect(logo.attributes('alt')).toBe('Hermes') expect(wrapper.find('.session-item-agent-name').exists()).toBe(false) }) @@ -159,7 +159,7 @@ describe('SessionListItem', () => { it('defaults old sessions without agent metadata to the Hermes logo', () => { const wrapper = mount(SessionListItem, { props: { - session: { ...session, source: undefined, agent: undefined, codingAgentId: undefined }, + session: { ...session, source: undefined, agent: undefined }, active: false, pinned: false, canDelete: true, @@ -172,50 +172,8 @@ describe('SessionListItem', () => { }) const logo = wrapper.get('.session-item-agent-logo') - expect(logo.attributes('src')).toBe('/coding-agents/hermes.png') + expect(logo.attributes('src')).toBe('/logo.png') expect(logo.attributes('alt')).toBe('Hermes') expect(wrapper.find('.session-item-agent-name').exists()).toBe(false) }) - - it('renders the Claude Code logo for Claude coding agent sessions', () => { - const wrapper = mount(SessionListItem, { - props: { - session: { ...session, source: 'coding_agent', agent: 'claude', codingAgentId: 'claude-code' }, - active: false, - pinned: false, - canDelete: true, - }, - global: { - stubs: { - ProfileAvatar: true, - }, - }, - }) - - const logo = wrapper.get('.session-item-agent-logo') - expect(logo.attributes('src')).toBe('/coding-agents/claude-code.svg') - expect(logo.attributes('alt')).toBe('Claude Code') - expect(wrapper.find('.session-item-agent-name').exists()).toBe(false) - }) - - it('renders the Codex logo for Codex coding agent sessions', () => { - const wrapper = mount(SessionListItem, { - props: { - session: { ...session, source: 'coding_agent', agent: 'codex', codingAgentId: 'codex' }, - active: false, - pinned: false, - canDelete: true, - }, - global: { - stubs: { - ProfileAvatar: true, - }, - }, - }) - - const logo = wrapper.get('.session-item-agent-logo') - expect(logo.attributes('src')).toBe('/coding-agents/codex-openai.png') - expect(logo.attributes('alt')).toBe('Codex') - expect(wrapper.find('.session-item-agent-name').exists()).toBe(false) - }) }) diff --git a/tests/ekko-agent/browser-tools.test.ts b/tests/ekko-agent/browser-tools.test.ts deleted file mode 100644 index 90454b6..0000000 --- a/tests/ekko-agent/browser-tools.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { EventEmitter } from 'node:events' -import { createHash } from 'node:crypto' -import { writeSync } from 'node:fs' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('node:child_process', () => ({ - spawn: vi.fn(), -})) - -import { spawn } from 'node:child_process' -import { - AgentBrowserTool, - createBrowserTools, -} from '../../packages/ekko-agent/src/index' - -const mockedSpawn = vi.mocked(spawn) - -beforeEach(() => { - vi.clearAllMocks() - process.env.AGENT_BROWSER_BIN = '/tmp/agent-browser' -}) - -afterEach(() => { - delete process.env.AGENT_BROWSER_BIN - delete process.env.OPENAI_API_KEY -}) - -describe('ekko-agent browser tools', () => { - it('exposes browser tool definitions even before the CLI is available', () => { - delete process.env.AGENT_BROWSER_BIN - - expect(createBrowserTools().map(tool => tool.definition.name).sort()).toEqual([ - 'browser_back', - 'browser_click', - 'browser_console', - 'browser_get_images', - 'browser_navigate', - 'browser_press', - 'browser_scroll', - 'browser_snapshot', - 'browser_type', - 'browser_vision', - ]) - }) - - it('exposes the full Hermes browser tool surface', () => { - expect(createBrowserTools().map(tool => tool.definition.name).sort()).toEqual([ - 'browser_back', - 'browser_click', - 'browser_console', - 'browser_get_images', - 'browser_navigate', - 'browser_press', - 'browser_scroll', - 'browser_snapshot', - 'browser_type', - 'browser_vision', - ]) - }) - - it('runs browser_navigate through agent-browser and auto snapshots the page', async () => { - mockBrowserSpawn({ success: true, data: { url: 'https://example.com/', title: 'Example' } }) - mockBrowserSpawn({ success: true, data: { snapshot: 'button "Continue" [ref=@e1]', refs: { '@e1': {} } } }) - - const tool = createBrowserTools().find(item => item.definition.name === 'browser_navigate') - const result = await tool?.execute({ url: 'example.com' }, { sessionId: 'session:123', cwd: process.cwd() }) - - expect(result).toMatchObject({ ok: true }) - expect(JSON.parse(result?.content || '{}')).toMatchObject({ - success: true, - url: 'https://example.com/', - title: 'Example', - snapshot: 'button "Continue" [ref=@e1]', - element_count: 1, - }) - expect(mockedSpawn).toHaveBeenNthCalledWith( - 1, - '/tmp/agent-browser', - ['--session', hashedSession('session:123'), '--json', 'open', 'https://example.com'], - expect.objectContaining({ shell: false }), - ) - expect(mockedSpawn).toHaveBeenNthCalledWith( - 2, - '/tmp/agent-browser', - ['--session', hashedSession('session:123'), '--json', 'snapshot', '-c'], - expect.objectContaining({ shell: false }), - ) - }) - - it('passes a sanitized browser environment to the CLI', async () => { - process.env.OPENAI_API_KEY = 'should-not-leak' - process.env.BROWSERBASE_API_KEY = 'browser-key' - mockBrowserSpawn({ success: true, data: { snapshot: 'ok', refs: {} } }) - - const tool = createBrowserTools().find(item => item.definition.name === 'browser_snapshot') - await tool?.execute({}, { browserSessionId: 'browser-session' }) - - const options = mockedSpawn.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } - expect(options.env?.BROWSERBASE_API_KEY).toBe('browser-key') - expect(options.env?.OPENAI_API_KEY).toBeUndefined() - expect(options.env?.AGENT_BROWSER_SOCKET_DIR).toBe(`/tmp/eab_${hashedSession('browser-session')}`) - expect((options.env?.AGENT_BROWSER_SOCKET_DIR || '').length).toBeLessThan(40) - }) - - it('blocks sensitive console expressions before spawning the browser CLI', async () => { - const tool = new AgentBrowserTool({ - name: 'browser_console', - parameters: { type: 'object' }, - }) - - const result = await tool.execute({ expression: 'document.cookie' }, { sessionId: 's1' }) - - expect(result).toMatchObject({ - ok: false, - error: 'Blocked: browser_console expression attempts to access sensitive browser or runtime state.', - }) - expect(mockedSpawn).not.toHaveBeenCalled() - }) - - it('parses image extraction results from browser_get_images', async () => { - mockBrowserSpawn({ - success: true, - data: { - result: JSON.stringify([{ src: 'https://example.com/logo.png', alt: 'Logo', width: 64, height: 64 }]), - }, - }) - - const tool = createBrowserTools().find(item => item.definition.name === 'browser_get_images') - const result = await tool?.execute({}, { sessionId: 'images' }) - - expect(result).toMatchObject({ ok: true }) - expect(JSON.parse(result?.content || '{}')).toMatchObject({ - success: true, - count: 1, - images: [{ src: 'https://example.com/logo.png', alt: 'Logo', width: 64, height: 64 }], - }) - expect(mockedSpawn.mock.calls[0]?.[1]).toEqual([ - '--session', - hashedSession('images'), - '--json', - 'eval', - expect.stringContaining('document.images'), - ]) - }) -}) - -function hashedSession(value: string): string { - return `e_${createHash('sha256').update(value).digest('hex').slice(0, 10)}` -} - -function mockBrowserSpawn(payload: unknown, exitCode = 0): void { - mockedSpawn.mockImplementationOnce((_command, _args, options) => { - const child = new EventEmitter() as EventEmitter & { - kill: ReturnType - } - child.kill = vi.fn() - const stdio = Array.isArray(options?.stdio) ? options.stdio : [] - if (typeof stdio[1] === 'number') { - writeSync(stdio[1], JSON.stringify(payload)) - } - process.nextTick(() => { - child.emit('close', exitCode) - }) - return child as any - }) -} diff --git a/tests/ekko-agent/messages.test.ts b/tests/ekko-agent/messages.test.ts deleted file mode 100644 index db371d4..0000000 --- a/tests/ekko-agent/messages.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - collectModelEvents, - createAssistantMessage, - createSystemMessage, - createToolResultMessage, - createUserMessage, - modelResponseToAgentMessage, - normalizeAgentMessage, - normalizeAgentMessages, -} from '../../packages/ekko-agent/src/index' -import type { ModelEvent } from '../../packages/ekko-agent/src/index' - -describe('ekko-agent unified messages', () => { - it('normalizes inbound strings and message-like objects', () => { - expect(normalizeAgentMessage('hello')).toEqual({ - role: 'user', - content: 'hello', - }) - - expect(normalizeAgentMessage({ - role: 'assistant', - text: 'done', - tool_calls: [{ - id: 'call_1', - name: 'read_file', - arguments: { path: 'README.md' }, - }], - })).toEqual({ - role: 'assistant', - content: 'done', - name: undefined, - toolCallId: undefined, - toolCalls: [{ - id: 'call_1', - name: 'read_file', - arguments: { path: 'README.md' }, - }], - }) - }) - - it('normalizes message arrays with one internal shape', () => { - expect(normalizeAgentMessages([ - createSystemMessage('Rules'), - 'Question', - { role: 'tool', content: { ok: true }, tool_call_id: 'call_1', name: 'lookup' }, - ])).toEqual([ - { role: 'system', content: 'Rules' }, - { role: 'user', content: 'Question' }, - { - role: 'tool', - content: '{"ok":true}', - name: 'lookup', - toolCallId: 'call_1', - toolCalls: undefined, - }, - ]) - }) - - it('creates canonical messages', () => { - expect(createUserMessage('Hi')).toEqual({ role: 'user', content: 'Hi' }) - expect(createAssistantMessage('Done')).toEqual({ role: 'assistant', content: 'Done', toolCalls: undefined }) - expect(createToolResultMessage('call_1', 'result', 'lookup')).toEqual({ - role: 'tool', - content: 'result', - toolCallId: 'call_1', - name: 'lookup', - }) - }) - - it('converts model responses to assistant output messages', () => { - expect(modelResponseToAgentMessage({ - id: 'res_1', - model: 'test-model', - content: 'Hello', - finishReason: 'stop', - usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 }, - })).toEqual({ - role: 'assistant', - id: 'res_1', - model: 'test-model', - content: 'Hello', - toolCalls: undefined, - finishReason: 'stop', - usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 }, - raw: undefined, - }) - }) - - it('collects stream events into one assistant output message', async () => { - async function *events(): AsyncIterable { - yield { type: 'text-delta', text: 'Hel' } - yield { type: 'reasoning-delta', text: 'thinking ' } - yield { type: 'reasoning-delta', text: 'step' } - yield { type: 'text-delta', text: 'lo' } - yield { - type: 'tool-call', - toolCall: { - id: 'call_1', - name: 'lookup', - arguments: { q: 'docs' }, - }, - } - yield { type: 'usage', usage: { inputTokens: 2, outputTokens: 3, totalTokens: 5 } } - yield { type: 'done', response: { id: 'res_1', model: 'stream-model', finishReason: 'tool_calls' } } - } - - await expect(collectModelEvents(events())).resolves.toEqual({ - events: [ - { type: 'text-delta', text: 'Hel' }, - { type: 'reasoning-delta', text: 'thinking ' }, - { type: 'reasoning-delta', text: 'step' }, - { type: 'text-delta', text: 'lo' }, - { - type: 'tool-call', - toolCall: { - id: 'call_1', - name: 'lookup', - arguments: { q: 'docs' }, - }, - }, - { type: 'usage', usage: { inputTokens: 2, outputTokens: 3, totalTokens: 5 } }, - { type: 'done', response: { id: 'res_1', model: 'stream-model', finishReason: 'tool_calls' } }, - ], - message: { - role: 'assistant', - id: 'res_1', - model: 'stream-model', - content: 'Hello', - reasoning: 'thinking step', - toolCalls: [{ - id: 'call_1', - name: 'lookup', - arguments: { q: 'docs' }, - }], - usage: { inputTokens: 2, outputTokens: 3, totalTokens: 5 }, - finishReason: 'tool_calls', - raw: undefined, - }, - }) - }) -}) diff --git a/tests/ekko-agent/model-request.test.ts b/tests/ekko-agent/model-request.test.ts deleted file mode 100644 index 148e861..0000000 --- a/tests/ekko-agent/model-request.test.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { - AnthropicMessagesModelClient, - ModelProviderError, - ModelProviderRegistry, - createModelClient, - toAnthropicMessagesPayload, - toGeminiContentsPayload, - normalizeOpenAIChatResponse, - resolveModelProviderConfigs, - toOpenAIResponsesPayload, - toOpenAIChatPayload, - toPromptCompletionPayload, -} from '../../packages/ekko-agent/src/index' -import type { ModelProviderConfig } from '../../packages/ekko-agent/src/index' - -const providerConfig: ModelProviderConfig = { - id: 'deepseek', - type: 'openai-compatible', - apiKey: 'test-key', - baseUrl: 'https://api.deepseek.com/v1', - defaultModel: 'deepseek-chat', -} - -describe('ekko-agent model requests', () => { - it('resolves provider configs from explicit api mode with inferred fallback', () => { - const resolved = resolveModelProviderConfigs({ - provider: 'glm', - baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', - apiKey: 'secret', - model: 'glm-5.2', - apiMode: 'codex_responses', - }) - - expect(resolved.providerConfig).toMatchObject({ - id: 'glm', - type: 'openai-compatible', - requestStyle: 'openai-responses', - baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', - apiKey: 'secret', - defaultModel: 'glm-5.2', - }) - expect(resolved.fallbackProviderConfig).toMatchObject({ - requestStyle: 'openai-chat', - defaultModel: 'glm-5.2', - }) - }) - - it('infers anthropic provider configs from anthropic URLs', () => { - const resolved = resolveModelProviderConfigs({ - provider: 'custom', - baseUrl: 'https://api.z.ai/api/anthropic', - model: 'glm-5.2', - }) - - expect(resolved.providerConfig).toMatchObject({ - type: 'anthropic', - requestStyle: 'anthropic-messages', - }) - expect(resolved.fallbackProviderConfig).toBeUndefined() - }) - - it('converts internal requests to OpenAI-compatible chat payloads', () => { - const payload = toOpenAIChatPayload(providerConfig, { - messages: [ - { role: 'system', content: 'Be concise.' }, - { role: 'user', content: 'List files.' }, - ], - tools: [ - { - name: 'read_file', - description: 'Read a file', - parameters: { - type: 'object', - properties: { - path: { type: 'string' }, - }, - }, - }, - ], - temperature: 0.2, - maxTokens: 1024, - }) - - expect(payload).toMatchObject({ - model: 'deepseek-chat', - messages: [ - { role: 'system', content: 'Be concise.' }, - { role: 'user', content: 'List files.' }, - ], - temperature: 0.2, - max_tokens: 1024, - tools: [ - { - type: 'function', - function: { - name: 'read_file', - description: 'Read a file', - }, - }, - ], - }) - }) - - it('normalizes OpenAI-compatible responses into the internal shape', () => { - const response = normalizeOpenAIChatResponse('deepseek', { - id: 'chatcmpl_1', - model: 'deepseek-chat', - choices: [ - { - message: { - content: 'Done.', - tool_calls: [ - { - id: 'call_1', - type: 'function', - function: { - name: 'read_file', - arguments: '{"path":"README.md"}', - }, - }, - ], - }, - finish_reason: 'tool_calls', - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - prompt_tokens_details: { cached_tokens: 7 }, - completion_tokens_details: { reasoning_tokens: 2 }, - }, - }) - - expect(response).toMatchObject({ - id: 'chatcmpl_1', - model: 'deepseek-chat', - content: 'Done.', - finishReason: 'tool_calls', - usage: { - inputTokens: 3, - outputTokens: 5, - totalTokens: 15, - cacheReadTokens: 7, - reasoningTokens: 2, - }, - toolCalls: [ - { - id: 'call_1', - name: 'read_file', - arguments: { path: 'README.md' }, - }, - ], - }) - }) - - it('creates OpenAI-compatible clients through the registry', () => { - const registry = new ModelProviderRegistry() - registry.register(providerConfig) - - const client = registry.create('deepseek', { - fetch: vi.fn(), - }) - - expect(client.provider).toBe('deepseek') - expect(client.requestStyle).toBe('openai-chat') - expect(client.capabilities.tools).toBe(true) - expect(registry.list()).toHaveLength(1) - }) - - it('creates clients for every supported request style', () => { - expect(createModelClient({ - id: 'openai-responses', - type: 'openai', - requestStyle: 'openai-responses', - defaultModel: 'gpt-4.1', - }).requestStyle).toBe('openai-responses') - - expect(createModelClient({ - id: 'claude', - type: 'anthropic', - defaultModel: 'claude-sonnet', - }).requestStyle).toBe('anthropic-messages') - - expect(createModelClient({ - id: 'gemini', - type: 'gemini', - defaultModel: 'gemini-2.5-pro', - }).requestStyle).toBe('gemini-contents') - - expect(createModelClient({ - id: 'legacy', - type: 'custom', - requestStyle: 'prompt-completion', - defaultModel: 'legacy-text', - }).requestStyle).toBe('prompt-completion') - - expect(createModelClient({ - id: 'runtime', - type: 'custom', - defaultModel: 'runtime-agent', - }).requestStyle).toBe('custom-runtime') - }) - - it('converts internal requests to OpenAI Responses payloads', () => { - const payload = toOpenAIResponsesPayload({ - id: 'openai', - type: 'openai', - requestStyle: 'openai-responses', - defaultModel: 'gpt-4.1', - }, { - messages: [ - { role: 'system', content: 'Be direct.' }, - { role: 'user', content: 'Search docs.' }, - ], - tools: [{ name: 'search', parameters: { type: 'object' } }], - maxTokens: 500, - context: { responseId: 'resp_previous' }, - }) - - expect(payload).toMatchObject({ - model: 'gpt-4.1', - instructions: 'Be direct.', - input: [{ role: 'user', content: 'Search docs.' }], - max_output_tokens: 500, - previous_response_id: 'resp_previous', - tools: [{ type: 'function', name: 'search' }], - }) - }) - - it('converts internal requests to Anthropic Messages payloads', () => { - const payload = toAnthropicMessagesPayload({ - id: 'claude', - type: 'anthropic', - defaultModel: 'claude-sonnet', - }, { - messages: [ - { role: 'system', content: 'Use short answers.' }, - { role: 'user', content: 'Hello.' }, - ], - tools: [{ name: 'read_file', parameters: { type: 'object' } }], - }) - - expect(payload).toMatchObject({ - model: 'claude-sonnet', - system: 'Use short answers.', - messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello.' }] }], - max_tokens: 4096, - tools: [{ name: 'read_file', input_schema: { type: 'object' } }], - }) - }) - - it('calls Anthropic-compatible /anthropic bases through /v1/messages', async () => { - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ - content: [{ type: 'text', text: 'OK' }], - stop_reason: 'end_turn', - }), { status: 200 })) - const client = new AnthropicMessagesModelClient({ - id: 'custom:glm-anthropic', - type: 'anthropic', - requestStyle: 'anthropic-messages', - baseUrl: 'https://api.z.ai/api/anthropic', - apiKey: 'test-key', - defaultModel: 'glm-5.2', - }, { fetch: fetchMock }) - - const response = await client.create({ - messages: [{ role: 'user', content: 'hi' }], - }) - - expect(response.content).toBe('OK') - expect(fetchMock.mock.calls[0]?.[0]).toBe('https://api.z.ai/api/anthropic/v1/messages') - expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ - authorization: 'Bearer test-key', - 'x-api-key': 'test-key', - }) - }) - - it('merges Anthropic streaming input, output, and cache usage', async () => { - const encoder = new TextEncoder() - const fetchMock = vi.fn(async () => new Response(new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode('event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":100,"output_tokens":0,"cache_read_input_tokens":80,"cache_creation_input_tokens":5}}}\n\n')) - controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"OK"}}\n\n')) - controller.enqueue(encoder.encode('event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}\n\n')) - controller.enqueue(encoder.encode('event: message_stop\ndata: {"type":"message_stop"}\n\n')) - controller.close() - }, - }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } })) - const client = new AnthropicMessagesModelClient({ - id: 'anthropic', - type: 'anthropic', - requestStyle: 'anthropic-messages', - baseUrl: 'https://api.anthropic.com', - apiKey: 'test-key', - defaultModel: 'claude-sonnet', - }, { fetch: fetchMock }) - - const events = [] - for await (const event of client.stream({ - messages: [{ role: 'user', content: 'hi' }], - stream: true, - })) events.push(event) - - expect(events).toContainEqual({ - type: 'usage', - usage: { - inputTokens: 100, - outputTokens: 7, - totalTokens: 107, - cacheReadTokens: 80, - cacheWriteTokens: 5, - reasoningTokens: undefined, - }, - }) - }) - - it('throws Anthropic-compatible JSON error bodies even when HTTP status is 200', async () => { - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ - code: 500, - msg: '404 NOT_FOUND', - success: false, - }), { status: 200 })) - const client = new AnthropicMessagesModelClient({ - id: 'custom:glm-anthropic', - type: 'anthropic', - requestStyle: 'anthropic-messages', - baseUrl: 'https://api.z.ai/api/anthropic/messages', - apiKey: 'test-key', - defaultModel: 'glm-5.2', - }, { fetch: fetchMock }) - - await expect(client.create({ - messages: [{ role: 'user', content: 'hi' }], - })).rejects.toMatchObject({ - message: '404 NOT_FOUND', - provider: 'custom:glm-anthropic', - }) - }) - - it('converts internal requests to Gemini Contents payloads', () => { - const payload = toGeminiContentsPayload({ - id: 'gemini', - type: 'gemini', - defaultModel: 'gemini-2.5-pro', - }, { - messages: [ - { role: 'system', content: 'Be brief.' }, - { role: 'user', content: 'Hello.' }, - ], - tools: [{ name: 'lookup', parameters: { type: 'object' } }], - temperature: 0.1, - }) - - expect(payload).toMatchObject({ - systemInstruction: { parts: [{ text: 'Be brief.' }] }, - contents: [{ role: 'user', parts: [{ text: 'Hello.' }] }], - generationConfig: { temperature: 0.1 }, - tools: [{ functionDeclarations: [{ name: 'lookup' }] }], - }) - }) - - it('converts internal requests to prompt completion payloads', () => { - const payload = toPromptCompletionPayload({ - id: 'legacy', - type: 'custom', - requestStyle: 'prompt-completion', - defaultModel: 'legacy-text', - }, { - messages: [ - { role: 'system', content: 'Instruction.' }, - { role: 'user', content: 'Question.' }, - ], - maxTokens: 100, - }) - - expect(payload).toEqual({ - model: 'legacy-text', - prompt: 'SYSTEM: Instruction.\n\nUSER: Question.', - max_tokens: 100, - stream: undefined, - temperature: undefined, - }) - }) - - it('sends requests with provider headers and normalizes the response', async () => { - const fetchMock = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response(JSON.stringify({ - id: 'chatcmpl_2', - model: 'deepseek-chat', - choices: [{ message: { content: 'Hello.' }, finish_reason: 'stop' }], - }))) - - const client = createModelClient(providerConfig, { fetch: fetchMock }) - const response = await client.create({ - messages: [{ role: 'user', content: 'Hello' }], - }) - - expect(response.content).toBe('Hello.') - expect(fetchMock).toHaveBeenCalledWith( - 'https://api.deepseek.com/v1/chat/completions', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - authorization: 'Bearer test-key', - 'content-type': 'application/json', - }), - body: expect.stringContaining('"model":"deepseek-chat"'), - }), - ) - }) - - it('throws normalized provider errors for failing HTTP responses', async () => { - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ - error: { - message: 'rate limited', - }, - }), { status: 429 })) - - const client = createModelClient(providerConfig, { fetch: fetchMock }) - - await expect(client.create({ - messages: [{ role: 'user', content: 'Hello' }], - })).rejects.toMatchObject({ - name: 'ModelProviderError', - provider: 'deepseek', - statusCode: 429, - retryable: true, - message: 'rate limited', - } satisfies Partial) - }) -}) diff --git a/tests/ekko-agent/runtime.test.ts b/tests/ekko-agent/runtime.test.ts deleted file mode 100644 index f6ca069..0000000 --- a/tests/ekko-agent/runtime.test.ts +++ /dev/null @@ -1,501 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { join } from 'node:path' -import { - AgentRuntime, - AgentToolRegistry, - DEFAULT_AGENT_MAX_STEPS, - DEFAULT_AGENT_MODEL_MAX_RETRIES, - buildSystemPrompt, -} from '../../packages/ekko-agent/src/index' -import type { - AgentTool, - AgentToolProvider, - ModelEvent, - ModelClient, - ModelRequest, - ModelResponse, -} from '../../packages/ekko-agent/src/index' - -function modelClient(responder: (request: ModelRequest, call: number) => ModelResponse): ModelClient { - let call = 0 - return { - provider: 'test', - requestStyle: 'custom-runtime', - capabilities: { - streaming: false, - tools: true, - vision: false, - jsonMode: false, - systemPrompt: true, - }, - create: vi.fn(async (request: ModelRequest) => responder(request, ++call)), - stream: vi.fn(), - } -} - -function streamingModelClient(events: ModelEvent[]): ModelClient { - return { - provider: 'test', - requestStyle: 'custom-runtime', - capabilities: { - streaming: true, - tools: true, - vision: false, - jsonMode: false, - systemPrompt: true, - }, - create: vi.fn(), - stream: vi.fn(async function *stream() { - for (const event of events) yield event - }), - } -} - -function emptyStreamingWithCreateFallback(response: ModelResponse): ModelClient { - return { - provider: 'test', - requestStyle: 'custom-runtime', - capabilities: { - streaming: true, - tools: true, - vision: false, - jsonMode: false, - systemPrompt: true, - }, - create: vi.fn(async () => response), - stream: vi.fn(async function *stream() { - yield { type: 'done', response: { finishReason: 'stop' } } - }), - } -} - -describe('ekko-agent runtime', () => { - it('runs a model request without tools', async () => { - const client = modelClient(() => ({ - content: 'hello', - model: 'test-model', - })) - const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() }) - const events: string[] = [] - - const result = await runtime.run({ - messages: ['hi'], - onEvent: event => events.push(event.type), - }) - - expect(result.output).toMatchObject({ - role: 'assistant', - content: 'hello', - model: 'test-model', - }) - expect(result.messages.map(message => message.role)).toEqual(['system', 'user', 'assistant']) - expect(events).toEqual(['run.started', 'model.started', 'context.estimated', 'model.message', 'run.completed']) - }) - - it('emits one model usage event for each completed non-streaming model call', async () => { - const client = modelClient(() => ({ - content: 'hello', - usage: { - inputTokens: 10, - outputTokens: 4, - cacheReadTokens: 6, - reasoningTokens: 2, - }, - })) - const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() }) - const usageEvents: any[] = [] - - await runtime.run({ - messages: ['hi'], - onEvent: event => { - if (event.type === 'model.usage') usageEvents.push(event) - }, - }) - - expect(usageEvents).toEqual([{ - type: 'model.usage', - runId: expect.any(String), - step: 1, - usage: { - inputTokens: 10, - outputTokens: 4, - cacheReadTokens: 6, - reasoningTokens: 2, - }, - }]) - }) - - it('collapses repeated streaming usage updates into one event per model call', async () => { - const client = streamingModelClient([ - { type: 'text-delta', text: 'ok' }, - { type: 'usage', usage: { inputTokens: 8, outputTokens: 1 } }, - { type: 'usage', usage: { inputTokens: 8, outputTokens: 2, cacheReadTokens: 5 } }, - { type: 'done', response: { finishReason: 'stop' } }, - ]) - const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() }) - const usageEvents: any[] = [] - - await runtime.run({ - messages: ['hi'], - onEvent: event => { - if (event.type === 'model.usage') usageEvents.push(event) - }, - }) - - expect(usageEvents).toHaveLength(1) - expect(usageEvents[0]).toMatchObject({ - step: 1, - usage: { inputTokens: 8, outputTokens: 2, cacheReadTokens: 5 }, - }) - }) - - it('emits model reasoning before the assistant message', async () => { - const client = modelClient(() => ({ - content: 'answer', - reasoning: 'thinking path', - })) - const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() }) - const events: string[] = [] - const reasoning: string[] = [] - - const result = await runtime.run({ - messages: ['hi'], - onEvent: event => { - events.push(event.type) - if (event.type === 'model.reasoning') reasoning.push(event.text) - }, - }) - - expect(result.output.reasoning).toBe('thinking path') - expect(reasoning).toEqual(['thinking path']) - expect(events).toEqual(['run.started', 'model.started', 'context.estimated', 'model.reasoning', 'model.message', 'run.completed']) - }) - - it('streams model text deltas before the final assistant message', async () => { - const client = streamingModelClient([ - { type: 'text-delta', text: 'Hel' }, - { type: 'text-delta', text: 'lo' }, - { type: 'done', response: { finishReason: 'stop' } }, - ]) - const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() }) - const events: string[] = [] - const deltas: string[] = [] - - const result = await runtime.run({ - messages: ['hi'], - onEvent: event => { - events.push(event.type) - if (event.type === 'model.delta') deltas.push(event.text) - }, - }) - - expect(client.create).not.toHaveBeenCalled() - expect(client.stream).toHaveBeenCalledTimes(1) - expect(result.output.content).toBe('Hello') - expect(deltas).toEqual(['Hel', 'lo']) - expect(events).toEqual(['run.started', 'model.started', 'context.estimated', 'model.delta', 'model.delta', 'model.message', 'run.completed']) - }) - - it('falls back to non-streaming create when a provider stream returns no output', async () => { - const client = emptyStreamingWithCreateFallback({ content: 'fallback answer', finishReason: 'stop' }) - const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() }) - - const result = await runtime.run({ messages: ['hi'] }) - - expect(result.output.content).toBe('fallback answer') - expect(client.stream).toHaveBeenCalledTimes(1) - expect(client.create).toHaveBeenCalledTimes(1) - expect(vi.mocked(client.create).mock.calls[0]?.[0]).toMatchObject({ stream: false }) - }) - - it('executes tool calls and continues the model loop', async () => { - const echoTool: AgentTool = { - definition: { - name: 'echo', - description: 'Echo text', - parameters: { type: 'object' }, - }, - async execute(input) { - return { ok: true, content: String(input.text || '') } - }, - } - const tools = new AgentToolRegistry() - tools.register(echoTool) - const client = modelClient((_request, call) => call === 1 - ? { - content: '', - toolCalls: [{ id: 'call_1', name: 'echo', arguments: { text: 'from-tool' } }], - finishReason: 'tool_calls', - } - : { content: 'tool said from-tool', finishReason: 'stop' }) - const runtime = new AgentRuntime({ modelClient: client, tools, toolDelayMs: 0 }) - - const result = await runtime.run({ messages: ['use echo'] }) - - expect(result.output.content).toBe('tool said from-tool') - expect(result.messages).toMatchObject([ - { role: 'system' }, - { role: 'user', content: 'use echo' }, - { role: 'assistant', toolCalls: [{ id: 'call_1', name: 'echo' }] }, - { role: 'tool', toolCallId: 'call_1', name: 'echo', content: 'from-tool' }, - { role: 'assistant', content: 'tool said from-tool' }, - ]) - expect(result.steps.map(step => step.type)).toEqual(['model', 'tool', 'model']) - }) - - it('discovers and executes MCP tools from the run tool context', async () => { - const client = modelClient((request, call) => { - if (call === 1) { - expect(request.tools?.some(tool => tool.name === 'fake_echo')).toBe(true) - return { - content: '', - toolCalls: [{ id: 'call_mcp', name: 'fake_echo', arguments: { text: 'hello' } }], - finishReason: 'tool_calls', - } - } - return { content: 'done', finishReason: 'stop' } - }) - const runtime = new AgentRuntime({ modelClient: client, toolDelayMs: 0 }) - - const result = await runtime.run({ - messages: ['use mcp'], - toolContext: { - mcpServers: { - fake: { - command: process.execPath, - args: [join(process.cwd(), 'tests/fixtures/fake-mcp-server.cjs')], - }, - }, - }, - }) - - expect(result.messages).toMatchObject([ - { role: 'system' }, - { role: 'user', content: 'use mcp' }, - { role: 'assistant', toolCalls: [{ id: 'call_mcp', name: 'fake_echo' }] }, - { role: 'tool', toolCallId: 'call_mcp', name: 'fake_echo', content: 'mcp:hello' }, - { role: 'assistant', content: 'done' }, - ]) - }) - - it('returns unknown tool failures as tool messages', async () => { - const client = modelClient((_request, call) => call === 1 - ? { - content: '', - toolCalls: [{ id: 'call_missing', name: 'missing_tool', arguments: {} }], - } - : { content: 'handled missing tool' }) - const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry(), maxSteps: 2, toolDelayMs: 0 }) - - const result = await runtime.run({ messages: ['call missing'] }) - - expect(result.messages[3]).toMatchObject({ - role: 'tool', - toolCallId: 'call_missing', - name: 'missing_tool', - content: 'Unknown tool: missing_tool', - }) - expect(result.output.content).toBe('handled missing tool') - }) - - it('stops after consecutive tool failures', async () => { - const client = modelClient((_request, call) => ({ - content: '', - toolCalls: [{ id: `call_missing_${call}`, name: 'missing_tool', arguments: {} }], - })) - const runtime = new AgentRuntime({ - modelClient: client, - tools: new AgentToolRegistry(), - maxConsecutiveToolFailures: 2, - maxSteps: 10, - toolDelayMs: 0, - }) - const events: string[] = [] - - const result = await runtime.run({ - messages: ['call missing repeatedly'], - onEvent: event => events.push(event.type), - }) - - expect(result.output).toMatchObject({ - content: 'Stopped after 2 consecutive tool failures.', - finishReason: 'tool_failure_limit', - }) - expect(result.steps.filter(step => step.type === 'tool')).toHaveLength(2) - expect(client.create).toHaveBeenCalledTimes(2) - expect(events).toContain('run.tool_failure_limit') - }) - - it('passes abort signals into model requests', async () => { - const controller = new AbortController() - const client = modelClient((request) => { - expect(request.signal).toBe(controller.signal) - return { content: 'done' } - }) - const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() }) - - const result = await runtime.run({ messages: ['hi'], signal: controller.signal }) - - expect(result.output.content).toBe('done') - }) - - it('stops before a model request when aborted', async () => { - const controller = new AbortController() - controller.abort() - const client = modelClient(() => ({ content: 'should not run' })) - const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() }) - - await expect(runtime.run({ messages: ['hi'], signal: controller.signal })).rejects.toThrow('Run aborted.') - expect(client.create).not.toHaveBeenCalled() - }) - - it('defaults maxSteps to 90', async () => { - const client = modelClient(() => ({ content: 'done' })) - const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() }) - const seen: number[] = [] - - await runtime.run({ - messages: ['hi'], - onEvent: event => { - if (event.type === 'run.started') seen.push(event.maxSteps) - }, - }) - - expect(DEFAULT_AGENT_MAX_STEPS).toBe(90) - expect(seen).toEqual([90]) - }) - - it('retries each model step before continuing the loop', async () => { - const client = modelClient((_request, call) => { - if (call < 3) throw new Error(`temporary failure ${call}`) - return { content: 'recovered' } - }) - const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() }) - const retries: number[] = [] - - const result = await runtime.run({ - messages: ['hi'], - onEvent: event => { - if (event.type === 'model.retry') retries.push(event.retry) - }, - }) - - expect(result.output.content).toBe('recovered') - expect(client.create).toHaveBeenCalledTimes(3) - expect(retries).toEqual([1, 2]) - }) - - it('stops the run after three failed model retries', async () => { - const client = modelClient(() => { - throw new Error('still failing') - }) - const runtime = new AgentRuntime({ modelClient: client, tools: new AgentToolRegistry() }) - const events: string[] = [] - - await expect(runtime.run({ - messages: ['hi'], - onEvent: event => events.push(event.type), - })).rejects.toThrow('still failing') - - expect(DEFAULT_AGENT_MODEL_MAX_RETRIES).toBe(3) - expect(client.create).toHaveBeenCalledTimes(4) - expect(events.filter(event => event === 'model.retry')).toHaveLength(3) - expect(events.at(-1)).toBe('run.failed') - }) - - it('builds a system prompt from runtime, skills, tools, and user system messages', async () => { - const requests: ModelRequest[] = [] - const client = modelClient((request) => { - requests.push(request) - return { content: 'ok' } - }) - const runtime = new AgentRuntime({ - modelClient: client, - tools: new AgentToolRegistry(), - systemPrompt: 'Base prompt.', - runtimeInstructions: ['Use tools carefully.'], - skills: [{ - id: 'review', - name: 'Review', - instructions: 'Review for correctness.', - }], - }) - - await runtime.run({ - messages: [ - { role: 'system', content: 'User system.' }, - { role: 'user', content: 'Go' }, - ], - }) - - expect(requests[0].messages[0].content).toContain('Base prompt.') - expect(requests[0].messages[0].content).toContain('Use tools carefully.') - expect(requests[0].messages[0].content).toContain('Review for correctness.') - expect(requests[0].messages[0].content).toContain('User system.') - expect(requests[0].messages.filter(message => message.role === 'system')).toHaveLength(1) - }) - - it('refreshes dynamic tool providers before running', async () => { - const providerTool: AgentTool = { - definition: { name: 'provided_tool', parameters: { type: 'object' } }, - async execute() { - return { ok: true, content: 'provided' } - }, - } - const provider: AgentToolProvider = { - id: 'test-provider', - async listTools() { - return [providerTool] - }, - } - const tools = new AgentToolRegistry() - tools.registerProvider(provider) - const client = modelClient((request) => { - expect(request.tools?.map(tool => tool.name)).toContain('provided_tool') - return { content: 'ok' } - }) - - await new AgentRuntime({ modelClient: client, tools }).run({ messages: ['hi'] }) - }) - - it('stores model context by session and sends it on follow-up runs', async () => { - const requests: ModelRequest[] = [] - const client = modelClient((request, call) => { - requests.push(request) - return { - content: `ok-${call}`, - context: { responseId: `resp-${call}` }, - } - }) - const runtime = new AgentRuntime({ modelClient: client }) - - const first = await runtime.run({ - messages: ['first'], - metadata: { session_id: 'session-a' }, - }) - const second = await runtime.run({ - messages: ['second'], - metadata: { session_id: 'session-a' }, - }) - await runtime.run({ - messages: ['other'], - metadata: { session_id: 'session-b' }, - }) - - expect(first.context).toEqual({ responseId: 'resp-1' }) - expect(second.context).toEqual({ responseId: 'resp-2' }) - expect(requests[0].context).toBeUndefined() - expect(requests[1].context).toEqual({ responseId: 'resp-1' }) - expect(requests[2].context).toBeUndefined() - }) - - it('buildSystemPrompt omits structured tool descriptions', () => { - const prompt = buildSystemPrompt({ - basePrompt: 'Base', - }) - - expect(prompt).toContain('Base') - expect(prompt).not.toContain('Available Tools') - expect(prompt).not.toContain('read_file') - }) -}) diff --git a/tests/ekko-agent/tools.test.ts b/tests/ekko-agent/tools.test.ts deleted file mode 100644 index a3e140e..0000000 --- a/tests/ekko-agent/tools.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises' -import os from 'node:os' -import path from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - AgentToolError, - ReadFileTool, - TerminalExecTool, - WriteFileTool, - createDefaultToolRegistry, -} from '../../packages/ekko-agent/src/index' - -let workspaceRoot = '' - -beforeEach(async () => { - workspaceRoot = await mkdtemp(path.join(os.tmpdir(), 'ekko-agent-tools-')) -}) - -afterEach(async () => { - await rm(workspaceRoot, { recursive: true, force: true }) - delete process.env.AGENT_BROWSER_BIN -}) - -describe('ekko-agent tools', () => { - it('writes and reads files inside the workspace', async () => { - const writer = new WriteFileTool() - const reader = new ReadFileTool() - - await expect(writer.execute({ - path: 'notes/todo.txt', - content: 'ship tools', - }, { workspaceRoot })).resolves.toMatchObject({ - ok: true, - data: { - bytes: 10, - }, - }) - - await expect(readFile(path.join(workspaceRoot, 'notes/todo.txt'), 'utf8')).resolves.toBe('ship tools') - await expect(reader.execute({ path: 'notes/todo.txt' }, { workspaceRoot })).resolves.toMatchObject({ - ok: true, - content: 'ship tools', - }) - }) - - it('blocks file paths outside workspaceRoot', async () => { - const reader = new ReadFileTool() - - await expect(reader.execute({ path: '../outside.txt' }, { workspaceRoot })).rejects.toBeInstanceOf(AgentToolError) - await expect(reader.execute({ path: '../outside.txt' }, { workspaceRoot })).rejects.toMatchObject({ - code: 'PATH_OUTSIDE_WORKSPACE', - }) - }) - - it('runs terminal commands with argument arrays', async () => { - const terminal = new TerminalExecTool() - - await expect(terminal.execute({ - command: process.execPath, - args: ['-e', 'process.stdout.write(process.argv[1])', 'hello-terminal'], - }, { workspaceRoot })).resolves.toMatchObject({ - ok: true, - content: 'hello-terminal', - data: { - args: ['-e', 'process.stdout.write(process.argv[1])', 'hello-terminal'], - exitCode: 0, - }, - }) - }) - - it('normalizes shell-like terminal command strings when args are omitted', async () => { - const terminal = new TerminalExecTool() - - await expect(terminal.execute({ - command: `${process.execPath} -e "process.stdout.write(process.argv[1])" hello-split`, - }, { workspaceRoot })).resolves.toMatchObject({ - ok: true, - content: 'hello-split', - data: { - command: process.execPath, - args: ['-e', 'process.stdout.write(process.argv[1])', 'hello-split'], - exitCode: 0, - }, - }) - }) - - it('does not start terminal commands when the signal is already aborted', async () => { - const terminal = new TerminalExecTool() - const controller = new AbortController() - controller.abort() - - await expect(terminal.execute({ - command: process.execPath, - args: ['-e', 'process.stdout.write("should-not-run")'], - }, { workspaceRoot, signal: controller.signal })).resolves.toMatchObject({ - ok: false, - content: 'Command aborted.', - error: 'Command aborted.', - data: { - aborted: true, - }, - }) - }) - - it('reports non-zero terminal exits without throwing', async () => { - const terminal = new TerminalExecTool() - - await expect(terminal.execute({ - command: process.execPath, - args: ['-e', 'process.stderr.write("bad"); process.exit(7)'], - }, { workspaceRoot })).resolves.toMatchObject({ - ok: false, - content: 'bad', - error: 'Command exited with code 7', - data: { - exitCode: 7, - }, - }) - }) - - it('registers default tools and exposes model definitions', async () => { - const registry = createDefaultToolRegistry() - - expect(registry.definitions().map(definition => definition.name).sort()).toEqual([ - 'browser_back', - 'browser_click', - 'browser_console', - 'browser_get_images', - 'browser_navigate', - 'browser_press', - 'browser_scroll', - 'browser_snapshot', - 'browser_type', - 'browser_vision', - 'read_file', - 'terminal_exec', - 'write_file', - ]) - - await expect(registry.execute('write_file', { - path: 'from-registry.txt', - content: 'ok', - }, { workspaceRoot })).resolves.toMatchObject({ ok: true }) - - await expect(registry.execute('read_file', { - path: 'from-registry.txt', - }, { workspaceRoot })).resolves.toMatchObject({ - ok: true, - content: 'ok', - }) - }) -}) diff --git a/tests/server/agent-runner-adapters.test.ts b/tests/server/agent-runner-adapters.test.ts deleted file mode 100644 index 7be5fd8..0000000 --- a/tests/server/agent-runner-adapters.test.ts +++ /dev/null @@ -1,687 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - anthropicMessageToResponses, - openAiChatToResponses, - responsesToAnthropicMessages, - responsesToOpenAiChat, -} from '../../packages/server/src/services/agent-runner/adapters/responses' -import { - anthropicToOpenAiChat, - anthropicToOpenAiResponses, - openAiResponsesToAnthropicMessage, - openAiToAnthropicMessage, -} from '../../packages/server/src/services/agent-runner/adapters/anthropic' -import { - openAiChatSseToAnthropicEvents, - openAiResponsesSseToAnthropicEvents, - type AnthropicStreamEvent, -} from '../../packages/server/src/services/agent-runner/adapters/anthropic-stream' -import { - anthropicMessagesSseToResponsesEvents, - openAiChatSseToResponsesEvents, - openAiResponsesSseToResponsesEvents, - type CanonicalResponsesEvent, -} from '../../packages/server/src/services/agent-runner/adapters/responses-stream' - -const target = { model: 'test-model' } -const codexTarget = { model: 'test-model', annotateMcpToolNamespaces: true } -const anthropicTarget = { provider: 'deepseek', model: 'deepseek-reasoner', baseUrl: 'https://api.deepseek.com/v1' } - -describe('agent runner Responses adapters', () => { - it('converts Responses input to OpenAI Chat messages and tools', () => { - const body = { - instructions: 'be terse', - max_output_tokens: 16, - temperature: 0.2, - top_p: 0.9, - input: [ - { role: 'user', content: [{ type: 'input_text', text: 'hello' }] }, - { role: 'developer', content: [{ type: 'input_text', text: 'rules' }] }, - { type: 'function_call', call_id: 'call_1', name: 'search', arguments: '{"q":"x"}' }, - { type: 'function_call_output', call_id: 'call_1', output: 'found' }, - ], - tools: [{ type: 'function', name: 'search', description: 'Search', parameters: { type: 'object' } }], - } - - expect(responsesToOpenAiChat(body, target)).toMatchObject({ - model: 'test-model', - max_tokens: 16, - temperature: 0.2, - top_p: 0.9, - stream: false, - messages: [ - { role: 'system', content: 'be terse' }, - { role: 'user', content: 'hello' }, - { role: 'system', content: 'rules' }, - { - role: 'assistant', - content: null, - tool_calls: [{ - id: 'call_1', - type: 'function', - function: { name: 'search', arguments: '{"q":"x"}' }, - }], - }, - { role: 'tool', tool_call_id: 'call_1', content: 'found' }, - ], - tools: [{ - type: 'function', - function: { name: 'search', description: 'Search', parameters: { type: 'object' } }, - }], - }) - }) - - it('groups parallel Responses function calls before Chat tool results', () => { - const body = { - input: [ - { role: 'user', content: [{ type: 'input_text', text: 'check repo' }] }, - { type: 'function_call', call_id: 'call_1', name: 'read_file', arguments: '{"path":"a.ts"}' }, - { type: 'function_call', call_id: 'call_2', name: 'search', arguments: '{"q":"todo"}' }, - { type: 'function_call_output', call_id: 'call_2', output: 'matches' }, - { type: 'function_call_output', call_id: 'call_1', output: 'file text' }, - { role: 'user', content: [{ type: 'input_text', text: 'continue' }] }, - ], - } - - expect(responsesToOpenAiChat(body, target).messages).toEqual([ - { role: 'user', content: 'check repo' }, - { - role: 'assistant', - content: null, - tool_calls: [ - { - id: 'call_1', - type: 'function', - function: { name: 'read_file', arguments: '{"path":"a.ts"}' }, - }, - { - id: 'call_2', - type: 'function', - function: { name: 'search', arguments: '{"q":"todo"}' }, - }, - ], - }, - { role: 'tool', tool_call_id: 'call_1', content: 'file text' }, - { role: 'tool', tool_call_id: 'call_2', content: 'matches' }, - { role: 'user', content: 'continue' }, - ]) - }) - - it('drops incomplete Responses function call history for Chat providers', () => { - const body = { - input: [ - { role: 'user', content: [{ type: 'input_text', text: 'hello' }] }, - { type: 'function_call', call_id: 'call_missing', name: 'search', arguments: '{"q":"x"}' }, - { role: 'user', content: [{ type: 'input_text', text: 'next turn' }] }, - { type: 'function_call_output', call_id: 'orphan_call', output: 'orphan' }, - ], - } - - expect(responsesToOpenAiChat(body, target).messages).toEqual([ - { role: 'user', content: 'hello' }, - { role: 'user', content: 'next turn' }, - ]) - }) - - it('converts Responses input to Anthropic messages', () => { - const body = { - instructions: 'system text', - input: [ - { role: 'user', content: [{ type: 'input_text', text: 'hello' }] }, - { type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{"id":1}' }, - { type: 'function_call_output', call_id: 'call_1', output: [{ text: 'ok' }] }, - ], - tools: [{ type: 'function', name: 'lookup', description: 'Lookup', parameters: { type: 'object' } }], - } - - expect(responsesToAnthropicMessages(body, target, true)).toMatchObject({ - model: 'test-model', - system: 'system text', - max_tokens: 4096, - stream: true, - messages: [ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [{ type: 'tool_use', id: 'call_1', name: 'lookup', input: { id: 1 } }] }, - { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'ok' }] }, - ], - tools: [{ name: 'lookup', description: 'Lookup', input_schema: { type: 'object' } }], - }) - }) - - it('expands Hermes MCP namespace tools for Chat and Anthropic providers', () => { - const body = { - input: [{ role: 'user', content: [{ type: 'input_text', text: 'list devices' }] }], - tools: [{ type: 'namespace', name: 'mcp__hermes_studio', description: 'Hermes tools' }], - } - - expect(responsesToOpenAiChat(body, target).tools).toEqual(expect.arrayContaining([ - expect.objectContaining({ - type: 'function', - function: expect.objectContaining({ - name: 'hermes_studio_lan_devices_scan', - parameters: expect.objectContaining({ - properties: expect.objectContaining({ - profile: expect.any(Object), - token: expect.any(Object), - }), - }), - }), - }), - ])) - - expect(responsesToAnthropicMessages(body, target).tools).toEqual(expect.arrayContaining([ - expect.objectContaining({ - name: 'hermes_studio_lan_devices_scan', - input_schema: expect.objectContaining({ - properties: expect.objectContaining({ - profile: expect.any(Object), - token: expect.any(Object), - }), - }), - }), - ])) - }) - - it('keeps unknown MCP namespaces callable through a generic function fallback', () => { - const body = { - input: [{ role: 'user', content: [{ type: 'input_text', text: 'call custom mcp' }] }], - tools: [{ type: 'namespace', name: 'mcp__custom_server', description: 'Custom server tools' }], - } - - expect(responsesToOpenAiChat(body, target).tools).toEqual(expect.arrayContaining([ - expect.objectContaining({ - type: 'function', - function: expect.objectContaining({ - name: 'mcp__custom_server', - parameters: expect.objectContaining({ - required: ['tool', 'arguments'], - }), - }), - }), - ])) - }) - - it('converts OpenAI Chat responses to Responses output', () => { - expect(openAiChatToResponses({ - id: 'chatcmpl_1', - created: 123, - choices: [{ - message: { - reasoning_content: 'think', - content: 'hi', - tool_calls: [{ - id: 'call_1', - function: { name: 'lookup', arguments: '{"id":1}' }, - }], - }, - }], - usage: { prompt_tokens: 2, completion_tokens: 3, total_tokens: 5 }, - }, target)).toMatchObject({ - id: 'chatcmpl_1', - object: 'response', - created_at: 123, - model: 'test-model', - output: [ - { type: 'reasoning', summary: [{ type: 'summary_text', text: 'think' }] }, - { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'hi', annotations: [] }] }, - { type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{"id":1}' }, - ], - usage: { input_tokens: 2, output_tokens: 3, total_tokens: 5 }, - }) - }) - - it('marks expanded Hermes MCP Chat tool calls with their Responses namespace', () => { - expect(openAiChatToResponses({ - id: 'chatcmpl_1', - created: 123, - choices: [{ - message: { - tool_calls: [{ - id: 'call_1', - function: { name: 'hermes_studio_lan_devices_scan', arguments: '{"profile":"default"}' }, - }], - }, - }], - }, target)).toMatchObject({ - output: [{ - type: 'function_call', - call_id: 'call_1', - name: 'hermes_studio_lan_devices_scan', - namespace: 'mcp__hermes_studio', - }], - }) - }) - - it('normalizes generic MCP namespace function calls back to Responses MCP calls', () => { - expect(openAiChatToResponses({ - id: 'chatcmpl_1', - created: 123, - choices: [{ - message: { - tool_calls: [{ - id: 'call_1', - function: { - name: 'mcp__custom_server', - arguments: '{"tool":"custom_lookup","arguments":{"id":1}}', - }, - }], - }, - }], - }, target)).toMatchObject({ - output: [{ - type: 'function_call', - call_id: 'call_1', - name: 'custom_lookup', - arguments: '{"id":1}', - namespace: 'mcp__custom_server', - }], - }) - }) - - it('converts Anthropic messages to Responses output', () => { - expect(anthropicMessageToResponses({ - id: 'msg_1', - content: [ - { type: 'thinking', thinking: 'anthropic think' }, - { type: 'text', text: 'hi' }, - { type: 'tool_use', id: 'toolu_1', name: 'lookup', input: { id: 1 } }, - ], - usage: { input_tokens: 4, output_tokens: 5 }, - }, target)).toMatchObject({ - id: 'msg_1', - object: 'response', - model: 'test-model', - output: [ - { type: 'reasoning', summary: [{ type: 'summary_text', text: 'anthropic think' }] }, - { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'hi', annotations: [] }] }, - { type: 'function_call', call_id: 'toolu_1', name: 'lookup', arguments: '{"id":1}' }, - ], - usage: { input_tokens: 4, output_tokens: 5, total_tokens: 9 }, - }) - }) - - it('marks expanded Hermes MCP Anthropic tool calls with their Responses namespace', () => { - expect(anthropicMessageToResponses({ - id: 'msg_1', - content: [ - { type: 'tool_use', id: 'toolu_1', name: 'hermes_studio_lan_devices_list', input: { profile: 'default' } }, - ], - usage: { input_tokens: 1, output_tokens: 1 }, - }, target)).toMatchObject({ - output: [{ - type: 'function_call', - call_id: 'toolu_1', - name: 'hermes_studio_lan_devices_list', - namespace: 'mcp__hermes_studio', - }], - }) - }) -}) - -async function* encodedChunks(chunks: string[]): AsyncGenerator { - const encoder = new TextEncoder() - for (const chunk of chunks) yield encoder.encode(chunk) -} - -async function collectEvents(events: AsyncIterable): Promise { - const collected: CanonicalResponsesEvent[] = [] - for await (const event of events) collected.push(event) - return collected -} - -async function collectAnthropicEvents(events: AsyncIterable): Promise { - const collected: AnthropicStreamEvent[] = [] - for await (const event of events) collected.push(event) - return collected -} - -describe('agent runner Responses stream adapters', () => { - it('normalizes OpenAI Chat SSE text and tool calls to Responses events', async () => { - const events = await collectEvents(openAiChatSseToResponsesEvents(encodedChunks([ - 'data: {"choices":[{"delta":{"reasoning_content":"think"}}]}\n\n', - 'data: {"choices":[{"delta":{"content":"he"}}]}\n\n', - 'data: {"choices":[{"delta":{"content":"llo"}}]}\r\n\r\n', - 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"lookup","arguments":"{\\"id\\":"}}]}}]}\n\n', - 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1}"}}]}}]}\n\n', - 'data: {"id":"chatcmpl_usage","choices":[],"usage":{"prompt_tokens":120,"completion_tokens":7,"prompt_tokens_details":{"cached_tokens":30}}}\n\n', - 'data: [DONE]\n\n', - ]), codexTarget)) - - expect(events.map(event => event.type)).toEqual([ - 'response.created', - 'response.reasoning.delta', - 'response.output_item.added', - 'response.content_part.added', - 'response.output_text.delta', - 'response.output_text.delta', - 'response.output_item.added', - 'response.function_call_arguments.delta', - 'response.function_call_arguments.delta', - 'response.output_text.done', - 'response.content_part.done', - 'response.output_item.done', - 'response.output_item.done', - 'response.completed', - ]) - expect(events[1].data).toMatchObject({ delta: 'think' }) - expect(events[4].data).toMatchObject({ delta: 'he' }) - expect(events[5].data).toMatchObject({ delta: 'llo' }) - expect(events[6].data).toMatchObject({ - item: { type: 'function_call', call_id: 'call_1', name: 'lookup' }, - }) - expect(events[13].data).toMatchObject({ - response: { - model: 'test-model', - status: 'completed', - id: expect.stringMatching(/^resp_/), - usage: { - input_tokens: 120, - output_tokens: 7, - total_tokens: 127, - input_tokens_details: { cached_tokens: 30 }, - }, - output: [ - { type: 'reasoning', summary: [{ type: 'summary_text', text: 'think' }] }, - { type: 'message', content: [{ type: 'output_text', text: 'hello' }] }, - { type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{"id":1}' }, - ], - }, - }) - expect((events[13].data as any).response.id).toBe((events[0].data as any).response.id) - }) - - it('marks expanded Hermes MCP Chat SSE tool calls with their Responses namespace', async () => { - const events = await collectEvents(openAiChatSseToResponsesEvents(encodedChunks([ - 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"hermes_studio_lan_devices_scan","arguments":"{}"}}]}}]}\n\n', - 'data: [DONE]\n\n', - ]), codexTarget)) - - expect(events).toEqual(expect.arrayContaining([ - expect.objectContaining({ - type: 'response.output_item.done', - data: expect.objectContaining({ - item: expect.objectContaining({ - type: 'function_call', - call_id: 'call_1', - name: 'hermes_studio_lan_devices_scan', - namespace: 'mcp__hermes_studio', - }), - }), - }), - ])) - }) - - it('normalizes Anthropic Messages SSE text and tool calls to Responses events', async () => { - const events = await collectEvents(anthropicMessagesSseToResponsesEvents(encodedChunks([ - 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":80,"cache_read_input_tokens":20}}}\n\n', - 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"think"}}\n\n', - 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}\n\n', - 'event: content_block_start\ndata: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"lookup","input":{}}}\r\n\r\n', - 'event: content_block_delta\ndata: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\\"id\\":1}"}}\n\n', - 'event: message_delta\ndata: {"type":"message_delta","usage":{"output_tokens":9}}\n\n', - ]), codexTarget)) - - expect(events.map(event => event.type)).toEqual([ - 'response.created', - 'response.reasoning.delta', - 'response.output_item.added', - 'response.content_part.added', - 'response.output_text.delta', - 'response.output_item.added', - 'response.function_call_arguments.delta', - 'response.output_text.done', - 'response.content_part.done', - 'response.output_item.done', - 'response.output_item.done', - 'response.completed', - ]) - expect(events[1].data).toMatchObject({ delta: 'think' }) - expect(events[2].data).toMatchObject({ item: { id: 'msg_msg_1' } }) - expect(events[5].data).toMatchObject({ - item: { type: 'function_call', call_id: 'toolu_1', name: 'lookup' }, - }) - expect(events[11].data).toMatchObject({ - response: { - id: 'msg_1', - usage: { input_tokens: 80, cache_read_input_tokens: 20, output_tokens: 9 }, - output: [ - { type: 'reasoning', summary: [{ type: 'summary_text', text: 'think' }] }, - { type: 'message', content: [{ type: 'output_text', text: 'hi' }] }, - { type: 'function_call', call_id: 'toolu_1', name: 'lookup', arguments: '{"id":1}' }, - ], - }, - }) - }) - - it('marks expanded Hermes MCP Anthropic SSE tool calls with their Responses namespace', async () => { - const events = await collectEvents(anthropicMessagesSseToResponsesEvents(encodedChunks([ - 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_1"}}\n\n', - 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"hermes_studio_lan_devices_list","input":{}}}\n\n', - 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"profile\\":\\"default\\"}"}}\n\n', - 'event: message_stop\ndata: {"type":"message_stop"}\n\n', - ]), codexTarget)) - - expect(events).toEqual(expect.arrayContaining([ - expect.objectContaining({ - type: 'response.output_item.done', - data: expect.objectContaining({ - item: expect.objectContaining({ - type: 'function_call', - call_id: 'toolu_1', - name: 'hermes_studio_lan_devices_list', - namespace: 'mcp__hermes_studio', - }), - }), - }), - ])) - }) - - it('passes native Responses SSE events through as canonical events', async () => { - const events = await collectEvents(openAiResponsesSseToResponsesEvents(encodedChunks([ - 'event: response.created\r\ndata: {"response":{"id":"resp_1"}}\r\n\r\n', - 'data: {"type":"response.output_text.delta","delta":"hi"}\n\n', - 'data: [DONE]\n\n', - ]))) - - expect(events).toEqual([ - { - type: 'response.created', - data: { type: 'response.created', response: { id: 'resp_1' } }, - }, - { - type: 'response.output_text.delta', - data: { type: 'response.output_text.delta', delta: 'hi' }, - }, - ]) - }) -}) - -describe('agent runner Anthropic adapters', () => { - it('converts Anthropic messages to OpenAI Chat with reasoning_content', () => { - const body = { - system: 'system text', - max_tokens: 32, - temperature: 0.1, - messages: [ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'need tool' }, - { type: 'tool_use', id: 'toolu_1', name: 'lookup', input: { id: 1 } }, - ], - }, - { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok' }] }, - ], - tools: [{ name: 'lookup', description: 'Lookup', input_schema: { type: 'object' } }], - } - - expect(anthropicToOpenAiChat(body, anthropicTarget)).toMatchObject({ - model: 'deepseek-reasoner', - max_tokens: 32, - temperature: 0.1, - stream: false, - messages: [ - { role: 'system', content: 'system text' }, - { role: 'user', content: 'hello' }, - { - role: 'assistant', - content: null, - reasoning_content: 'need tool', - tool_calls: [{ - id: 'toolu_1', - type: 'function', - function: { name: 'lookup', arguments: '{"id":1}' }, - }], - }, - { role: 'tool', tool_call_id: 'toolu_1', content: 'ok' }, - ], - tools: [{ - type: 'function', - function: { name: 'lookup', description: 'Lookup', parameters: { type: 'object' } }, - }], - }) - }) - - it('converts Anthropic messages to Responses input', () => { - expect(anthropicToOpenAiResponses({ - system: 'system text', - max_tokens: 64, - messages: [ - { role: 'user', content: 'hello' }, - { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'lookup', input: { id: 1 } }] }, - { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok' }] }, - ], - tools: [{ name: 'lookup', input_schema: { type: 'object' } }], - }, anthropicTarget, true)).toMatchObject({ - model: 'deepseek-reasoner', - instructions: 'system text', - max_output_tokens: 64, - stream: true, - store: false, - input: [ - { role: 'user', content: 'hello' }, - { type: 'function_call', call_id: 'toolu_1', name: 'lookup', arguments: '{"id":1}' }, - { type: 'function_call_output', call_id: 'toolu_1', output: 'ok' }, - ], - tools: [{ type: 'function', name: 'lookup', parameters: { type: 'object' } }], - }) - }) - - it('converts OpenAI Chat responses to Anthropic messages', () => { - expect(openAiToAnthropicMessage({ - id: 'chatcmpl_1', - choices: [{ - finish_reason: 'tool_calls', - message: { - reasoning_content: 'thinking', - content: 'hi', - tool_calls: [{ id: 'call_1', function: { name: 'lookup', arguments: '{"id":1}' } }], - }, - }], - usage: { prompt_tokens: 3, completion_tokens: 4 }, - }, anthropicTarget)).toMatchObject({ - id: 'chatcmpl_1', - type: 'message', - role: 'assistant', - model: 'deepseek-reasoner', - content: [ - { type: 'thinking', thinking: 'thinking' }, - { type: 'text', text: 'hi' }, - { type: 'tool_use', id: 'call_1', name: 'lookup', input: { id: 1 } }, - ], - stop_reason: 'tool_use', - usage: { input_tokens: 3, output_tokens: 4 }, - }) - }) - - it('converts Responses output to Anthropic messages', () => { - expect(openAiResponsesToAnthropicMessage({ - id: 'resp_1', - status: 'completed', - output: [ - { type: 'message', content: [{ type: 'output_text', text: 'hi' }] }, - { type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{"id":1}' }, - ], - usage: { input_tokens: 5, output_tokens: 6 }, - }, anthropicTarget)).toMatchObject({ - id: 'resp_1', - type: 'message', - role: 'assistant', - model: 'deepseek-reasoner', - content: [ - { type: 'text', text: 'hi' }, - { type: 'tool_use', id: 'call_1', name: 'lookup', input: { id: 1 } }, - ], - stop_reason: 'tool_use', - usage: { input_tokens: 5, output_tokens: 6 }, - }) - }) -}) - -describe('agent runner Anthropic stream adapters', () => { - it('normalizes OpenAI Chat SSE to Anthropic Messages events', async () => { - const events = await collectAnthropicEvents(openAiChatSseToAnthropicEvents(encodedChunks([ - 'data: {"choices":[{"delta":{"reasoning_content":"think"}}]}\n\n', - 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', - 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"lookup","arguments":"{\\"id\\":"}}]}}]}\r\n\r\n', - 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1}"}}]},"finish_reason":"tool_calls"}]}\n\n', - 'data: {"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":7}}\n\n', - ]), anthropicTarget)) - - expect(events.map(event => event.type)).toEqual([ - 'message_start', - 'content_block_start', - 'content_block_delta', - 'content_block_stop', - 'content_block_start', - 'content_block_delta', - 'content_block_stop', - 'content_block_start', - 'content_block_delta', - 'content_block_delta', - 'content_block_stop', - 'message_delta', - 'message_stop', - ]) - expect(events[1].data).toMatchObject({ content_block: { type: 'thinking' } }) - expect(events[2].data).toMatchObject({ delta: { type: 'thinking_delta', thinking: 'think' } }) - expect(events[5].data).toMatchObject({ delta: { type: 'text_delta', text: 'hi' } }) - expect(events[7].data).toMatchObject({ content_block: { type: 'tool_use', id: 'call_1', name: 'lookup' } }) - expect(events[11].data).toMatchObject({ - delta: { stop_reason: 'tool_use', stop_sequence: null }, - usage: { output_tokens: 7 }, - }) - }) - - it('normalizes Responses SSE to Anthropic Messages events', async () => { - const events = await collectAnthropicEvents(openAiResponsesSseToAnthropicEvents(encodedChunks([ - 'data: {"type":"response.created","response":{"id":"resp_1"}}\n\n', - 'data: {"type":"response.output_text.delta","delta":"hi"}\n\n', - 'data: {"type":"response.output_text.done"}\n\n', - 'data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_1","name":"lookup"}}\n\n', - 'data: {"type":"response.function_call_arguments.delta","item_id":"call_1","delta":"{\\"id\\":1}"}\n\n', - 'data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\\"id\\":1}"}}\n\n', - 'data: {"type":"response.completed","response":{"status":"completed","usage":{"output_tokens":3}}}\n\n', - ]), anthropicTarget)) - - expect(events.map(event => event.type)).toEqual([ - 'message_start', - 'content_block_start', - 'content_block_delta', - 'content_block_stop', - 'content_block_start', - 'content_block_delta', - 'content_block_stop', - 'message_delta', - 'message_stop', - ]) - expect(events[2].data).toMatchObject({ delta: { type: 'text_delta', text: 'hi' } }) - expect(events[4].data).toMatchObject({ content_block: { type: 'tool_use', id: 'call_1', name: 'lookup' } }) - expect(events[5].data).toMatchObject({ delta: { type: 'input_json_delta', partial_json: '{"id":1}' } }) - expect(events[7].data).toMatchObject({ - delta: { stop_reason: 'tool_use', stop_sequence: null }, - usage: { output_tokens: 3 }, - }) - }) -}) diff --git a/tests/server/agent-runner-gateway.test.ts b/tests/server/agent-runner-gateway.test.ts deleted file mode 100644 index 75deadc..0000000 --- a/tests/server/agent-runner-gateway.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { AgentRunGateway, ProviderApiError } from '../../packages/server/src/services/agent-runner/gateway' - -afterEach(() => { - vi.unstubAllGlobals() -}) - -describe('agent run gateway', () => { - it('posts JSON requests with bearer auth and custom headers', async () => { - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })) - vi.stubGlobal('fetch', fetchMock) - - const result = await new AgentRunGateway().completeJson({ - url: 'https://api.example.com/v1/messages', - apiKey: 'sk-test', - headers: { 'x-api-key': 'sk-test' }, - body: { model: 'm' }, - }) - - expect(result).toEqual({ ok: true }) - expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/v1/messages', expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - Authorization: 'Bearer sk-test', - 'Content-Type': 'application/json', - 'x-api-key': 'sk-test', - }), - body: '{"model":"m"}', - })) - }) - - it('throws structured provider errors', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ - error: { message: 'bad key' }, - }), { status: 401, headers: { 'Content-Type': 'application/json' } }))) - - await expect(new AgentRunGateway().completeJson({ - url: 'https://api.example.com/v1/messages', - apiKey: 'sk-test', - body: {}, - })).rejects.toMatchObject({ - name: 'ProviderApiError', - status: 401, - providerError: { error: { message: 'bad key' } }, - message: 'bad key', - } satisfies Partial) - }) - - it('returns provider byte streams', async () => { - const encoder = new TextEncoder() - vi.stubGlobal('fetch', vi.fn(async () => new Response(new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode('chunk')) - controller.close() - }, - }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }))) - - const stream = await new AgentRunGateway().streamBytes({ - url: 'https://api.example.com/v1/responses', - apiKey: 'sk-test', - body: { stream: true }, - }) - - const chunks: string[] = [] - for await (const chunk of stream) chunks.push(new TextDecoder().decode(chunk)) - expect(chunks).toEqual(['chunk']) - }) - - it('rejects JSON error bodies returned with HTTP 200 for stream requests', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ - code: 500, - msg: '404 NOT_FOUND', - success: false, - }), { status: 200, headers: { 'Content-Type': 'application/json' } }))) - - await expect(new AgentRunGateway().streamBytes({ - url: 'https://api.example.com/v1/messages', - apiKey: 'sk-test', - body: { stream: true }, - })).rejects.toMatchObject({ - name: 'ProviderApiError', - status: 200, - providerError: { code: 500, msg: '404 NOT_FOUND', success: false }, - } satisfies Partial) - }) -}) diff --git a/tests/server/agent-runner-utils.test.ts b/tests/server/agent-runner-utils.test.ts deleted file mode 100644 index db3a62c..0000000 --- a/tests/server/agent-runner-utils.test.ts +++ /dev/null @@ -1,1676 +0,0 @@ -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({ - 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( - 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( - 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) => { - 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', - }), - })) - }) -}) diff --git a/tests/server/api-docs-controller.test.ts b/tests/server/api-docs-controller.test.ts index a76ad0c..e5d2cb6 100644 --- a/tests/server/api-docs-controller.test.ts +++ b/tests/server/api-docs-controller.test.ts @@ -30,6 +30,6 @@ describe('api docs controller', () => { ) expect( ctx.body.paths['/api/chat-run/runs'].post.requestBody.content['application/json'].schema.properties.source.enum, - ).toEqual(['cli', 'coding_agent', 'global_agent']) + ).toEqual(['cli', 'global_agent', 'workflow']) }) }) diff --git a/tests/server/chat-run-bridge-readiness.test.ts b/tests/server/chat-run-bridge-readiness.test.ts index 82cb1be..fbb8095 100644 --- a/tests/server/chat-run-bridge-readiness.test.ts +++ b/tests/server/chat-run-bridge-readiness.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const handleBridgeRunMock = vi.hoisted(() => vi.fn(async () => {})) const resumeBridgeRunMock = vi.hoisted(() => vi.fn(async () => {})) -const handleCodingAgentRunMock = vi.hoisted(() => vi.fn(async () => {})) const loadSessionStateFromDbMock = vi.hoisted(() => vi.fn()) const ensureReadyMock = vi.hoisted(() => vi.fn()) const getRuntimeStateMock = vi.hoisted(() => vi.fn()) @@ -24,10 +23,6 @@ vi.mock('../../packages/server/src/services/hermes/run-chat/load-state', () => ( resolveRunSource: vi.fn((source?: string) => source || 'cli'), })) -vi.mock('../../packages/server/src/services/hermes/run-chat/handle-coding-agent-run', () => ({ - handleCodingAgentRun: handleCodingAgentRunMock, -})) - vi.mock('../../packages/server/src/services/hermes/run-chat/session-command', () => ({ handleSessionCommand: vi.fn(), isSessionCommand: vi.fn(() => false), @@ -109,7 +104,6 @@ describe('ensureBridgeReadyForChatRun', () => { bridgeMock.statusIfLoaded.mockReset() handleBridgeRunMock.mockReset() resumeBridgeRunMock.mockReset() - handleCodingAgentRunMock.mockReset() loadSessionStateFromDbMock.mockReset() getSessionMock.mockReset() getSessionMock.mockImplementation((sessionId?: string) => sessionId @@ -180,7 +174,6 @@ describe('ChatRunSocket bridge readiness gating', () => { bridgeMock.statusIfLoaded.mockReset() handleBridgeRunMock.mockReset() resumeBridgeRunMock.mockReset() - handleCodingAgentRunMock.mockReset() loadSessionStateFromDbMock.mockReset() ensureReadyMock.mockResolvedValue({ reachable: true, @@ -263,7 +256,7 @@ describe('ChatRunSocket bridge readiness gating', () => { expect(socket.emit).not.toHaveBeenCalledWith('run.failed', expect.anything()) }) - it('routes global coding-agent runs through the coding-agent path while preserving session source', async () => { + it('routes runs with an unknown legacy source through the bridge run path', async () => { const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat') const { handlers, io, socket } = makeServerHarness() const server = new ChatRunSocket(io as any) @@ -272,24 +265,18 @@ describe('ChatRunSocket bridge readiness gating', () => { await handlers.get('run')?.({ input: 'hello', session_id: 'session-1', - source: 'coding_agent', + // Sessions stored before Phase 003 can still carry the retired source. + source: 'retired_source', session_source: 'global_agent', - coding_agent_id: 'codex', }) - expect(ensureReadyMock).not.toHaveBeenCalled() - expect(handleCodingAgentRunMock).toHaveBeenCalledWith( - expect.anything(), - socket, - expect.objectContaining({ - source: 'coding_agent', - session_source: 'global_agent', - coding_agent_id: 'codex', - }), - 'default', - expect.any(Map), - ) - expect(handleBridgeRunMock).not.toHaveBeenCalled() + expect(ensureReadyMock).toHaveBeenCalledTimes(1) + expect(handleBridgeRunMock).toHaveBeenCalledTimes(1) + expect(handleBridgeRunMock.mock.calls[0][2]).toEqual(expect.objectContaining({ + source: 'retired_source', + session_source: 'global_agent', + })) + expect(socket.emit).not.toHaveBeenCalledWith('run.failed', expect.anything()) }) it('continues with remaining queued bridge runs when readiness fails before a dequeued run starts', async () => { @@ -496,10 +483,17 @@ describe('ChatRunSocket bridge readiness gating', () => { expect((server as any).bridgeResumePolls.size).toBe(0) }) - it('does not query Hermes bridge status when resuming a coding agent session', async () => { + it('reattaches a session stored with a retired source on the bridge', async () => { getSessionMock.mockImplementation((sessionId?: string) => sessionId - ? { id: sessionId, profile: 'default', source: 'coding_agent', model: 'codex', provider: 'codex' } + ? { id: sessionId, profile: 'default', source: 'retired_source', model: 'gpt-test', provider: 'openai' } : undefined) + bridgeMock.statusIfLoaded.mockResolvedValueOnce({ + ok: true, + exists: true, + running: true, + current_run_id: 'run-legacy', + loaded: true, + }) loadSessionStateFromDbMock.mockResolvedValueOnce({ messages: [], isWorking: false, @@ -507,7 +501,7 @@ describe('ChatRunSocket bridge readiness gating', () => { runId: undefined, activeRunMarker: undefined, profile: 'default', - source: 'coding_agent', + source: 'retired_source', events: [], queue: [], }) @@ -518,15 +512,25 @@ describe('ChatRunSocket bridge readiness gating', () => { ;(server as any).onConnection(socket) await handlers.get('resume')?.({ session_id: 'session-1' }) - expect(ensureReadyMock).not.toHaveBeenCalled() - expect(bridgeMock.statusIfLoaded).not.toHaveBeenCalled() - expect(resumeBridgeRunMock).not.toHaveBeenCalled() + expect(bridgeMock.statusIfLoaded).toHaveBeenCalledWith('session-1', 'default', { timeoutMs: 1000 }) + expect(resumeBridgeRunMock).toHaveBeenCalledWith( + expect.anything(), + socket, + expect.objectContaining({ sessionId: 'session-1', runId: 'run-legacy' }), + expect.any(Map), + bridgeMock, + expect.any(Function), + ) expect(emitted.some(({ event }) => event === 'run.reattach_failed')).toBe(false) expect(socket.emit).toHaveBeenCalledWith('resumed', expect.objectContaining({ session_id: 'session-1', - isWorking: false, + isWorking: true, events: [], })) + expect((server as any).sessionMap.get('session-1')).toEqual(expect.objectContaining({ + isWorking: true, + source: 'cli', + })) }) it('reattaches global-agent bridge runs with the global source preserved', async () => { diff --git a/tests/server/coding-agent-baseline.test.ts b/tests/server/coding-agent-baseline.test.ts deleted file mode 100644 index 694afca..0000000 --- a/tests/server/coding-agent-baseline.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const managerMock = vi.hoisted(() => ({ - runIdForSession: vi.fn(), - isSessionLaunchCompatible: vi.fn(), - isSessionProcessing: vi.fn(), - stop: vi.fn(), -})) -const startCodingAgentRunMock = vi.hoisted(() => vi.fn()) -const sendCodingAgentRunInputMock = vi.hoisted(() => vi.fn()) -const writeModelRunProfileTokenMock = vi.hoisted(() => vi.fn(async () => undefined)) -const getSystemPromptMock = vi.hoisted(() => vi.fn(() => 'system prompt')) -const getSessionMock = vi.hoisted(() => vi.fn()) - -vi.mock('../../packages/server/src/services/agent-runner/coding-agent-run-manager', () => ({ - codingAgentRunManager: managerMock, -})) -vi.mock('../../packages/server/src/services/coding-agents', () => ({ - startCodingAgentRun: startCodingAgentRunMock, - sendCodingAgentRunInput: sendCodingAgentRunInputMock, -})) -vi.mock('../../packages/server/src/services/hermes/run-chat/model-run-prompt', () => ({ - writeModelRunProfileToken: writeModelRunProfileTokenMock, -})) -vi.mock('../../packages/server/src/lib/llm-prompt', () => ({ - getSystemPrompt: getSystemPromptMock, -})) -vi.mock('../../packages/server/src/db/hermes/session-store', () => ({ - getSession: getSessionMock, -})) - -function state() { - return { - messages: [], - isWorking: false, - isAborting: false, - events: [], - queue: [], - } as any -} - -function socket() { - return { - data: {}, - join: vi.fn(), - emit: vi.fn(), - } -} - -describe('coding-agent dispatch baseline', () => { - beforeEach(() => { - vi.clearAllMocks() - managerMock.runIdForSession.mockReturnValue(undefined) - managerMock.isSessionLaunchCompatible.mockReturnValue(true) - managerMock.isSessionProcessing.mockReturnValue(false) - startCodingAgentRunMock.mockResolvedValue({ agentSessionId: 'agent-session-1' }) - sendCodingAgentRunInputMock.mockResolvedValue({ runId: 'agent-session-1' }) - writeModelRunProfileTokenMock.mockResolvedValue(undefined) - getSystemPromptMock.mockReturnValue('system prompt') - getSessionMock.mockReturnValue(null) - }) - - it('dispatches an explicit coding_agent_id and passes local proxy fields through', async () => { - const { handleCodingAgentRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-coding-agent-run') - const runState = state() - const sessionMap = new Map([['session-1', runState]]) - const runSocket = socket() - - await handleCodingAgentRun({} as any, runSocket as any, { - session_id: 'session-1', - input: 'hello codex', - source: 'coding_agent', - coding_agent_id: 'codex', - baseUrl: 'http://127.0.0.1:1234', - apiKey: 'local-test-key', - apiMode: 'responses', - }, 'default', sessionMap as any) - - expect(startCodingAgentRunMock).toHaveBeenCalledWith('codex', expect.objectContaining({ - sessionId: 'session-1', - mode: 'scoped', - profile: 'default', - baseUrl: 'http://127.0.0.1:1234', - apiKey: 'local-test-key', - apiMode: 'responses', - }), runState) - expect(sendCodingAgentRunInputMock).toHaveBeenCalledWith('session-1', 'hello codex', 'system prompt') - }) - - it('uses agent_id as an alias when coding_agent_id is omitted', async () => { - const { handleCodingAgentRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-coding-agent-run') - const runState = state() - const sessionMap = new Map([['session-1', runState]]) - - await handleCodingAgentRun({} as any, socket() as any, { - session_id: 'session-1', - input: 'hello alias', - source: 'coding_agent', - agent_id: 'codex', - base_url: 'http://127.0.0.1:2345', - api_key: 'alias-test-key', - api_mode: 'chat_completions', - }, 'default', sessionMap as any) - - expect(startCodingAgentRunMock).toHaveBeenCalledWith('codex', expect.objectContaining({ - baseUrl: 'http://127.0.0.1:2345', - apiKey: 'alias-test-key', - apiMode: 'chat_completions', - }), runState) - }) - - it('stringifies ContentBlock input before sending it to the coding agent', async () => { - const { handleCodingAgentRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-coding-agent-run') - const blocks = [{ type: 'text', text: 'hello blocks' }] - - await handleCodingAgentRun({} as any, socket() as any, { - session_id: 'session-1', - input: blocks as any, - coding_agent_id: 'claude-code', - }, 'default', new Map([['session-1', state()]]) as any) - - expect(sendCodingAgentRunInputMock).toHaveBeenCalledWith('session-1', JSON.stringify(blocks), 'system prompt') - }) - - it('fails fast when session_id is missing', async () => { - const { handleCodingAgentRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-coding-agent-run') - const runSocket = socket() - - await handleCodingAgentRun({} as any, runSocket as any, { - input: 'missing session', - coding_agent_id: 'codex', - }, 'default', new Map() as any) - - expect(runSocket.emit).toHaveBeenCalledWith('run.failed', { - event: 'run.failed', - error: 'session_id is required for coding agent runs', - }) - expect(startCodingAgentRunMock).not.toHaveBeenCalled() - }) -}) diff --git a/tests/server/coding-agent-resume-config.test.ts b/tests/server/coding-agent-resume-config.test.ts deleted file mode 100644 index 084070f..0000000 --- a/tests/server/coding-agent-resume-config.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from 'fs' -import { tmpdir } from 'os' -import { join } from 'path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const getSessionMock = vi.fn() -const updateSessionMock = vi.fn() -const readConfigYamlForProfileMock = vi.fn() -const safeReadFileMock = vi.fn() -const startRunMock = vi.fn() - -vi.doMock('../../packages/server/src/db/hermes/session-store', () => ({ - getSession: getSessionMock, - updateSession: updateSessionMock, -})) - -vi.doMock('../../packages/server/src/services/config-helpers', () => ({ - PROVIDER_ENV_MAP: { - deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: 'DEEPSEEK_BASE_URL' }, - }, - readConfigYamlForProfile: readConfigYamlForProfileMock, - safeReadFile: safeReadFileMock, -})) - -vi.doMock('../../packages/server/src/services/hermes/hermes-profile', () => ({ - getProfileDir: (profile: string) => `/tmp/hermes-profile/${profile}`, -})) - -vi.doMock('../../packages/server/src/services/agent-runner/coding-agent-run-manager', () => ({ - codingAgentRunManager: { - start: startRunMock, - }, -})) - -const homes: string[] = [] - -function makeHome() { - const home = mkdtempSync(join(tmpdir(), 'hermes-coding-agent-resume-')) - homes.push(home) - process.env.HERMES_WEB_UI_HOME = home - return home -} - -describe('coding agent resumed session config', () => { - beforeEach(() => { - vi.resetModules() - getSessionMock.mockReset() - updateSessionMock.mockReset() - readConfigYamlForProfileMock.mockReset() - safeReadFileMock.mockReset() - startRunMock.mockReset() - startRunMock.mockReturnValue({ runId: 'agent-session-1', pid: 0 }) - }) - - afterEach(() => { - delete process.env.HERMES_WEB_UI_HOME - for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }) - }) - - it('rebuilds Claude scoped proxy credentials from stored provider config after restart', async () => { - const home = makeHome() - getSessionMock.mockReturnValue({ - id: 'session-1', - profile: 'default', - source: 'coding_agent', - agent: 'claude', - agent_session_id: 'agent-session-1', - provider: 'custom:corp-claude', - model: 'claude-sonnet-test', - }) - readConfigYamlForProfileMock.mockResolvedValue({ - custom_providers: [{ - name: 'corp-claude', - base_url: 'https://provider.example/anthropic', - api_key: 'sk-upstream', - model: 'claude-sonnet-test', - api_mode: 'anthropic_messages', - }], - }) - safeReadFileMock.mockResolvedValue('') - - const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents') - await startCodingAgentRun('claude-code', { sessionId: 'session-1' }) - - expect(startRunMock).toHaveBeenCalledWith(expect.objectContaining({ - agentSessionId: 'agent-session-1', - provider: 'custom:corp-claude', - model: 'claude-sonnet-test', - sessionId: 'session-1', - })) - const launch = startRunMock.mock.calls[0][0] - expect(launch.env.ANTHROPIC_API_KEY).toMatch(/^hwui_/) - expect(launch.env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN') - expect(launch.env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/api\/claude-code-proxy\/.+$/) - const settings = JSON.parse(readFileSync(join(home, 'coding-agent', 'model', 'default', 'custom_corp-claude', 'claude-code', 'settings.json'), 'utf-8')) - expect(settings.env.ANTHROPIC_API_KEY).toBe(launch.env.ANTHROPIC_API_KEY) - }) - - it('recovers legacy sanitized custom provider keys from existing sessions', async () => { - const home = makeHome() - getSessionMock.mockReturnValue({ - id: 'session-1', - profile: 'default', - source: 'coding_agent', - agent: 'claude', - agent_session_id: 'agent-session-1', - provider: 'custom_glm-coding-plan', - model: 'glm-5-turbo', - }) - readConfigYamlForProfileMock.mockResolvedValue({ - custom_providers: [{ - name: 'glm-coding-plan', - base_url: 'https://api.z.ai/api/anthropic', - api_key: 'sk-glm', - model: 'glm-5-turbo', - }], - }) - safeReadFileMock.mockResolvedValue('') - - const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents') - const result = await startCodingAgentRun('claude-code', { sessionId: 'session-1' }) - - expect(result.provider).toBe('custom:glm-coding-plan') - expect(updateSessionMock).toHaveBeenCalledWith('session-1', expect.objectContaining({ - provider: 'custom:glm-coding-plan', - agent_native_session_id: expect.any(String), - })) - const launch = startRunMock.mock.calls[0][0] - expect(launch.env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/api\/claude-code-proxy\/.+$/) - expect(Buffer.from(launch.env.ANTHROPIC_BASE_URL.split('/').pop() || '', 'base64url').toString('utf8')).toContain('anthropic_messages') - const settings = JSON.parse(readFileSync(join(home, 'coding-agent', 'model', 'default', 'custom_glm-coding-plan', 'claude-code', 'settings.json'), 'utf-8')) - expect(settings.env.ANTHROPIC_API_KEY).toMatch(/^hwui_/) - }) - - it('preserves the stored workspace when a coding agent session switches provider and model', async () => { - const home = makeHome() - const originalWorkspace = join(home, 'coding-agent', 'workspace', 'default', 'openrouter') - getSessionMock.mockReturnValue({ - id: 'session-1', - profile: 'default', - source: 'coding_agent', - agent: 'codex', - agent_session_id: 'agent-session-1', - agent_native_session_id: 'old-native-thread', - provider: 'openrouter', - model: 'old-model', - workspace: originalWorkspace, - }) - - const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents') - await startCodingAgentRun('codex', { - sessionId: 'session-1', - provider: 'deepseek', - model: 'deepseek-reasoner', - baseUrl: 'https://api.deepseek.com/v1', - apiKey: 'sk-test', - apiMode: 'chat_completions', - }) - - const launch = startRunMock.mock.calls[0][0] - expect(launch.workspaceDir).toBe(originalWorkspace) - expect(launch.agentNativeSessionId).toBe('') - expect(launch.nativeResume).toBe(false) - expect(updateSessionMock).toHaveBeenCalledWith('session-1', expect.objectContaining({ - provider: 'deepseek', - model: 'deepseek-reasoner', - agent_native_session_id: '', - workspace: originalWorkspace, - })) - }) - - it('ignores a stale builtin base URL when the requested provider changed', async () => { - const home = makeHome() - getSessionMock.mockReturnValue({ - id: 'session-1', - profile: 'default', - source: 'coding_agent', - agent: 'codex', - agent_session_id: 'agent-session-1', - provider: 'deepseek', - model: 'deepseek-v4-pro', - }) - readConfigYamlForProfileMock.mockResolvedValue({}) - safeReadFileMock.mockResolvedValue('DEEPSEEK_API_KEY=sk-deepseek\n') - - const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents') - await startCodingAgentRun('codex', { - sessionId: 'session-1', - provider: 'deepseek', - model: 'deepseek-v4-pro', - baseUrl: 'https://api.xiaomimimo.com/v1', - apiKey: 'sk-xiaomi', - apiMode: 'chat_completions', - }) - - const config = readFileSync(join(home, 'coding-agent', 'model', 'default', 'deepseek', 'codex', 'config.toml'), 'utf-8') - const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || '' - const routeParts = Buffer.from(routeKey, 'base64url').toString('utf8').split('\0') - expect(routeParts).toEqual(expect.arrayContaining([ - 'deepseek', - 'deepseek-v4-pro', - 'chat_completions', - 'https://api.deepseek.com', - ])) - expect(routeParts).not.toContain('https://api.xiaomimimo.com/v1') - }) - - it('resumes Claude with a stored native session id after service restart', async () => { - makeHome() - getSessionMock.mockReturnValue({ - id: 'session-1', - profile: 'default', - source: 'coding_agent', - agent: 'claude', - agent_session_id: 'agent-session-1', - agent_native_session_id: '11111111-1111-4111-8111-111111111111', - provider: 'custom:corp-claude', - model: 'claude-sonnet-test', - }) - readConfigYamlForProfileMock.mockResolvedValue({ - custom_providers: [{ - name: 'corp-claude', - base_url: 'https://provider.example/anthropic', - api_key: 'sk-upstream', - model: 'claude-sonnet-test', - api_mode: 'anthropic_messages', - }], - }) - safeReadFileMock.mockResolvedValue('') - - const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents') - await startCodingAgentRun('claude-code', { sessionId: 'session-1' }) - - expect(startRunMock).toHaveBeenCalledWith(expect.objectContaining({ - agentNativeSessionId: '11111111-1111-4111-8111-111111111111', - nativeResume: true, - })) - }) - - it('does not resume a stored scoped Codex native session when launching global mode', async () => { - makeHome() - getSessionMock.mockReturnValue({ - id: 'session-1', - profile: 'default', - source: 'coding_agent', - agent: 'codex', - agent_mode: 'scoped', - agent_session_id: 'agent-session-1', - agent_native_session_id: 'codex-scoped-thread', - provider: 'custom:glm-coding-plan', - model: 'glm-5-turbo', - }) - readConfigYamlForProfileMock.mockResolvedValue({}) - safeReadFileMock.mockResolvedValue('') - - const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents') - const result = await startCodingAgentRun('codex', { sessionId: 'session-1', mode: 'global' }) - - expect(result).toEqual(expect.objectContaining({ - mode: 'global', - provider: 'global', - model: '', - })) - expect(startRunMock).toHaveBeenCalledWith(expect.objectContaining({ - agentSessionId: 'agent-session-1', - agentNativeSessionId: '', - nativeResume: false, - provider: 'global', - model: '', - env: {}, - args: [], - })) - expect(updateSessionMock).toHaveBeenCalledWith('session-1', expect.objectContaining({ - agent_mode: 'global', - agent_native_session_id: '', - provider: 'global', - model: '', - })) - }) - - - it('prefers a stored coding-agent API mode when resuming without an explicit mode', async () => { - const home = makeHome() - getSessionMock.mockReturnValue({ - id: 'session-1', - profile: 'default', - source: 'coding_agent', - agent: 'codex', - agent_session_id: 'agent-session-1', - provider: 'fun-codex', - model: 'gpt-5.5', - api_mode: 'chat_completions', - }) - readConfigYamlForProfileMock.mockResolvedValue({}) - safeReadFileMock.mockResolvedValue('') - - const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents') - await startCodingAgentRun('codex', { - sessionId: 'session-1', - baseUrl: 'https://api.apikey.fun/v1', - apiKey: 'sk-test', - }) - - const launch = startRunMock.mock.calls[0][0] - expect(launch.apiMode).toBe('chat_completions') - const config = readFileSync(join(home, 'coding-agent', 'model', 'default', 'fun-codex', 'codex', 'config.toml'), 'utf-8') - const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || '' - expect(Buffer.from(routeKey, 'base64url').toString('utf8')).toContain('chat_completions') - expect(updateSessionMock).toHaveBeenCalledWith('session-1', expect.objectContaining({ - api_mode: 'chat_completions', - })) - }) - - it('rejects OAuth/subscription providers for scoped coding-agent launches', async () => { - makeHome() - getSessionMock.mockReturnValue(null) - readConfigYamlForProfileMock.mockResolvedValue({}) - safeReadFileMock.mockResolvedValue('') - - const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents') - await expect(startCodingAgentRun('codex', { - sessionId: 'session-1', - mode: 'scoped', - profile: 'default', - provider: 'copilot', - model: 'gpt-5.5', - baseUrl: 'https://api.githubcopilot.com', - apiKey: 'oauth-token', - apiMode: 'codex_responses', - })).rejects.toThrow('does not support OAuth/subscription providers') - expect(startRunMock).not.toHaveBeenCalled() - }) - - it('fails clearly instead of launching Claude without scoped credentials', async () => { - makeHome() - getSessionMock.mockReturnValue({ - id: 'session-1', - profile: 'default', - source: 'coding_agent', - agent: 'claude', - agent_session_id: 'agent-session-1', - provider: 'custom:missing', - model: 'claude-sonnet-test', - }) - readConfigYamlForProfileMock.mockResolvedValue({ custom_providers: [] }) - safeReadFileMock.mockResolvedValue('') - - const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents') - await expect(startCodingAgentRun('claude-code', { sessionId: 'session-1' })) - .rejects.toThrow('Coding agent provider credentials are missing') - expect(startRunMock).not.toHaveBeenCalled() - }) - - it('resolves custom-provider credentials from key_env on continuation', async () => { - const home = makeHome() - getSessionMock.mockReturnValue({ - id: 'session-1', - profile: 'default', - source: 'coding_agent', - agent: 'claude', - agent_session_id: 'agent-session-1', - provider: 'custom:sensenova', - model: 'deepseek-v4-flash', - }) - readConfigYamlForProfileMock.mockResolvedValue({ - custom_providers: [{ - name: 'sensenova', - base_url: 'https://api.sensenova.cn/v1', - key_env: 'SENSENOVA_API_KEY', - model: 'deepseek-v4-flash', - }], - }) - safeReadFileMock.mockResolvedValue('SENSENOVA_API_KEY=sk-from-env\n') - - const { startCodingAgentRun } = await import('../../packages/server/src/services/coding-agents') - await expect(startCodingAgentRun('claude-code', { sessionId: 'session-1' })).resolves.toEqual( - expect.objectContaining({ - provider: 'custom:sensenova', - }), - ) - expect(startRunMock).toHaveBeenCalled() - expect(home).toBeTruthy() - }) -}) diff --git a/tests/server/coding-agent-run-manager-windows.test.ts b/tests/server/coding-agent-run-manager-windows.test.ts deleted file mode 100644 index 7f0302a..0000000 --- a/tests/server/coding-agent-run-manager-windows.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const testState = vi.hoisted(() => { - class TestEmitter { - private readonly handlers = new Map 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() - }) -}) diff --git a/tests/server/coding-agents-desktop-path.test.ts b/tests/server/coding-agents-desktop-path.test.ts deleted file mode 100644 index c3a9133..0000000 --- a/tests/server/coding-agents-desktop-path.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { delimiter } from 'path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const execState = vi.hoisted(() => { - const calls: Array<{ command: string; args: string[]; options: any }> = [] - const execFile = vi.fn() - ;(execFile as any)[Symbol.for('nodejs.util.promisify.custom')] = async (command: string, args: string[], options: any) => { - calls.push({ command, args, options }) - - if (command === '/bin/zsh') { - return { - stdout: ['/Users/example/.npm-global/bin', '/opt/homebrew/bin', '/usr/bin'].join(':'), - stderr: '', - } - } - - if (command === 'which' && args[0] === '-a' && args[1] === 'npm') { - throw Object.assign(new Error('npm not found'), { code: 'ENOENT' }) - } - - if (command === 'which' && args[0] === '-a' && args[1] === 'claude') { - if (!String(options.env?.PATH || '').split(':').includes('/Users/example/.npm-global/bin')) { - throw Object.assign(new Error('claude not found'), { code: 'ENOENT' }) - } - return { stdout: '/Users/example/.npm-global/bin/claude\n', stderr: '' } - } - - if (command === 'claude' && args[0] === '--version') { - return { stdout: '1.2.3\n', stderr: '' } - } - - throw new Error(`unexpected command: ${command} ${args.join(' ')}`) - } - return { calls, execFile } -}) - -vi.mock('child_process', () => ({ - execFile: execState.execFile, -})) - -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - existsSync: (path: string) => path === '/bin/zsh' || actual.existsSync(path), - } -}) - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') -const originalPath = process.env.PATH -const originalShell = process.env.SHELL -const originalHermesDesktop = process.env.HERMES_DESKTOP - -function setPlatform(platform: NodeJS.Platform) { - Object.defineProperty(process, 'platform', { value: platform }) -} - -beforeEach(() => { - execState.calls.length = 0 - setPlatform('darwin') - process.env.HERMES_DESKTOP = 'true' - process.env.SHELL = '/bin/zsh' - process.env.PATH = '/usr/bin' -}) - -afterEach(() => { - if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform) - process.env.PATH = originalPath - if (typeof originalShell === 'undefined') delete process.env.SHELL - else process.env.SHELL = originalShell - if (typeof originalHermesDesktop === 'undefined') delete process.env.HERMES_DESKTOP - else process.env.HERMES_DESKTOP = originalHermesDesktop - vi.resetModules() -}) - -describe('coding agent desktop PATH detection', () => { - it('detects Claude from the login shell PATH when Electron PATH is minimal', async () => { - const { getCodingAgentStatus } = await import('../../packages/server/src/services/coding-agents') - const status = await getCodingAgentStatus({ - id: 'claude-code', - name: 'Claude Code', - provider: 'Anthropic', - command: 'claude', - packageName: '@anthropic-ai/claude-code', - }) - - expect(status.installed).toBe(true) - expect(status.version).toBe('1.2.3') - - const versionCall = execState.calls.find(call => call.command === 'claude' && call.args[0] === '--version') - expect(versionCall?.options.env.PATH.split(delimiter)).toContain('/Users/example/.npm-global/bin') - }) -}) diff --git a/tests/server/coding-agents-launch.test.ts b/tests/server/coding-agents-launch.test.ts deleted file mode 100644 index 34432c8..0000000 --- a/tests/server/coding-agents-launch.test.ts +++ /dev/null @@ -1,1220 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' -import { tmpdir } from 'os' -import { dirname, join } from 'path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { claudeProxyMessages, claudeProxyModels, registerClaudeCodeProxyTarget } from '../../packages/server/src/services/agent-runner/proxies/claude-code-proxy' -import { codexProxyModels, codexProxyResponses, registerCodexProxyTarget } from '../../packages/server/src/services/agent-runner/proxies/codex-proxy' -import { prepareCodingAgentLaunch } from '../../packages/server/src/services/coding-agents' - -const homes: string[] = [] - -function mockProcessUid(uid: number) { - vi.stubGlobal('process', Object.assign(process, { - getuid: vi.fn(() => uid), - geteuid: vi.fn(() => uid), - })) -} - -function makeHome() { - const home = mkdtempSync(join(tmpdir(), 'hermes-coding-agent-launch-')) - homes.push(home) - process.env.HERMES_WEB_UI_HOME = home - process.env.HERMES_CODING_AGENT_GLOBAL_HOME = join(home, 'global-home') - return home -} - -beforeEach(() => { - mockProcessUid(1000) -}) - -afterEach(() => { - delete process.env.HERMES_WEB_UI_HOME - delete process.env.HERMES_CODING_AGENT_GLOBAL_HOME - delete process.env.HERMES_AGENT_NODE - vi.restoreAllMocks() - vi.unstubAllGlobals() - for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }) -}) - -function makeProxyContext(routeKey: string, token: string, body: any): any { - return { - params: { key: routeKey }, - request: { body }, - responseHeaders: {} as Record, - get(name: string) { - if (name.toLowerCase() === 'authorization') return `Bearer ${token}` - return '' - }, - set(name: string, value: string) { - this.responseHeaders[name] = value - }, - } -} - -describe('coding agent launch preparation', () => { - it('launches Claude Code with the global config when requested', async () => { - const home = makeHome() - - const result = await prepareCodingAgentLaunch('claude-code', { - mode: 'global', - profile: 'default', - }) - - expect(result).toMatchObject({ - agentId: 'claude-code', - mode: 'global', - profile: 'default', - provider: 'global', - model: '', - rootDir: join(home, 'coding-agent', 'workspace', 'default', 'global'), - workspaceDir: join(home, 'coding-agent', 'workspace', 'default', 'global'), - command: 'claude', - args: [ - '--append-system-prompt-file', - join(home, 'global-home', '.claude', 'hermes-rules.md'), - '--dangerously-skip-permissions', - ], - env: {}, - shellCommand: `cd ${join(home, 'coding-agent', 'workspace', 'default', 'global')} && claude --append-system-prompt-file ${join(home, 'global-home', '.claude', 'hermes-rules.md')} --dangerously-skip-permissions`, - files: [{ - key: 'prompt', - path: '~/.claude/hermes-rules.md', - absolutePath: join(home, 'global-home', '.claude', 'hermes-rules.md'), - }], - }) - const prompt = readFileSync(join(home, 'global-home', '.claude', 'hermes-rules.md'), 'utf-8') - expect(prompt).toContain('') - expect(prompt).toContain('# 输出格式规范') - }) - - it('uses Claude Code auto permission mode instead of dangerous bypass when running as root', async () => { - mockProcessUid(0) - const home = makeHome() - - const result = await prepareCodingAgentLaunch('claude-code', { - mode: 'global', - profile: 'default', - }) - - expect(result).toMatchObject({ - agentId: 'claude-code', - mode: 'global', - rootDir: join(home, 'coding-agent', 'workspace', 'default', 'global'), - command: 'claude', - args: [ - '--append-system-prompt-file', - join(home, 'global-home', '.claude', 'hermes-rules.md'), - '--permission-mode', - 'auto', - ], - shellCommand: `cd ${join(home, 'coding-agent', 'workspace', 'default', 'global')} && claude --append-system-prompt-file ${join(home, 'global-home', '.claude', 'hermes-rules.md')} --permission-mode auto`, - }) - }) - - it('launches Codex with the global config when requested', async () => { - const home = makeHome() - - const result = await prepareCodingAgentLaunch('codex', { - mode: 'global', - profile: 'default', - }) - - expect(result).toMatchObject({ - agentId: 'codex', - mode: 'global', - profile: 'default', - provider: 'global', - model: '', - rootDir: join(home, 'coding-agent', 'workspace', 'default', 'global'), - workspaceDir: join(home, 'coding-agent', 'workspace', 'default', 'global'), - command: 'codex', - args: [], - env: {}, - shellCommand: `cd ${join(home, 'coding-agent', 'workspace', 'default', 'global')} && codex`, - files: [], - }) - }) - - it('preserves existing global Claude Code prompt files while updating the Hermes block', async () => { - const home = makeHome() - const claudePromptPath = join(home, 'global-home', '.claude', 'hermes-rules.md') - mkdirSync(dirname(claudePromptPath), { recursive: true }) - writeFileSync(claudePromptPath, 'Existing Claude notes\n') - - await prepareCodingAgentLaunch('claude-code', { mode: 'global', profile: 'default' }) - await prepareCodingAgentLaunch('claude-code', { mode: 'global', profile: 'default' }) - - const claudePrompt = readFileSync(claudePromptPath, 'utf-8') - expect(claudePrompt).toContain('Existing Claude notes') - expect(claudePrompt.match(/BEGIN HERMES WEB UI PROMPT/g)).toHaveLength(1) - }) - - it('uses a selected workspace directory when launching a coding agent', async () => { - const home = makeHome() - const workspace = join(home, 'selected workspace') - - const result = await prepareCodingAgentLaunch('codex', { - profile: 'default', - provider: 'openrouter', - model: 'openai/gpt-oss-20b:free', - baseUrl: 'https://openrouter.ai/api/v1', - apiKey: 'sk-test', - workspace, - }) - - expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'openrouter', 'codex')) - expect(result.workspaceDir).toBe(workspace) - expect(result.shellCommand).toContain(workspace) - }) - - it('launches Claude Code with scoped settings instead of a CLI --model override', async () => { - const home = makeHome() - - const result = await prepareCodingAgentLaunch('claude-code', { - profile: 'default', - provider: 'openrouter', - model: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free', - baseUrl: 'https://openrouter.ai/api/v1', - apiKey: 'sk-test', - }) - - expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'openrouter', 'claude-code')) - expect(result.workspaceDir).toBe(join(home, 'coding-agent', 'workspace', 'default', 'openrouter')) - expect(result.args).toEqual([ - '--settings', - join(result.rootDir, 'settings.json'), - '--mcp-config', - join(result.rootDir, 'mcp.json'), - '--append-system-prompt-file', - join(result.rootDir, 'hermes-rules.md'), - '--dangerously-skip-permissions', - ]) - expect(result.shellCommand).toContain(`cd ${join(home, 'coding-agent', 'workspace', 'default', 'openrouter')} &&`) - expect(result.shellCommand).toContain(join(result.rootDir, 'launch.sh')) - expect(result.shellCommand).not.toContain('ANTHROPIC_API_KEY') - expect(result.shellCommand).not.toContain('hwui_') - expect(result.shellCommand).not.toContain('--model') - const launcher = readFileSync(join(result.rootDir, 'launch.sh'), 'utf-8') - expect(launcher).toContain('exec claude --settings') - expect(launcher).toContain('--dangerously-skip-permissions') - expect(launcher).not.toContain('--model') - - const settings = JSON.parse(readFileSync(join(result.rootDir, 'settings.json'), 'utf-8')) - expect(settings.model).toBe('cognitivecomputations/dolphin-mistral-24b-venice-edition:free') - expect(settings.env.ANTHROPIC_API_KEY).toMatch(/^hwui_/) - expect(settings.env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN') - expect(settings.env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/api\/claude-code-proxy\/.+$/) - expect(settings.env).toMatchObject({ - ANTHROPIC_MODEL: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free', - ANTHROPIC_CUSTOM_MODEL_OPTION: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free', - ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: 'Dolphin Mistral 24b Venice Edition:Free', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free', - ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME: 'Dolphin Mistral 24b Venice Edition:Free', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free', - ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: 'Dolphin Mistral 24b Venice Edition:Free', - ANTHROPIC_DEFAULT_OPUS_MODEL: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free', - ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: 'Dolphin Mistral 24b Venice Edition:Free', - }) - expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).not.toBe('claude-sonnet-4-6') - - const mcp = JSON.parse(readFileSync(join(result.rootDir, 'mcp.json'), 'utf-8')) - expect(mcp.mcpServers['hermes-studio-api']).toMatchObject({ - command: process.execPath, - args: [join(process.cwd(), 'bin/hermes-ui-mcp.mjs'), 'api'], - env: { - HERMES_WEB_UI_URL: 'http://127.0.0.1:8648', - HERMES_WEB_UI_HOME: home, - HERMES_WEBUI_STATE_DIR: home, - HERMES_WEB_UI_PROFILE: 'default', - HERMES_MCP_SERVER_NAME: 'hermes-studio-api', - HERMES_MCP_TOOLSET: 'api', - HERMES_WEB_UI_MANAGED_MCP: '1', - }, - }) - expect(mcp.mcpServers['hermes-studio-devices']).toMatchObject({ - command: process.execPath, - args: [join(process.cwd(), 'bin/hermes-ui-mcp.mjs'), 'devices'], - env: { - HERMES_MCP_SERVER_NAME: 'hermes-studio-devices', - HERMES_MCP_TOOLSET: 'devices', - }, - }) - expect(mcp.mcpServers['hermes-studio-use']).toMatchObject({ - command: process.execPath, - args: [join(process.cwd(), 'bin/hermes-ui-mcp.mjs'), 'use'], - env: { - HERMES_MCP_SERVER_NAME: 'hermes-studio-use', - HERMES_MCP_TOOLSET: 'use', - }, - }) - - const prompt = readFileSync(join(result.rootDir, 'hermes-rules.md'), 'utf-8') - expect(prompt).toContain('# 输出格式规范') - expect(prompt).toContain('当你的回复中包含图片、视频或文件引用时') - }) - - it('uses the desktop runtime node for scoped Hermes Studio MCP configs when available', async () => { - const home = makeHome() - process.env.HERMES_AGENT_NODE = '/runtime/node' - - const result = await prepareCodingAgentLaunch('claude-code', { - profile: 'default', - provider: 'openrouter', - model: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free', - baseUrl: 'https://openrouter.ai/api/v1', - apiKey: 'sk-test', - }) - - const mcp = JSON.parse(readFileSync(join(result.rootDir, 'mcp.json'), 'utf-8')) - expect(mcp.mcpServers['hermes-studio-api']).toMatchObject({ - command: '/runtime/node', - args: [join(process.cwd(), 'bin/hermes-ui-mcp.mjs'), 'api'], - env: { - HERMES_WEB_UI_HOME: home, - HERMES_MCP_SERVER_NAME: 'hermes-studio-api', - HERMES_MCP_TOOLSET: 'api', - }, - }) - expect(mcp.mcpServers['hermes-studio-devices'].command).toBe('/runtime/node') - expect(mcp.mcpServers['hermes-studio-use'].command).toBe('/runtime/node') - }) - - it('cleans legacy Hermes MCP entries from scoped Claude and Codex configs', async () => { - const home = makeHome() - const claudeRoot = join(home, 'coding-agent', 'model', 'default', 'openrouter', 'claude-code') - const claudeMcpPath = join(claudeRoot, 'mcp.json') - mkdirSync(dirname(claudeMcpPath), { recursive: true }) - writeFileSync(claudeMcpPath, `${JSON.stringify({ - mcpServers: { - 'hermes-studio': { - command: 'hermes-web-ui-mcp', - env: { HERMES_WEB_UI_MANAGED_MCP: '1' }, - }, - 'hermes-web-ui-mcp': { - command: 'hermes-web-ui-mcp', - env: { HERMES_WEB_UI_MANAGED_MCP: '1' }, - }, - custom: { - command: 'custom-mcp', - }, - }, - }, null, 2)}\n`) - - const claude = await prepareCodingAgentLaunch('claude-code', { - profile: 'default', - provider: 'openrouter', - model: 'anthropic/claude-sonnet-4.6', - baseUrl: 'https://openrouter.ai/api/v1', - apiKey: 'sk-test', - }) - const claudeMcp = JSON.parse(readFileSync(join(claude.rootDir, 'mcp.json'), 'utf-8')) - expect(claudeMcp.mcpServers['hermes-studio']).toBeUndefined() - expect(claudeMcp.mcpServers['hermes-web-ui-mcp']).toBeUndefined() - expect(claudeMcp.mcpServers.custom).toEqual({ command: 'custom-mcp' }) - expect(claudeMcp.mcpServers['hermes-studio-api']).toBeDefined() - expect(claudeMcp.mcpServers['hermes-studio-devices']).toBeDefined() - expect(claudeMcp.mcpServers['hermes-studio-use']).toBeDefined() - - const codexRoot = join(home, 'coding-agent', 'model', 'default', 'openrouter', 'codex') - const codexConfigPath = join(codexRoot, 'config.toml') - mkdirSync(dirname(codexConfigPath), { recursive: true }) - writeFileSync(codexConfigPath, [ - '[mcp_servers.hermes-studio]', - 'command = "hermes-web-ui-mcp"', - '[mcp_servers.hermes-web-ui-mcp]', - 'command = "hermes-web-ui-mcp"', - '', - ].join('\n')) - - const codex = await prepareCodingAgentLaunch('codex', { - profile: 'default', - provider: 'openrouter', - model: 'openai/gpt-oss-20b:free', - baseUrl: 'https://openrouter.ai/api/v1', - apiKey: 'sk-test', - }) - const codexConfig = readFileSync(join(codex.rootDir, 'config.toml'), 'utf-8') - expect(codexConfig).not.toContain('[mcp_servers.hermes-studio]') - expect(codexConfig).not.toContain('[mcp_servers.hermes-web-ui-mcp]') - expect(codexConfig).toContain('[mcp_servers.hermes-studio-api]') - expect(codexConfig).toContain('[mcp_servers.hermes-studio-devices]') - expect(codexConfig).toContain('[mcp_servers.hermes-studio-use]') - }) - - it('inherits external MCP configs for scoped Claude and Codex launches', async () => { - const home = makeHome() - const claudeGlobalMcpPath = join(home, 'global-home', '.claude', 'mcp.json') - const claudeGlobalSettingsPath = join(home, 'global-home', '.claude', 'settings.json') - const codexGlobalConfigPath = join(home, 'global-home', '.codex', 'config.toml') - const codexScopedConfigPath = join(home, 'coding-agent', 'model', 'default', 'openrouter', 'codex', 'config.toml') - mkdirSync(dirname(claudeGlobalMcpPath), { recursive: true }) - mkdirSync(dirname(codexGlobalConfigPath), { recursive: true }) - mkdirSync(dirname(codexScopedConfigPath), { recursive: true }) - writeFileSync(claudeGlobalMcpPath, `${JSON.stringify({ - mcpServers: { - 'nowledge-mem': { - type: 'streamableHttp', - url: 'https://nowledge-mem.example/remote-api/mcp/', - headers: { APP: 'claude code', Authorization: 'Bearer test' }, - }, - 'hermes-studio-api': { command: 'stale-managed' }, - }, - }, null, 2)} -`) - writeFileSync(claudeGlobalSettingsPath, `${JSON.stringify({ - enabledMcpjsonServers: ['nowledge-mem'], - plugins: { 'nowledge-mem@nowledge-community': true }, - }, null, 2)} -`) - writeFileSync(codexGlobalConfigPath, [ - '[mcp_servers.nowledge-mem]', - 'type = "streamableHttp"', - 'url = "https://nowledge-mem.example/remote-api/mcp/"', - '', - '[mcp_servers.nowledge-mem.http_headers]', - 'APP = "codex"', - 'Authorization = "Bearer test"', - '', - '[mcp_servers.hermes-studio-api]', - 'command = "stale-managed"', - '', - '[model_providers.unrelated]', - 'name = "should-not-be-copied"', - '', - ].join('\n')) - writeFileSync(codexScopedConfigPath, [ - '[mcp_servers.nowledge-mem]', - 'type = "streamableHttp"', - 'url = "https://nowledge-mem.scoped-latest.example/remote-api/mcp/"', - '', - '[mcp_servers.nowledge-mem.http_headers]', - 'APP = "codex-scoped"', - 'Authorization = "Bearer scoped"', - '', - '[mcp_servers.nowledge-mem]', - 'type = "streamableHttp"', - 'url = "https://nowledge-mem.scoped-latest.example/remote-api/mcp/"', - '', - '[mcp_servers.nowledge-mem.http_headers]', - 'APP = "codex-scoped-latest"', - 'Authorization = "Bearer scoped-latest"', - '', - ].join('\n')) - - const claude = await prepareCodingAgentLaunch('claude-code', { - profile: 'default', - provider: 'openrouter', - model: 'anthropic/claude-sonnet-4.6', - baseUrl: 'https://openrouter.ai/api/v1', - apiKey: 'test-api-key', - }) - const claudeSettings = JSON.parse(readFileSync(join(claude.rootDir, 'settings.json'), 'utf-8')) - const claudeMcp = JSON.parse(readFileSync(join(claude.rootDir, 'mcp.json'), 'utf-8')) - expect(claudeSettings.enabledMcpjsonServers).toEqual(['nowledge-mem']) - expect(claudeSettings.plugins).toMatchObject({ 'nowledge-mem@nowledge-community': true }) - expect(claudeMcp.mcpServers['nowledge-mem']).toMatchObject({ - type: 'http', - url: 'https://nowledge-mem.example/remote-api/mcp/', - }) - expect(claudeMcp.mcpServers['hermes-studio-api'].command).toBe(process.execPath) - - const codex = await prepareCodingAgentLaunch('codex', { - profile: 'default', - provider: 'openrouter', - model: 'openai/gpt-oss-20b:free', - baseUrl: 'https://openrouter.ai/api/v1', - apiKey: 'test-api-key', - }) - const codexConfig = readFileSync(join(codex.rootDir, 'config.toml'), 'utf-8') - expect(codexConfig.match(/^\[mcp_servers\.nowledge-mem\]$/gm)).toHaveLength(1) - expect(codexConfig.match(/^\[mcp_servers\.nowledge-mem\.http_headers\]$/gm)).toHaveLength(1) - expect(codexConfig).toContain('url = "https://nowledge-mem.scoped-latest.example/remote-api/mcp/"') - expect(codexConfig).toContain('APP = "codex-scoped-latest"') - expect(codexConfig).not.toContain('APP = "codex"') - expect(codexConfig).not.toContain('APP = "codex-scoped"') - expect(codexConfig).not.toContain('command = "stale-managed"') - expect(codexConfig).not.toContain('[model_providers.unrelated]') - expect(codexConfig).toContain('[mcp_servers.hermes-studio-api]') - expect(codexConfig).toContain('[mcp_servers.hermes-studio-devices]') - expect(codexConfig).toContain('[mcp_servers.hermes-studio-use]') - }) - - it('isolates Claude Code settings for hidden chat runs only', async () => { - const home = makeHome() - - const result = await prepareCodingAgentLaunch('claude-code', { - profile: 'default', - provider: 'openrouter', - model: 'anthropic/claude-sonnet-4.6', - baseUrl: 'https://openrouter.ai/api/v1', - apiKey: 'sk-test', - isolateSettings: true, - }) - - expect(result.args).toEqual([ - '--settings', - join(result.rootDir, 'settings.json'), - '--setting-sources', - 'local', - '--mcp-config', - join(result.rootDir, 'mcp.json'), - '--append-system-prompt-file', - join(result.rootDir, 'hermes-rules.md'), - '--dangerously-skip-permissions', - ]) - expect(result.shellCommand).not.toContain('--setting-sources local') - const launcher = readFileSync(join(result.rootDir, 'launch.sh'), 'utf-8') - expect(launcher).toContain('--setting-sources local') - expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'openrouter', 'claude-code')) - }) - - it('uses Claude Code auto permission mode for scoped root launches', async () => { - mockProcessUid(0) - const home = makeHome() - - const result = await prepareCodingAgentLaunch('claude-code', { - profile: 'default', - provider: 'openrouter', - model: 'anthropic/claude-sonnet-4.6', - baseUrl: 'https://openrouter.ai/api/v1', - apiKey: 'sk-test', - isolateSettings: true, - }) - - expect(result.args).toEqual([ - '--settings', - join(result.rootDir, 'settings.json'), - '--setting-sources', - 'local', - '--mcp-config', - join(result.rootDir, 'mcp.json'), - '--append-system-prompt-file', - join(result.rootDir, 'hermes-rules.md'), - '--permission-mode', - 'auto', - ]) - const launcher = readFileSync(join(result.rootDir, 'launch.sh'), 'utf-8') - expect(launcher).toContain('--permission-mode auto') - expect(launcher).not.toContain('--dangerously-skip-permissions') - expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'openrouter', 'claude-code')) - }) - - it('keeps Claude Code protocol overrides behind the local proxy', async () => { - const home = makeHome() - - const result = await prepareCodingAgentLaunch('claude-code', { - profile: 'default', - provider: 'openrouter', - model: 'anthropic/claude-sonnet-4.6', - baseUrl: 'https://openrouter.ai/api/v1', - apiKey: 'sk-test', - apiMode: 'anthropic_messages', - }) - - const settings = JSON.parse(readFileSync(join(result.rootDir, 'settings.json'), 'utf-8')) - expect(settings.env.ANTHROPIC_API_KEY).toMatch(/^hwui_/) - expect(settings.env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN') - expect(settings.env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/api\/claude-code-proxy\/.+$/) - }) - - it('keeps Codex model selection on the CLI while isolating CODEX_HOME', async () => { - const home = makeHome() - - const result = await prepareCodingAgentLaunch('codex', { - profile: 'default', - provider: 'openrouter', - model: 'openai/gpt-oss-20b:free', - baseUrl: 'https://openrouter.ai/api/v1', - apiKey: 'sk-test', - }) - - expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'openrouter', 'codex')) - expect(result.workspaceDir).toBe(join(home, 'coding-agent', 'workspace', 'default', 'openrouter')) - expect(result.args).toEqual(['--model', 'openai/gpt-oss-20b:free']) - expect(result.env).toEqual({ CODEX_HOME: result.rootDir }) - - const config = readFileSync(join(result.rootDir, 'config.toml'), 'utf-8') - expect(config).toContain('requires_openai_auth = false') - expect(config).toContain(`model_catalog_json = "${join(result.rootDir, 'codex-model-catalog.json')}"`) - expect(config).toContain('model_reasoning_summary = "auto"') - expect(config).toContain('developer_instructions = """') - expect(config).toContain('Hermes Studio MCP usage') - expect(config).toContain('# 输出格式规范') - expect(config).toContain('[mcp_servers.hermes-studio-api]') - expect(config).toContain('[mcp_servers.hermes-studio-devices]') - expect(config).toContain('[mcp_servers.hermes-studio-use]') - expect(config).toContain(`command = "${process.execPath}"`) - expect(config).toContain(`args = ["${join(process.cwd(), 'bin/hermes-ui-mcp.mjs')}", "api"]`) - expect(config).toContain(`args = ["${join(process.cwd(), 'bin/hermes-ui-mcp.mjs')}", "devices"]`) - expect(config).toContain(`args = ["${join(process.cwd(), 'bin/hermes-ui-mcp.mjs')}", "use"]`) - expect(config).toContain(`env = { HERMES_WEB_UI_URL = "http://127.0.0.1:8648", HERMES_WEB_UI_HOME = "${home}"`) - expect(config).toContain('HERMES_WEBUI_STATE_DIR = "') - expect(config).toContain('HERMES_WEB_UI_PROFILE = "default"') - expect(config).toContain('HERMES_MCP_SERVER_NAME = "hermes-studio-api"') - expect(config).toContain('HERMES_MCP_SERVER_NAME = "hermes-studio-devices"') - expect(config).toContain('HERMES_MCP_SERVER_NAME = "hermes-studio-use"') - expect(config).toContain('HERMES_MCP_TOOLSET = "api"') - expect(config).toContain('HERMES_MCP_TOOLSET = "devices"') - expect(config).toContain('HERMES_MCP_TOOLSET = "use"') - expect(config).toContain('HERMES_WEB_UI_MANAGED_MCP = "1"') - - expect(result.files.some(file => file.key === 'agents')).toBe(false) - - const catalog = JSON.parse(readFileSync(join(result.rootDir, 'codex-model-catalog.json'), 'utf-8')) - expect(catalog.models.some((entry: any) => entry.slug === 'openai/gpt-oss-20b:free')).toBe(true) - expect(catalog.models[0]).toHaveProperty('base_instructions') - expect(catalog.models[0]).toHaveProperty('model_messages') - expect(catalog.models[0]).toHaveProperty('default_reasoning_summary', 'auto') - }) - - it('points Codex Chat Completions providers at the local Responses proxy', async () => { - const home = makeHome() - - const result = await prepareCodingAgentLaunch('codex', { - profile: 'default', - provider: 'deepseek', - model: 'deepseek-v4-pro', - baseUrl: 'https://api.deepseek.com', - apiKey: 'sk-upstream', - apiMode: 'chat_completions', - }) - - const config = readFileSync(join(result.rootDir, 'config.toml'), 'utf-8') - expect(config).toContain(`base_url = "http://127.0.0.1:8648/api/codex-proxy/`) - expect(config).toContain('wire_api = "responses"') - expect(config).toContain('requires_openai_auth = false') - expect(config).toMatch(/experimental_bearer_token = "hwui_[^"]+"/) - expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'deepseek', 'codex')) - - const catalog = JSON.parse(readFileSync(join(result.rootDir, 'codex-model-catalog.json'), 'utf-8')) - const deepseekModel = catalog.models.find((entry: any) => entry.slug === 'deepseek-v4-pro') - expect(deepseekModel).toMatchObject({ - display_name: 'Deepseek V4 Pro', - }) - expect(deepseekModel.context_window).toBeGreaterThan(0) - expect(deepseekModel.max_context_window).toBe(deepseekModel.context_window) - expect(deepseekModel.model_messages.instructions_template).toContain('{{ base_instructions }}') - }) - - it('normalizes Codex app-server provider mode to Responses for scoped Codex runs', async () => { - const home = makeHome() - - const result = await prepareCodingAgentLaunch('codex', { - profile: 'default', - provider: 'ai-pixel.online', - model: 'gpt-5.5', - baseUrl: 'https://ai-pixel.online/v1', - apiKey: 'sk-upstream', - apiMode: 'codex_app_server' as any, - }) - - const config = readFileSync(join(result.rootDir, 'config.toml'), 'utf-8') - expect(config).toContain(`base_url = "http://127.0.0.1:`) - expect(config).toContain('/api/codex-proxy/') - expect(config).toContain('wire_api = "responses"') - expect(config).toContain('requires_openai_auth = false') - expect(config).toMatch(/experimental_bearer_token = "hwui_[^"]+"/) - expect(config).not.toContain('base_url = "https://ai-pixel.online/v1"') - expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'ai-pixel.online', 'codex')) - }) - - it('defaults Codex providers without an api mode to Chat Completions', async () => { - makeHome() - const launch = await prepareCodingAgentLaunch('codex', { - profile: 'default', - provider: 'xiaomi', - model: 'mimo-v2.5-pro', - baseUrl: 'https://api.xiaomimimo.com/v1', - apiKey: 'sk-upstream', - }) - const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8') - const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || '' - const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || '' - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ - id: 'chatcmpl_test', - choices: [{ - finish_reason: 'stop', - message: { role: 'assistant', content: 'ok' }, - }], - }), { status: 200, headers: { 'Content-Type': 'application/json' } })) - vi.stubGlobal('fetch', fetchMock) - - const ctx = makeProxyContext(routeKey, token, { - max_output_tokens: 16, - input: [{ role: 'user', content: [{ type: 'input_text', text: 'hello' }] }], - }) - - await codexProxyResponses(ctx) - - expect(fetchMock).toHaveBeenCalledWith('https://api.xiaomimimo.com/v1/chat/completions', expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ Authorization: 'Bearer sk-upstream' }), - })) - }) - - it('points Codex Responses providers at the local Responses proxy for stream capture', async () => { - const home = makeHome() - - const result = await prepareCodingAgentLaunch('codex', { - profile: 'default', - provider: 'openai-api', - model: 'gpt-5.5', - baseUrl: 'https://api.openai.com/v1', - apiKey: 'sk-upstream', - apiMode: 'codex_responses', - sessionId: 'chat-session-1', - agentSessionId: 'agent-session-1', - }) - - const config = readFileSync(join(result.rootDir, 'config.toml'), 'utf-8') - expect(config).toContain(`base_url = "http://127.0.0.1:8648/api/codex-proxy/`) - expect(config).toMatch(/experimental_bearer_token = "hwui_[^"]+"/) - expect(config).not.toContain('base_url = "https://api.openai.com/v1"') - expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'openai-api', 'codex')) - }) - - it('points Codex Anthropic Messages providers at the local Responses proxy', async () => { - const home = makeHome() - - const result = await prepareCodingAgentLaunch('codex', { - profile: 'default', - provider: 'anthropic-compatible', - model: 'claude-sonnet-4-6', - baseUrl: 'https://api.example.com', - apiKey: 'sk-upstream', - apiMode: 'anthropic_messages', - }) - - const config = readFileSync(join(result.rootDir, 'config.toml'), 'utf-8') - expect(config).toContain(`base_url = "http://127.0.0.1:8648/api/codex-proxy/`) - expect(config).toContain('wire_api = "responses"') - expect(config).toContain('requires_openai_auth = false') - expect(config).toMatch(/experimental_bearer_token = "hwui_[^"]+"/) - expect(result.rootDir).toBe(join(home, 'coding-agent', 'model', 'default', 'anthropic-compatible', 'codex')) - }) - - it('adapts Codex Responses requests to OpenAI Chat Completions', async () => { - makeHome() - const launch = await prepareCodingAgentLaunch('codex', { - profile: 'default', - provider: 'deepseek', - model: 'deepseek-v4-pro', - baseUrl: 'https://api.deepseek.com', - apiKey: 'sk-upstream', - apiMode: 'chat_completions', - }) - const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8') - const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || '' - const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || '' - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ - id: 'chatcmpl_test', - choices: [{ - finish_reason: 'stop', - message: { role: 'assistant', content: 'ok' }, - }], - usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 }, - }), { status: 200, headers: { 'Content-Type': 'application/json' } })) - vi.stubGlobal('fetch', fetchMock) - - const ctx = makeProxyContext(routeKey, token, { - max_output_tokens: 16, - input: [ - { role: 'user', content: [{ type: 'input_text', text: 'hello' }] }, - { role: 'developer', content: [{ type: 'input_text', text: 'be terse' }] }, - ], - }) - - await codexProxyResponses(ctx) - - expect(fetchMock).toHaveBeenCalledWith('https://api.deepseek.com/v1/chat/completions', expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ Authorization: 'Bearer sk-upstream' }), - })) - const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body) - expect(requestBody).toMatchObject({ - model: 'deepseek-v4-pro', - max_tokens: 16, - messages: [ - { role: 'user', content: 'hello' }, - { role: 'system', content: 'be terse' }, - ], - }) - expect(ctx.body.output[0].content[0].text).toBe('ok') - expect(ctx.body.usage).toMatchObject({ input_tokens: 3, output_tokens: 1, total_tokens: 4 }) - }) - - it('adapts Codex Responses requests to Anthropic Messages', async () => { - makeHome() - const launch = await prepareCodingAgentLaunch('codex', { - profile: 'default', - provider: 'anthropic-compatible', - model: 'claude-sonnet-4-6', - baseUrl: 'https://api.example.com', - apiKey: 'sk-upstream', - apiMode: 'anthropic_messages', - }) - const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8') - const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || '' - const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || '' - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ - id: 'msg_test', - type: 'message', - role: 'assistant', - model: 'claude-sonnet-4-6', - content: [ - { type: 'text', text: 'ok' }, - { type: 'tool_use', id: 'toolu_1', name: 'search', input: { query: 'repo' } }, - ], - stop_reason: 'tool_use', - usage: { input_tokens: 5, output_tokens: 2 }, - }), { status: 200, headers: { 'Content-Type': 'application/json' } })) - vi.stubGlobal('fetch', fetchMock) - - const ctx = makeProxyContext(routeKey, token, { - instructions: 'be terse', - max_output_tokens: 64, - input: [ - { role: 'user', content: [{ type: 'input_text', text: 'hello' }] }, - { type: 'function_call_output', call_id: 'call_0', output: 'done' }, - ], - tools: [{ - type: 'function', - name: 'search', - description: 'Search files', - parameters: { type: 'object', properties: { query: { type: 'string' } } }, - }], - }) - - await codexProxyResponses(ctx) - - expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/v1/messages', expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - Authorization: 'Bearer sk-upstream', - 'x-api-key': 'sk-upstream', - 'anthropic-version': '2023-06-01', - }), - })) - const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body) - expect(requestBody).toMatchObject({ - model: 'claude-sonnet-4-6', - system: 'be terse', - max_tokens: 64, - messages: [ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_0', content: 'done' }] }, - ], - tools: [{ - name: 'search', - description: 'Search files', - input_schema: { type: 'object', properties: { query: { type: 'string' } } }, - }], - }) - expect(ctx.body.output[0].content[0].text).toBe('ok') - expect(ctx.body.output[1]).toMatchObject({ - type: 'function_call', - call_id: 'toolu_1', - name: 'search', - arguments: '{"query":"repo"}', - }) - expect(ctx.body.usage).toMatchObject({ input_tokens: 5, output_tokens: 2, total_tokens: 7 }) - }) - - it('streams Codex proxy text as complete Responses message events', async () => { - makeHome() - const launch = await prepareCodingAgentLaunch('codex', { - profile: 'default', - provider: 'deepseek', - model: 'deepseek-v4-pro', - baseUrl: 'https://api.deepseek.com', - apiKey: 'sk-upstream', - apiMode: 'chat_completions', - }) - const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8') - const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || '' - const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || '' - const encoder = new TextEncoder() - const fetchMock = vi.fn(async () => new Response(new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"p"}}]}\n\n')) - controller.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"ong"}}]}\n\n')) - controller.enqueue(encoder.encode('data: {"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15}}\n\n')) - controller.enqueue(encoder.encode('data: [DONE]\n\n')) - controller.close() - }, - }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } })) - vi.stubGlobal('fetch', fetchMock) - - const ctx = makeProxyContext(routeKey, token, { - stream: true, - input: [{ role: 'user', content: [{ type: 'input_text', text: 'ping' }] }], - }) - - await codexProxyResponses(ctx) - - const chunks: string[] = [] - for await (const chunk of ctx.body) chunks.push(String(chunk)) - const sse = chunks.join('') - expect(sse).toContain('event: response.output_item.added') - expect(sse).toContain('event: response.content_part.added') - expect(sse).toContain('"delta":"p"') - expect(sse).toContain('"delta":"ong"') - expect(sse).toContain('event: response.output_text.done') - expect(sse).toContain('"text":"pong"') - expect(sse).toContain('event: response.output_item.done') - expect(sse).toContain('"output":[{"type":"message"') - expect(sse).not.toContain('"usage"') - }) - - it('streams Codex proxy Anthropic text as Responses message events', async () => { - makeHome() - const launch = await prepareCodingAgentLaunch('codex', { - profile: 'default', - provider: 'anthropic-compatible', - model: 'claude-sonnet-4-6', - baseUrl: 'https://api.example.com', - apiKey: 'sk-upstream', - apiMode: 'anthropic_messages', - }) - const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8') - const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || '' - const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || '' - const encoder = new TextEncoder() - const fetchMock = vi.fn(async () => new Response(new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode('event: message_start\ndata: {"type":"message_start","message":{"id":"msg_test","usage":{"input_tokens":3,"output_tokens":0}}}\n\n')) - controller.enqueue(encoder.encode('event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n')) - controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"he"}}\n\n')) - controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"llo"}}\n\n')) - controller.enqueue(encoder.encode('event: message_delta\ndata: {"type":"message_delta","usage":{"output_tokens":2}}\n\n')) - controller.enqueue(encoder.encode('event: message_stop\ndata: {"type":"message_stop"}\n\n')) - controller.close() - }, - }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } })) - vi.stubGlobal('fetch', fetchMock) - - const ctx = makeProxyContext(routeKey, token, { - stream: true, - input: [{ role: 'user', content: [{ type: 'input_text', text: 'ping' }] }], - }) - - await codexProxyResponses(ctx) - - const chunks: string[] = [] - for await (const chunk of ctx.body) chunks.push(String(chunk)) - const sse = chunks.join('') - expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/v1/messages', expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ 'anthropic-version': '2023-06-01' }), - })) - expect(sse).toContain('event: response.output_item.added') - expect(sse).toContain('"delta":"he"') - expect(sse).toContain('"delta":"llo"') - expect(sse).toContain('event: response.output_text.done') - expect(sse).toContain('"text":"hello"') - expect(sse).toContain('event: response.completed') - expect(sse).not.toContain('"usage"') - }) - - it('preserves native Responses usage for Codex Responses providers', async () => { - const target = registerCodexProxyTarget({ - profile: 'default', - provider: 'openai-api', - model: 'gpt-5.5', - baseUrl: 'https://api.openai.com/v1', - apiKey: 'sk-upstream', - apiMode: 'codex_responses', - }) - const encoder = new TextEncoder() - const fetchMock = vi.fn(async () => new Response(new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode('event: response.created\ndata: {"type":"response.created","response":{"id":"resp_native","status":"in_progress"}}\n\n')) - controller.enqueue(encoder.encode('event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"ok"}\n\n')) - controller.enqueue(encoder.encode('event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_native","status":"completed","usage":{"input_tokens":11,"output_tokens":2,"total_tokens":13}}}\n\n')) - controller.close() - }, - }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } })) - vi.stubGlobal('fetch', fetchMock) - - const ctx = makeProxyContext(target.routeKey, target.token, { - stream: true, - input: [{ role: 'user', content: [{ type: 'input_text', text: 'ping' }] }], - }) - await codexProxyResponses(ctx) - - const chunks: string[] = [] - for await (const chunk of ctx.body) chunks.push(String(chunk)) - const sse = chunks.join('') - expect(fetchMock).toHaveBeenCalledWith('https://api.openai.com/v1/responses', expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ Authorization: 'Bearer sk-upstream' }), - })) - expect(sse).toContain('"usage":{"input_tokens":11,"output_tokens":2,"total_tokens":13}') - }) - - it('exposes Codex proxy models with route-token authentication', async () => { - makeHome() - const launch = await prepareCodingAgentLaunch('codex', { - profile: 'default', - provider: 'deepseek', - model: 'deepseek-v4-pro', - baseUrl: 'https://api.deepseek.com', - apiKey: 'sk-upstream', - apiMode: 'chat_completions', - }) - const config = readFileSync(join(launch.rootDir, 'config.toml'), 'utf-8') - const routeKey = config.match(/\/api\/codex-proxy\/([^/]+)\/v1/)?.[1] || '' - const token = config.match(/experimental_bearer_token = "([^"]+)"/)?.[1] || '' - const ctx = makeProxyContext(routeKey, token, {}) - - await codexProxyModels(ctx) - - expect(ctx.body).toMatchObject({ - object: 'list', - data: [{ id: 'deepseek-v4-pro', object: 'model', owned_by: 'deepseek' }], - }) - }) - - it('adapts Claude Code streaming requests to the Responses API for codex_responses providers', async () => { - const target = registerClaudeCodeProxyTarget({ - provider: 'fun-codex', - model: 'gpt-5.5', - baseUrl: 'https://api.apikey.fun/v1', - apiKey: 'sk-upstream', - apiMode: 'codex_responses', - }) - const encoder = new TextEncoder() - const fetchMock = vi.fn(async () => new Response(new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode('data: {"type":"response.output_text.delta","delta":"hi"}\n\n')) - controller.enqueue(encoder.encode('data: {"type":"response.completed","response":{"status":"completed","usage":{"output_tokens":1}}}\n\n')) - controller.close() - }, - }), { status: 200 })) - vi.stubGlobal('fetch', fetchMock) - - const ctx = makeProxyContext(target.routeKey, target.token, { - stream: true, - max_tokens: 32, - messages: [{ role: 'user', content: 'hello' }], - }) - - await claudeProxyMessages(ctx) - - expect(fetchMock).toHaveBeenCalledWith('https://api.apikey.fun/v1/responses', expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ Authorization: 'Bearer sk-upstream' }), - })) - const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body) - expect(requestBody).toMatchObject({ - model: 'gpt-5.5', - stream: true, - store: false, - max_output_tokens: 32, - input: [{ role: 'user', content: 'hello' }], - }) - - const chunks: string[] = [] - for await (const chunk of ctx.body) chunks.push(String(chunk)) - const sse = chunks.join('') - expect(ctx.responseHeaders['Content-Type']).toContain('text/event-stream') - expect(sse).toContain('event: message_start') - expect(sse).toContain('"type":"text_delta","text":"hi"') - expect(sse).toContain('event: message_stop') - }) - - it('round-trips reasoning_content for DeepSeek-style OpenAI Chat tool calls', async () => { - const target = registerClaudeCodeProxyTarget({ - provider: 'deepseek', - model: 'deepseek-reasoner', - baseUrl: 'https://api.deepseek.com/v1', - apiKey: 'sk-upstream', - apiMode: 'chat_completions', - }) - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ - id: 'chatcmpl_test', - choices: [{ - finish_reason: 'tool_calls', - message: { - role: 'assistant', - reasoning_content: 'Need to inspect the repository first.', - content: null, - tool_calls: [{ - id: 'call_2', - type: 'function', - function: { name: 'search', arguments: '{"query":"proxy"}' }, - }], - }, - }], - usage: { prompt_tokens: 12, completion_tokens: 8 }, - }), { status: 200, headers: { 'Content-Type': 'application/json' } })) - vi.stubGlobal('fetch', fetchMock) - - const ctx = makeProxyContext(target.routeKey, target.token, { - max_tokens: 32, - messages: [ - { role: 'user', content: 'check it' }, - { - role: 'assistant', - content: [ - { type: 'thinking', thinking: 'Need the current repo files.' }, - { type: 'tool_use', id: 'call_1', name: 'search', input: { query: 'reasoning_content' } }, - ], - }, - { - role: 'user', - content: [ - { type: 'tool_result', tool_use_id: 'call_1', content: 'found one file' }, - ], - }, - ], - }) - - await claudeProxyMessages(ctx) - - const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body) - expect(requestBody.messages[1]).toMatchObject({ - role: 'assistant', - reasoning_content: 'Need the current repo files.', - tool_calls: [{ - id: 'call_1', - type: 'function', - function: { name: 'search', arguments: '{"query":"reasoning_content"}' }, - }], - }) - expect(ctx.body.content[0]).toEqual({ - type: 'thinking', - thinking: 'Need to inspect the repository first.', - }) - expect(ctx.body.content[1]).toMatchObject({ - type: 'tool_use', - id: 'call_2', - name: 'search', - input: { query: 'proxy' }, - }) - }) - - it('passes Anthropic Messages providers through the local proxy without exposing upstream credentials', async () => { - const target = registerClaudeCodeProxyTarget({ - provider: 'fun-claude', - model: 'claude-sonnet-4-6', - baseUrl: 'https://api.apikey.fun', - apiKey: 'sk-upstream', - apiMode: 'anthropic_messages', - }) - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ - id: 'msg_test', - type: 'message', - role: 'assistant', - model: 'claude-sonnet-4-6', - content: [{ type: 'text', text: 'hi' }], - stop_reason: 'end_turn', - usage: { input_tokens: 1, output_tokens: 1 }, - }), { status: 200, headers: { 'Content-Type': 'application/json' } })) - vi.stubGlobal('fetch', fetchMock) - - const ctx = makeProxyContext(target.routeKey, target.token, { - model: 'ignored-client-model', - max_tokens: 32, - messages: [{ role: 'user', content: 'hello' }], - }) - - await claudeProxyMessages(ctx) - - expect(fetchMock).toHaveBeenCalledWith('https://api.apikey.fun/v1/messages', expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - Authorization: 'Bearer sk-upstream', - 'x-api-key': 'sk-upstream', - }), - })) - const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body) - expect(requestBody.model).toBe('claude-sonnet-4-6') - expect(ctx.body.content[0].text).toBe('hi') - }) - - it('keeps Claude proxy routes separate for the same model with different protocols', () => { - const chat = registerClaudeCodeProxyTarget({ - provider: 'same-provider', - model: 'same-model', - baseUrl: 'https://api.example.com/v1', - apiKey: 'sk-chat', - apiMode: 'chat_completions', - }) - const anthropic = registerClaudeCodeProxyTarget({ - provider: 'same-provider', - model: 'same-model', - baseUrl: 'https://api.example.com/v1', - apiKey: 'sk-anthropic', - apiMode: 'anthropic_messages', - }) - - expect(chat.routeKey).not.toBe(anthropic.routeKey) - expect(chat.token).not.toBe(anthropic.token) - }) - - it('keeps proxy routes separate for different hidden agent sessions', () => { - const first = registerClaudeCodeProxyTarget({ - provider: 'same-provider', - model: 'same-model', - baseUrl: 'https://api.example.com/v1', - apiKey: 'sk-one', - apiMode: 'chat_completions', - agentSessionId: 'agent-one', - chatSessionId: 'chat-one', - }) - const second = registerClaudeCodeProxyTarget({ - provider: 'same-provider', - model: 'same-model', - baseUrl: 'https://api.example.com/v1', - apiKey: 'sk-two', - apiMode: 'chat_completions', - agentSessionId: 'agent-two', - chatSessionId: 'chat-two', - }) - - expect(first.routeKey).not.toBe(second.routeKey) - expect(first.token).not.toBe(second.token) - }) - - it('keeps Codex proxy routes separate for the same model with different upstream URLs', () => { - const first = registerCodexProxyTarget({ - profile: 'default', - provider: 'same-provider', - model: 'same-model', - baseUrl: 'https://api-one.example.com/v1', - apiKey: 'sk-one', - apiMode: 'chat_completions', - }) - const second = registerCodexProxyTarget({ - profile: 'default', - provider: 'same-provider', - model: 'same-model', - baseUrl: 'https://api-two.example.com/v1', - apiKey: 'sk-two', - apiMode: 'chat_completions', - }) - - expect(first.routeKey).not.toBe(second.routeKey) - expect(first.token).not.toBe(second.token) - }) - - it('exposes Claude-visible alias models from the local proxy models endpoint', async () => { - const target = registerClaudeCodeProxyTarget({ - provider: 'openrouter', - model: 'cognitivecomputations/dolphin-mistral-24b-venice-edition:free', - baseUrl: 'https://openrouter.ai/api/v1', - apiKey: 'sk-upstream', - apiMode: 'codex_responses', - }) - const ctx = makeProxyContext(target.routeKey, target.token, {}) - - await claudeProxyModels(ctx) - - const ids = ctx.body.data.map((model: any) => model.id) - expect(ids).toContain('claude-haiku-4-5') - expect(ids).toContain('claude-sonnet-4-6') - expect(ids).toContain('claude-opus-4-7') - expect(ids).toContain('cognitivecomputations/dolphin-mistral-24b-venice-edition:free') - }) -}) diff --git a/tests/server/coding-agents-windows-execution.test.ts b/tests/server/coding-agents-windows-execution.test.ts deleted file mode 100644 index 8cf810e..0000000 --- a/tests/server/coding-agents-windows-execution.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const execState = vi.hoisted(() => { - const customPromisify = Symbol.for('nodejs.util.promisify.custom') - const calls: Array<{ command: string; args: string[]; options: any }> = [] - const execFile = vi.fn() - ;(execFile as any)[customPromisify] = async (command: string, args: string[], options: any) => { - calls.push({ command, args, options }) - if (command === 'where' && args[0] === 'npm.cmd') { - throw Object.assign(new Error('npm not found'), { code: 'ENOENT' }) - } - if (command === 'where' && args[0] === 'codex') { - return { stdout: '"C:\\nvm4w\\nodejs\\codex.cmd"\r\n', stderr: '' } - } - if (command === 'cmd.exe') { - return { stdout: 'codex-cli 1.2.3\n', stderr: '' } - } - throw new Error(`unexpected command: ${command}`) - } - return { calls, execFile } -}) - -vi.mock('child_process', () => ({ - execFile: execState.execFile, -})) - -import { getCodingAgentStatus } from '../../packages/server/src/services/coding-agents' - -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') - -function setPlatform(platform: NodeJS.Platform) { - Object.defineProperty(process, 'platform', { value: platform }) -} - -beforeEach(() => { - execState.calls.length = 0 - setPlatform('win32') -}) - -afterEach(() => { - if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform) - vi.unstubAllEnvs() -}) - -describe('coding agent Windows command execution', () => { - it('normalizes quoted where.exe results and runs .cmd shims through verbatim cmd.exe', async () => { - const status = await getCodingAgentStatus({ - id: 'codex', - name: 'Codex', - provider: 'OpenAI', - command: 'codex', - packageName: '@openai/codex', - }) - - expect(status.installed).toBe(true) - expect(status.version).toBe('1.2.3') - - const versionCall = execState.calls.find(call => call.command === 'cmd.exe') - expect(versionCall).toBeTruthy() - expect(versionCall?.args).toEqual([ - '/d', - '/s', - '/c', - '"C:\\nvm4w\\nodejs\\codex.cmd ^"--version^""', - ]) - expect(versionCall?.options).toMatchObject({ - windowsHide: true, - windowsVerbatimArguments: true, - }) - }) -}) diff --git a/tests/server/ekko-agent-manager.test.ts b/tests/server/ekko-agent-manager.test.ts deleted file mode 100644 index 2827f1b..0000000 --- a/tests/server/ekko-agent-manager.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { GlobalEkkoAgent } from '../../packages/server/src/services/ekko-agent/manager' -import type { ModelClient, ModelRequest } from '../../packages/ekko-agent/src' - -function modelClient(content: string): ModelClient { - return { - provider: 'test', - requestStyle: 'custom-runtime', - capabilities: { - streaming: false, - tools: true, - vision: false, - jsonMode: false, - systemPrompt: true, - }, - create: vi.fn(async () => ({ content })), - stream: vi.fn(), - } -} - -describe('GlobalEkkoAgent', () => { - it('is created once and handles repeated runs through the same runtime', async () => { - const agent = new GlobalEkkoAgent() - const firstClient = modelClient('first') - const secondClient = modelClient('second') - - const first = await agent.run({ messages: ['hi'], modelClient: firstClient }) - const second = await agent.run({ messages: ['again'], modelClient: secondClient }) - - expect(first.output.content).toBe('first') - expect(second.output.content).toBe('second') - expect(agent.runCount).toBe(2) - expect(firstClient.create).toHaveBeenCalledTimes(1) - expect(secondClient.create).toHaveBeenCalledTimes(1) - }) - - it('passes per-run model defaults, metadata, and tool context', async () => { - const agent = new GlobalEkkoAgent() - const client = modelClient('ok') - - await agent.run({ - messages: ['hi'], - modelClient: client, - modelDefaults: { model: 'test-model' }, - metadata: { session_id: 'session-1' }, - toolContext: { mcpServers: { test: { command: 'node', enabled: false } } }, - }) - - const request = vi.mocked(client.create).mock.calls[0]?.[0] as ModelRequest - expect(request.model).toBe('test-model') - expect(request.metadata).toEqual({ session_id: 'session-1' }) - }) -}) diff --git a/tests/server/ekko-agent-mcp.test.ts b/tests/server/ekko-agent-mcp.test.ts deleted file mode 100644 index 1c5a492..0000000 --- a/tests/server/ekko-agent-mcp.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { join } from 'node:path' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const configMock = vi.hoisted(() => ({ - port: 8648, - appHome: '/Users/test/.hermes-web-ui', -})) - -vi.mock('../../packages/server/src/config', () => ({ - config: configMock, -})) - -describe('Ekko MCP server context', () => { - beforeEach(() => { - vi.resetModules() - delete process.env.HERMES_DESKTOP - delete process.env.HERMES_AGENT_NODE - delete process.env.HERMES_WEB_UI_DISABLE_MCP_AUTOINJECT - delete process.env.HERMES_WEB_UI_ALLOW_TRANSIENT_MCP_AUTOINJECT - delete process.env.HERMES_WEB_UI_MCP_BIN - configMock.port = 8648 - configMock.appHome = '/Users/test/.hermes-web-ui' - }) - - it('builds managed MCP servers for the current Web UI port and profile', async () => { - const { buildManagedEkkoMcpServers } = await import('../../packages/server/src/services/ekko-agent/mcp') - - const servers = buildManagedEkkoMcpServers('work') - - expect(servers['hermes-studio-api']).toEqual({ - command: process.execPath, - args: [join(process.cwd(), 'bin/hermes-ui-mcp.mjs'), 'api'], - env: { - HERMES_WEB_UI_URL: 'http://127.0.0.1:8648', - HERMES_WEB_UI_HOME: '/Users/test/.hermes-web-ui', - HERMES_WEBUI_STATE_DIR: '/Users/test/.hermes-web-ui', - HERMES_WEB_UI_PROFILE: 'work', - HERMES_MCP_SERVER_NAME: 'hermes-studio-api', - HERMES_MCP_TOOLSET: 'api', - HERMES_WEB_UI_MANAGED_MCP: '1', - }, - enabled: true, - }) - expect(servers['hermes-studio-devices']).toMatchObject({ - args: [join(process.cwd(), 'bin/hermes-ui-mcp.mjs'), 'devices'], - env: { - HERMES_WEB_UI_URL: 'http://127.0.0.1:8648', - HERMES_WEB_UI_PROFILE: 'work', - HERMES_MCP_TOOLSET: 'devices', - }, - }) - expect(servers['hermes-studio-use']).toMatchObject({ - args: [join(process.cwd(), 'bin/hermes-ui-mcp.mjs'), 'use'], - env: { - HERMES_WEB_UI_URL: 'http://127.0.0.1:8648', - HERMES_WEB_UI_PROFILE: 'work', - HERMES_MCP_TOOLSET: 'use', - }, - }) - }) - - it('merges caller-provided MCP servers and lets explicit entries override managed defaults', async () => { - const { resolveEkkoMcpServers } = await import('../../packages/server/src/services/ekko-agent/mcp') - - const servers = resolveEkkoMcpServers('default', { - 'hermes-studio-api': { command: 'custom-api' }, - custom: { command: 'custom-mcp' }, - }) - - expect(servers?.['hermes-studio-api']).toEqual({ command: 'custom-api' }) - expect(servers?.custom).toEqual({ command: 'custom-mcp' }) - expect(servers?.['hermes-studio-devices']).toBeDefined() - expect(servers?.['hermes-studio-use']).toBeDefined() - }) - - it('does not add managed MCP servers when startup autoinject is disabled or skipped', async () => { - process.env.HERMES_WEB_UI_DISABLE_MCP_AUTOINJECT = '1' - let mod = await import('../../packages/server/src/services/ekko-agent/mcp') - - expect(mod.resolveEkkoMcpServers('default')).toBeUndefined() - expect(mod.resolveEkkoMcpServers('default', { custom: { command: 'custom-mcp' } })).toEqual({ - custom: { command: 'custom-mcp' }, - }) - - vi.resetModules() - delete process.env.HERMES_WEB_UI_DISABLE_MCP_AUTOINJECT - configMock.appHome = '/private/tmp/wui-preview-home' - mod = await import('../../packages/server/src/services/ekko-agent/mcp') - - expect(mod.resolveEkkoMcpServers('default')).toBeUndefined() - - vi.resetModules() - process.env.HERMES_WEB_UI_ALLOW_TRANSIENT_MCP_AUTOINJECT = '1' - mod = await import('../../packages/server/src/services/ekko-agent/mcp') - - expect(mod.resolveEkkoMcpServers('default')?.['hermes-studio-api']).toBeDefined() - }) -}) diff --git a/tests/server/handle-coding-agent-run.test.ts b/tests/server/handle-coding-agent-run.test.ts deleted file mode 100644 index 810a358..0000000 --- a/tests/server/handle-coding-agent-run.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const managerMock = vi.hoisted(() => ({ - runIdForSession: vi.fn(), - isSessionLaunchCompatible: vi.fn(), - isSessionProcessing: vi.fn(), - stop: vi.fn(), -})) -const startCodingAgentRunMock = vi.hoisted(() => vi.fn()) -const sendCodingAgentRunInputMock = vi.hoisted(() => vi.fn()) -const writeModelRunProfileTokenMock = vi.hoisted(() => vi.fn(async () => undefined)) -const getSystemPromptMock = vi.hoisted(() => vi.fn(() => 'system prompt')) -const getSessionMock = vi.hoisted(() => vi.fn()) -const updateSessionMock = vi.hoisted(() => vi.fn()) - -vi.mock('../../packages/server/src/services/agent-runner/coding-agent-run-manager', () => ({ - codingAgentRunManager: managerMock, -})) - -vi.mock('../../packages/server/src/services/coding-agents', () => ({ - startCodingAgentRun: startCodingAgentRunMock, - sendCodingAgentRunInput: sendCodingAgentRunInputMock, -})) - -vi.mock('../../packages/server/src/services/hermes/run-chat/model-run-prompt', () => ({ - writeModelRunProfileToken: writeModelRunProfileTokenMock, -})) - -vi.mock('../../packages/server/src/lib/llm-prompt', () => ({ - getSystemPrompt: getSystemPromptMock, -})) - -vi.mock('../../packages/server/src/db/hermes/session-store', () => ({ - getSession: getSessionMock, - updateSession: updateSessionMock, -})) - -vi.mock('../../packages/server/src/services/logger', () => ({ - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, -})) - -describe('handleCodingAgentRun', () => { - beforeEach(() => { - vi.clearAllMocks() - getSessionMock.mockReturnValue(null) - managerMock.isSessionProcessing.mockReturnValue(false) - writeModelRunProfileTokenMock.mockResolvedValue(undefined) - getSystemPromptMock.mockReturnValue('system prompt') - }) - - it('restarts an existing coding-agent runner when the requested launch mode changes', async () => { - managerMock.runIdForSession.mockReturnValue('agent-session-1') - managerMock.isSessionLaunchCompatible.mockReturnValue(false) - startCodingAgentRunMock.mockResolvedValue({ agentSessionId: 'agent-session-2' }) - sendCodingAgentRunInputMock.mockResolvedValue({ runId: 'agent-session-2' }) - - const { handleCodingAgentRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-coding-agent-run') - const state = { - messages: [], - isWorking: false, - isAborting: false, - events: [], - queue: [], - } - const sessionMap = new Map([['session-1', state]]) - const socket = { - join: vi.fn(), - emit: vi.fn(), - } - - await handleCodingAgentRun({} as any, socket as any, { - session_id: 'session-1', - input: 'use global codex', - coding_agent_id: 'codex', - mode: 'global', - }, 'default', sessionMap as any) - - expect(managerMock.isSessionLaunchCompatible).toHaveBeenCalledWith('session-1', { - agentId: 'codex', - mode: 'global', - provider: undefined, - model: undefined, - }) - expect(managerMock.stop).toHaveBeenCalledWith('session-1', { reportClosed: false }) - expect(startCodingAgentRunMock).toHaveBeenCalledWith('codex', expect.objectContaining({ - sessionId: 'session-1', - mode: 'global', - profile: 'default', - }), state) - expect(sendCodingAgentRunInputMock).toHaveBeenCalledWith('session-1', 'use global codex', 'system prompt') - }) - - it('restarts an existing scoped runner when the stored session model changed even if the socket payload omits it', async () => { - managerMock.runIdForSession.mockReturnValue('agent-session-1') - managerMock.isSessionLaunchCompatible.mockReturnValue(false) - getSessionMock.mockReturnValue({ - id: 'session-1', - source: 'coding_agent', - agent: 'codex', - provider: 'deepseek', - model: 'deepseek-v4-pro', - }) - startCodingAgentRunMock.mockResolvedValue({ agentSessionId: 'agent-session-2' }) - sendCodingAgentRunInputMock.mockResolvedValue({ runId: 'agent-session-2' }) - - const { handleCodingAgentRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-coding-agent-run') - const state = { - messages: [], - isWorking: false, - isAborting: false, - events: [], - queue: [], - } - const sessionMap = new Map([['session-1', state]]) - const socket = { - join: vi.fn(), - emit: vi.fn(), - } - - await handleCodingAgentRun({} as any, socket as any, { - session_id: 'session-1', - input: 'continue with new model', - coding_agent_id: 'codex', - mode: 'scoped', - }, 'default', sessionMap as any) - - expect(managerMock.isSessionLaunchCompatible).toHaveBeenCalledWith('session-1', { - agentId: 'codex', - mode: 'scoped', - provider: 'deepseek', - model: 'deepseek-v4-pro', - }) - expect(managerMock.stop).toHaveBeenCalledWith('session-1', { reportClosed: false }) - expect(startCodingAgentRunMock).toHaveBeenCalledWith('codex', expect.objectContaining({ - sessionId: 'session-1', - mode: 'scoped', - profile: 'default', - }), state) - expect(sendCodingAgentRunInputMock).toHaveBeenCalledWith('session-1', 'continue with new model', 'system prompt') - }) - - it('passes global session source through to the coding-agent runner', async () => { - managerMock.runIdForSession.mockReturnValue(undefined) - managerMock.isSessionLaunchCompatible.mockReturnValue(true) - startCodingAgentRunMock.mockResolvedValue({ agentSessionId: 'agent-session-1' }) - sendCodingAgentRunInputMock.mockResolvedValue({ runId: 'agent-session-1' }) - - const { handleCodingAgentRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-coding-agent-run') - const state = { - messages: [], - isWorking: false, - isAborting: false, - events: [], - queue: [], - } - const sessionMap = new Map([['session-1', state]]) - const socket = { - join: vi.fn(), - emit: vi.fn(), - } - - await handleCodingAgentRun({} as any, socket as any, { - session_id: 'session-1', - input: 'hello codex', - coding_agent_id: 'codex', - session_source: 'global_agent', - }, 'default', sessionMap as any) - - expect(startCodingAgentRunMock).toHaveBeenCalledWith('codex', expect.objectContaining({ - sessionId: 'session-1', - sessionSource: 'global_agent', - }), state) - expect(sendCodingAgentRunInputMock).toHaveBeenCalledWith('session-1', 'hello codex', 'system prompt') - }) - - it('passes the Hermes system prompt on every scoped Claude Code run', async () => { - managerMock.runIdForSession.mockReturnValue(undefined) - managerMock.isSessionLaunchCompatible.mockReturnValue(true) - startCodingAgentRunMock.mockResolvedValue({ agentSessionId: 'agent-session-1' }) - sendCodingAgentRunInputMock.mockResolvedValue({ runId: 'agent-session-1' }) - - const { handleCodingAgentRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-coding-agent-run') - const state = { - messages: [], - isWorking: false, - isAborting: false, - events: [], - queue: [], - } - const sessionMap = new Map([['session-1', state]]) - const socket = { - join: vi.fn(), - emit: vi.fn(), - } - - await handleCodingAgentRun({} as any, socket as any, { - session_id: 'session-1', - input: 'hello claude', - coding_agent_id: 'claude-code', - }, 'default', sessionMap as any) - - expect(sendCodingAgentRunInputMock).toHaveBeenCalledWith('session-1', 'hello claude', 'system prompt') - }) - - it('reopens an ended coding-agent session before sending a new input', async () => { - managerMock.runIdForSession.mockReturnValue('agent-session-1') - managerMock.isSessionLaunchCompatible.mockReturnValue(true) - sendCodingAgentRunInputMock.mockResolvedValue({ runId: 'agent-session-1' }) - getSessionMock.mockReturnValue({ - id: 'session-1', - source: 'coding_agent', - agent: 'codex', - ended_at: 123, - end_reason: 'complete', - }) - - const { handleCodingAgentRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-coding-agent-run') - const state = { - messages: [], - isWorking: false, - isAborting: false, - events: [], - queue: [], - } - const sessionMap = new Map([['session-1', state]]) - const socket = { - join: vi.fn(), - emit: vi.fn(), - } - - await handleCodingAgentRun({} as any, socket as any, { - session_id: 'session-1', - input: 'continue', - coding_agent_id: 'codex', - }, 'default', sessionMap as any) - - expect(updateSessionMock).toHaveBeenCalledWith('session-1', expect.objectContaining({ - ended_at: null, - end_reason: null, - last_active: expect.any(Number), - })) - expect(sendCodingAgentRunInputMock).toHaveBeenCalledWith('session-1', 'continue', 'system prompt') - }) - - it('marks a reopened coding-agent session as errored when input send fails before processing starts', async () => { - managerMock.runIdForSession.mockReturnValue('agent-session-1') - managerMock.isSessionLaunchCompatible.mockReturnValue(true) - managerMock.isSessionProcessing.mockReturnValue(false) - sendCodingAgentRunInputMock.mockRejectedValue(new Error('send failed')) - - const { handleCodingAgentRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-coding-agent-run') - const state = { - messages: [], - isWorking: false, - isAborting: false, - events: [], - queue: [], - } - const sessionMap = new Map([['session-1', state]]) - const socket = { - join: vi.fn(), - emit: vi.fn(), - } - - await expect(handleCodingAgentRun({} as any, socket as any, { - session_id: 'session-1', - input: 'continue', - coding_agent_id: 'codex', - }, 'default', sessionMap as any)).rejects.toThrow('send failed') - - expect(updateSessionMock.mock.calls.map(call => call[1])).toEqual([ - expect.objectContaining({ - ended_at: null, - end_reason: null, - last_active: expect.any(Number), - }), - expect.objectContaining({ - ended_at: expect.any(Number), - end_reason: 'error', - }), - ]) - expect(state.isWorking).toBe(false) - }) - - it('keeps profile token handling separate from the system prompt for authenticated users', async () => { - managerMock.runIdForSession.mockReturnValue('agent-session-1') - managerMock.isSessionLaunchCompatible.mockReturnValue(true) - sendCodingAgentRunInputMock.mockResolvedValue({ runId: 'agent-session-1' }) - writeModelRunProfileTokenMock.mockResolvedValue(undefined) - getSystemPromptMock.mockReturnValue([ - 'system prompt', - 'Hermes Studio MCP usage: call hermes_studio_api_openapi_get before calling unfamiliar Web UI endpoints.', - 'Use hermes_studio_api_request with method, relative path, and JSON body/query fields.', - ].join('\n')) - - const { handleCodingAgentRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-coding-agent-run') - const state = { - messages: [], - isWorking: false, - isAborting: false, - events: [], - queue: [], - } - const sessionMap = new Map([['session-1', state]]) - const socket = { - data: { user: { id: 1, username: 'admin', role: 'super_admin' } }, - join: vi.fn(), - emit: vi.fn(), - } - - await handleCodingAgentRun({} as any, socket as any, { - session_id: 'session-1', - input: 'hello codex', - coding_agent_id: 'codex', - }, 'default', sessionMap as any) - - expect(writeModelRunProfileTokenMock).toHaveBeenCalledWith( - { id: 1, username: 'admin', role: 'super_admin' }, - 'default', - ) - expect(sendCodingAgentRunInputMock).toHaveBeenCalledWith( - 'session-1', - 'hello codex', - expect.stringContaining('system prompt\nHermes Studio MCP usage'), - ) - const prompt = sendCodingAgentRunInputMock.mock.calls.at(-1)?.[2] - expect(prompt).toContain('hermes_studio_api_request') - expect(prompt).not.toContain('run-token') - expect(prompt).not.toContain('[Current Hermes profile:') - expect(prompt).not.toContain('Current Hermes Web UI model run token') - expect(prompt).not.toContain('Hermes Web UI LAN device capabilities are MCP tools') - expect(prompt).not.toContain('list_mcp_resources') - expect(prompt).not.toContain('mcp__hermes-studio__') - }) -}) diff --git a/tests/server/hermes-ui-mcp.test.ts b/tests/server/hermes-ui-mcp.test.ts index 63a7915..2a4975e 100644 --- a/tests/server/hermes-ui-mcp.test.ts +++ b/tests/server/hermes-ui-mcp.test.ts @@ -322,11 +322,11 @@ describe('hermes-ui MCP server', () => { }) return } - if (req.url === '/api/hermes/sessions?limit=2&source=coding_agent') { + if (req.url === '/api/hermes/sessions?limit=2&source=workflow') { res.end(JSON.stringify({ sessions: [{ id: 'session-1' }] })) return } - if (req.url === '/api/hermes/sessions/count?source=coding_agent') { + if (req.url === '/api/hermes/sessions/count?source=workflow') { res.end(JSON.stringify({ count: 7 })) return } @@ -514,7 +514,7 @@ describe('hermes-ui MCP server', () => { }) writeRpc(child, 4, 'tools/call', { name: 'hermes_studio_use_sessions_list', - arguments: { limit: 2, source: 'coding_agent' }, + arguments: { limit: 2, source: 'workflow' }, }) writeRpc(child, 5, 'tools/call', { name: 'hermes_studio_use_session_get', @@ -522,7 +522,7 @@ describe('hermes-ui MCP server', () => { }) writeRpc(child, 10, 'tools/call', { name: 'hermes_studio_use_sessions_count', - arguments: { source: 'coding_agent' }, + arguments: { source: 'workflow' }, }) writeRpc(child, 6, 'tools/call', { name: 'hermes_studio_use_session_messages', diff --git a/tests/server/run-chat-abort-goal.test.ts b/tests/server/run-chat-abort-goal.test.ts index 8ad5ff7..d2dc363 100644 --- a/tests/server/run-chat-abort-goal.test.ts +++ b/tests/server/run-chat-abort-goal.test.ts @@ -3,13 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const updateSessionStatsMock = vi.fn() const updateSessionMock = vi.fn() const flushBridgePendingToDbMock = vi.fn() -const flushResponseRunToDbMock = vi.fn() const replaceStateMock = vi.fn() const calcAndUpdateUsageMock = vi.fn() -const codingAgentRunManagerMock = vi.hoisted(() => ({ - hasSession: vi.fn(() => false), - stop: vi.fn(() => false), -})) vi.mock('../../packages/server/src/db/hermes/session-store', () => ({ updateSession: updateSessionMock, @@ -24,10 +19,6 @@ vi.mock('../../packages/server/src/services/hermes/run-chat/bridge-message', () flushBridgePendingToDb: flushBridgePendingToDbMock, })) -vi.mock('../../packages/server/src/services/hermes/run-chat/response-stream', () => ({ - flushResponseRunToDb: flushResponseRunToDbMock, -})) - vi.mock('../../packages/server/src/services/hermes/run-chat/compression', () => ({ replaceState: replaceStateMock, })) @@ -36,10 +27,6 @@ vi.mock('../../packages/server/src/services/hermes/run-chat/usage', () => ({ calcAndUpdateUsage: calcAndUpdateUsageMock, })) -vi.mock('../../packages/server/src/services/agent-runner/coding-agent-run-manager', () => ({ - codingAgentRunManager: codingAgentRunManagerMock, -})) - function makeHarness() { const emit = vi.fn() const nsp = { @@ -56,8 +43,6 @@ function makeHarness() { describe('run chat abort goal handling', () => { beforeEach(() => { vi.clearAllMocks() - codingAgentRunManagerMock.hasSession.mockReturnValue(false) - codingAgentRunManagerMock.stop.mockReturnValue(false) calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 0, outputTokens: 0 }) }) @@ -149,72 +134,76 @@ describe('run chat abort goal handling', () => { }) - it('stops a workflow-scoped coding-agent run instead of misrouting abort through the bridge', async () => { + it('aborts a workflow-scoped run through the bridge', async () => { const { handleAbort } = await import('../../packages/server/src/services/hermes/run-chat/abort') const { emit, nsp, socket } = makeHarness() - codingAgentRunManagerMock.hasSession.mockReturnValue(true) - codingAgentRunManagerMock.stop.mockReturnValue(true) const state = { messages: [], isWorking: true, isAborting: false, events: [], queue: [], - runId: 'coding-run-1', + runId: 'workflow-run-1', profile: 'default', source: 'workflow', } as any const sessionMap = new Map([['session-1', state]]) const bridge = { - interrupt: vi.fn().mockRejectedValue(new Error('unknown session: session-1')), - goalPause: vi.fn(), + interrupt: vi.fn().mockResolvedValue({ ok: true, synced: true }), + goalPause: vi.fn().mockResolvedValue({ handled: true }), } const runQueuedItem = vi.fn() await handleAbort(nsp as any, socket as any, 'session-1', sessionMap, bridge, runQueuedItem) - expect(bridge.interrupt).not.toHaveBeenCalled() - expect(bridge.goalPause).not.toHaveBeenCalled() - expect(codingAgentRunManagerMock.stop).toHaveBeenCalledWith('session-1', { reportClosed: false }) + expect(bridge.interrupt).toHaveBeenCalledWith('session-1', 'Aborted by user', 'default') + expect(bridge.goalPause).toHaveBeenCalled() expect(updateSessionMock).toHaveBeenCalledWith('session-1', expect.objectContaining({ ended_at: expect.any(Number), end_reason: 'abort', })) - expect(flushResponseRunToDbMock).toHaveBeenCalledWith(expect.objectContaining({ + expect(flushBridgePendingToDbMock).toHaveBeenCalledWith(expect.objectContaining({ source: 'workflow', }), 'session-1') - expect(flushBridgePendingToDbMock).not.toHaveBeenCalled() - expect(codingAgentRunManagerMock.stop).toHaveBeenCalledWith('session-1', { reportClosed: false }) expect(state.isWorking).toBe(false) expect(state.isAborting).toBe(false) expect(emit).toHaveBeenCalledWith('abort.completed', expect.objectContaining({ session_id: 'session-1', - run_id: 'coding-run-1', + run_id: 'workflow-run-1', synced: true, })) }) - it('stops a coding-agent run even when chat-run state was not marked working', async () => { + it('aborts a session stored with a retired source through the bridge', async () => { const { handleAbort } = await import('../../packages/server/src/services/hermes/run-chat/abort') const { emit, nsp, socket } = makeHarness() - const sessionMap = new Map() - codingAgentRunManagerMock.hasSession.mockReturnValue(true) - codingAgentRunManagerMock.stop.mockReturnValue(true) + const state = { + messages: [], + isWorking: true, + isAborting: false, + events: [], + queue: [], + runId: 'legacy-run-1', + profile: 'default', + source: 'retired_source', + } as any + const sessionMap = new Map([['session-1', state]]) + const bridge = { + interrupt: vi.fn().mockResolvedValue({ ok: true, synced: true }), + goalPause: vi.fn().mockResolvedValue({ handled: true }), + } const runQueuedItem = vi.fn() - await handleAbort(nsp as any, socket as any, 'session-1', sessionMap as any, {}, runQueuedItem) + await handleAbort(nsp as any, socket as any, 'session-1', sessionMap, bridge, runQueuedItem) - expect(codingAgentRunManagerMock.stop).toHaveBeenCalledWith('session-1', { reportClosed: false }) - expect(sessionMap.get('session-1')).toEqual(expect.objectContaining({ - isWorking: false, - isAborting: false, - source: 'coding_agent', + expect(bridge.interrupt).toHaveBeenCalledWith('session-1', 'Aborted by user', 'default') + expect(updateSessionMock).toHaveBeenCalledWith('session-1', expect.objectContaining({ + ended_at: expect.any(Number), + end_reason: 'abort', })) - expect(flushResponseRunToDbMock).toHaveBeenCalledWith(expect.objectContaining({ - source: 'coding_agent', - }), 'session-1') expect(emit).toHaveBeenCalledWith('abort.completed', expect.objectContaining({ session_id: 'session-1', + run_id: 'legacy-run-1', synced: true, })) }) diff --git a/tests/server/run-chat-ekko-context.test.ts b/tests/server/run-chat-ekko-context.test.ts deleted file mode 100644 index 2c0263a..0000000 --- a/tests/server/run-chat-ekko-context.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const getSessionMock = vi.hoisted(() => vi.fn()) -const createSessionMock = vi.hoisted(() => vi.fn()) -const addMessageMock = vi.hoisted(() => vi.fn()) -const updateSessionMock = vi.hoisted(() => vi.fn()) -const updateSessionStatsMock = vi.hoisted(() => vi.fn()) -const resolveBridgeRunModelConfigMock = vi.hoisted(() => vi.fn()) -const agentRunMock = vi.hoisted(() => vi.fn()) -const recordSessionUsageMock = vi.hoisted(() => vi.fn()) - -vi.mock('../../packages/server/src/db/hermes/session-store', () => ({ - getSession: getSessionMock, - createSession: createSessionMock, - addMessage: addMessageMock, - updateSession: updateSessionMock, - updateSessionStats: updateSessionStatsMock, -})) - -vi.mock('../../packages/server/src/services/hermes/run-chat/model-config', () => ({ - resolveBridgeRunModelConfig: resolveBridgeRunModelConfigMock, -})) - -vi.mock('../../packages/server/src/services/ekko-agent/manager', () => ({ - getGlobalEkkoAgent: vi.fn(() => ({ - run: agentRunMock, - })), -})) - -vi.mock('../../packages/server/src/services/ekko-agent/mcp', () => ({ - resolveEkkoMcpServers: vi.fn(() => undefined), -})) - -vi.mock('../../packages/ekko-agent/src', () => ({ - createModelClient: vi.fn(() => ({ - provider: 'test', - requestStyle: 'custom-runtime', - capabilities: { - streaming: false, - tools: true, - vision: false, - jsonMode: false, - systemPrompt: true, - }, - })), - resolveModelProviderConfigs: vi.fn(() => ({ - providerConfig: { - provider: 'test', - model: 'ekko-test-model', - apiMode: 'chat_completions', - }, - })), -})) - -vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({ - getProfileDir: vi.fn(() => '/tmp/hermes-default'), -})) - -vi.mock('../../packages/server/src/services/hermes/pet-state-socket', () => ({ - observeRunChatPetEvent: vi.fn(), -})) - -vi.mock('../../packages/server/src/services/logger', () => ({ - logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, -})) - -vi.mock('../../packages/server/src/services/usage-recorder', () => ({ - recordSessionUsage: recordSessionUsageMock, -})) - -function makeHarness() { - const roomTarget = { emit: vi.fn(), except: vi.fn(() => ({ emit: vi.fn() })) } - const nsp = { - adapter: { rooms: new Map([['session:session-1', new Set(['socket-1'])]]) }, - to: vi.fn(() => roomTarget), - } - const socket = { - id: 'socket-1', - connected: true, - join: vi.fn(), - emit: vi.fn(), - to: vi.fn(() => roomTarget), - } - const state = { - messages: [], - isWorking: false, - events: [], - queue: [], - inputTokens: 10, - outputTokens: 5, - } - const sessionMap = new Map([['session-1', state]]) - const events: Array<{ event: string; payload: any }> = [] - return { nsp, socket, sessionMap, state, events } -} - -describe('ekko-agent context usage events', () => { - beforeEach(() => { - vi.clearAllMocks() - getSessionMock.mockReturnValue({ - id: 'session-1', - profile: 'default', - source: 'coding_agent', - agent: 'ekko-agent', - model: 'ekko-test-model', - provider: 'test-provider', - workspace: '/tmp/workspace', - }) - addMessageMock.mockReturnValue(1) - resolveBridgeRunModelConfigMock.mockResolvedValue({ - model: 'ekko-test-model', - provider: 'test-provider', - }) - }) - - it('does not publish step context estimates as formal usage updates', async () => { - agentRunMock.mockImplementationOnce(async (input: any) => { - input.onEvent({ type: 'run.started', runId: 'run-1', maxSteps: 3 }) - input.onEvent({ - type: 'context.estimated', - runId: 'run-1', - step: 1, - estimate: { - contextTokens: 10_000, - systemPromptTokens: 1_000, - messageTokens: 2_000, - toolTokens: 7_000, - modelContextTokens: 0, - messageCount: 2, - toolCount: 5, - }, - }) - input.onEvent({ - type: 'context.estimated', - runId: 'run-1', - step: 2, - estimate: { - contextTokens: 30_000, - systemPromptTokens: 1_000, - messageTokens: 22_000, - toolTokens: 7_000, - modelContextTokens: 0, - messageCount: 4, - toolCount: 5, - }, - }) - input.onEvent({ - type: 'model.usage', - runId: 'run-1', - step: 2, - usage: { - inputTokens: 3, - outputTokens: 2, - cacheReadTokens: 1, - reasoningTokens: 1, - }, - }) - return { - runId: 'run-1', - output: { role: 'assistant', content: 'done', usage: { inputTokens: 3, outputTokens: 2 } }, - steps: [], - messages: [], - events: [], - contextEstimate: { contextTokens: 30_000 }, - } - }) - const { handleEkkoAgentRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-ekko-agent-run') - const { nsp, socket, sessionMap, state, events } = makeHarness() - - await handleEkkoAgentRun(nsp as any, socket as any, { - session_id: 'session-1', - input: 'continue', - coding_agent_id: 'ekko-agent', - onEvent: (event: string, payload: any) => events.push({ event, payload }), - }, 'default', sessionMap, vi.fn(() => false)) - - expect(events.filter(item => item.event === 'context.estimated').map(item => item.payload.contextTokens)).toEqual([ - 10_000, - 30_000, - ]) - const usageEvents = events.filter(item => item.event === 'usage.updated') - expect(usageEvents).toHaveLength(1) - expect(usageEvents[0].payload).toEqual(expect.objectContaining({ - input_tokens: 13, - output_tokens: 7, - total_tokens: 20, - contextTokens: 30_000, - })) - expect(state.contextTokens).toBe(30_000) - expect(recordSessionUsageMock).toHaveBeenCalledWith({ - sessionId: 'session-1', - runId: 'run-1:step:2:call:1', - source: 'ekko_agent', - agent: 'ekko_agent', - usageScope: 'model_call', - apiCalls: 1, - usage: { - inputTokens: 3, - outputTokens: 2, - cacheReadTokens: 1, - reasoningTokens: 1, - }, - profile: 'default', - model: 'ekko-test-model', - provider: 'test-provider', - isEstimated: false, - }) - expect(updateSessionMock).toHaveBeenCalledWith('session-1', expect.objectContaining({ - ended_at: null, - end_reason: null, - last_active: expect.any(Number), - })) - expect(updateSessionMock).toHaveBeenCalledWith('session-1', expect.objectContaining({ - ended_at: expect.any(Number), - end_reason: 'complete', - })) - }) - - it('includes paired tool results in Ekko history for follow-up turns', async () => { - agentRunMock.mockResolvedValueOnce({ - runId: 'run-1', - output: { role: 'assistant', content: 'follow-up done', usage: { inputTokens: 3, outputTokens: 2 } }, - steps: [], - messages: [], - events: [], - contextEstimate: { contextTokens: 12_000 }, - }) - const { handleEkkoAgentRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-ekko-agent-run') - const { nsp, socket, sessionMap, state } = makeHarness() - state.messages = [ - { - id: 1, - session_id: 'session-1', - role: 'user', - content: 'check weather', - timestamp: 1000, - }, - { - id: 2, - session_id: 'session-1', - role: 'assistant', - content: '', - tool_calls: [{ - id: 'call_weather', - type: 'function', - function: { - name: 'browser_navigate', - arguments: '{"url":"https://weather.example"}', - }, - }], - timestamp: 1001, - }, - { - id: 3, - session_id: 'session-1', - role: 'tool', - content: '{"forecast":"sunny"}', - tool_call_id: 'call_weather', - tool_name: 'browser_navigate', - timestamp: 1002, - }, - { - id: 4, - session_id: 'session-1', - role: 'tool', - content: 'orphan result', - tool_call_id: 'call_orphan', - tool_name: 'browser_navigate', - timestamp: 1003, - }, - ] - - await handleEkkoAgentRun(nsp as any, socket as any, { - session_id: 'session-1', - input: 'thanks', - coding_agent_id: 'ekko-agent', - }, 'default', sessionMap, vi.fn(() => false)) - - const runInput = agentRunMock.mock.calls[0][0] - expect(runInput.messages).toMatchObject([ - { role: 'user', content: 'check weather' }, - { - role: 'assistant', - content: '', - toolCalls: [{ - id: 'call_weather', - name: 'browser_navigate', - arguments: { url: 'https://weather.example' }, - rawArguments: '{"url":"https://weather.example"}', - }], - }, - { - role: 'tool', - content: '{"forecast":"sunny"}', - toolCallId: 'call_weather', - name: 'browser_navigate', - }, - { role: 'user', content: 'thanks' }, - ]) - expect(runInput.messages.some((message: any) => message.content === 'orphan result')).toBe(false) - }) -}) diff --git a/tests/server/run-chat-queued-item.test.ts b/tests/server/run-chat-queued-item.test.ts index 9a95a86..acf2b92 100644 --- a/tests/server/run-chat-queued-item.test.ts +++ b/tests/server/run-chat-queued-item.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const handleBridgeRunMock = vi.hoisted(() => vi.fn(async () => {})) const resumeBridgeRunMock = vi.hoisted(() => vi.fn(async () => {})) -const handleCodingAgentRunMock = vi.hoisted(() => vi.fn(async () => {})) const loadSessionStateFromDbMock = vi.hoisted(() => vi.fn()) const ensureReadyMock = vi.hoisted(() => vi.fn()) const sessionCommandMocks = vi.hoisted(() => ({ @@ -30,10 +29,6 @@ vi.mock('../../packages/server/src/services/hermes/run-chat/load-state', () => ( resolveRunSource: vi.fn((source?: string) => source || 'cli'), })) -vi.mock('../../packages/server/src/services/hermes/run-chat/handle-coding-agent-run', () => ({ - handleCodingAgentRun: handleCodingAgentRunMock, -})) - vi.mock('../../packages/server/src/services/hermes/run-chat/session-command', () => sessionCommandMocks) vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({ @@ -294,7 +289,7 @@ describe('ChatRunSocket queued bridge runs', () => { expect(call[6]).toBe(false) }) - it('queues coding-agent messages while a coding-agent turn is active', async () => { + it('queues messages carrying a retired legacy source while a turn is active', async () => { const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat') const { handlers, io, namespace, socket } = makeServerHarness() const server = new ChatRunSocket(io as any) @@ -305,33 +300,31 @@ describe('ChatRunSocket queued bridge runs', () => { isAborting: false, events: [], queue: [], - source: 'coding_agent', + source: 'retired_source', }) await handlers.get('run')?.({ session_id: 'session-1', - input: 'queued codex follow-up', - source: 'coding_agent', - coding_agent_id: 'codex', - queue_id: 'queue-codex', + input: 'queued follow-up', + source: 'retired_source', + queue_id: 'queue-legacy', model: 'gpt-5-codex', provider: 'openai-codex', profile: 'default', }) - expect(handleCodingAgentRunMock).not.toHaveBeenCalled() + expect(handleBridgeRunMock).not.toHaveBeenCalled() expect((server as any).sessionMap.get('session-1').queue).toEqual([ expect.objectContaining({ - queue_id: 'queue-codex', - input: 'queued codex follow-up', - source: 'coding_agent', - codingAgentId: 'codex', + queue_id: 'queue-legacy', + input: 'queued follow-up', + source: 'retired_source', }), ]) expect(namespace.to).toHaveBeenCalledWith('session:session-1') }) - it('dequeues coding-agent messages when an external coding-agent run completes', async () => { + it('dequeues messages carrying a retired legacy source on the bridge', async () => { const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat') const { io, socket } = makeServerHarness() const server = new ChatRunSocket(io as any) @@ -341,27 +334,25 @@ describe('ChatRunSocket queued bridge runs', () => { isAborting: false, events: [], queue: [{ - queue_id: 'queue-codex', - input: 'queued codex follow-up', - source: 'coding_agent', - codingAgentId: 'codex', + queue_id: 'queue-legacy', + input: 'queued follow-up', + source: 'retired_source', model: 'gpt-5-codex', provider: 'openai-codex', profile: 'default', originSocketId: socket.id, }], - source: 'coding_agent', + source: 'retired_source', }) ;(server as any).markExternalRunCompleted('session-1', 'run.completed') - await vi.waitFor(() => expect(handleCodingAgentRunMock).toHaveBeenCalled()) - const call = handleCodingAgentRunMock.mock.calls.at(-1)! + await vi.waitFor(() => expect(handleBridgeRunMock).toHaveBeenCalled()) + const call = handleBridgeRunMock.mock.calls.at(-1)! expect(call[2]).toEqual(expect.objectContaining({ - input: 'queued codex follow-up', - source: 'coding_agent', - coding_agent_id: 'codex', - queue_id: 'queue-codex', + input: 'queued follow-up', + source: 'retired_source', + queue_id: 'queue-legacy', })) expect((server as any).sessionMap.get('session-1').queue).toEqual([]) }) diff --git a/tests/server/session-command-branch.test.ts b/tests/server/session-command-branch.test.ts index 763df62..9b8c262 100644 --- a/tests/server/session-command-branch.test.ts +++ b/tests/server/session-command-branch.test.ts @@ -149,25 +149,6 @@ describe('branch session command', () => { })) }) - it('rejects /fork for coding agent sessions', async () => { - const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command') - const { nsp, socket, namespaceEmit } = makeSocketHarness() - const sessionMap = new Map([ - ['session-1', { messages: [], isWorking: false, events: [], queue: [] }], - ]) - getSessionMock.mockReturnValue(makeParentSession({ source: 'coding_agent', agent: 'codex' })) - - await handleSessionCommand('session-1', parseSessionCommand('/fork side path')!, makeCtx(sessionMap, nsp, socket)) - - expect(createBranchedSessionMock).not.toHaveBeenCalled() - expect(namespaceEmit).toHaveBeenCalledWith('session.command', expect.objectContaining({ - action: 'branch', - ok: false, - terminal: true, - message: expect.stringContaining('Cannot branch coding agent sessions'), - })) - }) - it('rejects /fork when there are no visible conversation messages', async () => { const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command') const { nsp, socket, namespaceEmit } = makeSocketHarness() diff --git a/tests/server/sessions-controller.test.ts b/tests/server/sessions-controller.test.ts index a1c8554..89dbca1 100644 --- a/tests/server/sessions-controller.test.ts +++ b/tests/server/sessions-controller.test.ts @@ -34,9 +34,6 @@ const listUserProfilesMock = vi.fn() const readConfigYamlForProfileMock = vi.fn() const bridgeSwitchSessionModelMock = vi.fn() const bridgeGetRuntimeStateMock = vi.fn() -const codingAgentRunManagerMock = vi.hoisted(() => ({ - stop: vi.fn(), -})) vi.mock('../../packages/server/src/db/hermes/conversations-db', () => ({ listConversationSummariesFromDb: listConversationSummariesFromDbMock, @@ -126,10 +123,6 @@ vi.mock('../../packages/server/src/services/config-helpers', () => ({ readConfigYamlForProfile: readConfigYamlForProfileMock, })) -vi.mock('../../packages/server/src/services/agent-runner/coding-agent-run-manager', () => ({ - codingAgentRunManager: codingAgentRunManagerMock, -})) - vi.mock('../../packages/server/src/db/hermes/compression-snapshot', () => ({ getCompressionSnapshot: getCompressionSnapshotMock, })) @@ -198,7 +191,6 @@ describe('session conversations controller', () => { bridgeSwitchSessionModelMock.mockReset() bridgeGetRuntimeStateMock.mockReset() bridgeGetRuntimeStateMock.mockReturnValue({ ready: false, running: false, endpoint: 'ipc:///tmp/hermes-agent-bridge.sock' }) - codingAgentRunManagerMock.stop.mockReset() }) it('lists conversations from the local session store', async () => { @@ -698,7 +690,7 @@ describe('session conversations controller', () => { listUserProfilesMock.mockReturnValue([{ profile_name: 'default' }, { profile_name: 'travel' }]) localListSessionsMock.mockReturnValue([ { id: 'default-session', profile: 'default', source: 'cli' }, - { id: 'travel-session', profile: 'travel', source: 'coding_agent' }, + { id: 'travel-session', profile: 'travel', source: 'global_agent' }, { id: 'archived-session', profile: 'default', source: 'cli', is_archived: 1 }, { id: 'secret-session', profile: 'secret', source: 'cli' }, { id: 'unknown-profile-session', profile: 'missing', source: 'cli' }, @@ -839,14 +831,13 @@ describe('session conversations controller', () => { ]) }) - it('keeps archived coding-agent sessions visible in Hermes history', async () => { + it('keeps archived sessions stored with a retired source visible in Hermes history', async () => { localListSessionsMock.mockReturnValue([{ - id: 'codex-archived', + id: 'legacy-archived', profile: 'travel', - source: 'coding_agent', - agent: 'codex', + source: 'retired_source', model: 'gpt-5', - title: 'Archived Codex', + title: 'Archived legacy session', started_at: 1, ended_at: null, last_active: 2, @@ -872,7 +863,7 @@ describe('session conversations controller', () => { await mod.listHermesSessions(ctx) expect(ctx.body.sessions).toEqual([ - expect.objectContaining({ id: 'codex-archived', source: 'coding_agent', agent: 'codex', webui_imported: true }), + expect.objectContaining({ id: 'legacy-archived', source: 'retired_source', webui_imported: true }), ]) }) @@ -916,7 +907,7 @@ describe('session conversations controller', () => { }) it('unarchives an existing accessible session', async () => { - getSessionMock.mockReturnValue({ id: 'session-1', profile: 'default', source: 'coding_agent', is_archived: 1 }) + getSessionMock.mockReturnValue({ id: 'session-1', profile: 'default', source: 'retired_source', is_archived: 1 }) localSetSessionArchivedMock.mockReturnValue(true) const mod = await import('../../packages/server/src/controllers/hermes/sessions') @@ -1351,13 +1342,12 @@ describe('session conversations controller', () => { expect(ctx.body).toEqual({ ok: true }) }) - it('stores a coding agent session model and API mode without stopping the runner or notifying the Hermes bridge', async () => { + it('treats a session stored with a retired source like any bridge session when switching models', async () => { bridgeGetRuntimeStateMock.mockReturnValue({ ready: true, running: true, endpoint: 'ipc:///tmp/hermes-agent-bridge.sock' }) getSessionMock.mockReturnValue({ - id: 'codex-session', + id: 'legacy-session', profile: 'default', - source: 'coding_agent', - agent: 'codex', + source: 'retired_source', model: 'old-model', provider: 'openrouter', api_mode: 'codex_responses', @@ -1367,20 +1357,23 @@ describe('session conversations controller', () => { const mod = await import('../../packages/server/src/controllers/hermes/sessions') const ctx: any = { - params: { id: 'codex-session' }, - request: { body: { model: 'gpt-5.5', provider: 'openai-codex', apiMode: 'chat_completions' } }, + params: { id: 'legacy-session' }, + request: { body: { model: 'gpt-5.5', provider: 'openai', apiMode: 'chat_completions' } }, body: null, } await mod.setModel(ctx) - expect(localUpdateSessionMock).toHaveBeenCalledWith('codex-session', { + expect(localUpdateSessionMock).toHaveBeenCalledWith('legacy-session', { model: 'gpt-5.5', - provider: 'openai-codex', + provider: 'openai', api_mode: 'chat_completions', - agent_native_session_id: '', }) - expect(codingAgentRunManagerMock.stop).not.toHaveBeenCalled() - expect(bridgeSwitchSessionModelMock).not.toHaveBeenCalled() + expect(bridgeSwitchSessionModelMock).toHaveBeenCalledWith( + 'legacy-session', + 'gpt-5.5', + 'openai', + 'default', + ) expect(ctx.body).toEqual({ ok: true }) }) @@ -1404,22 +1397,22 @@ describe('session conversations controller', () => { }) }) - it('deletes a local coding-agent session without invoking Hermes CLI deletion', async () => { + it('deletes a session stored with a retired source through the normal path', async () => { getSessionMock.mockReturnValue({ - id: 'codex-session', + id: 'legacy-session', profile: 'default', - source: 'coding_agent', + source: 'retired_source', }) localDeleteSessionMock.mockReturnValue(true) + getExactSessionDetailFromDbWithProfileMock.mockResolvedValue(null) const mod = await import('../../packages/server/src/controllers/hermes/sessions') - const ctx: any = { params: { id: 'codex-session' }, body: null } + const ctx: any = { params: { id: 'legacy-session' }, body: null } await mod.remove(ctx) - expect(codingAgentRunManagerMock.stop).toHaveBeenCalledWith('codex-session', { reportClosed: false }) - expect(getExactSessionDetailFromDbWithProfileMock).not.toHaveBeenCalled() + expect(getExactSessionDetailFromDbWithProfileMock).toHaveBeenCalledWith('legacy-session', 'default') expect(deleteHermesSessionForProfileMock).not.toHaveBeenCalled() - expect(localDeleteSessionMock).toHaveBeenCalledWith('codex-session') + expect(localDeleteSessionMock).toHaveBeenCalledWith('legacy-session') expect(ctx.body).toEqual({ ok: true, deleted: true, @@ -1463,29 +1456,29 @@ describe('session conversations controller', () => { expect(ctx.body).toMatchObject({ ok: true, deleted: 2, failed: 0, hermesDeleted: 2 }) }) - it('batch deletes local coding-agent sessions without invoking Hermes CLI deletion', async () => { + it('batch deletes sessions stored with a retired source through the normal path', async () => { getSessionMock.mockReturnValue({ - id: 'codex-session', + id: 'legacy-session', profile: 'default', - source: 'coding_agent', + source: 'retired_source', }) localDeleteSessionMock.mockReturnValue(true) + getExactSessionDetailFromDbWithProfileMock.mockResolvedValue(null) const mod = await import('../../packages/server/src/controllers/hermes/sessions') const ctx: any = { request: { body: { - sessions: [{ id: 'codex-session', profile: 'default' }], + sessions: [{ id: 'legacy-session', profile: 'default' }], }, }, body: null, } await mod.batchRemove(ctx) - expect(codingAgentRunManagerMock.stop).toHaveBeenCalledWith('codex-session', { reportClosed: false }) - expect(getExactSessionDetailFromDbWithProfileMock).not.toHaveBeenCalled() + expect(getExactSessionDetailFromDbWithProfileMock).toHaveBeenCalledWith('legacy-session', 'default') expect(deleteHermesSessionForProfileMock).not.toHaveBeenCalled() - expect(localDeleteSessionMock).toHaveBeenCalledWith('codex-session') + expect(localDeleteSessionMock).toHaveBeenCalledWith('legacy-session') expect(ctx.body).toMatchObject({ ok: true, deleted: 1, failed: 0, hermesDeleted: 0 }) }) diff --git a/tests/server/usage-recorder.test.ts b/tests/server/usage-recorder.test.ts index 44e9fab..55eb062 100644 --- a/tests/server/usage-recorder.test.ts +++ b/tests/server/usage-recorder.test.ts @@ -96,8 +96,8 @@ describe('usage recorder', () => { recordSessionUsage({ sessionId: 'session-1', runId: 'run-1', - source: 'coding_agent', - agent: 'codex', + source: 'hermes', + agent: 'hermes', profile: 'work', model: 'gpt-5', provider: 'openai', @@ -106,8 +106,8 @@ describe('usage recorder', () => { expect(updateUsageMock).toHaveBeenCalledWith('session-1', { runId: 'run-1', - source: 'coding_agent', - agent: 'codex', + source: 'hermes', + agent: 'hermes', usageScope: undefined, apiCalls: undefined, inputTokens: 10, diff --git a/tests/server/usage-store-agent-stats-db.test.ts b/tests/server/usage-store-agent-stats-db.test.ts index 6877e63..ebf4046 100644 --- a/tests/server/usage-store-agent-stats-db.test.ts +++ b/tests/server/usage-store-agent-stats-db.test.ts @@ -44,14 +44,14 @@ describe('usage store agent breakdown', () => { cache_read_tokens, model, profile, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `) - insert.run('new-claude', 'coding_agent', 'claude_code', 100, 10, 40, 'claude-model', 'default', now) - insert.run('new-codex', 'coding_agent', 'codex', 80, 20, 30, 'codex-model', 'default', now) + insert.run('session-workflow', 'workflow', 'workflow', 100, 10, 40, 'workflow-model', 'default', now) + insert.run('session-global', 'global_agent', 'global_agent', 80, 20, 30, 'global-model', 'default', now) insert.run('hermes-1', 'hermes', 'hermes', 30, 3, 6, 'hermes-model', 'default', now) const { getLocalUsageStats } = await import('../../packages/server/src/db/hermes/usage-store') expect(getLocalUsageStats('default', 7).by_agent).toEqual([ - { agent: 'claude_code', input_tokens: 100, output_tokens: 10, cache_read_tokens: 40, cache_write_tokens: 0, reasoning_tokens: 0, sessions: 1 }, - { agent: 'codex', input_tokens: 80, output_tokens: 20, cache_read_tokens: 30, cache_write_tokens: 0, reasoning_tokens: 0, sessions: 1 }, + { agent: 'workflow', input_tokens: 100, output_tokens: 10, cache_read_tokens: 40, cache_write_tokens: 0, reasoning_tokens: 0, sessions: 1 }, + { agent: 'global_agent', input_tokens: 80, output_tokens: 20, cache_read_tokens: 30, cache_write_tokens: 0, reasoning_tokens: 0, sessions: 1 }, { agent: 'hermes', input_tokens: 30, output_tokens: 3, cache_read_tokens: 6, cache_write_tokens: 0, reasoning_tokens: 0, sessions: 1 }, ]) }) diff --git a/tests/server/usage-store.test.ts b/tests/server/usage-store.test.ts index d607009..06420f7 100644 --- a/tests/server/usage-store.test.ts +++ b/tests/server/usage-store.test.ts @@ -273,7 +273,7 @@ describe('Usage Store (SQLite path)', () => { { model: 'gpt-test', input_tokens: 100, output_tokens: 40, cache_read_tokens: 30, cache_write_tokens: 5, reasoning_tokens: 8, sessions: 2 }, ]) .mockReturnValueOnce([ - { agent: 'coding_agent', input_tokens: 100, output_tokens: 40, cache_read_tokens: 30, cache_write_tokens: 5, reasoning_tokens: 8, sessions: 2 }, + { agent: 'global_agent', input_tokens: 100, output_tokens: 40, cache_read_tokens: 30, cache_write_tokens: 5, reasoning_tokens: 8, sessions: 2 }, ]) .mockReturnValueOnce([ { date: '2026-07-11', input_tokens: 100, output_tokens: 40, cache_read_tokens: 30, cache_write_tokens: 5, sessions: 2 }, @@ -295,7 +295,7 @@ describe('Usage Store (SQLite path)', () => { { model: 'gpt-test', input_tokens: 100, output_tokens: 40, cache_read_tokens: 30, cache_write_tokens: 5, reasoning_tokens: 8, sessions: 2 }, ], by_agent: [ - { agent: 'coding_agent', input_tokens: 100, output_tokens: 40, cache_read_tokens: 30, cache_write_tokens: 5, reasoning_tokens: 8, sessions: 2 }, + { agent: 'global_agent', input_tokens: 100, output_tokens: 40, cache_read_tokens: 30, cache_write_tokens: 5, reasoning_tokens: 8, sessions: 2 }, ], by_day: [ { date: '2026-07-11', input_tokens: 100, output_tokens: 40, cache_read_tokens: 30, cache_write_tokens: 5, sessions: 2, errors: 0, cost: 0 }, diff --git a/tests/server/workflow-manager.test.ts b/tests/server/workflow-manager.test.ts index 40cb2e8..ccb673d 100644 --- a/tests/server/workflow-manager.test.ts +++ b/tests/server/workflow-manager.test.ts @@ -61,31 +61,20 @@ describe('workflow manager', () => { expect(updates).toEqual([status]) }) - it('maps workflow node agents to the existing run backends', async () => { + it('routes every workflow node through the Hermes bridge', async () => { const { resolveWorkflowNodeRunTarget } = await import('../../packages/server/src/services/workflow-manager') - expect(resolveWorkflowNodeRunTarget('hermes')).toEqual({ + const expected = { type: 'workflow', source: 'workflow', agent: 'hermes', - }) - expect(resolveWorkflowNodeRunTarget('claude-code')).toEqual({ - type: 'workflow', - source: 'workflow', - agent: 'claude', - codingAgentId: 'claude-code', - }) - expect(resolveWorkflowNodeRunTarget('codex')).toEqual({ - type: 'workflow', - source: 'workflow', - agent: 'codex', - codingAgentId: 'codex', - }) - expect(resolveWorkflowNodeRunTarget('unknown')).toEqual({ - type: 'workflow', - source: 'workflow', - agent: 'hermes', - }) + } + expect(resolveWorkflowNodeRunTarget('hermes')).toEqual(expected) + // Workflow graphs saved before Phase 003 still name the removed coding + // agents; they must keep loading and run on the bridge. + expect(resolveWorkflowNodeRunTarget('claude-code')).toEqual(expected) + expect(resolveWorkflowNodeRunTarget('codex')).toEqual(expected) + expect(resolveWorkflowNodeRunTarget('unknown')).toEqual(expected) }) it('requires workflow node approval only when explicitly enabled', async () => {