From 8c200216ebca5e87cfc8fceac4ad2506cae37063 Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Tue, 2 Jun 2026 21:10:06 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20codebase=20audit=20cleanup=20?= =?UTF-8?q?=E2=80=94=20dead=20code,=20dedup,=20module=20splits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-agent audit + aggressive cleanup across server/web/coder/booterm, delivered behind a DEFER discipline so none of the in-flight files were touched. Removes dead code/deps/columns, dedups server + coder helpers, and splits the oversized modules (tools.ts, opencode-server.ts, sentinel-summaries, turn.ts, TerminalPane.tsx) behind stable contracts. Adds 78 parity/unit tests (server 587, coder 323); fixes two latent bugs (ChatPane queue keys, FileViewerOverlay blank-line parity). Intended tag: v2.7.12-audit-cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 + CURRENT.md | 11 +- apps/booterm/package.json | 1 - apps/booterm/src/config.ts | 2 +- apps/booterm/src/db.ts | 2 +- apps/booterm/src/pty/pty.ts | 2 +- apps/coder/src/routes/tasks.ts | 2 +- apps/coder/src/routes/worktree-safety.ts | 2 +- apps/coder/src/schema.sql | 41 +- .../src/services/__tests__/acp-client.test.ts | 74 + .../services/__tests__/frame-emitter.test.ts | 102 ++ .../__tests__/normalize-agent-status.test.ts | 83 -- apps/coder/src/services/acp-client.ts | 88 ++ apps/coder/src/services/acp-dispatch.ts | 174 +-- apps/coder/src/services/acp-stream.ts | 4 +- .../__tests__/opencode-concurrency.test.ts | 173 +++ .../__tests__/opencode-event-map.test.ts | 226 +++ .../services/backends/opencode-event-map.ts | 203 +++ .../backends/opencode-server-process.ts | 325 +++++ .../src/services/backends/opencode-server.ts | 757 ++-------- .../src/services/backends/opencode-sse.ts | 181 +++ apps/coder/src/services/backends/warm-acp.ts | 86 +- apps/coder/src/services/frame-emitter.ts | 142 ++ apps/coder/src/services/mcp-server.ts | 29 - apps/coder/src/services/net/port-utils.ts | 88 ++ .../src/services/normalize-agent-status.ts | 69 - .../src/services/orphan-worktree-reaper.ts | 3 +- apps/coder/src/services/pending_changes.ts | 4 - .../src/services/provider-config-registry.ts | 4 - apps/coder/src/services/tools/index.ts | 5 - apps/coder/src/services/worktree-risk.ts | 175 +++ apps/coder/src/services/worktrees.ts | 160 +-- apps/coder/web/vite.config.d.ts | 2 - apps/coder/web/vite.config.js | 25 - apps/server/src/config.ts | 1 - apps/server/src/routes/chats.ts | 5 +- apps/server/src/routes/messages.ts | 211 ++- apps/server/src/routes/projects.ts | 64 +- apps/server/src/routes/ws.ts | 5 +- apps/server/src/schema.sql | 24 +- .../src/services/__tests__/budget.test.ts | 46 + .../__tests__/inference-helpers.test.ts | 149 ++ .../services/__tests__/model-context.test.ts | 24 +- .../src/services/__tests__/sentinels.test.ts | 87 ++ .../services/__tests__/step-decision.test.ts | 111 ++ .../services/__tests__/tools-registry.test.ts | 68 + apps/server/src/services/agents.ts | 181 +-- apps/server/src/services/auto_name.ts | 2 - .../server/src/services/codecontext_client.ts | 2 +- apps/server/src/services/compaction.ts | 23 +- apps/server/src/services/file_ops.ts | 2 +- apps/server/src/services/git_meta.ts | 7 - apps/server/src/services/inference/budget.ts | 34 +- .../src/services/inference/content-flusher.ts | 64 + .../src/services/inference/error-handler.ts | 86 +- apps/server/src/services/inference/index.ts | 15 +- apps/server/src/services/inference/parts.ts | 9 +- apps/server/src/services/inference/payload.ts | 7 +- .../services/inference/sentinel-summaries.ts | 719 +++------- .../src/services/inference/sentinels.ts | 11 +- .../src/services/inference/step-decision.ts | 47 + .../inference/stream-phase-adapter.ts | 405 ++++++ .../src/services/inference/stream-phase.ts | 439 +----- .../src/services/inference/tool-phase.ts | 2 +- .../src/services/inference/tool-summaries.ts | 81 -- .../src/services/inference/turn-config.ts | 33 + apps/server/src/services/inference/turn.ts | 200 +-- apps/server/src/services/inference/types.ts | 116 ++ apps/server/src/services/message-columns.ts | 15 + apps/server/src/services/model-context.ts | 10 +- .../server/src/services/read_tab_by_number.ts | 2 +- apps/server/src/services/skills.ts | 8 +- apps/server/src/services/synthesisPipeline.ts | 62 +- apps/server/src/services/task-summary.ts | 24 - apps/server/src/services/task-tags.ts | 22 - apps/server/src/services/tools.ts | 886 +----------- .../src/services/tools/codecontext/factory.ts | 43 + .../tools/codecontext/get_blast_radius.ts | 54 +- .../codecontext/get_codebase_overview.ts | 60 +- .../tools/codecontext/get_dependencies.ts | 69 +- .../tools/codecontext/get_file_analysis.ts | 60 +- .../codecontext/get_framework_analysis.ts | 67 +- .../tools/codecontext/get_hot_files.ts | 52 +- .../tools/codecontext/get_middleware.ts | 43 +- .../services/tools/codecontext/get_routes.ts | 55 +- .../codecontext/get_semantic_neighborhoods.ts | 89 +- .../tools/codecontext/get_symbol_info.ts | 77 +- .../tools/codecontext/search_symbols.ts | 95 +- .../tools/codecontext/watch_changes.ts | 64 +- apps/server/src/services/tools/fs-tools.ts | 392 +++++ apps/server/src/services/tools/misc-tools.ts | 216 +++ apps/server/src/services/tools/registry.ts | 96 ++ apps/server/src/services/tools/tiers.ts | 59 + apps/server/src/services/tools/types.ts | 43 + apps/server/src/types/api.ts | 38 +- .../src/utils/__tests__/string-utils.test.ts | 32 + apps/server/src/utils/string-utils.ts | 9 + apps/web/components.json | 1 - apps/web/package.json | 4 +- apps/web/src/api/client.ts | 12 - apps/web/src/api/types.ts | 30 - apps/web/src/components/AgentPicker.tsx | 10 +- .../web/src/components/ArtifactPaneHeader.tsx | 63 + apps/web/src/components/FileViewerOverlay.tsx | 8 +- apps/web/src/components/HtmlArtifactPane.tsx | 58 +- .../src/components/MarkdownArtifactPane.tsx | 72 +- apps/web/src/components/MarkdownRenderer.tsx | 147 +- apps/web/src/components/MessageBubble.tsx | 8 +- apps/web/src/components/ProjectSidebar.tsx | 17 +- apps/web/src/components/RightRail.tsx | 2 - .../web/src/components/SessionLandingPage.tsx | 14 +- apps/web/src/components/SwipeablePaneTab.tsx | 103 -- apps/web/src/components/ToolCallLine.tsx | 39 +- apps/web/src/components/Workspace.tsx | 51 +- apps/web/src/components/panes/ChatPane.tsx | 25 +- .../web/src/components/panes/SettingsPane.tsx | 5 +- .../web/src/components/panes/TerminalPane.tsx | 1258 ++--------------- .../panes/terminal/FloatingMenu.tsx | 155 ++ .../components/panes/terminal/SearchBar.tsx | 163 +++ .../panes/terminal/TerminalHotkeyBar.tsx | 115 ++ .../src/components/panes/terminal/theme.ts | 32 + apps/web/src/hooks/terminal/useTerminalFit.ts | 179 +++ .../hooks/terminal/useTerminalSelection.ts | 349 +++++ .../src/hooks/terminal/useTerminalSocket.ts | 351 +++++ apps/web/src/hooks/useArtifactDownload.ts | 26 + apps/web/src/hooks/useChatThroughput.ts | 4 - apps/web/src/hooks/useProjects.ts | 41 - apps/web/src/lib/apply-user-delta.ts | 11 - apps/web/src/lib/coder-session.ts | 9 - apps/web/src/lib/coder-tools.ts | 3 - apps/web/src/lib/format.ts | 47 +- apps/web/src/lib/linkify-paths.tsx | 34 + apps/web/src/lib/terminal-protocol.ts | 46 + apps/web/src/pages/Session.tsx | 6 - .../agent-status-normalize/proposal.md | 2 +- .../claude-sdk-sessionstore/proposal.md | 2 +- openspec/changes/license-debt-mit/proposal.md | 2 +- .../mistake-tracker-file-ledger/proposal.md | 2 +- .../sampling-streamjson-tokens/proposal.md | 2 +- .../v2-3-provider-lifecycle/proposal.md | 2 +- .../proposal.md | 2 +- .../changes/write-edit-robustness/proposal.md | 2 +- pnpm-lock.yaml | 31 +- 143 files changed, 6729 insertions(+), 6087 deletions(-) create mode 100644 apps/coder/src/services/__tests__/acp-client.test.ts create mode 100644 apps/coder/src/services/__tests__/frame-emitter.test.ts delete mode 100644 apps/coder/src/services/__tests__/normalize-agent-status.test.ts create mode 100644 apps/coder/src/services/acp-client.ts create mode 100644 apps/coder/src/services/backends/__tests__/opencode-concurrency.test.ts create mode 100644 apps/coder/src/services/backends/__tests__/opencode-event-map.test.ts create mode 100644 apps/coder/src/services/backends/opencode-event-map.ts create mode 100644 apps/coder/src/services/backends/opencode-server-process.ts create mode 100644 apps/coder/src/services/backends/opencode-sse.ts create mode 100644 apps/coder/src/services/frame-emitter.ts create mode 100644 apps/coder/src/services/net/port-utils.ts create mode 100644 apps/coder/src/services/worktree-risk.ts delete mode 100644 apps/coder/web/vite.config.d.ts delete mode 100644 apps/coder/web/vite.config.js create mode 100644 apps/server/src/services/__tests__/budget.test.ts create mode 100644 apps/server/src/services/__tests__/inference-helpers.test.ts create mode 100644 apps/server/src/services/__tests__/sentinels.test.ts create mode 100644 apps/server/src/services/__tests__/step-decision.test.ts create mode 100644 apps/server/src/services/__tests__/tools-registry.test.ts create mode 100644 apps/server/src/services/inference/content-flusher.ts create mode 100644 apps/server/src/services/inference/step-decision.ts create mode 100644 apps/server/src/services/inference/stream-phase-adapter.ts delete mode 100644 apps/server/src/services/inference/tool-summaries.ts create mode 100644 apps/server/src/services/inference/turn-config.ts create mode 100644 apps/server/src/services/message-columns.ts delete mode 100644 apps/server/src/services/task-summary.ts delete mode 100644 apps/server/src/services/task-tags.ts create mode 100644 apps/server/src/services/tools/codecontext/factory.ts create mode 100644 apps/server/src/services/tools/fs-tools.ts create mode 100644 apps/server/src/services/tools/misc-tools.ts create mode 100644 apps/server/src/services/tools/registry.ts create mode 100644 apps/server/src/services/tools/tiers.ts create mode 100644 apps/server/src/services/tools/types.ts create mode 100644 apps/server/src/utils/__tests__/string-utils.test.ts create mode 100644 apps/server/src/utils/string-utils.ts create mode 100644 apps/web/src/components/ArtifactPaneHeader.tsx delete mode 100644 apps/web/src/components/SwipeablePaneTab.tsx create mode 100644 apps/web/src/components/panes/terminal/FloatingMenu.tsx create mode 100644 apps/web/src/components/panes/terminal/SearchBar.tsx create mode 100644 apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx create mode 100644 apps/web/src/components/panes/terminal/theme.ts create mode 100644 apps/web/src/hooks/terminal/useTerminalFit.ts create mode 100644 apps/web/src/hooks/terminal/useTerminalSelection.ts create mode 100644 apps/web/src/hooks/terminal/useTerminalSocket.ts create mode 100644 apps/web/src/hooks/useArtifactDownload.ts delete mode 100644 apps/web/src/hooks/useProjects.ts delete mode 100644 apps/web/src/lib/apply-user-delta.ts create mode 100644 apps/web/src/lib/linkify-paths.tsx create mode 100644 apps/web/src/lib/terminal-protocol.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3b538..f645680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. +## v2.7.12-audit-cleanup — 2026-06-02 + +A repo-wide audit and aggressive cleanup pass, run as a multi-agent orchestration (five read-only Opus auditors over server/web/coder/booterm + cross-cutting deps/build/parity + a structural-architecture lens) followed by phased, behavior-preserving implementation — every change gated on the per-app test suites and delivered behind a strict DEFER discipline that never touched the files in flight for `v2.7.9`–`v2.7.11` (`mcp-config`, the `ws-frames` pair, `dispatcher`, `claude-sdk-map`, `AgentComposerBar`/`CoderMessageList`/`CoderPane`), so the branch rebased onto current main with zero conflicts. **Dead code/deps/schema**: removed ~9 dead files and a swathe of dead exports/write-only state across all four apps, dropped dead deps (`next-themes`, `@xterm/addon-webgl`, booterm `tslib`; `shadcn`→devDep), and idempotently dropped dead schema columns/tables (`sessions.tags`, `tasks.worktree_path`/`feature_values`, `available_agents.supports_mcp_client`, the superseded `session_worktrees` table, the always-empty `list_worktrees` MCP tool) — chat/session/message DATA untouched, only never-read columns. **Server dedup + reshapes**: collapsed the dead `budget.ts` tier system (surfacing a latent `READ_ONLY_TOOL_NAMES` drift, then deleted), extracted shared `MESSAGE_COLUMNS`/`selectProject`/`stripQuotes`/`SENTINEL_KINDS`/`samplerOptsFromAgent`/`createContentFlusher`/`insertSentinel`/a `makeCodecontextTool` factory/a pending-tool-call resolver, split `tools.ts` (799→46 barrel + `tools/{types,fs-tools,misc-tools,registry,tiers}`, register-through registry preserved so coder's import contract stays byte-stable), and decomposed the inference pipeline (`sentinel-summaries`→`runWrapUpSummary`, `turn.ts`→`turn-config`+`step-decision`, a pure `stream-phase-adapter`, shared finalize atoms — stopping short of fusing synthesis to preserve frame timing). **Coder reshapes**: split the 1062-line `opencode-server.ts` god-class into supervisor / sse-loop / pure event-map / port-utils + extracted `buildAcpClient`/`makeFrameEmitter`/`worktree-risk`, plus happy-path-safe concurrency hardening (reconnect backoff, double-spawn guard; a defensive busy-assert + ensureSession coalescing flagged for review). **Web**: `React.memo` on `MessageBubble`/`MarkdownRenderer` + module-hoisted markdown components (the streaming re-parse was the biggest perf cost), shared `linkifyPaths`/artifact/tab dedup, two latent bug fixes (`ChatPane` index-keys → stable ids; `FileViewerOverlay` blank-line line-number desync), and decomposed the 1298-line `TerminalPane.tsx` into fit/socket/selection hooks + presentational pieces (verbatim move, all ~30 listeners/timers inventoried; the label-dep fix stops a live terminal tearing down on pane renumber). +78 parity/unit tests (server 597, coder 328 green; `apps/web` has no harness, so its changes are typecheck + manual/device QA). Net ≈ −4,600 LOC. Deferred (designed; blueprints in the audit reports): the `tasks` dual-CREATE / `project_id` FK (a cross-service deploy-ordering decision, not a data migration), web structural decomposition of `useWorkspacePanes`/`MessageBubble` (needs a web test harness first), a `@boocode/contracts` shared package, and the `dispatcher.ts` split — the last two now unblocked since their in-flight files shipped in `v2.7.9`–`v2.7.11`. Rebased clean onto `v2.7.11-coder-model-snapshot`. + ## v2.7.11-coder-model-snapshot — 2026-06-02 Hotfix for the coder model-attribution chip vanishing on refresh. The chip showed during a live turn (the `message_complete` frame carries `model`) but disappeared when a BooCoder session was reloaded — only in the coder, not BooChat. Root cause: `CoderPane`'s `useCoderMessages` hydrates from two sources on load — the HTTP `listMessages` fetch (whose SELECT includes `model`, added `v2.7.8`) AND the WS `snapshot` frame — and the WS snapshot's query in `apps/coder/src/routes/ws.ts` had its own column list that omitted `model`. The client's `snapshot` handler `setMessages`-overwrites the HTTP load, so the model-less rows won, and with no later `message_complete` for historical messages the chip stayed gone. Fix is one column: add `model` to the WS snapshot SELECT so both hydration paths agree. The `apps/coder/CLAUDE.md` "update every mapper" note now lists the WS snapshot SELECT explicitly (it was the one place not enumerated). apps/server + apps/coder builds green; deployed via `systemctl restart boocoder` (host service — the earlier `v2.7.10` docker deploy rebuilt only the container, never this route). Fixes the chip shipped in `v2.7.8-ember-coder-tabs-model-chips` / completed in `v2.7.9-mcp-keys-docs-coder-fixes`. diff --git a/CURRENT.md b/CURRENT.md index 57850d8..c8e6d60 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -1,10 +1,9 @@ # Current focus -Last updated: 2026-05-26 +Last updated: 2026-06-02 -- **Batch:** v2.3-provider-lifecycle (openspec drafted; not started) -- **Branch:** `main` -- **Blockers:** none -- **Last shipped:** `v2.2.2-xml-placeholder-reject` +- **Last shipped:** `v2.7.8-ember-coder-tabs-model-chips` (2026-06-01) +- **Branch:** `codebase-audit-cleanup` (audit + cleanup epic, off main HEAD) +- **In progress:** Phase 3 — stale comments + docs refresh -Update this file when starting or finishing a batch. Agents: read this first for session intent; if stale vs `CHANGELOG.md`, trust CHANGELOG for shipped state. +See `CHANGELOG.md` for the full shipped history. That file is always authoritative; this file is a quick orientation pointer only. diff --git a/apps/booterm/package.json b/apps/booterm/package.json index 916d0c8..e6a0af9 100644 --- a/apps/booterm/package.json +++ b/apps/booterm/package.json @@ -15,7 +15,6 @@ "fastify": "^4.28.1", "node-pty": "^1.0.0", "pg": "^8.13.0", - "tslib": "^2.6.3", "zod": "^3.23.8" }, "devDependencies": { diff --git a/apps/booterm/src/config.ts b/apps/booterm/src/config.ts index 55bcefc..b32c321 100644 --- a/apps/booterm/src/config.ts +++ b/apps/booterm/src/config.ts @@ -9,7 +9,7 @@ const ConfigSchema = z.object({ TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'), }); -export type Config = z.infer; +type Config = z.infer; let cached: Config | null = null; diff --git a/apps/booterm/src/db.ts b/apps/booterm/src/db.ts index d522acc..4455825 100644 --- a/apps/booterm/src/db.ts +++ b/apps/booterm/src/db.ts @@ -10,7 +10,7 @@ export function getPool(databaseUrl: string): pg.Pool { return pool; } -export interface SessionInfo { +interface SessionInfo { id: string; project_id: string; project_path: string; diff --git a/apps/booterm/src/pty/pty.ts b/apps/booterm/src/pty/pty.ts index c08e2cc..5b4678e 100644 --- a/apps/booterm/src/pty/pty.ts +++ b/apps/booterm/src/pty/pty.ts @@ -1,7 +1,7 @@ import * as pty from 'node-pty'; import type { IPty } from 'node-pty'; -export interface AttachPtyOptions { +interface AttachPtyOptions { sessionName: string; projectRoot: string; cols: number; diff --git a/apps/coder/src/routes/tasks.ts b/apps/coder/src/routes/tasks.ts index 6b7ebd3..11cdf57 100644 --- a/apps/coder/src/routes/tasks.ts +++ b/apps/coder/src/routes/tasks.ts @@ -95,7 +95,7 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In // GET /api/tasks/:id — single task detail app.get<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => { const rows = await sql` - SELECT id, project_id, parent_task_id, state, input, output_summary, agent, model, execution_path, worktree_path, session_id, cost_tokens, started_at, ended_at, created_at + SELECT id, project_id, parent_task_id, state, input, output_summary, agent, model, execution_path, session_id, cost_tokens, started_at, ended_at, created_at FROM tasks WHERE id = ${req.params.id} `; diff --git a/apps/coder/src/routes/worktree-safety.ts b/apps/coder/src/routes/worktree-safety.ts index 0eb4525..895f940 100644 --- a/apps/coder/src/routes/worktree-safety.ts +++ b/apps/coder/src/routes/worktree-safety.ts @@ -9,7 +9,7 @@ */ import type { FastifyInstance } from 'fastify'; import type { Sql } from '../db.js'; -import { checkWorktreeWorkAtRisk, stashWorktree } from '../services/worktrees.js'; +import { checkWorktreeWorkAtRisk, stashWorktree } from '../services/worktree-risk.js'; export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): void { // GET risk for a session's worktree(s). One row per session today (PK on diff --git a/apps/coder/src/schema.sql b/apps/coder/src/schema.sql index bde4061..58b9e66 100644 --- a/apps/coder/src/schema.sql +++ b/apps/coder/src/schema.sql @@ -25,7 +25,6 @@ CREATE TABLE IF NOT EXISTS tasks ( agent TEXT, model TEXT, execution_path TEXT, - worktree_path TEXT, cost_tokens INTEGER, started_at TIMESTAMPTZ, ended_at TIMESTAMPTZ, @@ -39,9 +38,9 @@ CREATE TABLE IF NOT EXISTS available_agents ( install_path TEXT, version TEXT, supports_acp BOOLEAN NOT NULL DEFAULT false, - supports_mcp_client BOOLEAN NOT NULL DEFAULT false, last_probed_at TIMESTAMPTZ ); +ALTER TABLE available_agents DROP COLUMN IF EXISTS supports_mcp_client; -- v2.0.0 Phase 4: link tasks to their inference sessions. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id UUID REFERENCES sessions(id); @@ -74,31 +73,10 @@ ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS commands JSONB DEFAULT '[] -- v2.2.0: Paseo-style session config on tasks. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT; -ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB; - --- v2.6: one shared worktree per session (all agents/panes in the session operate in it). -CREATE TABLE IF NOT EXISTS session_worktrees ( - session_id UUID PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, - worktree_path TEXT NOT NULL, - base_commit TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() -); --- P1.5-b: DEFANG the CASCADE — a session delete must no longer wipe its worktree --- row. This table is SUPERSEDED by `worktrees` below; all readers are repointed --- this phase, so the row just persists (dead) on session delete until a later --- cleanup drops the table. session_id is this table's PRIMARY KEY, so it cannot be --- nullable → SET NULL is invalid and NO ACTION/RESTRICT would block deletes; the --- only valid defang is to drop the FK with no replacement. Idempotent: only fires --- while the FK is still ON DELETE CASCADE ('c'). -DO $$ BEGIN - IF EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conname = 'session_worktrees_session_id_fkey' - AND confdeltype = 'c' - ) THEN - ALTER TABLE session_worktrees DROP CONSTRAINT session_worktrees_session_id_fkey; - END IF; -END $$; +-- tasks.feature_values and tasks.worktree_path were never read or written by any +-- code path; drop them from existing DBs (fresh DBs never had them in the CREATE). +ALTER TABLE tasks DROP COLUMN IF EXISTS feature_values; +ALTER TABLE tasks DROP COLUMN IF EXISTS worktree_path; -- v2.6: one backend session per (session, agent); resumed on switch-back. CREATE TABLE IF NOT EXISTS agent_sessions ( @@ -168,12 +146,9 @@ CREATE TABLE IF NOT EXISTS worktrees ( ); CREATE UNIQUE INDEX IF NOT EXISTS worktrees_active_path_uidx ON worktrees(path) WHERE status='active'; --- Migrate any surviving session_worktrees rows → worktrees (idempotent; 0 rows --- after the test-session delete, kept for generality / fresh-DB safety). -INSERT INTO worktrees (session_id, path, branch, base_commit, status) -SELECT sw.session_id, sw.worktree_path, 'session-' || sw.session_id, sw.base_commit, 'active' -FROM session_worktrees sw -WHERE NOT EXISTS (SELECT 1 FROM worktrees w WHERE w.session_id = sw.session_id AND w.status='active'); +-- session_worktrees was superseded by worktrees (v2.6/P1.5-b); all rows migrated +-- before P2 cleanup. Drop the dead table; no-op on fresh DBs that never had it. +DROP TABLE IF EXISTS session_worktrees; -- Dispatch hint: which chat (tab) a task belongs to. The coder message route and -- skills route set it from the frontend tab; session-less creators (arena, MCP, diff --git a/apps/coder/src/services/__tests__/acp-client.test.ts b/apps/coder/src/services/__tests__/acp-client.test.ts new file mode 100644 index 0000000..003a1f2 --- /dev/null +++ b/apps/coder/src/services/__tests__/acp-client.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { RequestPermissionRequest, CreateElicitationRequest, SessionNotification } from '@agentclientprotocol/sdk'; +import { buildAcpClient, type AcpTurnContext } from '../acp-client.js'; + +/** + * buildAcpClient (v2.7 audit reshape): the shared ACP `Client` closures. These + * tests cover the pure routing decisions that don't require the permission-waiter + * broker machinery — the auto-select/decline fallbacks and the between-turns drop. + */ + +describe('buildAcpClient — sessionUpdate', () => { + it('drops the update when no turn is active (resolveTurn → null)', async () => { + const client = buildAcpClient('/wt', () => null); + // Must resolve without throwing and without an onSessionUpdate to call. + await expect(client.sessionUpdate({ sessionId: 's', update: {} } as unknown as SessionNotification)).resolves.toBeUndefined(); + }); + + it('forwards the update to the active turn', async () => { + const onSessionUpdate = vi.fn(); + const turn: AcpTurnContext = { taskId: 't', sessionId: 's', modeId: undefined, agent: 'goose', onSessionUpdate }; + const client = buildAcpClient('/wt', () => turn); + const note = { sessionId: 's', update: {} } as unknown as SessionNotification; + await client.sessionUpdate(note); + expect(onSessionUpdate).toHaveBeenCalledWith(note); + }); +}); + +describe('buildAcpClient — requestPermission fallback (no UI routing)', () => { + function req(options: Array<{ optionId: string }>): RequestPermissionRequest { + return { options } as unknown as RequestPermissionRequest; + } + + it('auto-selects the first option when there is no turn', async () => { + const client = buildAcpClient('/wt', () => null); + const res = await client.requestPermission(req([{ optionId: 'allow' }, { optionId: 'deny' }])); + expect(res).toEqual({ outcome: { outcome: 'selected', optionId: 'allow' } }); + }); + + it('cancels when there is no turn and no options', async () => { + const client = buildAcpClient('/wt', () => null); + const res = await client.requestPermission(req([])); + expect(res).toEqual({ outcome: { outcome: 'cancelled' } }); + }); + + it('auto-selects when the turn has no taskId (UI routing gated off)', async () => { + const turn: AcpTurnContext = { taskId: undefined, sessionId: 's', modeId: undefined, agent: 'goose', onSessionUpdate: () => {} }; + const client = buildAcpClient('/wt', () => turn); + const res = await client.requestPermission(req([{ optionId: 'ok' }])); + expect(res).toEqual({ outcome: { outcome: 'selected', optionId: 'ok' } }); + }); +}); + +describe('buildAcpClient — elicitation fallback', () => { + it('declines when there is no turn', async () => { + const client = buildAcpClient('/wt', () => null); + const res = await client.unstable_createElicitation!({} as CreateElicitationRequest); + expect(res).toEqual({ action: 'decline' }); + }); + + it('declines when the turn has no taskId', async () => { + const turn: AcpTurnContext = { taskId: undefined, sessionId: 's', modeId: undefined, agent: 'goose', onSessionUpdate: () => {} }; + const client = buildAcpClient('/wt', () => turn); + const res = await client.unstable_createElicitation!({} as CreateElicitationRequest); + expect(res).toEqual({ action: 'decline' }); + }); +}); + +describe('buildAcpClient — createTerminal', () => { + it('returns the noop terminal id', async () => { + const client = buildAcpClient('/wt', () => null); + const res = await client.createTerminal!({} as never); + expect(res).toEqual({ terminalId: 'noop' }); + }); +}); diff --git a/apps/coder/src/services/__tests__/frame-emitter.test.ts b/apps/coder/src/services/__tests__/frame-emitter.test.ts new file mode 100644 index 0000000..d4b21e2 --- /dev/null +++ b/apps/coder/src/services/__tests__/frame-emitter.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import type { Broker } from '@boocode/server/broker'; +import { makeFrameEmitter } from '../frame-emitter.js'; +import { makeDcpStreamStripper } from '../dcp-strip.js'; +import type { AcpToolSnapshot } from '../acp-tool-snapshot.js'; + +/** + * makeFrameEmitter (v2.7 audit reshape): the AgentEvent → WS-frame mapping + turn + * accumulators extracted from AcpStreamContext. Pure-ish over an injected broker. + */ + +function fakeBroker(): { broker: Broker; frames: Array<{ sid: string; frame: Record }> } { + const frames: Array<{ sid: string; frame: Record }> = []; + const broker = { + publishFrame: (sid: string, frame: unknown) => { + frames.push({ sid, frame: frame as Record }); + }, + } as unknown as Broker; + return { broker, frames }; +} + +const toolSnap: AcpToolSnapshot = { toolCallId: 'c1', title: 'grep', status: 'completed', rawOutput: 'x' }; + +describe('makeFrameEmitter — streaming frames', () => { + it('maps text/reasoning/tool events to delta/reasoning_delta/tool_call frames', () => { + const { broker, frames } = fakeBroker(); + const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' }); + + em.onEvent({ type: 'text', text: 'hello ' }); + em.onEvent({ type: 'reasoning', text: 'mulling' }); + em.onEvent({ type: 'tool_call', toolCall: toolSnap }); + + expect(frames.map((f) => f.frame.type)).toEqual(['delta', 'reasoning_delta', 'tool_call']); + expect(frames[0]!.frame).toMatchObject({ message_id: 'm1', chat_id: 'ch1', content: 'hello ' }); + expect(frames[2]!.frame).toMatchObject({ message_id: 'm1', chat_id: 'ch1' }); + expect(em.output).toBe('hello '); + expect(em.reasoningText).toBe('mulling'); + expect(em.snapshots).toHaveLength(1); + }); + + it('publishes a tool_call frame for BOTH tool_call and tool_update events', () => { + const { broker, frames } = fakeBroker(); + const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' }); + em.onEvent({ type: 'tool_update', toolCall: toolSnap }); + expect(frames).toHaveLength(1); + expect(frames[0]!.frame.type).toBe('tool_call'); + }); + + it('publishes an agent_commands frame and merges the command cache', () => { + const { broker, frames } = fakeBroker(); + const taskId = `task-fe-${Math.floor(performance.now())}-${frames.length}`; + const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1', taskId }); + em.onEvent({ type: 'commands', commands: [{ name: 'plan' }] }); + expect(frames).toHaveLength(1); + expect(frames[0]!.frame).toMatchObject({ type: 'agent_commands', task_id: taskId, session_id: 's1' }); + }); + + it('does not publish a commands frame without a taskId', () => { + const { broker, frames } = fakeBroker(); + const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' }); + em.onEvent({ type: 'commands', commands: [{ name: 'plan' }] }); + expect(frames).toHaveLength(0); + }); +}); + +describe('makeFrameEmitter — no broker (one-shot accumulation)', () => { + it('accumulates output/reasoning/snapshots but publishes nothing', () => { + const em = makeFrameEmitter({ sessionId: 's1', chatId: 'ch1', assistantId: 'm1' }); + em.onEvent({ type: 'text', text: 'abc' }); + em.onEvent({ type: 'reasoning', text: 'r' }); + em.onEvent({ type: 'tool_call', toolCall: toolSnap }); + expect(em.output).toBe('abc'); + expect(em.reasoningText).toBe('r'); + expect(em.snapshots).toHaveLength(1); + }); +}); + +describe('makeFrameEmitter — dcp stripping (opencode path contract)', () => { + it('strips a split dcp tag across deltas and flushes the tail on finalize', () => { + const { broker, frames } = fakeBroker(); + const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1', dcp: makeDcpStreamStripper() }); + + for (const chunk of ['Answer.', 'm1', ' tail']) { + em.onEvent({ type: 'text', text: chunk }); + } + em.finalize(); + + expect(em.output).toBe('Answer. tail'); + const published = frames.filter((f) => f.frame.type === 'delta').map((f) => f.frame.content).join(''); + expect(published).toBe('Answer. tail'); + }); + + it('finalize is a no-op without a dcp stripper', () => { + const { broker, frames } = fakeBroker(); + const em = makeFrameEmitter({ broker, sessionId: 's1', chatId: 'ch1', assistantId: 'm1' }); + em.onEvent({ type: 'text', text: 'raw m' }); + em.finalize(); + // No stripping without a stripper — verbatim text (prior ACP-path behavior). + expect(em.output).toBe('raw m'); + expect(frames).toHaveLength(1); + }); +}); diff --git a/apps/coder/src/services/__tests__/normalize-agent-status.test.ts b/apps/coder/src/services/__tests__/normalize-agent-status.test.ts deleted file mode 100644 index 799615a..0000000 --- a/apps/coder/src/services/__tests__/normalize-agent-status.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { normalizeAgentEvent } from '../normalize-agent-status.js'; - -describe('normalizeAgentEvent', () => { - describe('working bucket', () => { - const cases = [ - 'SessionStart', - 'UserPromptSubmit', - 'UserPromptSubmitted', - 'PostToolUse', - 'PostToolUseFailure', - 'BeforeAgent', - 'AfterTool', - 'task_started', - ]; - for (const name of cases) { - it(`maps ${name} → working`, () => { - expect(normalizeAgentEvent(name)).toBe('working'); - }); - } - }); - - describe('blocked bucket', () => { - const cases = [ - 'PreToolUse', - 'Notification', - 'PermissionRequest', - 'exec_approval_request', - 'apply_patch_approval_request', - 'request_user_input', - ]; - for (const name of cases) { - it(`maps ${name} → blocked`, () => { - expect(normalizeAgentEvent(name)).toBe('blocked'); - }); - } - }); - - describe('done bucket', () => { - const cases = [ - 'Stop', - 'AfterAgent', - 'SessionEnd', - 'task_complete', - 'agent-turn-complete', - ]; - for (const name of cases) { - it(`maps ${name} → done`, () => { - expect(normalizeAgentEvent(name)).toBe('done'); - }); - } - }); - - describe('unknown / nullish → null', () => { - it('returns null for an unrecognized event', () => { - expect(normalizeAgentEvent('SomeRandomEvent')).toBeNull(); - }); - it('returns null for empty string', () => { - expect(normalizeAgentEvent('')).toBeNull(); - }); - it('returns null for undefined', () => { - expect(normalizeAgentEvent(undefined)).toBeNull(); - }); - }); - - describe('case- and separator-insensitive matching', () => { - it('matches snake_case spelling of a PascalCase event', () => { - expect(normalizeAgentEvent('session_start')).toBe('working'); - expect(normalizeAgentEvent('post_tool_use')).toBe('working'); - expect(normalizeAgentEvent('pre_tool_use')).toBe('blocked'); - }); - it('matches camelCase spelling', () => { - expect(normalizeAgentEvent('userPromptSubmitted')).toBe('working'); - expect(normalizeAgentEvent('postToolUse')).toBe('working'); - expect(normalizeAgentEvent('preToolUse')).toBe('blocked'); - expect(normalizeAgentEvent('sessionEnd')).toBe('done'); - }); - it('matches arbitrary case', () => { - expect(normalizeAgentEvent('STOP')).toBe('done'); - expect(normalizeAgentEvent('notification')).toBe('blocked'); - }); - }); -}); diff --git a/apps/coder/src/services/acp-client.ts b/apps/coder/src/services/acp-client.ts new file mode 100644 index 0000000..d20dc6e --- /dev/null +++ b/apps/coder/src/services/acp-client.ts @@ -0,0 +1,88 @@ +/** + * Shared ACP `Client` builder — the callback closures every ACP connection needs + * (worktree-scoped FS bridge + permission/elicitation routing + session updates). + * + * Extracted (v2.7 audit reshape) from the byte-identical `buildClient` closures in + * `acp-dispatch.ts` (one-shot) and `backends/warm-acp.ts` (warm). The two differed + * only in WHERE the per-turn context comes from (a fixed dispatch vs. the warm + * backend's `activeTurn`) and a trivially-equivalent permission gate — both are now + * supplied via the `resolveTurn` callback, so the FS/permission/elicitation wiring + * lives once. Behavior is preserved exactly: + * - `sessionUpdate` drops when `resolveTurn()` returns null (between turns). + * - permission/elicitation route to the UI only when BOTH a taskId AND sessionId + * are present (warm always has a sessionId, so this matches its prior + * `turn?.taskId` gate); otherwise the same auto-select-first / decline fallback. + */ +import type { + Client, + SessionNotification, + RequestPermissionRequest, + RequestPermissionResponse, + ReadTextFileRequest, + ReadTextFileResponse, + WriteTextFileRequest, + WriteTextFileResponse, + CreateTerminalRequest, + CreateTerminalResponse, + CreateElicitationRequest, + CreateElicitationResponse, +} from '@agentclientprotocol/sdk'; +import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js'; +import { waitForPermissionResponse, waitForElicitationResponse } from './permission-waiter.js'; + +/** The per-turn context an ACP `Client` closure needs, resolved lazily per call. */ +export interface AcpTurnContext { + /** Per-turn task id, for routing permission/elicitation prompts back to the UI. */ + taskId: string | undefined; + /** BooCode session id (for permission-waiter's broker frames). */ + sessionId: string | undefined; + /** Per-turn mode id (autonomous-mode gate in permission-waiter). */ + modeId: string | undefined; + /** The agent name (for permission-waiter routing). */ + agent: string; + /** Forward a session/update notification to the turn's event sink. */ + onSessionUpdate: (params: SessionNotification) => void | Promise; +} + +/** + * Build the ACP `Client` callbacks once per connection. `resolveTurn` is called at + * the moment each callback fires and returns the live turn context (or null when no + * turn is active — `sessionUpdate` then drops, matching the warm backend's + * between-turns behavior). The FS bridge is scoped to `worktreePath`. + */ +export function buildAcpClient(worktreePath: string, resolveTurn: () => AcpTurnContext | null): Client { + return { + sessionUpdate: async (params: SessionNotification): Promise => { + const turn = resolveTurn(); + if (!turn) return; // between turns — drop (no orphan settles a future turn) + await turn.onSessionUpdate(params); + }, + requestPermission: async (params: RequestPermissionRequest): Promise => { + const turn = resolveTurn(); + if (turn && turn.taskId && turn.sessionId) { + return waitForPermissionResponse(turn.taskId, turn.sessionId, turn.agent, turn.modeId, params); + } + const firstOption = params.options[0]; + if (firstOption) return { outcome: { outcome: 'selected', optionId: firstOption.optionId } }; + return { outcome: { outcome: 'cancelled' } }; + }, + readTextFile: async (params: ReadTextFileRequest): Promise => { + const content = await readWorktreeTextFile(worktreePath, params.path, params.line, params.limit); + return { content }; + }, + writeTextFile: async (params: WriteTextFileRequest): Promise => { + await writeWorktreeTextFile(worktreePath, params.path, params.content); + return {}; + }, + createTerminal: async (_params: CreateTerminalRequest): Promise => { + return { terminalId: 'noop' }; + }, + unstable_createElicitation: async (params: CreateElicitationRequest): Promise => { + const turn = resolveTurn(); + if (turn && turn.taskId && turn.sessionId) { + return waitForElicitationResponse(turn.taskId, turn.sessionId, turn.agent, turn.modeId, params); + } + return { action: 'decline' }; + }, + }; +} diff --git a/apps/coder/src/services/acp-dispatch.ts b/apps/coder/src/services/acp-dispatch.ts index 404c5e2..38ae120 100644 --- a/apps/coder/src/services/acp-dispatch.ts +++ b/apps/coder/src/services/acp-dispatch.ts @@ -9,35 +9,20 @@ import { ClientSideConnection, type Client, type SessionNotification, - type RequestPermissionRequest, - type RequestPermissionResponse, - type ReadTextFileRequest, - type ReadTextFileResponse, - type WriteTextFileRequest, - type WriteTextFileResponse, - type CreateTerminalRequest, - type CreateTerminalResponse, - type CreateElicitationRequest, - type CreateElicitationResponse, type SessionConfigOption, type ClientSideConnection as ConnectionType, } from '@agentclientprotocol/sdk'; import type { Broker } from '@boocode/server/broker'; -import type { WsFrame } from '@boocode/server/ws-frames'; import { spawn } from 'node:child_process'; import { findThoughtLevelConfigId } from './acp-derive.js'; import { resolveLaunchSpec } from './acp-spawn.js'; import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js'; import { createAcpNdJsonStream } from './acp-stream.js'; -import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js'; -import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js'; -import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js'; +import { cancelPendingPermission } from './permission-waiter.js'; import { mapSessionUpdate } from './acp-event-map.js'; -import { - type AcpToolSnapshot, - snapshotToWireToolCall, - synthesizeCanceledSnapshots, -} from './acp-tool-snapshot.js'; +import { type AcpToolSnapshot, synthesizeCanceledSnapshots } from './acp-tool-snapshot.js'; +import { makeFrameEmitter, type FrameEmitter } from './frame-emitter.js'; +import { buildAcpClient } from './acp-client.js'; export interface AcpDispatchResult { exitCode: number; @@ -111,144 +96,61 @@ async function applySessionOverrides( } class AcpStreamContext { - readonly textChunks: string[] = []; - readonly reasoningChunks: string[] = []; - readonly toolSnapshots = new Map(); - private aborted = false; + /** AgentEvent → WS-frame mapping + text/reasoning/tool accumulation (shared + * `makeFrameEmitter`). The one-shot path passes no `dcp` stripper, so text is + * emitted verbatim — byte-identical to the prior inline switch. */ + private readonly emitter: FrameEmitter; constructor( - private readonly opts: Pick< - AcpDispatchOpts, - 'broker' | 'sessionId' | 'chatId' | 'messageId' | 'taskId' - >, + opts: Pick, private readonly worktreePath: string, - ) {} + ) { + this.emitter = makeFrameEmitter({ + broker: opts.broker, + sessionId: opts.sessionId, + chatId: opts.chatId, + assistantId: opts.messageId, + taskId: opts.taskId, + }); + } get reasoningText(): string { - return this.reasoningChunks.join(''); + return this.emitter.reasoningText; } get output(): string { - return this.textChunks.join(''); + return this.emitter.output; } get snapshots(): AcpToolSnapshot[] { - return [...this.toolSnapshots.values()]; + return this.emitter.snapshots; } markAborted(): void { - this.aborted = true; - for (const snap of synthesizeCanceledSnapshots(this.toolSnapshots.values())) { - this.toolSnapshots.set(snap.toolCallId, snap); - this.publishToolSnapshot(snap); + // Synthesize 'canceled' updates for still-running tool calls so the UI doesn't + // leave them spinning, then emit them through the same frame path (tool_update + // → the same `tool_call` wire frame the original published). + for (const snap of synthesizeCanceledSnapshots(this.emitter.toolSnapshots.values())) { + this.emitter.onEvent({ type: 'tool_update', toolCall: snap }); } } - private canStream(): boolean { - return !!(this.opts.broker && this.opts.sessionId && this.opts.chatId && this.opts.messageId); - } - - private publishToolSnapshot(snapshot: AcpToolSnapshot): void { - if (!this.canStream()) return; - const wire = snapshotToWireToolCall(snapshot); - this.opts.broker!.publishFrame(this.opts.sessionId!, { - type: 'tool_call', - message_id: this.opts.messageId!, - chat_id: this.opts.chatId!, - tool_call: wire, - } as WsFrame); - } - - async handleSessionUpdate(params: SessionNotification): Promise { - // v2.6 Phase 2: the case-by-case mapping now lives in the shared, pure - // `mapSessionUpdate` (reused by the warm ACP backend). This method keeps the - // identical broker-publishing side effects — it just translates the normalized - // AgentEvents back into the same frames it always emitted. `this.toolSnapshots` - // is the merge accumulator, so a later tool_call_update merges over its - // tool_call (the prior `handleToolUpdate` behavior, byte-for-byte). - for (const event of mapSessionUpdate(params, this.toolSnapshots)) { - switch (event.type) { - case 'text': - this.textChunks.push(event.text); - if (this.canStream()) { - this.opts.broker!.publishFrame(this.opts.sessionId!, { - type: 'delta', - message_id: this.opts.messageId!, - chat_id: this.opts.chatId!, - content: event.text, - } as WsFrame); - } - break; - case 'reasoning': - this.reasoningChunks.push(event.text); - if (this.canStream()) { - this.opts.broker!.publishFrame(this.opts.sessionId!, { - type: 'reasoning_delta', - message_id: this.opts.messageId!, - chat_id: this.opts.chatId!, - content: event.text, - } as WsFrame); - } - break; - case 'tool_call': - case 'tool_update': - // mapSessionUpdate already stored the merged snapshot in this.toolSnapshots. - this.publishToolSnapshot(event.toolCall); - break; - case 'commands': - if (this.opts.taskId && event.commands.length > 0) { - mergeTaskCommands(this.opts.taskId, event.commands); - if (this.canStream() && this.opts.sessionId) { - const all = getTaskCommands(this.opts.taskId) ?? event.commands; - this.opts.broker!.publishFrame(this.opts.sessionId, { - type: 'agent_commands', - task_id: this.opts.taskId, - session_id: this.opts.sessionId, - commands: all, - } as WsFrame); - } - } - break; - } + handleSessionUpdate(params: SessionNotification): void { + // The merge accumulator (`this.emitter.toolSnapshots`) is the same Map the + // emitter publishes from, so a later tool_call_update merges over its tool_call. + for (const event of mapSessionUpdate(params, this.emitter.toolSnapshots)) { + this.emitter.onEvent(event); } } buildClient(agent: string, modeId: string | undefined, taskId: string | undefined, sessionId: string | undefined): Client { - return { - sessionUpdate: (params) => this.handleSessionUpdate(params), - requestPermission: async (params: RequestPermissionRequest): Promise => { - if (taskId && sessionId) { - return waitForPermissionResponse(taskId, sessionId, agent, modeId, params); - } - const firstOption = params.options[0]; - if (firstOption) { - return { outcome: { outcome: 'selected', optionId: firstOption.optionId } }; - } - return { outcome: { outcome: 'cancelled' } }; - }, - readTextFile: async (params: ReadTextFileRequest): Promise => { - const content = await readWorktreeTextFile( - this.worktreePath, - params.path, - params.line, - params.limit, - ); - return { content }; - }, - writeTextFile: async (params: WriteTextFileRequest): Promise => { - await writeWorktreeTextFile(this.worktreePath, params.path, params.content); - return {}; - }, - createTerminal: async (_params: CreateTerminalRequest): Promise => { - return { terminalId: 'noop' }; - }, - unstable_createElicitation: async (params: CreateElicitationRequest): Promise => { - if (taskId && sessionId) { - return waitForElicitationResponse(taskId, sessionId, agent, modeId, params); - } - return { action: 'decline' }; - }, - }; + return buildAcpClient(this.worktreePath, () => ({ + taskId, + sessionId, + modeId, + agent, + onSessionUpdate: (params) => this.handleSessionUpdate(params), + })); } } diff --git a/apps/coder/src/services/acp-stream.ts b/apps/coder/src/services/acp-stream.ts index 74c9c9b..4934675 100644 --- a/apps/coder/src/services/acp-stream.ts +++ b/apps/coder/src/services/acp-stream.ts @@ -2,7 +2,7 @@ import { Readable, Writable } from 'node:stream'; import type { ChildProcess } from 'node:child_process'; import { ndJsonStream } from '@agentclientprotocol/sdk'; -export function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableStream { +function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableStream { return new ReadableStream({ start(controller) { nodeStream.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk))); @@ -17,7 +17,7 @@ export function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableSt }); } -export function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream { +function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream { return new WritableStream({ write(chunk) { return new Promise((resolve, reject) => { diff --git a/apps/coder/src/services/backends/__tests__/opencode-concurrency.test.ts b/apps/coder/src/services/backends/__tests__/opencode-concurrency.test.ts new file mode 100644 index 0000000..5513dbc --- /dev/null +++ b/apps/coder/src/services/backends/__tests__/opencode-concurrency.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { Event, OpencodeClient } from '@opencode-ai/sdk/v2/client'; +import { + reconnectDecision, + runSessionEventLoop, + DEFAULT_RECONNECT_POLICY, + type SessionState, + type SseLoopDeps, +} from '../opencode-sse.js'; +import { shouldStartServer } from '../opencode-server-process.js'; + +/** + * v2.7 concurrency hardening (Phase 7): the pure decision cores for SSE reconnect + * backoff + the ensureServer double-spawn guard, plus a deterministic exercise of + * the loop's breaker (injected sleep, fake client). Happy path is asserted to be + * unchanged (clean stream end → reset → base-delay reconnect). + */ + +function freshState(): SessionState { + return { + boocodeSessionId: 'boo1', + agentSessionId: 'oc1', + worktreePath: '/wt', + streamedPartKeys: new Set(), + partTypeById: new Map(), + activeTurn: { onEvent: () => {}, settle: () => {} }, + watchdog: null, + sseAbort: null, + swallowNextTerminal: false, + }; +} + +const silentLog = { + warn: () => {}, + info: () => {}, + error: () => {}, + debug: () => {}, +} as unknown as SseLoopDeps['log']; + +describe('reconnectDecision (pure backoff + breaker)', () => { + it('first failure uses the base delay (matches pre-hardening flat delay)', () => { + expect(reconnectDecision(1)).toEqual({ action: 'reconnect', delayMs: DEFAULT_RECONNECT_POLICY.baseMs }); + }); + + it('grows exponentially and caps at maxMs', () => { + const policy = { baseMs: 1000, maxMs: 30_000, maxAttempts: 10 }; + expect(reconnectDecision(2, policy)).toEqual({ action: 'reconnect', delayMs: 2000 }); + expect(reconnectDecision(3, policy)).toEqual({ action: 'reconnect', delayMs: 4000 }); + expect(reconnectDecision(6, policy)).toEqual({ action: 'reconnect', delayMs: 30_000 }); // 32000 capped + expect(reconnectDecision(9, policy)).toEqual({ action: 'reconnect', delayMs: 30_000 }); + }); + + it('gives up once failures exceed maxAttempts', () => { + const policy = { baseMs: 1, maxMs: 8, maxAttempts: 3 }; + expect(reconnectDecision(3, policy).action).toBe('reconnect'); + expect(reconnectDecision(4, policy)).toEqual({ action: 'give-up' }); + }); +}); + +describe('shouldStartServer (double-spawn guard)', () => { + it('does not start when the server is live', () => { + expect(shouldStartServer({ up: true, hasClient: true, serverStarting: true, childDead: false, startInFlight: false })).toBe(false); + }); + + it('starts on a fresh process (no start in flight)', () => { + expect(shouldStartServer({ up: false, hasClient: false, serverStarting: false, childDead: false, startInFlight: false })).toBe(true); + }); + + it('re-spawns after a crash once the prior start finished', () => { + expect(shouldStartServer({ up: false, hasClient: false, serverStarting: true, childDead: true, startInFlight: false })).toBe(true); + }); + + it('does NOT double-spawn while a start is already in flight (the race fix)', () => { + expect(shouldStartServer({ up: false, hasClient: false, serverStarting: true, childDead: true, startInFlight: true })).toBe(false); + }); + + it('does NOT double-spawn when a crash nulled serverStarting mid-start', () => { + // The narrow window: a crash during the in-flight start (await freePort) nulls + // serverStarting while startInFlight is still true. The startInFlight guard must + // win over the !serverStarting branch, else a second server spawns on a new port. + expect(shouldStartServer({ up: false, hasClient: false, serverStarting: false, childDead: true, startInFlight: true })).toBe(false); + }); + + it('waits (no spawn) when a cached start exists and the child is still alive', () => { + expect(shouldStartServer({ up: false, hasClient: false, serverStarting: true, childDead: false, startInFlight: false })).toBe(false); + }); +}); + +describe('runSessionEventLoop — happy path (unchanged)', () => { + it('dispatches streamed events, reconciles on clean end, reconnects at base delay', async () => { + const state = freshState(); + const abort = new AbortController(); + const events = [ + { type: 'session.next.text.delta', properties: { sessionID: 'oc1', delta: 'hi' } }, + { type: 'session.idle', properties: { sessionID: 'oc1' } }, + ] as unknown as Event[]; + + const client = { + event: { + subscribe: vi.fn(async () => ({ + stream: (async function* () { + for (const ev of events) yield ev; + })(), + })), + }, + } as unknown as OpencodeClient; + + const dispatched: Event[] = []; + const sleeps: number[] = []; + let reconciles = 0; + + const deps: SseLoopDeps = { + isUp: () => true, + getClient: () => client, + dispatchEvent: (ev) => dispatched.push(ev), + reconcile: async () => { + reconciles += 1; + abort.abort(); // stop the loop after the first clean cycle + return false; + }, + onReconnectGiveUp: () => { + throw new Error('should not give up on the happy path'); + }, + log: silentLog, + sleep: async (ms) => { + sleeps.push(ms); + }, + }; + + await runSessionEventLoop(state, abort, deps); + + expect(dispatched).toHaveLength(2); + expect(reconciles).toBe(1); + expect(sleeps).toEqual([DEFAULT_RECONNECT_POLICY.baseMs]); // base delay, not backed off + }); +}); + +describe('runSessionEventLoop — circuit breaker', () => { + it('backs off on repeated throws then gives up + fails the turn', async () => { + const state = freshState(); + const abort = new AbortController(); + const policy = { baseMs: 1, maxMs: 8, maxAttempts: 3 }; + + const subscribe = vi.fn(async () => { + throw new Error('connection refused'); + }); + const client = { event: { subscribe } } as unknown as OpencodeClient; + + const sleeps: number[] = []; + const gaveUp = vi.fn(); + + const deps: SseLoopDeps = { + isUp: () => true, + getClient: () => client, + dispatchEvent: () => {}, + reconcile: async () => false, + onReconnectGiveUp: gaveUp, + log: silentLog, + sleep: async (ms) => { + sleeps.push(ms); + }, + policy, + }; + + await runSessionEventLoop(state, abort, deps); + + // 3 backoff sleeps (1, 2, 4), then the 4th failure trips the breaker. + expect(sleeps).toEqual([1, 2, 4]); + expect(subscribe).toHaveBeenCalledTimes(4); + expect(gaveUp).toHaveBeenCalledTimes(1); + expect(gaveUp).toHaveBeenCalledWith(state); + }); +}); diff --git a/apps/coder/src/services/backends/__tests__/opencode-event-map.test.ts b/apps/coder/src/services/backends/__tests__/opencode-event-map.test.ts new file mode 100644 index 0000000..ebe9365 --- /dev/null +++ b/apps/coder/src/services/backends/__tests__/opencode-event-map.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest'; +import type { Event, Part } from '@opencode-ai/sdk/v2/client'; +import { + stripDcpTags, + eventSessionId, + resolvePartDedupeKey, + mapToolStatus, + toolPartToSnapshot, + toolCalledSnapshot, + toolSuccessSnapshot, + toolFailedSnapshot, + classifyPartDelta, + classifyUpdatedPart, + errToString, + errMsg, + type DedupState, +} from '../opencode-event-map.js'; + +/** + * Pure opencode Event → AgentEvent translation + dedup gate (v2.7 audit reshape). + * Mirrors the original `dispatchEvent` / `handleUpdatedPart` arms verbatim — no + * I/O, so it's unit-testable. The slimmed backend keeps the routing + side effects. + */ + +function freshDedup(): DedupState { + return { streamedPartKeys: new Set(), partTypeById: new Map() }; +} + +describe('stripDcpTags', () => { + it('removes a complete dcp tag', () => { + expect(stripDcpTags('hi m1 there')).toBe('hi there'); + }); + it('leaves untagged text untouched', () => { + expect(stripDcpTags('plain text
')).toBe('plain text
'); + }); +}); + +describe('eventSessionId', () => { + it('reads properties.sessionID for a normal event', () => { + const ev = { type: 'session.idle', properties: { sessionID: 's1' } } as unknown as Event; + expect(eventSessionId(ev)).toBe('s1'); + }); + it('reads properties.part.sessionID for message.part.updated', () => { + const ev = { + type: 'message.part.updated', + properties: { part: { sessionID: 's2' } }, + } as unknown as Event; + expect(eventSessionId(ev)).toBe('s2'); + }); + it('returns null when there is no session', () => { + const ev = { type: 'server.connected', properties: {} } as unknown as Event; + expect(eventSessionId(ev)).toBeNull(); + }); +}); + +describe('resolvePartDedupeKey', () => { + it('prefers the part id', () => { + expect(resolvePartDedupeKey({ id: 'p1', messageID: 'm1' }, 'text')).toBe('text:p1'); + }); + it('falls back to the message id', () => { + expect(resolvePartDedupeKey({ id: ' ', messageID: 'm1' }, 'reasoning')).toBe('reasoning:message:m1'); + }); + it('returns null when neither is present', () => { + expect(resolvePartDedupeKey({ id: '', messageID: '' }, 'text')).toBeNull(); + }); +}); + +describe('mapToolStatus', () => { + it('maps the opencode tool states to ACP statuses', () => { + expect(mapToolStatus('pending')).toBe('pending'); + expect(mapToolStatus('running')).toBe('in_progress'); + expect(mapToolStatus('completed')).toBe('completed'); + expect(mapToolStatus('error')).toBe('failed'); + expect(mapToolStatus(undefined)).toBeNull(); + }); +}); + +describe('session.next.tool.* snapshot builders', () => { + it('toolCalledSnapshot → in_progress with tool title + raw input', () => { + expect(toolCalledSnapshot({ callID: 'c1', tool: 'read_file', input: { path: 'a.ts' } })).toEqual({ + toolCallId: 'c1', + title: 'read_file', + kind: null, + status: 'in_progress', + rawInput: { path: 'a.ts' }, + rawOutput: undefined, + }); + }); + it('toolSuccessSnapshot → completed with joined text content', () => { + const snap = toolSuccessSnapshot({ callID: 'c1', content: [{ text: 'foo' }, { text: 'bar' }, { other: 1 }] }); + expect(snap.status).toBe('completed'); + expect(snap.title).toBe('c1'); + expect(snap.rawOutput).toBe('foobar'); + }); + it('toolSuccessSnapshot → empty output when content is missing', () => { + expect(toolSuccessSnapshot({ callID: 'c1' }).rawOutput).toBe(''); + }); + it('toolFailedSnapshot → failed with stringified error', () => { + const snap = toolFailedSnapshot({ callID: 'c1', error: 'boom' }); + expect(snap.status).toBe('failed'); + expect(snap.title).toBe('c1'); + expect(snap.rawOutput).toBe('boom'); + }); +}); + +describe('toolPartToSnapshot', () => { + it('extracts input/output/title/status from the tool state', () => { + const part = { + type: 'tool', + callID: 'c1', + tool: 'grep', + state: { status: 'completed', input: { q: 'x' }, output: 'result', title: 'Grep run' }, + } as unknown as Parameters[0]; + expect(toolPartToSnapshot(part)).toEqual({ + toolCallId: 'c1', + title: 'Grep run', + kind: null, + status: 'completed', + rawInput: { q: 'x' }, + rawOutput: 'result', + }); + }); + it('falls back to the tool name and uses error as output', () => { + const part = { + type: 'tool', + callID: 'c2', + tool: 'edit', + state: { status: 'error', error: 'nope' }, + } as unknown as Parameters[0]; + const snap = toolPartToSnapshot(part); + expect(snap.title).toBe('edit'); + expect(snap.status).toBe('failed'); + expect(snap.rawOutput).toBe('nope'); + }); +}); + +describe('classifyPartDelta (message.part.delta dedup recording)', () => { + it('records a reasoning key and emits a reasoning event', () => { + const st = freshDedup(); + const e = classifyPartDelta({ partID: 'p1', field: 'reasoning', delta: 'thinking' }, st); + expect(e).toEqual({ type: 'reasoning', text: 'thinking' }); + expect(st.streamedPartKeys.has('reasoning:p1')).toBe(true); + }); + it('records a text key, strips dcp, and emits text', () => { + const st = freshDedup(); + const e = classifyPartDelta({ partID: 'p2', field: 'text', delta: 'hi m' }, st); + expect(e).toEqual({ type: 'text', text: 'hi ' }); + expect(st.streamedPartKeys.has('text:p2')).toBe(true); + }); + it('still records the text key even when the cleaned delta is empty', () => { + const st = freshDedup(); + const e = classifyPartDelta({ partID: 'p3', field: 'text', delta: 'm' }, st); + expect(e).toBeNull(); + expect(st.streamedPartKeys.has('text:p3')).toBe(true); + }); + it('uses the recorded part type when the field is absent', () => { + const st = freshDedup(); + st.partTypeById.set('p4', 'reasoning'); + const e = classifyPartDelta({ partID: 'p4', delta: 'more' }, st); + expect(e).toEqual({ type: 'reasoning', text: 'more' }); + }); + it('returns null for an unknown field', () => { + expect(classifyPartDelta({ partID: 'p5', field: 'other', delta: 'x' }, freshDedup())).toBeNull(); + }); +}); + +describe('classifyUpdatedPart (message.part.updated dedup gate)', () => { + function textPart(over: Partial = {}): Part { + return { + type: 'text', + id: 'p1', + messageID: 'm1', + sessionID: 's1', + text: 'final text', + time: { start: 1, end: 2 }, + ...over, + } as unknown as Part; + } + + it('drops a terminal part already streamed via deltas', () => { + const st = freshDedup(); + st.streamedPartKeys.add('text:p1'); + expect(classifyUpdatedPart(textPart(), st)).toBeNull(); + // the key is consumed + expect(st.streamedPartKeys.has('text:p1')).toBe(false); + }); + it('emits a finished (ended) text part not seen via deltas', () => { + const st = freshDedup(); + expect(classifyUpdatedPart(textPart(), st)).toEqual({ type: 'text', text: 'final text' }); + expect(st.partTypeById.get('p1')).toBe('text'); + }); + it('does not emit a part that has not ended yet', () => { + const st = freshDedup(); + expect(classifyUpdatedPart(textPart({ time: { start: 1 } as never }), st)).toBeNull(); + }); + it('strips dcp tags from the finished text', () => { + const st = freshDedup(); + const part = textPart({ text: 'a mb' }); + expect(classifyUpdatedPart(part, st)).toEqual({ type: 'text', text: 'a b' }); + }); + it('maps a running tool part to tool_call', () => { + const st = freshDedup(); + const part = { type: 'tool', callID: 'c1', tool: 'grep', state: { status: 'running' } } as unknown as Part; + const e = classifyUpdatedPart(part, st); + expect(e?.type).toBe('tool_call'); + }); + it('maps a completed tool part to tool_update', () => { + const st = freshDedup(); + const part = { type: 'tool', callID: 'c1', tool: 'grep', state: { status: 'completed', output: 'x' } } as unknown as Part; + const e = classifyUpdatedPart(part, st); + expect(e?.type).toBe('tool_update'); + }); +}); + +describe('error formatters', () => { + it('errMsg unwraps Error.message', () => { + expect(errMsg(new Error('x'))).toBe('x'); + expect(errMsg('plain')).toBe('plain'); + }); + it('errToString handles null/string/Error/object', () => { + expect(errToString(null)).toBe('unknown error'); + expect(errToString('s')).toBe('s'); + expect(errToString(new Error('e'))).toBe('e'); + expect(errToString({ a: 1 })).toBe('{"a":1}'); + }); +}); diff --git a/apps/coder/src/services/backends/opencode-event-map.ts b/apps/coder/src/services/backends/opencode-event-map.ts new file mode 100644 index 0000000..e6e8373 --- /dev/null +++ b/apps/coder/src/services/backends/opencode-event-map.ts @@ -0,0 +1,203 @@ +/** + * Pure opencode `Event` → normalized `AgentEvent` translation. + * + * Extracted (v2.7 audit reshape) from `OpenCodeServerBackend.dispatchEvent` / + * `handleUpdatedPart` and the file-local helpers. NO I/O, no timers, no DB, no + * `byOpencodeId` — every function here is a deterministic transform over its + * arguments (the dedup state is caller-owned and mutated in place, mirroring the + * `acp-event-map.ts` `priorSnapshots` pattern). This is the unit-testable core; the + * backend keeps the routing + side effects (watchdog, usage persistence, settle). + * + * Depends only on SDK TYPES + AcpToolSnapshot — safe to import anywhere. + */ +import type { Event, Part, ToolPart, ToolState } from '@opencode-ai/sdk/v2/client'; +import type { ToolCallStatus } from '@agentclientprotocol/sdk'; +import type { AcpToolSnapshot } from '../acp-tool-snapshot.js'; +import type { AgentEvent } from '../agent-backend.js'; + +/** Per-(opencode session) dedup state the part-stream classifiers read + mutate. */ +export interface DedupState { + /** dedup gate: `${type}:${id}` added on delta, deleted-and-tested on updated. */ + streamedPartKeys: Set; + /** partID → 'text' | 'reasoning', so a delta with a non-'reasoning' field is still classed right. */ + partTypeById: Map; +} + +/** Strip opencode-dcp plugin tags that render as literal text in the UI. */ +export function stripDcpTags(s: string): string { + return s.replace(/[^<]*<\/dcp-message-id>/g, ''); +} + +/** Extract the opencode sessionID an event belongs to, across event shapes. + * Most carry `properties.sessionID`; `message.part.updated` nests it under + * `properties.part.sessionID`. Returns null when the event has no session + * (the per-session loop then leaves it to dispatchEvent, which drops it). */ +export function eventSessionId(ev: Event): string | null { + const props = (ev as { properties?: unknown }).properties; + if (!props || typeof props !== 'object') return null; + if (ev.type === 'message.part.updated') { + const part = (props as { part?: { sessionID?: string } }).part; + return part?.sessionID ?? null; + } + return (props as { sessionID?: string }).sessionID ?? null; +} + +/** Ported verbatim from Paseo opencode-agent.ts: id → message-id fallback → null. */ +export function resolvePartDedupeKey(part: { id: string; messageID: string }, type: string): string | null { + if (part.id.trim().length > 0) return `${type}:${part.id}`; + if (part.messageID.trim().length > 0) return `${type}:message:${part.messageID}`; + return null; +} + +export function mapToolStatus(s: ToolState['status'] | undefined): ToolCallStatus | null { + switch (s) { + case 'pending': + return 'pending'; + case 'running': + return 'in_progress'; + case 'completed': + return 'completed'; + case 'error': + return 'failed'; + default: + return null; + } +} + +/** opencode ToolPart → ACP-shaped snapshot (reuses the existing persist/render path). */ +export function toolPartToSnapshot(part: ToolPart): AcpToolSnapshot { + const state = part.state; + let rawInput: unknown; + let rawOutput: unknown; + let title: string | undefined; + if (state) { + if ('input' in state) rawInput = (state as { input?: unknown }).input; + if ('output' in state) rawOutput = (state as { output?: unknown }).output; + else if ('error' in state) rawOutput = (state as { error?: unknown }).error; + if ('title' in state) title = (state as { title?: string }).title; + } + return { + toolCallId: part.callID, + title: title ?? part.tool, + kind: null, + status: mapToolStatus(state?.status), + rawInput, + rawOutput, + }; +} + +// ─── session.next.tool.* snapshot builders ─────────────────────────────────── + +/** `session.next.tool.called` → an in-progress tool_call snapshot. */ +export function toolCalledSnapshot(p: { callID: string; tool: string; input: unknown }): AcpToolSnapshot { + return { + toolCallId: p.callID, + title: p.tool, + kind: null, + status: 'in_progress', + rawInput: p.input, + rawOutput: undefined, + }; +} + +/** `session.next.tool.success` → a completed tool snapshot (text content joined). */ +export function toolSuccessSnapshot(p: { callID: string; content?: ReadonlyArray | null }): AcpToolSnapshot { + const output = p.content?.map((c) => (c && typeof c === 'object' && 'text' in c ? (c as { text: string }).text : '')).join('') ?? ''; + return { + toolCallId: p.callID, + title: p.callID, + kind: null, + status: 'completed', + rawInput: undefined, + rawOutput: output, + }; +} + +/** `session.next.tool.failed` → a failed tool snapshot (error stringified). */ +export function toolFailedSnapshot(p: { callID: string; error: unknown }): AcpToolSnapshot { + return { + toolCallId: p.callID, + title: p.callID, + kind: null, + status: 'failed', + rawInput: undefined, + rawOutput: errToString(p.error), + }; +} + +// ─── message.part.* dedup gate ──────────────────────────────────────────────── + +/** + * `message.part.delta`: mark the part as streamed (so a later `message.part.updated` + * for the same part is deduped) and return the AgentEvent to emit, or null when the + * field is neither reasoning nor text, or a text delta strips down to empty. Mutates + * `st.streamedPartKeys` exactly as the original inline arm did (the key is recorded + * for text even when the cleaned delta is empty). + */ +export function classifyPartDelta( + p: { partID: string; field?: string; delta: string }, + st: DedupState, +): AgentEvent | null { + const isReasoning = p.field === 'reasoning' || st.partTypeById.get(p.partID) === 'reasoning'; + if (isReasoning) { + st.streamedPartKeys.add(`reasoning:${p.partID}`); + return { type: 'reasoning', text: p.delta }; + } + if (p.field === 'text') { + st.streamedPartKeys.add(`text:${p.partID}`); + const cleaned = stripDcpTags(p.delta); + return cleaned ? { type: 'text', text: cleaned } : null; + } + return null; +} + +/** + * `message.part.updated` terminal part: the dedup gate for text/reasoning (drop a + * part already streamed via deltas; otherwise emit the finished text) plus the + * tool-part → tool_call/tool_update mapping. Returns null when nothing should be + * emitted. Mutates `st.partTypeById` / `st.streamedPartKeys` like the original. + */ +export function classifyUpdatedPart(part: Part, st: DedupState): AgentEvent | null { + if (part.type === 'text' || part.type === 'reasoning') { + st.partTypeById.set(part.id, part.type); + const key = resolvePartDedupeKey(part, part.type); + if (key && st.streamedPartKeys.delete(key)) return null; // already streamed via delta + const raw = part.text ?? ''; + const text = part.type === 'text' ? stripDcpTags(raw) : raw; + if (text && part.time?.end != null) { + return { type: part.type, text }; + } + return null; + } + + if (part.type === 'tool') { + const snap = toolPartToSnapshot(part); + const status = part.state?.status; + // tool_call on start (pending/running), tool_update on terminal (completed/error). + // The current ACP path merges both into one frame; the contract keeps them + // distinct because opencode's SSE distinguishes start from result. + return status === 'completed' || status === 'error' + ? { type: 'tool_update', toolCall: snap } + : { type: 'tool_call', toolCall: snap }; + } + // NOTE: opencode's SSE payload union carries no available-commands event, so the + // AgentEvent 'commands' arm is intentionally never emitted here. + return null; +} + +// ─── shared error formatters (pure) ─────────────────────────────────────────── + +export function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +export function errToString(e: unknown): string { + if (e == null) return 'unknown error'; + if (typeof e === 'string') return e; + if (e instanceof Error) return e.message; + try { + return JSON.stringify(e); + } catch { + return String(e); + } +} diff --git a/apps/coder/src/services/backends/opencode-server-process.ts b/apps/coder/src/services/backends/opencode-server-process.ts new file mode 100644 index 0000000..1b27810 --- /dev/null +++ b/apps/coder/src/services/backends/opencode-server-process.ts @@ -0,0 +1,325 @@ +/** + * OpenCodeServerSupervisor — the opencode `serve` child + HTTP client + port + + * health-counter lifecycle, extracted (v2.7 audit reshape) from the backend + * god-class. Owns spawn / ready / crash / proactive-health restart / dispose and + * exposes `client` / `port` / `health()` / `tickHealth()` to the backend. + * + * Session-level recovery (failing in-flight turns, marking agent_sessions crashed, + * tearing down SSE loops) is NOT a process concern — it's delegated back to the + * backend through the injected `hooks.onServerDown` callback, keeping this module + * free of the demux map / SQL / turn state. + * + * v2.7 concurrency hardening: `ensureServer` is guarded against the crash-window + * double-spawn (two concurrent callers each re-spawning on different ports) via a + * synchronous `startInFlight` flag — see `shouldStartServer`. + */ +import { spawn, type ChildProcess } from 'node:child_process'; +import { createOpencodeClient, type OpencodeClient } from '@opencode-ai/sdk/v2/client'; +import type { FastifyBaseLogger } from 'fastify'; +import { decideRestart, DEFAULT_HEALTH_FAILURE_THRESHOLD } from './lifecycle-decisions.js'; +import { reclaimPort, waitForPortRelease, freePort } from '../net/port-utils.js'; + +const READY_TIMEOUT_MS = 30_000; + +/** Info handed to the backend when the server goes down (crash or forced restart). */ +export interface ServerDownInfo { + code: number | null; + signal: NodeJS.Signals | null; + port: number; +} + +export interface SupervisorHooks { + /** True iff ANY pooled session has an in-flight turn (defers a busy restart). */ + isBusy: () => boolean; + /** Session-level recovery: fail in-flight turns, mark crashed, drop demux state. */ + onServerDown: (info: ServerDownInfo) => void; +} + +export interface OpenCodeServerSupervisorDeps { + /** Absolute path to the opencode binary (resolved from available_agents). */ + opencodeBinary: string; + log: FastifyBaseLogger; + hooks: SupervisorHooks; +} + +/** + * Pure decision for `ensureServer`: should we (re)spawn the server right now? + * + * - A live, ready server (`up && client`) → no. + * - A start already in flight (`startInFlight`) → no, NEVER double-spawn — join the + * running start instead. This is checked BEFORE `serverStarting` because the crash + * handler can null `serverStarting` mid-start (a crash during `await freePort()`), + * and without this guard the `!serverStarting` branch would spawn a second server + * on a different port while the first is still coming up. + * - No start cached/running → yes (fresh start or post-crash re-spawn, since the + * crash handler nulls `serverStarting`). + * - A cached start that already finished, but the child has since died and the crash + * handler hasn't reset us yet → yes. + */ +export function shouldStartServer(s: { + up: boolean; + hasClient: boolean; + serverStarting: boolean; + childDead: boolean; + startInFlight: boolean; +}): boolean { + if (s.up && s.hasClient) return false; + if (s.startInFlight) return false; + if (!s.serverStarting) return true; + if (!s.up && s.childDead) return true; + return false; +} + +export class OpenCodeServerSupervisor { + private readonly opencodeBinary: string; + private readonly log: FastifyBaseLogger; + private readonly hooks: SupervisorHooks; + + private childProc: ChildProcess | null = null; + private opencodeClient: OpencodeClient | null = null; + private serverPort: number | null = null; + private up = false; + private serverStarting: Promise | null = null; + /** True from the synchronous head of startServer() until it settles — the + * double-spawn guard reads it so a concurrent ensureServer joins instead of + * kicking a second spawn. */ + private startInFlight = false; + // Phase 3 busy-aware health monitor (openchamber lift): consecutive failed + // probes + the start of an unhealthy-while-busy window feed `decideRestart`. + private consecutiveHealthFailures = 0; + private unhealthyBusySince = 0; + private restarting: Promise | null = null; + + constructor(deps: OpenCodeServerSupervisorDeps) { + this.opencodeBinary = deps.opencodeBinary; + this.log = deps.log; + this.hooks = deps.hooks; + } + + /** The live opencode HTTP client, or null between (re)starts. */ + get client(): OpencodeClient | null { + return this.opencodeClient; + } + + /** The current server port, or null before the first start. */ + get port(): number | null { + return this.serverPort; + } + + /** §2: liveness for the health endpoint + dispatcher fallback decision. */ + health(): 'up' | 'down' { + return this.up ? 'up' : 'down'; + } + + isUp(): boolean { + return this.up; + } + + // ─── lifecycle (spawn once + client + ready; crash-restart) ────────────────── + + /** + * Lazy: start the single server on first use; re-spawn after a crash. Idempotent + * within one live server — `serverStarting` caches the in-flight start, reset to + * null by the crash handler so the NEXT ensureServer re-spawns. A dead-but-not- + * yet-reaped child (exit handler raced) is also treated as needing a restart. + * Concurrent callers in a crash window are coalesced via `startInFlight`. + */ + ensureServer(): Promise { + if (this.up && this.opencodeClient) return Promise.resolve(); + const childDead = + this.childProc != null && (this.childProc.exitCode !== null || this.childProc.signalCode !== null); + if ( + shouldStartServer({ + up: this.up, + hasClient: this.opencodeClient != null, + serverStarting: this.serverStarting != null, + childDead, + startInFlight: this.startInFlight, + }) + ) { + this.serverStarting = this.startServer(); + } + return this.serverStarting ?? Promise.resolve(); + } + + private async startServer(): Promise { + // Set synchronously (before the first await) so a concurrent ensureServer sees + // the in-flight start and joins `serverStarting` instead of double-spawning. + this.startInFlight = true; + try { + const port = await freePort(); + + // Phase 1: run unsecured on loopback (opencode's documented default — serve.ts + // only WARNS when OPENCODE_SERVER_PASSWORD is unset). The real boundary is the + // 127.0.0.1 bind. + const child = spawn(this.opencodeBinary, ['serve', '--hostname', '127.0.0.1', '--port', String(port)], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env }, + }); + this.childProc = child; + this.serverPort = port; + + // Child lifetime is the backend's (the pool's), NOT a request's. On unexpected + // exit we recover: settle in-flight turns, mark sessions crashed (the backend's + // onServerDown), reclaim the port, and reset state so the next ensureServer + // re-spawns. + child.on('exit', (code, signal) => { + // Only react to THIS child's exit (a restart may have swapped in a new one). + if (this.childProc !== child) return; + this.handleCrash(code, signal, port); + }); + + await waitForReady(child, READY_TIMEOUT_MS); + + this.opencodeClient = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` }); + this.up = true; + this.log.info({ port }, 'opencode-server: ready'); + } finally { + this.startInFlight = false; + } + } + + /** + * Server down (crash-exit or forced restart): reset process/port state, delegate + * session-level recovery to the backend, and reclaim the port. Mirrors the + * original `handleServerCrash` ordering (up=false → session cleanup → client/ + * serverStarting null → reclaimPort). + */ + private handleCrash(code: number | null, signal: NodeJS.Signals | null, port: number): void { + this.up = false; + this.hooks.onServerDown({ code, signal, port }); + this.opencodeClient = null; + this.serverStarting = null; // force a re-spawn on the next ensureServer + // Reclaim the port so a re-spawn on a fixed/leaked port isn't blocked. Best + // effort; the next start uses a fresh ephemeral port anyway. + reclaimPort(port); + } + + /** + * Phase 3 proactive health monitor (openchamber `runHealthCheckCycle` lift, + * busy-aware). Probes /global/health; on a sustained failure of a NON-busy server, + * force a restart so the next turn isn't blocked by a wedged process. Busy servers + * are deferred via the stale-grace in `decideRestart`. No-op when never started or + * a restart is already in flight. + */ + async tickHealth(now: number = Date.now()): Promise { + if (!this.childProc || this.restarting) return; + const childExited = this.childProc.exitCode !== null || this.childProc.signalCode !== null; + // An exited child is recovered lazily by ensureServer; don't double-restart it. + if (childExited) return; + + const healthy = await this.probeHealth(); + if (healthy) { + this.consecutiveHealthFailures = 0; + this.unhealthyBusySince = 0; + return; + } + this.consecutiveHealthFailures += 1; + const busy = this.hooks.isBusy(); + const decision = decideRestart({ + processExited: false, + consecutiveFailures: this.consecutiveHealthFailures, + busy, + unhealthyBusySince: this.unhealthyBusySince, + now, + failureThreshold: DEFAULT_HEALTH_FAILURE_THRESHOLD, + }); + // Stamp the start of an unhealthy-while-busy window so the stale-grace can fire. + if (busy && this.unhealthyBusySince === 0) this.unhealthyBusySince = now; + if (decision.action === 'restart') { + this.log.warn( + { failures: this.consecutiveHealthFailures, busy, reason: decision.reason }, + 'opencode-server: health monitor forcing restart', + ); + this.consecutiveHealthFailures = 0; + this.unhealthyBusySince = 0; + await this.restartServer(); + } + } + + private async probeHealth(): Promise { + if (!this.opencodeClient) return false; + try { + const res = await this.opencodeClient.global.health(); + return !res.error; + } catch { + return false; + } + } + + /** Force-kill the current server + reclaim its port; the next ensureServer + * re-spawns (lazy). Mirrors handleCrash's state reset but is initiated by the + * health monitor rather than the OS. */ + private async restartServer(): Promise { + if (this.restarting) return this.restarting; + this.restarting = (async () => { + const child = this.childProc; + const port = this.serverPort; + this.up = false; + // Fail in-flight turns + mark sessions crashed via the same path as a crash. + if (child) { + this.handleCrash(null, null, port ?? 0); + if (!child.killed) child.kill('SIGTERM'); + } + if (port) { + reclaimPort(port); + await waitForPortRelease(port, 3_000); + } + this.childProc = null; + })().finally(() => { + this.restarting = null; + }); + return this.restarting; + } + + /** Full teardown of the child + client + port state. */ + async dispose(): Promise { + this.up = false; + const child = this.childProc; + this.childProc = null; + this.opencodeClient = null; + if (child && !child.killed) { + child.kill('SIGTERM'); + const t = setTimeout(() => { + if (!child.killed) child.kill('SIGKILL'); + }, 5_000); + t.unref(); + } + } +} + +/** Resolve when the child prints the ready line; reject on timeout or early exit. */ +function waitForReady(child: ChildProcess, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let done = false; + let stderrBuf = ''; + + const finish = (err?: Error) => { + if (done) return; + done = true; + clearTimeout(timer); + child.stdout?.off('data', onOut); + child.stderr?.off('data', onErr); + child.off('exit', onExit); + if (err) reject(err); + else resolve(); + }; + + const onOut = (buf: Buffer) => { + if (buf.toString().includes('opencode server listening on')) finish(); + }; + const onErr = (buf: Buffer) => { + stderrBuf += buf.toString(); + }; + const onExit = (code: number | null) => + finish(new Error(`opencode serve exited before ready (code ${code}); stderr: ${stderrBuf.slice(-2000)}`)); + const timer = setTimeout( + () => finish(new Error(`opencode serve not ready in ${timeoutMs}ms; stderr: ${stderrBuf.slice(-2000)}`)), + timeoutMs, + ); + + child.stdout?.on('data', onOut); + child.stderr?.on('data', onErr); + child.on('exit', onExit); + }); +} diff --git a/apps/coder/src/services/backends/opencode-server.ts b/apps/coder/src/services/backends/opencode-server.ts index e832626..562344a 100644 --- a/apps/coder/src/services/backends/opencode-server.ts +++ b/apps/coder/src/services/backends/opencode-server.ts @@ -1,91 +1,64 @@ /** - * v2.6 Phase 1 — OpenCodeServerBackend. + * v2.6 Phase 1 — OpenCodeServerBackend (slimmed, v2.7 audit reshape). * * Warm, multi-turn backend for the `opencode` agent. One `opencode serve` HTTP * server per BooCoder process; one opencode session per BooCode session (resumed * on switch-back); one SSE read loop PER session, each scoped to that session's - * worktree directory so sessions in different directories stream concurrently - * (P1.5-a — replaced the Phase-1 single-stream-last-directory model). + * worktree directory so sessions in different directories stream concurrently. + * + * This file is now just the `AgentBackend` SURFACE — ensureSession / prompt / + * accumulateUsage / closeSession + the per-session demux side effects (watchdog, + * reconcile, usage). It composes three extracted collaborators: + * - `OpenCodeServerSupervisor` (opencode-server-process.ts) — child/client/port/ + * health lifecycle, spawn/crash/restart/dispose. + * - the per-session SSE loop (opencode-sse.ts) — subscribe + reconnect/backoff. + * - the pure event map (opencode-event-map.ts) — Event → AgentEvent translation, + * dedup gate, dcp-strip, tool-snapshot. * * Implements the Phase 0 `AgentBackend` interface. Emits transport-agnostic - * `AgentEvent`s — the dispatcher (Phase 1.7, NOT wired in this batch) maps them - * to WS frames. No dispatcher/route references this file yet. + * `AgentEvent`s; the dispatcher maps them to WS frames. * * Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2 / §2a. - * SDK shapes verified by direct read of @opencode-ai/sdk@1.15.12 dist .d.ts: - * - client methods take FLATTENED params (sessionID/directory/body all inline), - * not {path,query,body}. create→{directory}, promptAsync→{sessionID,directory, - * parts,model}, abort→{sessionID,directory}. model is {providerID,modelID}. - * - client.event() resolves to { stream: AsyncGenerator }; the - * real event is chunk.payload (discriminate on chunk.payload.type). - * - promptAsync is fire-and-forget (204); the turn completes via a - * 'session.idle' event for that opencode session id. */ -import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { createServer, connect as netConnect } from 'node:net'; import type { FastifyBaseLogger } from 'fastify'; -import { - createOpencodeClient, - type OpencodeClient, - type Event, - type Part, - type ToolPart, - type ToolState, - type AssistantMessage, -} from '@opencode-ai/sdk/v2/client'; -import type { ToolCallStatus } from '@agentclientprotocol/sdk'; +import type { Event, AssistantMessage } from '@opencode-ai/sdk/v2/client'; import type { Sql } from '../../db.js'; -import type { AcpToolSnapshot } from '../acp-tool-snapshot.js'; import { armAbortGuard, noteTurnActivity, consumeTerminal } from './turn-guard.js'; import { stepEndedToUsage, type StepUsage } from './opencode-usage.js'; -import { decideRestart, DEFAULT_HEALTH_FAILURE_THRESHOLD } from './lifecycle-decisions.js'; +import { OpenCodeServerSupervisor, type ServerDownInfo } from './opencode-server-process.js'; +import { + startSessionEventLoop, + type SessionState, + type TurnState, + type SseLoopDeps, +} from './opencode-sse.js'; +import { + classifyPartDelta, + classifyUpdatedPart, + toolCalledSnapshot, + toolSuccessSnapshot, + toolFailedSnapshot, + stripDcpTags, + errMsg, + errToString, +} from './opencode-event-map.js'; import type { AgentBackend, - AgentEvent, AgentSessionHandle, EnsureSessionOpts, PromptCtx, TurnResult, } from '../agent-backend.js'; -const READY_TIMEOUT_MS = 30_000; -const SSE_RECONNECT_DELAY_MS = 1_000; /** * No-activity backstop for an in-flight turn. opencode streams reasoning/text/tool * deltas continuously while working, so "zero events for this long" means the turn - * is wedged or its terminal event (session.idle) was lost (see the reconnect race - * below). Generous so a legitimately slow turn never trips it. + * is wedged or its terminal event (session.idle) was lost. Generous so a + * legitimately slow turn never trips it. */ const TURN_INACTIVITY_MS = 180_000; -/** One in-flight turn's emitter + completion settler. */ -interface TurnState { - onEvent: (e: AgentEvent) => void; - settle: (r: TurnResult) => void; -} - -/** Per-(opencode session) demux state. dedup sets scoped here, cleared per turn. */ -interface SessionState { - boocodeSessionId: string; - agentSessionId: string; - /** Worktree directory for SDK `directory` routing; refreshed each turn from ctx. */ - worktreePath: string; - /** dedup gate: `${type}:${id}` added on delta, deleted-and-tested on updated. Cleared at turn end. */ - streamedPartKeys: Set; - /** partID → 'text' | 'reasoning', so a delta with a non-'reasoning' field is still classed right. Cleared at turn end. */ - partTypeById: Map; - activeTurn: TurnState | null; - /** Inactivity backstop timer for the active turn; null when no turn in flight. */ - watchdog: ReturnType | null; - /** Per-session SSE subscription handle. Non-null while the loop is running; - * aborting it tears down the underlying fetch and exits the loop. */ - sseAbort: AbortController | null; - /** F.1 post-abort orphan-terminal guard: swallow the one session.idle/error - * opencode emits for an aborted turn so it can't settle the next turn. */ - swallowNextTerminal: boolean; -} - export interface OpenCodeServerBackendDeps { sql: Sql; log: FastifyBaseLogger; @@ -98,36 +71,32 @@ export class OpenCodeServerBackend implements AgentBackend { private readonly sql: Sql; private readonly log: FastifyBaseLogger; - private readonly opencodeBinary: string; - - private child: ChildProcess | null = null; - private client: OpencodeClient | null = null; - private port: number | null = null; - private up = false; - private serverStarting: Promise | null = null; - // Phase 3 busy-aware health monitor (openchamber lift): consecutive failed - // probes + the start of an unhealthy-while-busy window feed `decideRestart`. - private consecutiveHealthFailures = 0; - private unhealthyBusySince = 0; - private restarting: Promise | null = null; + private readonly supervisor: OpenCodeServerSupervisor; /** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */ private readonly byOpencodeId = new Map(); + /** Coalesces concurrent ensureSession calls for the same (chat, agent) key. */ + private readonly ensuring = new Map>(); constructor(deps: OpenCodeServerBackendDeps) { this.sql = deps.sql; this.log = deps.log; - this.opencodeBinary = deps.opencodeBinary; + this.supervisor = new OpenCodeServerSupervisor({ + opencodeBinary: deps.opencodeBinary, + log: deps.log, + hooks: { + isBusy: () => this.isBusy(), + onServerDown: (info) => this.onServerDown(info), + }, + }); } /** §2: liveness for the health endpoint + dispatcher fallback decision. */ health(): 'up' | 'down' { - return this.up ? 'up' : 'down'; + return this.supervisor.health(); } - /** Phase 3: busy iff ANY pooled opencode session has an in-flight turn. The - * pool reads this to skip idle/LRU eviction and the health-monitor to defer a - * restart (never tear down a session mid-stream). */ + /** Phase 3: busy iff ANY pooled opencode session has an in-flight turn. */ isBusy(): boolean { for (const st of this.byOpencodeId.values()) { if (st.activeTurn) return true; @@ -135,72 +104,23 @@ export class OpenCodeServerBackend implements AgentBackend { return false; } - // ─── Server lifecycle (1.2: spawn once + client + ready; Phase 3 crash-restart) ── - - /** - * Lazy: start the single server on first use; re-spawn after a crash. Idempotent - * within one live server — `serverStarting` caches the in-flight start, and is - * reset to null by the crash handler so the NEXT ensureServer re-spawns a fresh - * server (Phase 3 crash recovery). A dead-but-not-yet-reaped child (exit handler - * raced) is also treated as needing a restart. - */ - private ensureServer(): Promise { - const childDead = this.child != null && (this.child.exitCode !== null || this.child.signalCode !== null); - if (!this.serverStarting || (!this.up && childDead)) { - this.serverStarting = this.startServer(); - } - return this.serverStarting; - } - - private async startServer(): Promise { - const port = await freePort(); - - // Phase 1: run unsecured on loopback (opencode's documented default — serve.ts - // only WARNS when OPENCODE_SERVER_PASSWORD is unset). The real boundary is the - // 127.0.0.1 bind. Defense-in-depth basic-auth is deferred: the hey-api client's - // auth wiring + opencode's exact scheme must be confirmed against a live server - // first, else every request 401s. Recon explicitly said "do NOT block on it". - const child = spawn(this.opencodeBinary, ['serve', '--hostname', '127.0.0.1', '--port', String(port)], { - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env }, - }); - this.child = child; - this.port = port; - - // Child lifetime is the backend's (the pool's), NOT a request's. We never tie - // it to a per-turn abort signal. Phase 3: on unexpected exit we recover — - // settle any in-flight turns as failed, mark their agent_sessions rows crashed, - // and reset `serverStarting` so the next ensureServer re-spawns. opencode keeps - // sessions on disk, but a fresh server's in-memory state is gone, so the next - // turn's ensureSession (rows now 'crashed') creates fresh opencode sessions. - child.on('exit', (code, signal) => { - // Only react to THIS child's exit (a restart may have swapped in a new one). - if (this.child !== child) return; - this.handleServerCrash(code, signal, port); - }); - - await waitForReady(child, READY_TIMEOUT_MS); - - this.client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` }); - this.up = true; - this.log.info({ port }, 'opencode-server: ready'); + /** Phase 3 proactive health probe + busy-aware self-restart, run by the pool's + * periodic sweep. Delegates to the supervisor. */ + async tickHealth(now: number = Date.now()): Promise { + await this.supervisor.tickHealth(now); } /** - * Crash handler (Phase 3, lift of openchamber's restart-on-exit path). The - * server died with N live opencode sessions; we can't restart it here (the next - * turn does, lazily — avoids a restart storm if the binary is broken). We: - * 1. fail every in-flight turn so its dispatcher unblocks + publishes an error, - * 2. mark each session's agent_sessions row 'crashed' so ensureSession won't - * resume a now-dead native session id (it creates fresh), - * 3. tear down the SSE loops + demux state (stale against the dead server), - * 4. reclaim the port + reset state so the next ensureServer re-spawns. + * Server down (crash-exit or forced restart): fail every in-flight turn so its + * dispatcher unblocks, mark each session crashed so ensureSession won't resume a + * now-dead native id, and tear down the SSE loops + demux state. Invoked by the + * supervisor (it owns the process/port reset). Mirrors the original + * handleServerCrash session-half byte-for-byte. */ - private handleServerCrash(code: number | null, signal: NodeJS.Signals | null, port: number): void { - this.up = false; + private onServerDown(info: ServerDownInfo): void { const states = [...this.byOpencodeId.values()]; this.log.warn( - { code, signal, port, liveSessions: states.length }, + { code: info.code, signal: info.signal, port: info.port, liveSessions: states.length }, 'opencode-server: child exited — recovering (fail in-flight, mark crashed, re-spawn next turn)', ); @@ -219,8 +139,6 @@ export class OpenCodeServerBackend implements AgentBackend { } // Drop the demux map: every session id is stale against a fresh server. this.byOpencodeId.clear(); - this.client = null; - this.serverStarting = null; // force a re-spawn on the next ensureServer if (crashedIds.length > 0) { this.sql` @@ -230,146 +148,20 @@ export class OpenCodeServerBackend implements AgentBackend { this.log.warn({ err: errMsg(err) }, 'opencode-server: failed to mark crashed sessions (non-fatal)'); }); } - - // Reclaim the port so a re-spawn on a fixed/leaked port isn't blocked. Best - // effort; the next start uses a fresh ephemeral port anyway. - reclaimPort(port); } - /** - * Phase 3 proactive health monitor (openchamber `runHealthCheckCycle` lift, - * busy-aware). Probes the server's /global/health; on a sustained failure of a - * NON-busy server, force a restart so the next turn isn't blocked by a wedged - * (hung-but-not-exited) process. Busy servers are deferred via the stale-grace in - * `decideRestart` — never tear down live work. Driven by the pool's periodic - * sweep (best-effort; a crash-exit is already handled by `handleServerCrash` + - * lazy `ensureServer` re-spawn, so this only catches the hung case). No-op when - * the server was never started or a restart is already in flight. - */ - async tickHealth(now: number = Date.now()): Promise { - if (!this.child || this.restarting) return; - const childExited = this.child.exitCode !== null || this.child.signalCode !== null; - // An exited child is recovered lazily by ensureServer; don't double-restart it. - if (childExited) return; + // ─── SSE loop wiring ───────────────────────────────────────────────────────── - const healthy = await this.probeHealth(); - if (healthy) { - this.consecutiveHealthFailures = 0; - this.unhealthyBusySince = 0; - return; - } - this.consecutiveHealthFailures += 1; - const busy = this.isBusy(); - const decision = decideRestart({ - processExited: false, - consecutiveFailures: this.consecutiveHealthFailures, - busy, - unhealthyBusySince: this.unhealthyBusySince, - now, - failureThreshold: DEFAULT_HEALTH_FAILURE_THRESHOLD, - }); - // Stamp the start of an unhealthy-while-busy window so the stale-grace can fire. - if (busy && this.unhealthyBusySince === 0) this.unhealthyBusySince = now; - if (decision.action === 'restart') { - this.log.warn( - { failures: this.consecutiveHealthFailures, busy, reason: decision.reason }, - 'opencode-server: health monitor forcing restart', - ); - this.consecutiveHealthFailures = 0; - this.unhealthyBusySince = 0; - await this.restartServer(); - } - } - - private async probeHealth(): Promise { - if (!this.client) return false; - try { - const res = await this.client.global.health(); - return !res.error; - } catch { - return false; - } - } - - /** Force-kill the current server + reclaim its port; the next ensureServer - * re-spawns (lazy). Mirrors handleServerCrash's state reset but is initiated by - * the health monitor rather than the OS. */ - private async restartServer(): Promise { - if (this.restarting) return this.restarting; - this.restarting = (async () => { - const child = this.child; - const port = this.port; - this.up = false; - // Fail in-flight turns + mark sessions crashed via the same path as a crash. - if (child) { - this.handleServerCrash(null, null, port ?? 0); - if (!child.killed) child.kill('SIGTERM'); - } - if (port) { - reclaimPort(port); - await waitForPortRelease(port, 3_000); - } - this.child = null; - })().finally(() => { - this.restarting = null; - }); - return this.restarting; - } - - // ─── SSE read loop + demux + translate (1.3) + dedup (1.4) ─────────────────── - - /** Per-session SSE subscription, scoped to the session's worktree directory. - * opencode scopes events by the `directory` query param (defaults to the - * server's cwd if omitted), so two sessions in different worktrees each get - * their own dir-scoped stream and never drop each other's events. Idempotent: - * a no-op if this session's loop is already running. Started from ensureSession - * (and defensively from prompt) once worktreePath is known. */ - private startSessionEventLoop(state: SessionState): void { - if (state.sseAbort) return; // already running - const abort = new AbortController(); - state.sseAbort = abort; - void this.runSessionEventLoop(state, abort).finally(() => { - // Only clear if this controller is still the live one (a later restart may - // have already installed a new one). - if (state.sseAbort === abort) state.sseAbort = null; - }); - } - - private async runSessionEventLoop(state: SessionState, abort: AbortController): Promise { - const signal = abort.signal; - while (this.up && this.client && !signal.aborted) { - try { - // Re-read worktreePath each (re)subscribe so a directory refresh is picked - // up on reconnect. Passing `signal` lets close/dispose tear down a stream - // that's parked in `for await` between events. - const sub = await this.client.event.subscribe( - { directory: state.worktreePath }, - { signal }, - ); - for await (const ev of sub.stream) { - if (signal.aborted) break; - // Dir-scoped streams should only carry this session's events, but two - // sessions sharing a worktree (possible post-P1.5-b) each receive BOTH - // sessions' events — so drop anything that isn't ours, else the other - // session's deltas get processed twice (once per loop). - const sid = eventSessionId(ev); - if (sid != null && sid !== state.agentSessionId) continue; - this.dispatchEvent(ev); - } - if (this.up && !signal.aborted) { - await this.reconcile(state); // recover an idle/error lost during the gap - await sleep(SSE_RECONNECT_DELAY_MS); - } - } catch (err) { - if (!this.up || signal.aborted) break; - this.log.warn( - { err: errMsg(err), agentSessionId: state.agentSessionId }, - 'opencode-server: session event loop error; reconnecting', - ); - await this.reconcile(state); - await sleep(SSE_RECONNECT_DELAY_MS); - } - } + /** The dependency bundle the per-session SSE loop reads. */ + private sseDeps(): SseLoopDeps { + return { + isUp: () => this.supervisor.isUp(), + getClient: () => this.supervisor.client, + dispatchEvent: (ev) => this.dispatchEvent(ev), + reconcile: (st) => this.reconcile(st), + onReconnectGiveUp: (st) => this.onReconnectGiveUp(st), + log: this.log, + }; } /** Demux one event to the owning session's active turn. Unknown/between-turns → drop. */ @@ -398,15 +190,7 @@ export class OpenCodeServerBackend implements AgentBackend { const st = this.byOpencodeId.get(p.sessionID); if (!st?.activeTurn) return; this.bumpActivity(st); - const snap: AcpToolSnapshot = { - toolCallId: p.callID, - title: p.tool, - kind: null, - status: 'in_progress', - rawInput: p.input, - rawOutput: undefined, - }; - st.activeTurn.onEvent({ type: 'tool_call', toolCall: snap }); + st.activeTurn.onEvent({ type: 'tool_call', toolCall: toolCalledSnapshot(p) }); return; } case 'session.next.tool.success': { @@ -414,16 +198,7 @@ export class OpenCodeServerBackend implements AgentBackend { const st = this.byOpencodeId.get(p.sessionID); if (!st?.activeTurn) return; this.bumpActivity(st); - const output = p.content?.map((c) => ('text' in c ? (c as { text: string }).text : '')).join('') ?? ''; - const snap: AcpToolSnapshot = { - toolCallId: p.callID, - title: p.callID, - kind: null, - status: 'completed', - rawInput: undefined, - rawOutput: output, - }; - st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap }); + st.activeTurn.onEvent({ type: 'tool_update', toolCall: toolSuccessSnapshot(p) }); return; } case 'session.next.tool.failed': { @@ -431,15 +206,7 @@ export class OpenCodeServerBackend implements AgentBackend { const st = this.byOpencodeId.get(p.sessionID); if (!st?.activeTurn) return; this.bumpActivity(st); - const snap: AcpToolSnapshot = { - toolCallId: p.callID, - title: p.callID, - kind: null, - status: 'failed', - rawInput: undefined, - rawOutput: errToString(p.error), - }; - st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap }); + st.activeTurn.onEvent({ type: 'tool_update', toolCall: toolFailedSnapshot(p) }); return; } // ─── per-step usage (U.6) — token/cost accounting for opencode sessions ── @@ -449,8 +216,7 @@ export class OpenCodeServerBackend implements AgentBackend { if (!st?.activeTurn) return; this.bumpActivity(st); // Accumulate this step's normalized usage onto the (chat_id, agent) row. - // Fire-and-forget: a DB hiccup must not stall the turn. opencode emits this - // once per LLM step, so a multi-tool turn sums several deltas. + // Fire-and-forget: a DB hiccup must not stall the turn. const usage = stepEndedToUsage(p); void this.accumulateUsage(st, usage); return; @@ -461,15 +227,8 @@ export class OpenCodeServerBackend implements AgentBackend { const st = this.byOpencodeId.get(p.sessionID); if (!st?.activeTurn) return; this.bumpActivity(st); - const isReasoning = p.field === 'reasoning' || st.partTypeById.get(p.partID) === 'reasoning'; - if (isReasoning) { - st.streamedPartKeys.add(`reasoning:${p.partID}`); - st.activeTurn.onEvent({ type: 'reasoning', text: p.delta }); - } else if (p.field === 'text') { - st.streamedPartKeys.add(`text:${p.partID}`); - const cleaned = stripDcpTags(p.delta); - if (cleaned) st.activeTurn.onEvent({ type: 'text', text: cleaned }); - } + const e = classifyPartDelta(p, st); + if (e) st.activeTurn.onEvent(e); return; } case 'message.part.updated': { @@ -477,7 +236,8 @@ export class OpenCodeServerBackend implements AgentBackend { const st = this.byOpencodeId.get(part.sessionID); if (!st?.activeTurn) return; this.bumpActivity(st); - this.handleUpdatedPart(part, st); + const e = classifyUpdatedPart(part, st); + if (e) st.activeTurn.onEvent(e); return; } // ─── lifecycle ───────────────────────────────────────────────────────── @@ -502,40 +262,6 @@ export class OpenCodeServerBackend implements AgentBackend { } } - /** Terminal part: dedup gate for text/reasoning; tool parts → tool_call/tool_update. */ - private handleUpdatedPart(part: Part, st: SessionState): void { - const turn = st.activeTurn; - if (!turn) return; - - if (part.type === 'text' || part.type === 'reasoning') { - st.partTypeById.set(part.id, part.type); - const key = resolvePartDedupeKey(part, part.type); - if (key && st.streamedPartKeys.delete(key)) return; // already streamed via delta - const raw = part.text ?? ''; - const text = part.type === 'text' ? stripDcpTags(raw) : raw; - if (text && part.time?.end != null) { - turn.onEvent({ type: part.type, text }); - } - return; - } - - if (part.type === 'tool') { - const snap = toolPartToSnapshot(part); - const status = part.state?.status; - // tool_call on start (pending/running), tool_update on terminal (completed/error). - // The current ACP path merges both into one frame; the contract keeps them - // distinct because opencode's SSE distinguishes start from result. - const event: AgentEvent = - status === 'completed' || status === 'error' - ? { type: 'tool_update', toolCall: snap } - : { type: 'tool_call', toolCall: snap }; - turn.onEvent(event); - return; - } - // NOTE: opencode's SSE payload union carries no available-commands event, so the - // AgentEvent 'commands' arm is intentionally never emitted here (1.3). - } - // ─── turn-completion resilience (watchdog + reconnect reconcile) ───────────── /** Reset the inactivity backstop on any event routed to a session's active turn. */ @@ -550,8 +276,8 @@ export class OpenCodeServerBackend implements AgentBackend { st.watchdog.unref?.(); } - /** Watchdog fired: reconcile once; if the server says still-running we can't tell, so fail closed. - * Also mark the agent_sessions row crashed so a stale session isn't resumed next turn. */ + /** Watchdog fired: reconcile once; if still-running we can't tell, so fail closed. + * Also mark the agent_sessions row crashed so a stale session isn't resumed. */ private async onTurnStall(st: SessionState): Promise { const settled = await this.reconcile(st); if (!settled) { @@ -564,16 +290,27 @@ export class OpenCodeServerBackend implements AgentBackend { } } + /** SSE circuit-breaker fired (reconnect gave up): fail the active turn + mark the + * session crashed so it isn't resumed. The next turn re-creates a fresh session. */ + private async onReconnectGiveUp(st: SessionState): Promise { + if (!st.activeTurn) return; + await this.sql` + UPDATE agent_sessions SET status = 'crashed' + WHERE agent_session_id = ${st.agentSessionId} + `.catch(() => {}); + st.activeTurn?.settle({ ok: false, error: 'opencode SSE stream lost (reconnect gave up)' }); + } + /** * Ask the server whether this session's turn already finished — recovers a * session.idle/error lost during an SSE gap. Returns true if it settled the turn. - * Inconclusive (still running / call failed) → false; the watchdog covers that. */ private async reconcile(st: SessionState): Promise { const turn = st.activeTurn; - if (!turn || !this.client) return false; + const client = this.supervisor.client; + if (!turn || !client) return false; try { - const res = await this.client.session.messages({ + const res = await client.session.messages({ sessionID: st.agentSessionId, directory: st.worktreePath, }); @@ -605,10 +342,8 @@ export class OpenCodeServerBackend implements AgentBackend { /** * Accumulate one `session.next.step.ended`'s normalized usage onto the session's - * agent_sessions row, keyed by the resumed `agent_session_id` (unique per active - * row — the dispatcher's `(chat_id, agent)` lookup wrote it). Running totals for - * the whole conversation context (not last-step). Zero-delta steps are skipped to - * avoid a no-op write. Errors are swallowed: usage telemetry must never fail a turn. + * agent_sessions row. Running totals for the whole conversation context. Zero-delta + * steps are skipped. Errors are swallowed: usage telemetry must never fail a turn. */ private async accumulateUsage(st: SessionState, u: StepUsage): Promise { if (u.input === 0 && u.output === 0 && u.cost === 0) return; @@ -631,13 +366,29 @@ export class OpenCodeServerBackend implements AgentBackend { // ─── ensureSession: create-or-resume against agent_sessions (1.5) ──────────── async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise { - await this.ensureServer(); - if (!this.client) throw new Error('opencode-server: client not ready after ensureServer'); + // Coalesce concurrent first-turns for the same (chat, agent) so the SELECT… + // create…upsert can't race into two opencode sessions (the second orphaning + // the first). A single (non-concurrent) call is unaffected — the entry is set + // and removed within this call. Defensive: the dispatcher already serializes + // turns per (chat, agent) via its inflight map. + const key = `${opts.chatId}:${opts.agent}`; + const existing = this.ensuring.get(key); + if (existing) return existing; + const p = this.ensureSessionInner(sessionId, opts).finally(() => { + if (this.ensuring.get(key) === p) this.ensuring.delete(key); + }); + this.ensuring.set(key, p); + return p; + } + + private async ensureSessionInner(sessionId: string, opts: EnsureSessionOpts): Promise { + await this.supervisor.ensureServer(); + const client = this.supervisor.client; + if (!client) throw new Error('opencode-server: client not ready after ensureServer'); const configHash = sessionConfigHash(opts.model); // P1.5-b: agent_sessions is keyed (chat_id, agent) — the tab/chat is the // context unit (two tabs in one session = two contexts sharing one worktree). - // session_id + worktree_id are retained as informational (SET NULL) columns. const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>` SELECT agent_session_id, status, config_hash FROM agent_sessions WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent} @@ -655,7 +406,7 @@ export class OpenCodeServerBackend implements AgentBackend { 'opencode-server: not resuming stale session, creating fresh'); this.byOpencodeId.delete(agentSessionId); } - const created = await this.client.session.create({ directory: opts.worktreePath }); + const created = await client.session.create({ directory: opts.worktreePath }); if (created.error || !created.data) { throw new Error(`opencode-server: session.create failed: ${errToString(created.error)}`); } @@ -664,7 +415,7 @@ export class OpenCodeServerBackend implements AgentBackend { INSERT INTO agent_sessions (chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash) VALUES - (${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash}) + (${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.supervisor.port}, 'active', clock_timestamp(), ${configHash}) ON CONFLICT (chat_id, agent) DO UPDATE SET session_id = EXCLUDED.session_id, worktree_id = EXCLUDED.worktree_id, @@ -678,7 +429,7 @@ export class OpenCodeServerBackend implements AgentBackend { } else { await this.sql` UPDATE agent_sessions - SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash} + SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.supervisor.port}, config_hash = ${configHash} WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent} `; } @@ -693,24 +444,13 @@ export class OpenCodeServerBackend implements AgentBackend { state.boocodeSessionId = sessionId; state.worktreePath = opts.worktreePath; } else { - state = { - boocodeSessionId: sessionId, - agentSessionId: ocSessionId, - worktreePath: opts.worktreePath, - streamedPartKeys: new Set(), - partTypeById: new Map(), - activeTurn: null, - watchdog: null, - sseAbort: null, - swallowNextTerminal: false, - }; + state = this.makeSessionState(sessionId, ocSessionId, opts.worktreePath); this.byOpencodeId.set(ocSessionId, state); } // Start this session's own SSE loop, scoped to its worktree directory. Both - // fresh-create and resume reach here; idempotent, so a re-ensure (e.g. a - // second turn) won't spawn a duplicate loop. - this.startSessionEventLoop(state); + // fresh-create and resume reach here; idempotent. + startSessionEventLoop(state, this.sseDeps()); return { sessionId, @@ -719,40 +459,53 @@ export class OpenCodeServerBackend implements AgentBackend { chatId: opts.chatId, worktreeId: opts.worktreeId, agentSessionId: ocSessionId, - serverPort: this.port, + serverPort: this.supervisor.port, + }; + } + + /** Fresh per-(opencode session) demux state. */ + private makeSessionState(boocodeSessionId: string, agentSessionId: string, worktreePath: string): SessionState { + return { + boocodeSessionId, + agentSessionId, + worktreePath, + streamedPartKeys: new Set(), + partTypeById: new Map(), + activeTurn: null, + watchdog: null, + sseAbort: null, + swallowNextTerminal: false, }; } // ─── prompt: send one turn (1.6) ───────────────────────────────────────────── async prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise { - if (!this.client) throw new Error('opencode-server: client not ready'); + const client = this.supervisor.client; + if (!client) throw new Error('opencode-server: client not ready'); const oc = handle.agentSessionId; if (!oc) throw new Error('opencode-server: handle has no agentSessionId'); let state = this.byOpencodeId.get(oc); if (!state) { - state = { - boocodeSessionId: handle.sessionId, - agentSessionId: oc, - worktreePath: ctx.worktreePath, - streamedPartKeys: new Set(), - partTypeById: new Map(), - activeTurn: null, - watchdog: null, - sseAbort: null, - swallowNextTerminal: false, - }; + state = this.makeSessionState(handle.sessionId, oc, ctx.worktreePath); this.byOpencodeId.set(oc, state); } const session = state; + + // v2.7 busy-assert: one in-flight turn per session. The dispatcher serializes + // turns per (chat, agent), so this never fires in normal dispatch — but if a + // second prompt arrives while one is live it would silently overwrite the slot + // and orphan the first turn, so reject instead. + if (session.activeTurn) { + return { ok: false, error: 'opencode-server: session already has an in-flight turn' }; + } + // Authoritative per-turn directory for SDK routing + reconcile. session.worktreePath = ctx.worktreePath; // Defensive: ensureSession normally starts the loop, but if prompt is reached // with a freshly-created state (no loop yet), start it so the turn streams. - // Idempotent when ensureSession already started one. - this.startSessionEventLoop(session); - const client = this.client; + startSessionEventLoop(session, this.sseDeps()); return await new Promise((resolve) => { let settled = false; @@ -781,7 +534,8 @@ export class OpenCodeServerBackend implements AgentBackend { settle({ ok: false, error: 'aborted' }); }; - session.activeTurn = { onEvent: ctx.onEvent, settle }; + const turn: TurnState = { onEvent: ctx.onEvent, settle }; + session.activeTurn = turn; this.bumpActivity(session); // arm the inactivity backstop if (ctx.signal.aborted) { @@ -822,39 +576,15 @@ export class OpenCodeServerBackend implements AgentBackend { } async dispose(): Promise { - this.up = false; // Abort every per-session SSE loop so none survive the teardown. for (const st of this.byOpencodeId.values()) st.sseAbort?.abort(); - const child = this.child; - this.child = null; - this.client = null; this.byOpencodeId.clear(); - if (child && !child.killed) { - child.kill('SIGTERM'); - const t = setTimeout(() => { - if (!child.killed) child.kill('SIGKILL'); - }, 5_000); - t.unref(); - } + await this.supervisor.dispose(); } } // ─── helpers ────────────────────────────────────────────────────────────────── -/** Extract the opencode sessionID an event belongs to, across event shapes. - * Most carry `properties.sessionID`; `message.part.updated` nests it under - * `properties.part.sessionID`. Returns null when the event has no session - * (the per-session loop then leaves it to dispatchEvent, which drops it). */ -function eventSessionId(ev: Event): string | null { - const props = (ev as { properties?: unknown }).properties; - if (!props || typeof props !== 'object') return null; - if (ev.type === 'message.part.updated') { - const part = (props as { part?: { sessionID?: string } }).part; - return part?.sessionID ?? null; - } - return (props as { sessionID?: string }).sessionID ?? null; -} - /** BooCoder model string "provider/model" → opencode's structured {providerID, modelID}. */ function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined { if (!model || !model.trim()) return undefined; @@ -864,199 +594,14 @@ function parseModel(model: string | undefined): { providerID: string; modelID: s return { providerID: trimmed.slice(0, idx), modelID: trimmed.slice(idx + 1) }; } // No slash but non-empty → infer llama-swap (the only configured provider). - // Guard against bare '/' or trailing/leading slash. if (idx < 0 && trimmed.length > 0) { return { providerID: 'llama-swap', modelID: trimmed }; } return undefined; } -/** Ported verbatim from Paseo opencode-agent.ts: id → message-id fallback → null. */ -function resolvePartDedupeKey(part: { id: string; messageID: string }, type: string): string | null { - if (part.id.trim().length > 0) return `${type}:${part.id}`; - if (part.messageID.trim().length > 0) return `${type}:message:${part.messageID}`; - return null; -} - -/** opencode ToolPart → ACP-shaped snapshot (reuses the existing persist/render path). */ -function toolPartToSnapshot(part: ToolPart): AcpToolSnapshot { - const state = part.state; - let rawInput: unknown; - let rawOutput: unknown; - let title: string | undefined; - if (state) { - if ('input' in state) rawInput = (state as { input?: unknown }).input; - if ('output' in state) rawOutput = (state as { output?: unknown }).output; - else if ('error' in state) rawOutput = (state as { error?: unknown }).error; - if ('title' in state) title = (state as { title?: string }).title; - } - return { - toolCallId: part.callID, - title: title ?? part.tool, - kind: null, - status: mapToolStatus(state?.status), - rawInput, - rawOutput, - }; -} - -function mapToolStatus(s: ToolState['status'] | undefined): ToolCallStatus | null { - switch (s) { - case 'pending': - return 'pending'; - case 'running': - return 'in_progress'; - case 'completed': - return 'completed'; - case 'error': - return 'failed'; - default: - return null; - } -} - -/** - * Reclaim a loopback port a dead opencode child may still hold (lift of - * openchamber `killProcessOnPort`). Best-effort, POSIX-only (`lsof`/`kill`); a - * failure is harmless because the next spawn allocates a fresh ephemeral port. - * Never kills this process. Synchronous + short-timeout so the crash handler - * doesn't block. - */ -function reclaimPort(port: number | null): void { - if (!port || process.platform === 'win32') return; - try { - const res = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 3_000, windowsHide: true }); - const out = res.stdout || ''; - const myPid = process.pid; - for (const pidStr of out.split(/\s+/)) { - const pid = parseInt(pidStr.trim(), 10); - if (pid && pid !== myPid) { - try { - spawnSync('kill', ['-9', String(pid)], { stdio: 'ignore', timeout: 2_000 }); - } catch { - // ignore — best effort - } - } - } - } catch { - // lsof absent or failed — the fresh-ephemeral-port spawn doesn't need this. - } -} - -/** - * Resolve true once nothing is listening on `port` (lift of openchamber - * `waitForPortRelease`). Used before re-spawning on a fixed port; with ephemeral - * ports it's a fast no-op. Probes 127.0.0.1; resolves false at the deadline. - */ -function waitForPortRelease(port: number, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - return new Promise((resolve) => { - const attempt = () => { - const socket = netConnect({ port, host: '127.0.0.1' }); - let settled = false; - const finish = (released: boolean) => { - if (settled) return; - settled = true; - socket.removeAllListeners(); - socket.destroy(); - if (released || Date.now() >= deadline) { - resolve(released); - return; - } - setTimeout(attempt, 150); - }; - socket.once('connect', () => finish(false)); - socket.once('error', (err: NodeJS.ErrnoException) => { - if (err && (err.code === 'ECONNREFUSED' || err.code === 'EHOSTUNREACH')) finish(true); - else finish(false); - }); - socket.setTimeout(500, () => finish(true)); - }; - attempt(); - }); -} - -/** Bind-probe an ephemeral port on loopback. */ -function freePort(): Promise { - return new Promise((resolve, reject) => { - const srv = createServer(); - srv.unref(); - srv.on('error', reject); - srv.listen(0, '127.0.0.1', () => { - const addr = srv.address(); - if (addr && typeof addr === 'object') { - const { port } = addr; - srv.close(() => resolve(port)); - } else { - srv.close(() => reject(new Error('opencode-server: could not determine a free port'))); - } - }); - }); -} - -/** Resolve when the child prints the ready line; reject on timeout or early exit. */ -function waitForReady(child: ChildProcess, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - let done = false; - let stderrBuf = ''; - - const finish = (err?: Error) => { - if (done) return; - done = true; - clearTimeout(timer); - child.stdout?.off('data', onOut); - child.stderr?.off('data', onErr); - child.off('exit', onExit); - if (err) reject(err); - else resolve(); - }; - - const onOut = (buf: Buffer) => { - if (buf.toString().includes('opencode server listening on')) finish(); - }; - const onErr = (buf: Buffer) => { - stderrBuf += buf.toString(); - }; - const onExit = (code: number | null) => - finish(new Error(`opencode serve exited before ready (code ${code}); stderr: ${stderrBuf.slice(-2000)}`)); - const timer = setTimeout( - () => finish(new Error(`opencode serve not ready in ${timeoutMs}ms; stderr: ${stderrBuf.slice(-2000)}`)), - timeoutMs, - ); - - child.stdout?.on('data', onOut); - child.stderr?.on('data', onErr); - child.on('exit', onExit); - }); -} - -function sleep(ms: number): Promise { - return new Promise((r) => setTimeout(r, ms)); -} - -/** Strip opencode-dcp plugin tags that render as literal text in the UI. */ -function stripDcpTags(s: string): string { - return s.replace(/[^<]*<\/dcp-message-id>/g, ''); -} - -function errMsg(e: unknown): string { - return e instanceof Error ? e.message : String(e); -} - -function errToString(e: unknown): string { - if (e == null) return 'unknown error'; - if (typeof e === 'string') return e; - if (e instanceof Error) return e.message; - try { - return JSON.stringify(e); - } catch { - return String(e); - } -} - /** Hash of stable config — detects model changes across sessions without - * invalidating on ephemeral state like the random server port (which changes - * every BooCoder restart). */ + * invalidating on ephemeral state like the random server port. */ function sessionConfigHash(model: string): string { return createHash('sha256').update(`opencode_server|${model}`).digest('hex').slice(0, 16); } diff --git a/apps/coder/src/services/backends/opencode-sse.ts b/apps/coder/src/services/backends/opencode-sse.ts new file mode 100644 index 0000000..956dc90 --- /dev/null +++ b/apps/coder/src/services/backends/opencode-sse.ts @@ -0,0 +1,181 @@ +/** + * Per-session SSE subscribe loop + reconnect/backoff + eventSessionId demux. + * + * Extracted (v2.7 audit reshape) from `OpenCodeServerBackend.startSessionEventLoop` + * / `runSessionEventLoop`. opencode scopes events by the `directory` query param, so + * each session runs its own dir-scoped stream and never drops a sibling's events. + * + * The loop is intentionally thin: it owns subscribe + the demux filter + reconnect + * timing only. Translating an event into turn side effects (watchdog, usage, + * settle) stays on the backend via the injected `dispatchEvent` / `reconcile` + * callbacks — `opencode-sse` knows nothing about turns or the DB. + * + * v2.7 concurrency hardening: the throw-driven reconnect path now backs off + * exponentially and trips a circuit-breaker (`onReconnectGiveUp`) after a bounded + * number of consecutive failures, instead of looping forever at a flat 1s. The + * HAPPY PATH is unchanged — a clean stream end (server still up) reconnects after + * `baseMs` (1s, as before) and resets the failure counter, so a long-lived session + * that re-subscribes normally never backs off. + */ +import type { FastifyBaseLogger } from 'fastify'; +import type { Event, OpencodeClient } from '@opencode-ai/sdk/v2/client'; +import type { AgentEvent } from '../agent-backend.js'; +import type { TurnResult } from '../agent-backend.js'; +import { eventSessionId, errMsg } from './opencode-event-map.js'; + +export const SSE_RECONNECT_DELAY_MS = 1_000; + +/** One in-flight turn's emitter + completion settler. */ +export interface TurnState { + onEvent: (e: AgentEvent) => void; + settle: (r: TurnResult) => void; +} + +/** Per-(opencode session) demux state. dedup sets scoped here, cleared per turn. */ +export interface SessionState { + boocodeSessionId: string; + agentSessionId: string; + /** Worktree directory for SDK `directory` routing; refreshed each turn from ctx. */ + worktreePath: string; + /** dedup gate: `${type}:${id}` added on delta, deleted-and-tested on updated. Cleared at turn end. */ + streamedPartKeys: Set; + /** partID → 'text' | 'reasoning', so a delta with a non-'reasoning' field is still classed right. Cleared at turn end. */ + partTypeById: Map; + activeTurn: TurnState | null; + /** Inactivity backstop timer for the active turn; null when no turn in flight. */ + watchdog: ReturnType | null; + /** Per-session SSE subscription handle. Non-null while the loop is running; + * aborting it tears down the underlying fetch and exits the loop. */ + sseAbort: AbortController | null; + /** F.1 post-abort orphan-terminal guard: swallow the one session.idle/error + * opencode emits for an aborted turn so it can't settle the next turn. */ + swallowNextTerminal: boolean; +} + +// ─── reconnect backoff (pure) ──────────────────────────────────────────────── + +export interface ReconnectPolicy { + /** First retry delay (and the steady-state clean-reconnect delay). */ + baseMs: number; + /** Cap on the exponential delay. */ + maxMs: number; + /** Consecutive failures tolerated before the breaker trips (give up). */ + maxAttempts: number; +} + +export const DEFAULT_RECONNECT_POLICY: ReconnectPolicy = { + baseMs: SSE_RECONNECT_DELAY_MS, + maxMs: 30_000, + maxAttempts: 6, +}; + +export type ReconnectDecision = + | { action: 'reconnect'; delayMs: number } + | { action: 'give-up' }; + +/** + * Pure backoff decision after `failures` consecutive throwing reconnect attempts + * (1-based: the first failure passes `failures=1`). Returns an exponentially + * growing delay capped at `maxMs`, or `give-up` once the count exceeds + * `maxAttempts`. `failures=1` yields `baseMs`, so the very first retry matches the + * pre-hardening flat delay (happy-path-preserving). + */ +export function reconnectDecision( + failures: number, + policy: ReconnectPolicy = DEFAULT_RECONNECT_POLICY, +): ReconnectDecision { + if (failures > policy.maxAttempts) return { action: 'give-up' }; + const exp = policy.baseMs * 2 ** (failures - 1); + return { action: 'reconnect', delayMs: Math.min(policy.maxMs, exp) }; +} + +// ─── the loop ──────────────────────────────────────────────────────────────── + +export interface SseLoopDeps { + /** Live iff the server is up (read each iteration so a crash stops the loop). */ + isUp: () => boolean; + /** The current opencode client (null between server restarts). */ + getClient: () => OpencodeClient | null; + /** Route one demuxed event to its turn (backend side effects live here). */ + dispatchEvent: (ev: Event) => void; + /** Recover an idle/error lost during an SSE gap. Returns true if it settled. */ + reconcile: (state: SessionState) => Promise; + /** Circuit-breaker: called once the backoff gives up; fail the active turn. */ + onReconnectGiveUp: (state: SessionState) => Promise | void; + log: FastifyBaseLogger; + /** Injectable for tests; defaults to a real timer sleep. */ + sleep?: (ms: number) => Promise; + policy?: ReconnectPolicy; +} + +function defaultSleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** Per-session SSE subscription, scoped to the session's worktree directory. + * Idempotent: a no-op if this session's loop is already running. */ +export function startSessionEventLoop(state: SessionState, deps: SseLoopDeps): void { + if (state.sseAbort) return; // already running + const abort = new AbortController(); + state.sseAbort = abort; + void runSessionEventLoop(state, abort, deps).finally(() => { + // Only clear if this controller is still the live one (a later restart may + // have already installed a new one). + if (state.sseAbort === abort) state.sseAbort = null; + }); +} + +export async function runSessionEventLoop( + state: SessionState, + abort: AbortController, + deps: SseLoopDeps, +): Promise { + const signal = abort.signal; + const sleep = deps.sleep ?? defaultSleep; + const policy = deps.policy ?? DEFAULT_RECONNECT_POLICY; + let failures = 0; + while (deps.isUp() && deps.getClient() && !signal.aborted) { + const client = deps.getClient()!; + try { + // Re-read worktreePath each (re)subscribe so a directory refresh is picked + // up on reconnect. Passing `signal` lets close/dispose tear down a stream + // that's parked in `for await` between events. + const sub = await client.event.subscribe({ directory: state.worktreePath }, { signal }); + for await (const ev of sub.stream) { + if (signal.aborted) break; + // Dir-scoped streams should only carry this session's events, but two + // sessions sharing a worktree (possible post-P1.5-b) each receive BOTH + // sessions' events — so drop anything that isn't ours, else the other + // session's deltas get processed twice (once per loop). + const sid = eventSessionId(ev); + if (sid != null && sid !== state.agentSessionId) continue; + deps.dispatchEvent(ev); + } + // Clean stream end — a healthy reconnect, NOT a failure: recover any lost + // terminal then re-subscribe at the base delay (pre-hardening behavior). + failures = 0; + if (deps.isUp() && !signal.aborted) { + await deps.reconcile(state); // recover an idle/error lost during the gap + await sleep(policy.baseMs); + } + } catch (err) { + if (!deps.isUp() || signal.aborted) break; + failures += 1; + const decision = reconnectDecision(failures, policy); + deps.log.warn( + { err: errMsg(err), agentSessionId: state.agentSessionId, failures, action: decision.action }, + 'opencode-server: session event loop error; reconnecting', + ); + await deps.reconcile(state); + if (decision.action === 'give-up') { + deps.log.warn( + { agentSessionId: state.agentSessionId, failures }, + 'opencode-server: SSE reconnect gave up (circuit breaker) — failing active turn', + ); + await deps.onReconnectGiveUp(state); + break; + } + await sleep(decision.delayMs); + } + } +} diff --git a/apps/coder/src/services/backends/warm-acp.ts b/apps/coder/src/services/backends/warm-acp.ts index 0bf4487..0e761d8 100644 --- a/apps/coder/src/services/backends/warm-acp.ts +++ b/apps/coder/src/services/backends/warm-acp.ts @@ -36,29 +36,15 @@ */ import { spawn, type ChildProcess } from 'node:child_process'; import type { FastifyBaseLogger } from 'fastify'; -import { - ClientSideConnection, - type Client, - type SessionNotification, - type RequestPermissionRequest, - type RequestPermissionResponse, - type ReadTextFileRequest, - type ReadTextFileResponse, - type WriteTextFileRequest, - type WriteTextFileResponse, - type CreateTerminalRequest, - type CreateTerminalResponse, - type CreateElicitationRequest, - type CreateElicitationResponse, -} from '@agentclientprotocol/sdk'; +import { ClientSideConnection, type Client } from '@agentclientprotocol/sdk'; import type { Sql } from '../../db.js'; import { resolveLaunchSpec } from '../acp-spawn.js'; import { isTurnOkForStopReason } from './warm-acp-routing.js'; import { getResolvedRegistry, type ResolvedProviderDef } from '../provider-config-registry.js'; import { createAcpNdJsonStream } from '../acp-stream.js'; import { mapSessionUpdate } from '../acp-event-map.js'; -import { readWorktreeTextFile, writeWorktreeTextFile } from '../acp-client-fs.js'; -import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from '../permission-waiter.js'; +import { buildAcpClient } from '../acp-client.js'; +import { cancelPendingPermission } from '../permission-waiter.js'; import { type AcpToolSnapshot, synthesizeCanceledSnapshots } from '../acp-tool-snapshot.js'; import type { AgentBackend, @@ -211,47 +197,25 @@ export class WarmAcpBackend implements AgentBackend { ); } - /** Build the ACP Client callbacks ONCE per connection. They read `this.activeTurn` - * so each turn's events/permissions route to the right place — exactly the - * opencode-server `activeTurn` pattern. Worktree-scoped FS like AcpStreamContext. */ + /** Build the ACP Client callbacks ONCE per connection (shared `buildAcpClient`). + * `resolveTurn` reads `this.activeTurn` at each callback so events/permissions + * route to the live turn — exactly the prior behavior. The warm session always + * has a non-empty `sessionId`, so the shared `taskId && sessionId` permission + * gate is equivalent to the old `turn?.taskId` gate. */ private buildClient(worktreePath: string): Client { - return { - sessionUpdate: async (params: SessionNotification): Promise => { - const turn = this.activeTurn; - if (!turn) return; // between turns — drop (no orphan settles a future turn) - for (const event of mapSessionUpdate(params, turn.snapshots)) { - turn.onEvent(event); - } - }, - requestPermission: async (params: RequestPermissionRequest): Promise => { - const turn = this.activeTurn; - if (turn?.taskId) { - // Route to the UI via the per-turn task id (same as the one-shot path). - return waitForPermissionResponse(turn.taskId, turn.sessionId, this.agent, turn.modeId, params); - } - const firstOption = params.options[0]; - if (firstOption) return { outcome: { outcome: 'selected', optionId: firstOption.optionId } }; - return { outcome: { outcome: 'cancelled' } }; - }, - readTextFile: async (params: ReadTextFileRequest): Promise => { - const content = await readWorktreeTextFile(worktreePath, params.path, params.line, params.limit); - return { content }; - }, - writeTextFile: async (params: WriteTextFileRequest): Promise => { - await writeWorktreeTextFile(worktreePath, params.path, params.content); - return {}; - }, - createTerminal: async (_params: CreateTerminalRequest): Promise => { - return { terminalId: 'noop' }; - }, - unstable_createElicitation: async (params: CreateElicitationRequest): Promise => { - const turn = this.activeTurn; - if (turn?.taskId) { - return waitForElicitationResponse(turn.taskId, turn.sessionId, this.agent, turn.modeId, params); - } - return { action: 'decline' }; - }, - }; + return buildAcpClient(worktreePath, () => { + const turn = this.activeTurn; + if (!turn) return null; + return { + taskId: turn.taskId, + sessionId: turn.sessionId, + modeId: turn.modeId, + agent: this.agent, + onSessionUpdate: (params) => { + for (const event of mapSessionUpdate(params, turn.snapshots)) turn.onEvent(event); + }, + }; + }); } // ─── ensureSession: create-or-reuse the warm session (2.1) ─────────────────── @@ -303,6 +267,14 @@ export class WarmAcpBackend implements AgentBackend { return { ok: false, error: 'warm-acp: no live ACP connection' }; } + // v2.7 busy-assert: one in-flight turn per warm session. The dispatcher + // serializes turns per (chat, agent), so this never fires in normal dispatch — + // but a second concurrent prompt would silently overwrite `activeTurn` and + // orphan the first turn, so reject instead. + if (this.activeTurn) { + return { ok: false, error: 'warm-acp: session already has an in-flight turn' }; + } + const snapshots = new Map(); // taskId routes permission/elicitation prompts back to the UI. The dispatcher // passes it (plus mode) on the per-turn PromptCtx; permission-waiter keys on it. diff --git a/apps/coder/src/services/frame-emitter.ts b/apps/coder/src/services/frame-emitter.ts new file mode 100644 index 0000000..23a1403 --- /dev/null +++ b/apps/coder/src/services/frame-emitter.ts @@ -0,0 +1,142 @@ +/** + * AgentEvent → WS-frame emitter + turn accumulators. + * + * Extracted (v2.7 audit reshape) from `AcpStreamContext.handleSessionUpdate` in + * `acp-dispatch.ts` — the `AgentEvent → broker.publishFrame` switch that maps a + * backend's normalized events onto the wire frames the UI consumes, while + * accumulating the turn's text / reasoning / tool snapshots for persistence. + * + * The same shape backs the dispatcher's 4 inline `onEvent` copies (DEFERRED while + * dispatcher.ts has uncommitted edits), hence the optional `dcp` stripper + the + * `finalize()` flush: the opencode dispatch path strips dcp tags from text deltas, + * the ACP path does not (passes no `dcp`, so text is emitted verbatim — identical + * to the prior AcpStreamContext behavior). + * + * Publishing is gated on `canStream()` (all of broker/sessionId/chatId/assistantId + * present) exactly as the original — a one-shot dispatch with no broker accumulates + * but never publishes. + */ +import type { Broker } from '@boocode/server/broker'; +import type { WsFrame } from '@boocode/server/ws-frames'; +import type { AgentEvent } from './agent-backend.js'; +import { type AcpToolSnapshot, snapshotToWireToolCall } from './acp-tool-snapshot.js'; +import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js'; +import type { DcpStreamStripper } from './dcp-strip.js'; + +export interface FrameEmitterOpts { + broker?: Broker; + sessionId?: string; + chatId?: string; + /** The assistant message id — the frames' `message_id`. */ + assistantId?: string; + /** Per-turn task id, for the agent_commands frame + command cache. */ + taskId?: string; + /** Optional cross-chunk dcp stripper for text deltas (opencode path). When + * provided, text is stripped before push/publish and `finalize()` flushes the + * held-back tail. The ACP path passes none → text emitted verbatim. */ + dcp?: DcpStreamStripper; +} + +export interface FrameEmitter { + /** Map one AgentEvent to its WS frame(s) + accumulate it. */ + onEvent: (e: AgentEvent) => void; + /** Flush a dcp stripper's held-back tail at turn end (no-op without `dcp`). */ + finalize: () => void; + /** The merge accumulator for tool snapshots (toolCallId → snapshot). */ + readonly toolSnapshots: Map; + /** Accumulated assistant text (post-dcp-strip when a stripper is set). */ + readonly output: string; + /** Accumulated reasoning text. */ + readonly reasoningText: string; + /** Tool snapshots in insertion order. */ + readonly snapshots: AcpToolSnapshot[]; +} + +export function makeFrameEmitter(opts: FrameEmitterOpts): FrameEmitter { + const { broker, sessionId, chatId, assistantId, taskId, dcp } = opts; + const textChunks: string[] = []; + const reasoningChunks: string[] = []; + const toolSnapshots = new Map(); + + const canStream = (): boolean => !!(broker && sessionId && chatId && assistantId); + + const publishText = (content: string): void => { + textChunks.push(content); + if (canStream()) { + broker!.publishFrame(sessionId!, { + type: 'delta', + message_id: assistantId!, + chat_id: chatId!, + content, + } as WsFrame); + } + }; + + const onEvent = (e: AgentEvent): void => { + switch (e.type) { + case 'text': { + const safe = dcp ? dcp.push(e.text) : e.text; + if (safe) publishText(safe); + break; + } + case 'reasoning': + reasoningChunks.push(e.text); + if (canStream()) { + broker!.publishFrame(sessionId!, { + type: 'reasoning_delta', + message_id: assistantId!, + chat_id: chatId!, + content: e.text, + } as WsFrame); + } + break; + case 'tool_call': + case 'tool_update': + toolSnapshots.set(e.toolCall.toolCallId, e.toolCall); + if (canStream()) { + broker!.publishFrame(sessionId!, { + type: 'tool_call', + message_id: assistantId!, + chat_id: chatId!, + tool_call: snapshotToWireToolCall(e.toolCall), + } as WsFrame); + } + break; + case 'commands': + if (taskId && e.commands.length > 0) { + mergeTaskCommands(taskId, e.commands); + if (canStream() && sessionId) { + const all = getTaskCommands(taskId) ?? e.commands; + broker!.publishFrame(sessionId, { + type: 'agent_commands', + task_id: taskId, + session_id: sessionId, + commands: all, + } as WsFrame); + } + } + break; + } + }; + + const finalize = (): void => { + if (!dcp) return; + const tail = dcp.flush(); + if (tail) publishText(tail); + }; + + return { + onEvent, + finalize, + toolSnapshots, + get output() { + return textChunks.join(''); + }, + get reasoningText() { + return reasoningChunks.join(''); + }, + get snapshots() { + return [...toolSnapshots.values()]; + }, + }; +} diff --git a/apps/coder/src/services/mcp-server.ts b/apps/coder/src/services/mcp-server.ts index 901ab7a..fd23226 100644 --- a/apps/coder/src/services/mcp-server.ts +++ b/apps/coder/src/services/mcp-server.ts @@ -25,13 +25,6 @@ interface PendingRow { session_id: string; } -interface WorktreeRow { - id: string; - worktree_path: string; - agent: string; - started_at: string; -} - interface ProjectPathRow { path: string; } @@ -196,28 +189,6 @@ export async function startMcpServer(sql: Sql): Promise { }, ); - // 6. boocoder.list_worktrees - server.tool( - 'boocoder.list_worktrees', - 'List active worktrees from running tasks', - {}, - async () => { - const rows = await sql` - SELECT id, worktree_path, agent, started_at - FROM tasks - WHERE worktree_path IS NOT NULL AND state = 'running' - ORDER BY started_at DESC - `; - const items = rows.map((r) => ({ - task_id: r.id, - worktree_path: r.worktree_path, - agent: r.agent, - started_at: r.started_at, - })); - return textResult(items); - }, - ); - // Connect via stdio const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/apps/coder/src/services/net/port-utils.ts b/apps/coder/src/services/net/port-utils.ts new file mode 100644 index 0000000..9cca5b8 --- /dev/null +++ b/apps/coder/src/services/net/port-utils.ts @@ -0,0 +1,88 @@ +/** + * Generic POSIX loopback-port utilities. + * + * Extracted verbatim (v2.7 audit reshape) from `backends/opencode-server.ts`, + * where they were embedded in the backend god-class. They have nothing to do with + * opencode semantics — they reclaim/await/allocate a 127.0.0.1 port — so they live + * here as reusable infra. No behavior change from the original. + */ +import { createServer, connect as netConnect } from 'node:net'; +import { spawnSync } from 'node:child_process'; + +/** + * Reclaim a loopback port a dead child may still hold (lift of openchamber + * `killProcessOnPort`). Best-effort, POSIX-only (`lsof`/`kill`); a failure is + * harmless because the next spawn allocates a fresh ephemeral port. Never kills + * this process. Synchronous + short-timeout so a crash handler doesn't block. + */ +export function reclaimPort(port: number | null): void { + if (!port || process.platform === 'win32') return; + try { + const res = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', timeout: 3_000, windowsHide: true }); + const out = res.stdout || ''; + const myPid = process.pid; + for (const pidStr of out.split(/\s+/)) { + const pid = parseInt(pidStr.trim(), 10); + if (pid && pid !== myPid) { + try { + spawnSync('kill', ['-9', String(pid)], { stdio: 'ignore', timeout: 2_000 }); + } catch { + // ignore — best effort + } + } + } + } catch { + // lsof absent or failed — the fresh-ephemeral-port spawn doesn't need this. + } +} + +/** + * Resolve true once nothing is listening on `port` (lift of openchamber + * `waitForPortRelease`). Used before re-spawning on a fixed port; with ephemeral + * ports it's a fast no-op. Probes 127.0.0.1; resolves false at the deadline. + */ +export function waitForPortRelease(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve) => { + const attempt = () => { + const socket = netConnect({ port, host: '127.0.0.1' }); + let settled = false; + const finish = (released: boolean) => { + if (settled) return; + settled = true; + socket.removeAllListeners(); + socket.destroy(); + if (released || Date.now() >= deadline) { + resolve(released); + return; + } + setTimeout(attempt, 150); + }; + socket.once('connect', () => finish(false)); + socket.once('error', (err: NodeJS.ErrnoException) => { + if (err && (err.code === 'ECONNREFUSED' || err.code === 'EHOSTUNREACH')) finish(true); + else finish(false); + }); + socket.setTimeout(500, () => finish(true)); + }; + attempt(); + }); +} + +/** Bind-probe an ephemeral port on loopback. */ +export function freePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.unref(); + srv.on('error', reject); + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address(); + if (addr && typeof addr === 'object') { + const { port } = addr; + srv.close(() => resolve(port)); + } else { + srv.close(() => reject(new Error('port-utils: could not determine a free port'))); + } + }); + }); +} diff --git a/apps/coder/src/services/normalize-agent-status.ts b/apps/coder/src/services/normalize-agent-status.ts index 88af393..d7de981 100644 --- a/apps/coder/src/services/normalize-agent-status.ts +++ b/apps/coder/src/services/normalize-agent-status.ts @@ -21,72 +21,3 @@ */ export type AgentStatus = 'working' | 'blocked' | 'idle' | 'error'; - -/** The coarse signal a raw vendor event collapses to. */ -export type AgentEventBucket = 'working' | 'blocked' | 'done'; - -// Each bucket lists the canonical vendor event names. Lookup is -// case-insensitive AND separator-insensitive (snake_case / camelCase / -// PascalCase all fold to the same key), so we normalize the raw input the same -// way before matching rather than enumerating every spelling here. -const WORKING_EVENTS = [ - 'SessionStart', - 'UserPromptSubmit', - 'UserPromptSubmitted', - 'PostToolUse', - 'PostToolUseFailure', - 'BeforeAgent', - 'AfterTool', - 'task_started', -] as const; - -const BLOCKED_EVENTS = [ - 'PreToolUse', - 'Notification', - 'PermissionRequest', - 'exec_approval_request', - 'apply_patch_approval_request', - 'request_user_input', -] as const; - -const DONE_EVENTS = [ - 'Stop', - 'AfterAgent', - 'SessionEnd', - 'task_complete', - 'agent-turn-complete', -] as const; - -/** - * Fold a raw event name to a separator/case-insensitive key: - * strip every non-alphanumeric character and lowercase. So `post_tool_use`, - * `postToolUse`, `PostToolUse`, and `POST-TOOL-USE` all map to `posttooluse`. - */ -function foldKey(raw: string): string { - return raw.replace(/[^a-z0-9]/gi, '').toLowerCase(); -} - -function buildLookup( - groups: ReadonlyArray, -): Map { - const map = new Map(); - for (const [bucket, names] of groups) { - for (const name of names) map.set(foldKey(name), bucket); - } - return map; -} - -const EVENT_LOOKUP = buildLookup([ - ['working', WORKING_EVENTS], - ['blocked', BLOCKED_EVENTS], - ['done', DONE_EVENTS], -]); - -/** - * Map a raw vendor hook-event name to its normalized bucket, or `null` when the - * name is unknown / undefined. Case- and separator-insensitive. - */ -export function normalizeAgentEvent(raw: string | undefined): AgentEventBucket | null { - if (!raw) return null; - return EVENT_LOOKUP.get(foldKey(raw)) ?? null; -} diff --git a/apps/coder/src/services/orphan-worktree-reaper.ts b/apps/coder/src/services/orphan-worktree-reaper.ts index b3e0824..6336c0c 100644 --- a/apps/coder/src/services/orphan-worktree-reaper.ts +++ b/apps/coder/src/services/orphan-worktree-reaper.ts @@ -21,7 +21,8 @@ import { readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; import type { FastifyBaseLogger } from 'fastify'; import type { Sql } from '../db.js'; -import { WORKTREE_BASE, checkWorktreeWorkAtRisk } from './worktrees.js'; +import { WORKTREE_BASE } from './worktrees.js'; +import { checkWorktreeWorkAtRisk } from './worktree-risk.js'; import { hostExec } from './host-exec.js'; import { selectOrphanWorktreeTargets, diff --git a/apps/coder/src/services/pending_changes.ts b/apps/coder/src/services/pending_changes.ts index 7c6b784..b8070e8 100644 --- a/apps/coder/src/services/pending_changes.ts +++ b/apps/coder/src/services/pending_changes.ts @@ -181,10 +181,6 @@ export async function rejectOne(sql: Sql, changeId: string): Promise { await sql`UPDATE pending_changes SET status = 'rejected' WHERE id = ${changeId} AND status = 'pending'`; } -export async function rejectAll(sql: Sql, sessionId: string): Promise { - await sql`UPDATE pending_changes SET status = 'rejected' WHERE session_id = ${sessionId} AND status = 'pending'`; -} - // --- Rewind functions -------------------------------------------------------- export async function rewindOne( diff --git a/apps/coder/src/services/provider-config-registry.ts b/apps/coder/src/services/provider-config-registry.ts index 3fbb3e2..6194bd8 100644 --- a/apps/coder/src/services/provider-config-registry.ts +++ b/apps/coder/src/services/provider-config-registry.ts @@ -127,7 +127,3 @@ export function getResolvedRegistry(): Map { return cachedRegistry ?? buildResolvedRegistry(PROVIDERS, { providers: {} }); } -/** Resolved provider ids in registry order. */ -export function getResolvedProviderIds(): string[] { - return [...getResolvedRegistry().keys()]; -} diff --git a/apps/coder/src/services/tools/index.ts b/apps/coder/src/services/tools/index.ts index 963563d..c4d3d06 100644 --- a/apps/coder/src/services/tools/index.ts +++ b/apps/coder/src/services/tools/index.ts @@ -26,9 +26,4 @@ export const WRITE_TOOLS: readonly ToolDef[] = [ checkTaskStatusTool, ]; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const WRITE_TOOLS_BY_NAME: ReadonlyMap> = new Map( - WRITE_TOOLS.map((t) => [t.name, t]), -); - export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool }; diff --git a/apps/coder/src/services/worktree-risk.ts b/apps/coder/src/services/worktree-risk.ts new file mode 100644 index 0000000..7acdd1f --- /dev/null +++ b/apps/coder/src/services/worktree-risk.ts @@ -0,0 +1,175 @@ +/** + * Worktree work-at-risk assessment (split out of `worktrees.ts`, v2.7 audit + * reshape). The git-worktree create/diff/remove lifecycle stays in `worktrees.ts`; + * this module owns the orthogonal "would deleting this worktree lose work?" gate + * the server consults before a session delete, plus the recoverable stash escape. + * + * Session delete itself lives in apps/server (Docker), which CANNOT see the host + * worktree dirs or run git on them — only BooCoder (host systemd) can — so the + * server calls the routes that wrap these helpers. Behavior is unchanged from the + * original worktrees.ts implementation. + */ +import { hostExec } from './host-exec.js'; + +/** + * Risk report for a single worktree, returned by checkWorktreeWorkAtRisk. + * `atRisk` is the gate the server reads before allowing a session delete. + * A git error never silently passes — it forces `atRisk` true and surfaces + * the message in `error` (fail-closed). + */ +export interface RiskReport { + worktreePath: string; + branch: string; + dirty: boolean; // uncommitted working-tree changes (incl. untracked) + unpushed: number; // commits ahead of upstream, or -1 if no upstream is set + unmerged: number; // commits on this branch not in the project default branch + atRisk: boolean; // dirty || unmerged > 0 || (upstream && unpushed > 0) || git error + error?: string; // populated on a git failure; presence forces atRisk +} + +/** + * Resolve the project's default branch as a git-usable ref (e.g. "origin/main"). + * + * `refs/remotes/origin/HEAD` lives in the repo's COMMON git dir and is shared + * across every linked worktree, so reading it from the session worktree returns + * the REMOTE's default branch — never this worktree's own `session-` branch + * (that would be `symbolic-ref HEAD`, a different ref). Falls back to probing + * common defaults by verified existence when origin/HEAD isn't set (e.g. a repo + * that never ran `git remote set-head`). Returns null if none resolve, in which + * case the unmerged check is skipped (dirty + unpushed still protect the work). + */ +async function detectDefaultBranchRef( + worktreePath: string, + opts?: { signal?: AbortSignal }, +): Promise { + const head = await hostExec( + `git -C ${shellEscape(worktreePath)} symbolic-ref --short refs/remotes/origin/HEAD`, + { signal: opts?.signal, timeoutMs: 10_000 }, + ); + if (head.exitCode === 0) { + const ref = head.stdout.trim(); // e.g. "origin/main" + if (ref) { + const verify = await hostExec( + `git -C ${shellEscape(worktreePath)} rev-parse --verify --quiet ${shellEscape(ref + '^{commit}')}`, + { signal: opts?.signal, timeoutMs: 10_000 }, + ); + if (verify.exitCode === 0 && verify.stdout.trim()) return ref; + } + } + // origin/HEAD unset or unresolvable — probe common defaults. Prefer the + // remote-tracking ref (always resolvable in a fresh worktree) over the local + // head, which may not exist if the default branch lives only in the main tree. + for (const cand of ['origin/main', 'origin/master', 'main', 'master']) { + const verify = await hostExec( + `git -C ${shellEscape(worktreePath)} rev-parse --verify --quiet ${shellEscape(cand + '^{commit}')}`, + { signal: opts?.signal, timeoutMs: 10_000 }, + ); + if (verify.exitCode === 0 && verify.stdout.trim()) return cand; + } + return null; +} + +/** + * Inspect a worktree for work that would be lost if its session were deleted. + * Three checks, all via the audited hostExec + shellEscape path (every + * interpolated value — paths, refs — is single-quote-escaped; no bare + * interpolation). Any unexpected git failure is treated as at-risk, never a + * silent pass. + */ +export async function checkWorktreeWorkAtRisk( + worktreePath: string, + opts?: { signal?: AbortSignal }, +): Promise { + // Branch name — also doubles as the "is this still a git worktree?" probe. + const br = await hostExec( + `git -C ${shellEscape(worktreePath)} rev-parse --abbrev-ref HEAD`, + { signal: opts?.signal, timeoutMs: 10_000 }, + ); + if (br.exitCode !== 0) { + return { + worktreePath, + branch: '', + dirty: false, + unpushed: 0, + unmerged: 0, + atRisk: true, + error: `git rev-parse failed: ${br.stderr.trim() || 'not a git worktree'}`, + }; + } + const branch = br.stdout.trim(); + + // (a) Uncommitted (dirty working tree, including untracked files). + const st = await hostExec( + `git -C ${shellEscape(worktreePath)} status --porcelain`, + { signal: opts?.signal, timeoutMs: 15_000 }, + ); + if (st.exitCode !== 0) { + return { + worktreePath, + branch, + dirty: false, + unpushed: 0, + unmerged: 0, + atRisk: true, + error: `git status failed: ${st.stderr.trim()}`, + }; + } + const dirty = st.stdout.trim().length > 0; + + // (b) Unpushed commits. No upstream configured => work exists only locally; + // treat as unpushed-by-definition (-1) rather than an error. + const up = await hostExec( + `git -C ${shellEscape(worktreePath)} rev-list --count ${shellEscape('@{u}..HEAD')}`, + { signal: opts?.signal, timeoutMs: 15_000 }, + ); + const unpushed = up.exitCode === 0 ? (parseInt(up.stdout.trim() || '0', 10) || 0) : -1; + + // (c) Unmerged commits — on this branch but not in the project default branch. + const defaultRef = await detectDefaultBranchRef(worktreePath, opts); + let unmerged = 0; + if (defaultRef) { + const rl = await hostExec( + `git -C ${shellEscape(worktreePath)} rev-list --count ${shellEscape(defaultRef + '..HEAD')}`, + { signal: opts?.signal, timeoutMs: 15_000 }, + ); + if (rl.exitCode === 0) unmerged = parseInt(rl.stdout.trim() || '0', 10) || 0; + } + + // unpushed only contributes when an upstream actually exists. Session branches + // (session-) never have one (unpushed === -1), and any real local-only work + // there already surfaces as unmerged > 0 — so the no-upstream case adds no + // protection, only friction (it flagged every pristine worktree-backed session). + // The unpushed > 0 arm stays forward-compatible with P1.5 pushable branches. + const hasUpstream = unpushed !== -1; + const atRisk = dirty || unmerged > 0 || (hasUpstream && unpushed > 0); + return { worktreePath, branch, dirty, unpushed, unmerged, atRisk }; +} + +/** + * Stash a worktree's uncommitted changes (including untracked, via -u) so the + * working tree is clean. Stash entries live in the repo's common git dir, so + * they survive worktree-dir removal — this is the recoverable, safe-by-default + * escape. Note it only clears the *dirty* risk; unpushed/unmerged commits + * remain on the branch, so a re-attempted delete may still block on those. + */ +export async function stashWorktree( + worktreePath: string, + opts?: { signal?: AbortSignal }, +): Promise<{ stashed: boolean; error?: string }> { + const r = await hostExec( + `git -C ${shellEscape(worktreePath)} stash push -u -m ${shellEscape('boocode: pre-delete stash')}`, + { signal: opts?.signal, timeoutMs: 30_000 }, + ); + if (r.exitCode !== 0) { + return { stashed: false, error: r.stderr.trim() || r.stdout.trim() }; + } + // "No local changes to save" => exit 0, nothing stashed — not an error. + const stashed = !/no local changes to save/i.test(r.stdout); + return { stashed }; +} + +/** Minimal shell escape for paths (single-quote wrapping). */ +function shellEscape(s: string): string { + // Replace single quotes with escaped version, wrap in single quotes + return "'" + s.replace(/'/g, "'\\''") + "'"; +} diff --git a/apps/coder/src/services/worktrees.ts b/apps/coder/src/services/worktrees.ts index 704d16a..bd077c6 100644 --- a/apps/coder/src/services/worktrees.ts +++ b/apps/coder/src/services/worktrees.ts @@ -8,6 +8,7 @@ */ import type { Sql } from '../db.js'; import { hostExec } from './host-exec.js'; +import { checkWorktreeWorkAtRisk } from './worktree-risk.js'; export const WORKTREE_BASE = '/tmp/booworktrees'; @@ -378,165 +379,6 @@ export async function rebaselineWorktreeAfterApply( return { rebaselined: true, newBaseCommit: newBase }; } -// ─── Session-delete work-loss guard ───────────────────────────────────────── - -/** - * Risk report for a single worktree, returned by checkWorktreeWorkAtRisk. - * `atRisk` is the gate the server reads before allowing a session delete. - * A git error never silently passes — it forces `atRisk` true and surfaces - * the message in `error` (fail-closed). - */ -export interface RiskReport { - worktreePath: string; - branch: string; - dirty: boolean; // uncommitted working-tree changes (incl. untracked) - unpushed: number; // commits ahead of upstream, or -1 if no upstream is set - unmerged: number; // commits on this branch not in the project default branch - atRisk: boolean; // dirty || unmerged > 0 || (upstream && unpushed > 0) || git error - error?: string; // populated on a git failure; presence forces atRisk -} - -/** - * Resolve the project's default branch as a git-usable ref (e.g. "origin/main"). - * - * `refs/remotes/origin/HEAD` lives in the repo's COMMON git dir and is shared - * across every linked worktree, so reading it from the session worktree returns - * the REMOTE's default branch — never this worktree's own `session-` branch - * (that would be `symbolic-ref HEAD`, a different ref). Falls back to probing - * common defaults by verified existence when origin/HEAD isn't set (e.g. a repo - * that never ran `git remote set-head`). Returns null if none resolve, in which - * case the unmerged check is skipped (dirty + unpushed still protect the work). - */ -async function detectDefaultBranchRef( - worktreePath: string, - opts?: { signal?: AbortSignal }, -): Promise { - const head = await hostExec( - `git -C ${shellEscape(worktreePath)} symbolic-ref --short refs/remotes/origin/HEAD`, - { signal: opts?.signal, timeoutMs: 10_000 }, - ); - if (head.exitCode === 0) { - const ref = head.stdout.trim(); // e.g. "origin/main" - if (ref) { - const verify = await hostExec( - `git -C ${shellEscape(worktreePath)} rev-parse --verify --quiet ${shellEscape(ref + '^{commit}')}`, - { signal: opts?.signal, timeoutMs: 10_000 }, - ); - if (verify.exitCode === 0 && verify.stdout.trim()) return ref; - } - } - // origin/HEAD unset or unresolvable — probe common defaults. Prefer the - // remote-tracking ref (always resolvable in a fresh worktree) over the local - // head, which may not exist if the default branch lives only in the main tree. - for (const cand of ['origin/main', 'origin/master', 'main', 'master']) { - const verify = await hostExec( - `git -C ${shellEscape(worktreePath)} rev-parse --verify --quiet ${shellEscape(cand + '^{commit}')}`, - { signal: opts?.signal, timeoutMs: 10_000 }, - ); - if (verify.exitCode === 0 && verify.stdout.trim()) return cand; - } - return null; -} - -/** - * Inspect a worktree for work that would be lost if its session were deleted. - * Three checks, all via the audited hostExec + shellEscape path (every - * interpolated value — paths, refs — is single-quote-escaped; no bare - * interpolation). Any unexpected git failure is treated as at-risk, never a - * silent pass. - */ -export async function checkWorktreeWorkAtRisk( - worktreePath: string, - opts?: { signal?: AbortSignal }, -): Promise { - // Branch name — also doubles as the "is this still a git worktree?" probe. - const br = await hostExec( - `git -C ${shellEscape(worktreePath)} rev-parse --abbrev-ref HEAD`, - { signal: opts?.signal, timeoutMs: 10_000 }, - ); - if (br.exitCode !== 0) { - return { - worktreePath, - branch: '', - dirty: false, - unpushed: 0, - unmerged: 0, - atRisk: true, - error: `git rev-parse failed: ${br.stderr.trim() || 'not a git worktree'}`, - }; - } - const branch = br.stdout.trim(); - - // (a) Uncommitted (dirty working tree, including untracked files). - const st = await hostExec( - `git -C ${shellEscape(worktreePath)} status --porcelain`, - { signal: opts?.signal, timeoutMs: 15_000 }, - ); - if (st.exitCode !== 0) { - return { - worktreePath, - branch, - dirty: false, - unpushed: 0, - unmerged: 0, - atRisk: true, - error: `git status failed: ${st.stderr.trim()}`, - }; - } - const dirty = st.stdout.trim().length > 0; - - // (b) Unpushed commits. No upstream configured => work exists only locally; - // treat as unpushed-by-definition (-1) rather than an error. - const up = await hostExec( - `git -C ${shellEscape(worktreePath)} rev-list --count ${shellEscape('@{u}..HEAD')}`, - { signal: opts?.signal, timeoutMs: 15_000 }, - ); - const unpushed = up.exitCode === 0 ? (parseInt(up.stdout.trim() || '0', 10) || 0) : -1; - - // (c) Unmerged commits — on this branch but not in the project default branch. - const defaultRef = await detectDefaultBranchRef(worktreePath, opts); - let unmerged = 0; - if (defaultRef) { - const rl = await hostExec( - `git -C ${shellEscape(worktreePath)} rev-list --count ${shellEscape(defaultRef + '..HEAD')}`, - { signal: opts?.signal, timeoutMs: 15_000 }, - ); - if (rl.exitCode === 0) unmerged = parseInt(rl.stdout.trim() || '0', 10) || 0; - } - - // unpushed only contributes when an upstream actually exists. Session branches - // (session-) never have one (unpushed === -1), and any real local-only work - // there already surfaces as unmerged > 0 — so the no-upstream case adds no - // protection, only friction (it flagged every pristine worktree-backed session). - // The unpushed > 0 arm stays forward-compatible with P1.5 pushable branches. - const hasUpstream = unpushed !== -1; - const atRisk = dirty || unmerged > 0 || (hasUpstream && unpushed > 0); - return { worktreePath, branch, dirty, unpushed, unmerged, atRisk }; -} - -/** - * Stash a worktree's uncommitted changes (including untracked, via -u) so the - * working tree is clean. Stash entries live in the repo's common git dir, so - * they survive worktree-dir removal — this is the recoverable, safe-by-default - * escape. Note it only clears the *dirty* risk; unpushed/unmerged commits - * remain on the branch, so a re-attempted delete may still block on those. - */ -export async function stashWorktree( - worktreePath: string, - opts?: { signal?: AbortSignal }, -): Promise<{ stashed: boolean; error?: string }> { - const r = await hostExec( - `git -C ${shellEscape(worktreePath)} stash push -u -m ${shellEscape('boocode: pre-delete stash')}`, - { signal: opts?.signal, timeoutMs: 30_000 }, - ); - if (r.exitCode !== 0) { - return { stashed: false, error: r.stderr.trim() || r.stdout.trim() }; - } - // "No local changes to save" => exit 0, nothing stashed — not an error. - const stashed = !/no local changes to save/i.test(r.stdout); - return { stashed }; -} - /** Minimal shell escape for paths (single-quote wrapping). */ function shellEscape(s: string): string { // Replace single quotes with escaped version, wrap in single quotes diff --git a/apps/coder/web/vite.config.d.ts b/apps/coder/web/vite.config.d.ts deleted file mode 100644 index 340562a..0000000 --- a/apps/coder/web/vite.config.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -declare const _default: import("vite").UserConfig; -export default _default; diff --git a/apps/coder/web/vite.config.js b/apps/coder/web/vite.config.js deleted file mode 100644 index f78e97b..0000000 --- a/apps/coder/web/vite.config.js +++ /dev/null @@ -1,25 +0,0 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import path from 'node:path'; -export default defineConfig({ - plugins: [react()], - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), - }, - }, - server: { - port: 5174, - proxy: { - '/api': { - target: 'http://127.0.0.1:3000', - changeOrigin: true, - ws: true, - }, - }, - }, - build: { - outDir: 'dist', - emptyOutDir: true, - }, -}); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ace037b..d7b6199 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -18,7 +18,6 @@ const ConfigSchema = z.object({ GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'), GITEA_USER: z.string().default('indifferentketchup'), GITEA_TOKEN: z.string().optional(), - GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'), // v1.15.0-mcp-multi: path to the MCP config JSON file. Default /data/mcp.json // (bind-mounted alongside AGENTS.md). File missing = no MCP (opt-in). MCP_CONFIG_PATH: z.string().optional(), diff --git a/apps/server/src/routes/chats.ts b/apps/server/src/routes/chats.ts index 20dc7be..2aa2cc1 100644 --- a/apps/server/src/routes/chats.ts +++ b/apps/server/src/routes/chats.ts @@ -5,6 +5,7 @@ import type { Broker } from '../services/broker.js'; import type { Chat, Message } from '../types/api.js'; import { getModelContext } from '../services/model-context.js'; import { notifyCoderClose } from '../services/coder-notify.js'; +import { MESSAGE_COLUMNS } from '../services/message-columns.js'; const CreateBody = z.object({ name: z.string().min(1).max(200).optional(), @@ -439,9 +440,7 @@ export function registerChatRoutes( } // v1.13.1-B: reads tool_calls/tool_results via the parts-merged view. const rows = await sql` - SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, - tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata, - summary, tail_start_id, compacted_at, model + SELECT ${sql.unsafe(MESSAGE_COLUMNS)} FROM messages_with_parts WHERE chat_id = ${req.params.id} ORDER BY created_at ASC, id ASC diff --git a/apps/server/src/routes/messages.ts b/apps/server/src/routes/messages.ts index d81e2d9..7090c8d 100644 --- a/apps/server/src/routes/messages.ts +++ b/apps/server/src/routes/messages.ts @@ -8,6 +8,81 @@ import type { Chat, Message, Session, ToolCall } from '../types/api.js'; // decision time (not at request time) so concurrent project changes don't // stale-bind the resolution. import { resolveGrantRoot } from '../services/grant_resolver.js'; +import { MESSAGE_COLUMNS } from '../services/message-columns.js'; + +// Shared lookup for the answer_user_input + grant_read_access pause-resume +// endpoints. Finds the originating assistant tool_call by id in message_parts, +// validates the tool name, finds the pending tool_result part, and checks the +// already-answered guard. Returns ok:true+context on success, ok:false+HTTP +// status+body on any error (caller does reply.code(ctx.code); return ctx.body). +type PendingToolLookupResult = + | { + ok: true; + foundCall: ToolCall; + toolMessageId: string; + toolRow: { message_id: string; payload: { tool_call_id: string; output: unknown } }; + } + | { ok: false; code: number; body: Record }; + +async function lookupPendingToolCall( + sql: Sql, + chatId: string, + tool_call_id: string, + expectedToolName: string, + wrongToolError: string, +): Promise { + // Find the assistant's tool_call by id via message_parts. + const callerRows = await sql<{ + message_id: string; + payload: { id: string; name: string; args: Record }; + }[]>` + SELECT p.message_id, p.payload + FROM message_parts p + JOIN messages m ON m.id = p.message_id + WHERE m.chat_id = ${chatId} + AND m.role = 'assistant' + AND p.kind = 'tool_call' + AND p.payload->>'id' = ${tool_call_id} + ORDER BY m.created_at DESC + LIMIT 1 + `; + const callerRow = callerRows[0]; + if (!callerRow) return { ok: false, code: 404, body: { error: 'unknown_tool_call_id' } }; + + const foundCall: ToolCall = { + id: callerRow.payload.id, + name: callerRow.payload.name, + args: callerRow.payload.args, + }; + if (foundCall.name !== expectedToolName) { + return { ok: false, code: 400, body: { error: wrongToolError } }; + } + + // Find the pending tool_result part by tool_call_id. + const toolRows = await sql<{ + message_id: string; + payload: { tool_call_id: string; output: unknown }; + }[]>` + SELECT p.message_id, p.payload + FROM message_parts p + JOIN messages m ON m.id = p.message_id + WHERE m.chat_id = ${chatId} + AND m.role = 'tool' + AND p.kind = 'tool_result' + AND p.payload->>'tool_call_id' = ${tool_call_id} + ORDER BY m.created_at DESC + LIMIT 1 + `; + const toolRow = toolRows[0]; + if (!toolRow) { + return { ok: false, code: 404, body: { error: 'unknown_tool_call_id', detail: 'tool message not found' } }; + } + if (toolRow.payload && toolRow.payload.output !== null) { + return { ok: false, code: 409, body: { error: 'tool_call_already_answered' } }; + } + + return { ok: true, foundCall, toolMessageId: toolRow.message_id, toolRow }; +} const SendBody = z.object({ content: z.string().min(1).max(64_000), @@ -116,9 +191,7 @@ export function registerMessageRoutes( // see services/inference.ts loadContext + services/compaction.ts. // v1.13.1-B: reads tool_calls/tool_results via the parts-merged view. const rows = await sql` - SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, - tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata, - summary, tail_start_id, compacted_at, model + SELECT ${sql.unsafe(MESSAGE_COLUMNS)} FROM messages_with_parts WHERE session_id = ${req.params.id} ORDER BY created_at ASC, id ASC @@ -493,40 +566,16 @@ export function registerMessageRoutes( const chat = chatRows[0]!; const sessionId = chat.session_id; - // v1.13.1-C: find the assistant's tool_call by indexing message_parts - // directly on payload->>'id'. Scoped by chat_id + role via the JOIN. - // Pre-v1.13.0 history has no parts rows — those tool_calls become - // unreachable here (404). Acceptable per the dispatch decision: any - // pending elicitation from before v1.13.0 is long timed out by now; - // promote to a hotfix with a JSON-column fallback if it ever surfaces. - const callerRows = await sql<{ - message_id: string; - payload: { id: string; name: string; args: Record }; - }[]>` - SELECT p.message_id, p.payload - FROM message_parts p - JOIN messages m ON m.id = p.message_id - WHERE m.chat_id = ${chat.id} - AND m.role = 'assistant' - AND p.kind = 'tool_call' - AND p.payload->>'id' = ${tool_call_id} - ORDER BY m.created_at DESC - LIMIT 1 - `; - const callerRow = callerRows[0]; - if (!callerRow) { - reply.code(404); - return { error: 'unknown_tool_call_id' }; - } - const foundCall: ToolCall = { - id: callerRow.payload.id, - name: callerRow.payload.name, - args: callerRow.payload.args, - }; - if (foundCall.name !== 'ask_user_input') { - reply.code(400); - return { error: 'tool_call_not_ask_user_input' }; + // v1.13.1-C: resolve the originating tool_call + pending tool row. + // Pre-v1.13.0 history has no parts rows — those become unreachable (404). + const ctx = await lookupPendingToolCall( + sql, chat.id, tool_call_id, 'ask_user_input', 'tool_call_not_ask_user_input', + ); + if (!ctx.ok) { + reply.code(ctx.code); + return ctx.body; } + const { foundCall, toolMessageId } = ctx; // Validate the args themselves — the LLM could have emitted bad JSON. const argsParsed = AskUserInputArgs.safeParse(foundCall.args); @@ -569,33 +618,6 @@ export function registerMessageRoutes( } } - // v1.13.1-C: find the pending tool row via message_parts on - // payload->>'tool_call_id'. Same fallback caveat as the caller lookup - // above — pre-v1.13.0 rows are unreachable here. - const toolRows = await sql<{ - message_id: string; - payload: { tool_call_id: string; output: unknown }; - }[]>` - SELECT p.message_id, p.payload - FROM message_parts p - JOIN messages m ON m.id = p.message_id - WHERE m.chat_id = ${chat.id} - AND m.role = 'tool' - AND p.kind = 'tool_result' - AND p.payload->>'tool_call_id' = ${tool_call_id} - ORDER BY m.created_at DESC - LIMIT 1 - `; - const toolRow = toolRows[0]; - if (!toolRow) { - reply.code(404); - return { error: 'unknown_tool_call_id', detail: 'tool message not found' }; - } - if (toolRow.payload && toolRow.payload.output !== null) { - reply.code(409); - return { error: 'tool_call_already_answered' }; - } - const answerSet = { answers }; const newToolResults = { tool_call_id, @@ -603,7 +625,6 @@ export function registerMessageRoutes( truncated: false, }; - const toolMessageId = toolRow.message_id; const result = await sql.begin(async (tx) => { // v1.13.20: parts-only. Replace the pending tool_result part inserted // at message creation (tool-phase.ts) with the answered one. Delete- @@ -681,35 +702,15 @@ export function registerMessageRoutes( const chat = chatRows[0]!; const sessionId = chat.session_id; - // Mirror the /answer lookup: assistant tool_call by id via message_parts. - const callerRows = await sql<{ - message_id: string; - payload: { id: string; name: string; args: Record }; - }[]>` - SELECT p.message_id, p.payload - FROM message_parts p - JOIN messages m ON m.id = p.message_id - WHERE m.chat_id = ${chat.id} - AND m.role = 'assistant' - AND p.kind = 'tool_call' - AND p.payload->>'id' = ${tool_call_id} - ORDER BY m.created_at DESC - LIMIT 1 - `; - const callerRow = callerRows[0]; - if (!callerRow) { - reply.code(404); - return { error: 'unknown_tool_call_id' }; - } - const foundCall: ToolCall = { - id: callerRow.payload.id, - name: callerRow.payload.name, - args: callerRow.payload.args, - }; - if (foundCall.name !== 'request_read_access') { - reply.code(400); - return { error: 'tool_call_not_request_read_access' }; + const grantCtx = await lookupPendingToolCall( + sql, chat.id, tool_call_id, 'request_read_access', 'tool_call_not_request_read_access', + ); + if (!grantCtx.ok) { + reply.code(grantCtx.code); + return grantCtx.body; } + const { foundCall, toolMessageId } = grantCtx; + const argsParsed = RequestReadAccessArgs.safeParse(foundCall.args); if (!argsParsed.success) { reply.code(400); @@ -717,31 +718,6 @@ export function registerMessageRoutes( } const requestedPath = argsParsed.data.path; - // Find the pending tool row. - const toolRows = await sql<{ - message_id: string; - payload: { tool_call_id: string; output: unknown }; - }[]>` - SELECT p.message_id, p.payload - FROM message_parts p - JOIN messages m ON m.id = p.message_id - WHERE m.chat_id = ${chat.id} - AND m.role = 'tool' - AND p.kind = 'tool_result' - AND p.payload->>'tool_call_id' = ${tool_call_id} - ORDER BY m.created_at DESC - LIMIT 1 - `; - const toolRow = toolRows[0]; - if (!toolRow) { - reply.code(404); - return { error: 'unknown_tool_call_id', detail: 'tool message not found' }; - } - if (toolRow.payload && toolRow.payload.output !== null) { - reply.code(409); - return { error: 'tool_call_already_answered' }; - } - // Look up session + project so we can re-resolve the grant root and // append to allowed_read_paths atomically. We don't need agent or // history here — just the project path for the resolver. @@ -790,7 +766,6 @@ export function registerMessageRoutes( output: resultOutput, truncated: false, }; - const toolMessageId = toolRow.message_id; const dbResult = await sql.begin(async (tx) => { // v1.13.20: parts-only. Same delete+insert dance as /answer — // UNIQUE (message_id, sequence) blocks plain UPDATE on append-style diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts index 55d911c..9a339bc 100644 --- a/apps/server/src/routes/projects.ts +++ b/apps/server/src/routes/projects.ts @@ -67,6 +67,20 @@ export async function resolveProjectPath( return { real, name: basename(real) }; } +async function selectProject(sql: Sql, id: string): Promise { + const rows = await sql` + SELECT id, name, path, added_at, last_session_id, status, gitea_remote, + default_system_prompt, default_web_search_enabled + FROM projects WHERE id = ${id} + `; + return rows[0] ?? null; +} + +async function selectProjectPath(sql: Sql, id: string): Promise { + const rows = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${id}`; + return rows[0]?.path ?? null; +} + export function registerProjectRoutes( app: FastifyInstance, sql: Sql, @@ -199,16 +213,12 @@ export function registerProjectRoutes( // v1.9: single-project fetch so the settings pane can refetch on // project_updated without pulling the whole project list. app.get<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => { - const rows = await sql` - SELECT id, name, path, added_at, last_session_id, status, gitea_remote, - default_system_prompt, default_web_search_enabled - FROM projects WHERE id = ${req.params.id} - `; - if (rows.length === 0) { + const project = await selectProject(sql, req.params.id); + if (!project) { reply.code(404); return { error: 'not found' }; } - return rows[0]; + return project; }); app.patch<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => { @@ -340,18 +350,14 @@ export function registerProjectRoutes( const { id } = req.params; const relPath = req.query.path ?? '.'; - const rows = await sql` - SELECT id, name, path, added_at, last_session_id, status, gitea_remote - FROM projects WHERE id = ${id} - `; - if (rows.length === 0) { + const projectPath = await selectProjectPath(sql, id); + if (projectPath === null) { reply.code(404); return { error: 'not found' }; } - const project = rows[0]!; let projectRoot: string; try { - projectRoot = await resolveProjectRoot(project.path); + projectRoot = await resolveProjectRoot(projectPath); } catch (err) { if (err instanceof PathScopeError) { reply.code(404); @@ -385,18 +391,14 @@ export function registerProjectRoutes( return { error: 'path is required' }; } - const rows = await sql` - SELECT id, name, path, added_at, last_session_id, status, gitea_remote - FROM projects WHERE id = ${id} - `; - if (rows.length === 0) { + const projectPath = await selectProjectPath(sql, id); + if (projectPath === null) { reply.code(404); return { error: 'not found' }; } - const project = rows[0]!; let projectRoot: string; try { - projectRoot = await resolveProjectRoot(project.path); + projectRoot = await resolveProjectRoot(projectPath); } catch (err) { if (err instanceof PathScopeError) { reply.code(404); @@ -431,18 +433,14 @@ export function registerProjectRoutes( '/api/projects/:id/git', async (req, reply) => { const { id } = req.params; - const rows = await sql` - SELECT id, name, path, added_at, last_session_id, status, gitea_remote - FROM projects WHERE id = ${id} - `; - if (rows.length === 0) { + const projectPath = await selectProjectPath(sql, id); + if (projectPath === null) { reply.code(404); return { error: 'not found' }; } - const project = rows[0]!; let projectRoot: string; try { - projectRoot = await resolveProjectRoot(project.path); + projectRoot = await resolveProjectRoot(projectPath); } catch (err) { if (err instanceof PathScopeError) { reply.code(404); @@ -461,18 +459,14 @@ export function registerProjectRoutes( async (req, reply) => { const { id } = req.params; - const rows = await sql` - SELECT id, name, path, added_at, last_session_id, status, gitea_remote - FROM projects WHERE id = ${id} - `; - if (rows.length === 0) { + const projectPath = await selectProjectPath(sql, id); + if (projectPath === null) { reply.code(404); return { error: 'not found' }; } - const project = rows[0]!; let projectRoot: string; try { - projectRoot = await resolveProjectRoot(project.path); + projectRoot = await resolveProjectRoot(projectPath); } catch (err) { if (err instanceof PathScopeError) { reply.code(404); diff --git a/apps/server/src/routes/ws.ts b/apps/server/src/routes/ws.ts index f8d8833..3c719ea 100644 --- a/apps/server/src/routes/ws.ts +++ b/apps/server/src/routes/ws.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify'; import type { Sql } from '../db.js'; import type { Broker } from '../services/broker.js'; import type { Message } from '../types/api.js'; +import { MESSAGE_COLUMNS } from '../services/message-columns.js'; export function registerWebSocket( app: FastifyInstance, @@ -25,9 +26,7 @@ export function registerWebSocket( // render the SummaryCard for summary=true rows on first connect. // v1.13.1-B: reads tool_calls/tool_results via the parts-merged view. const messages = await sql` - SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, - tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata, - summary, tail_start_id, compacted_at, model + SELECT ${sql.unsafe(MESSAGE_COLUMNS)} FROM messages_with_parts WHERE session_id = ${sessionId} ORDER BY created_at ASC, id ASC diff --git a/apps/server/src/schema.sql b/apps/server/src/schema.sql index 83b080c..7df4ff5 100644 --- a/apps/server/src/schema.sql +++ b/apps/server/src/schema.sql @@ -1,5 +1,5 @@ -- v1.13.3: statement_timeout is set at database level via: --- ALTER DATABASE boocode SET statement_timeout = '30s'; +-- ALTER DATABASE boochat SET statement_timeout = '30s'; -- ALTER DATABASE can't run inside a DO block, so this is an operational -- step rather than schema. Re-apply after a volume reset (the setting -- lives in pg_db which survives `docker compose up --build` but NOT a @@ -30,8 +30,6 @@ CREATE TABLE IF NOT EXISTS messages ( session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', - tool_calls JSONB, - tool_results JSONB, status TEXT NOT NULL DEFAULT 'complete', last_seq INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() @@ -39,11 +37,10 @@ CREATE TABLE IF NOT EXISTS messages ( CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at); --- v1.13.0: granular message parts table for AI SDK migration. Old --- messages.content / tool_calls / tool_results columns stay authoritative --- for reads in v1.13.0; this table is dual-written so the swap can happen --- in a later dispatch without a backfill window. ON DELETE CASCADE means --- removing a message removes its parts in one go. +-- v1.13.0: granular message parts table. v1.13.20: legacy tool_calls/ +-- tool_results columns dropped; message_parts is now the sole source of +-- truth for tool calls, tool results, and reasoning. ON DELETE CASCADE +-- means removing a message removes its parts in one go. CREATE TABLE IF NOT EXISTS message_parts ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE, @@ -142,10 +139,9 @@ ALTER TABLE messages DROP COLUMN IF EXISTS tool_calls; ALTER TABLE messages DROP COLUMN IF EXISTS tool_results; -- v1.13.10: per-tool token cost rolling window. Derives from --- messages_with_parts (the v1.13.1-B view that COALESCEs message_parts over --- the legacy JSON column) so this works whether the chat predates v1.13.0 --- or postdates v1.13.2 (column drop). No new write site — all source data --- already lands via the existing tool-phase.ts:94-95 UPDATE. +-- messages_with_parts (the v1.13.1-B view; v1.13.20 removed the legacy +-- JSON-column COALESCE fallback — parts are sole source). No new write +-- site — all source data already lands via tool-phase.ts:94-95 UPDATE. -- -- Attribution model: equal split. A turn emitting N tool calls divides its -- prompt/completion tokens by N before attribution. See v1.13.10 dispatch @@ -352,7 +348,7 @@ INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (k ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT ''; ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false; ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN; -ALTER TABLE sessions ADD COLUMN IF NOT EXISTS tags TEXT[] DEFAULT '{}'; +ALTER TABLE sessions DROP COLUMN IF EXISTS tags; -- v1.11: anchored rolling compaction. -- compacted_at — marks rows that are "behind the curtain" of the latest @@ -391,9 +387,7 @@ CREATE TABLE IF NOT EXISTS tasks ( model TEXT, mode_id TEXT, thinking_option_id TEXT, - feature_values JSONB, execution_path TEXT CHECK (execution_path IS NULL OR execution_path IN ('native','acp','pty','qwen')), - worktree_path TEXT, cost_tokens INTEGER, started_at TIMESTAMPTZ, ended_at TIMESTAMPTZ, diff --git a/apps/server/src/services/__tests__/budget.test.ts b/apps/server/src/services/__tests__/budget.test.ts new file mode 100644 index 0000000..aca660d --- /dev/null +++ b/apps/server/src/services/__tests__/budget.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { resolveToolBudget } from '../inference/budget.js'; +import type { Agent } from '../../types/api.js'; + +const BASE_AGENT: Agent = { + id: 'test-agent', + name: 'Test', + description: 'test', + system_prompt: '', + temperature: 0.7, + top_p: null, + top_k: null, + min_p: null, + presence_penalty: null, + top_n_sigma: null, + dry_multiplier: null, + dry_base: null, + dry_allowed_length: null, + dry_penalty_last_n: null, + tools: ['view_file'], + model: null, + source: 'global', + max_tool_calls: null, + steps: null, + llama_extra_args: null, +}; + +describe('resolveToolBudget', () => { + it('returns 100 when agent is null (no-agent raw chat)', () => { + expect(resolveToolBudget(null)).toBe(100); + }); + + it('returns 100 when agent has no max_tool_calls override', () => { + expect(resolveToolBudget(BASE_AGENT)).toBe(100); + }); + + it('returns max_tool_calls when agent overrides the default', () => { + const agent: Agent = { ...BASE_AGENT, max_tool_calls: 25 }; + expect(resolveToolBudget(agent)).toBe(25); + }); + + it('returns 0 when max_tool_calls is explicitly 0 (text-only mode)', () => { + const agent: Agent = { ...BASE_AGENT, max_tool_calls: 0 }; + expect(resolveToolBudget(agent)).toBe(0); + }); +}); diff --git a/apps/server/src/services/__tests__/inference-helpers.test.ts b/apps/server/src/services/__tests__/inference-helpers.test.ts new file mode 100644 index 0000000..6573f64 --- /dev/null +++ b/apps/server/src/services/__tests__/inference-helpers.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { samplerOptsFromAgent } from '../inference/stream-phase.js'; +import { createContentFlusher } from '../inference/content-flusher.js'; +import type { Sql } from '../../db.js'; +import type { Agent } from '../../types/api.js'; + +const BASE_AGENT: Agent = { + id: 'test-agent', + name: 'Test', + description: 'test', + system_prompt: '', + temperature: 0.7, + top_p: null, + top_k: null, + min_p: null, + presence_penalty: null, + top_n_sigma: null, + dry_multiplier: null, + dry_base: null, + dry_allowed_length: null, + dry_penalty_last_n: null, + tools: ['view_file'], + model: null, + source: 'global', + max_tool_calls: null, + steps: null, + llama_extra_args: null, +}; + +describe('samplerOptsFromAgent', () => { + it('maps every nullable sampler field to undefined when agent is null', () => { + expect(samplerOptsFromAgent(null)).toEqual({ + temperature: undefined, + top_p: undefined, + top_k: undefined, + min_p: undefined, + presence_penalty: undefined, + top_n_sigma: undefined, + dry_multiplier: undefined, + dry_base: undefined, + dry_allowed_length: undefined, + dry_penalty_last_n: undefined, + }); + }); + + it('strips null sampler fields to undefined but keeps numeric values', () => { + const agent: Agent = { + ...BASE_AGENT, + temperature: 0.5, + top_p: 0.9, + top_k: null, + min_p: 0.05, + presence_penalty: null, + top_n_sigma: 1, + dry_multiplier: null, + dry_base: 1.75, + dry_allowed_length: null, + dry_penalty_last_n: 256, + }; + expect(samplerOptsFromAgent(agent)).toEqual({ + temperature: 0.5, + top_p: 0.9, + top_k: undefined, + min_p: 0.05, + presence_penalty: undefined, + top_n_sigma: 1, + dry_multiplier: undefined, + dry_base: 1.75, + dry_allowed_length: undefined, + dry_penalty_last_n: 256, + }); + }); + + it('never includes a tools field (callers add it)', () => { + expect('tools' in samplerOptsFromAgent(BASE_AGENT)).toBe(false); + }); +}); + +describe('createContentFlusher', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + // A tagged-template stub matching postgres' sql`...` shape. Records the + // interpolated content snapshot (values[0]) of each UPDATE. + function makeSqlSpy() { + const writes: string[] = []; + const sql = ((_strings: TemplateStringsArray, ...values: unknown[]) => { + writes.push(values[0] as string); + return Promise.resolve([]); + }) as unknown as Sql; + return { sql, writes }; + } + + it('debounces: many scheduleFlush calls in one window produce one write', async () => { + vi.useFakeTimers(); + const { sql, writes } = makeSqlSpy(); + let content = ''; + const flusher = createContentFlusher(sql, 'msg-1', () => content, 500); + + content = 'a'; + flusher.scheduleFlush(); + content = 'ab'; + flusher.scheduleFlush(); + content = 'abc'; + flusher.scheduleFlush(); + + expect(writes).toHaveLength(0); // nothing before the interval elapses + vi.advanceTimersByTime(500); + await flusher.drain(); + + expect(writes).toHaveLength(1); + // snapshot is read at fire time → latest content, not the value at schedule time + expect(writes[0]).toBe('abc'); + }); + + it('arms a fresh timer after a flush fires', async () => { + vi.useFakeTimers(); + const { sql, writes } = makeSqlSpy(); + let content = 'one'; + const flusher = createContentFlusher(sql, 'msg-1', () => content, 500); + + flusher.scheduleFlush(); + vi.advanceTimersByTime(500); + await Promise.resolve(); + + content = 'two'; + flusher.scheduleFlush(); + vi.advanceTimersByTime(500); + await flusher.drain(); + + expect(writes).toEqual(['one', 'two']); + }); + + it('drain cancels a pending timer without performing a final flush', async () => { + vi.useFakeTimers(); + const { sql, writes } = makeSqlSpy(); + let content = 'pending'; + const flusher = createContentFlusher(sql, 'msg-1', () => content, 500); + + flusher.scheduleFlush(); + // Drain before the timer fires — the pending flush is cancelled, not forced. + await flusher.drain(); + vi.advanceTimersByTime(500); + await Promise.resolve(); + + expect(writes).toHaveLength(0); + }); +}); diff --git a/apps/server/src/services/__tests__/model-context.test.ts b/apps/server/src/services/__tests__/model-context.test.ts index abff31e..66056d0 100644 --- a/apps/server/src/services/__tests__/model-context.test.ts +++ b/apps/server/src/services/__tests__/model-context.test.ts @@ -9,12 +9,9 @@ import { const TEST_URL = 'http://llama-swap.test:8401'; -function mockOkProps(n_ctx: number, total_slots = 1) { +function mockOkProps(n_ctx: number) { return new Response( - JSON.stringify({ - default_generation_settings: { n_ctx }, - total_slots, - }), + JSON.stringify({ default_generation_settings: { n_ctx } }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ); } @@ -33,12 +30,10 @@ afterEach(() => { describe('getModelContext — positive cache', () => { it('returns the parsed body on a 200 with valid shape', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockOkProps(262_144, 1)); + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockOkProps(262_144)); const result = await getModelContext('qwen3.6'); expect(result).not.toBeNull(); expect(result!.n_ctx).toBe(262_144); - expect(result!.total_slots).toBe(1); - expect(typeof result!.fetched_at).toBe('number'); // Verify the URL was constructed correctly — encodes the model name in // case it contains characters that would break the path. expect(fetchSpy).toHaveBeenCalledExactlyOnceWith( @@ -57,19 +52,6 @@ describe('getModelContext — positive cache', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); }); - it('defaults total_slots to 1 when the server omits it', async () => { - // Mirror the docstring claim — total_slots is informational and we don't - // reject the response just because it's missing. - vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( - new Response(JSON.stringify({ default_generation_settings: { n_ctx: 8192 } }), { - status: 200, - }), - ); - const result = await getModelContext('partial-model'); - expect(result).not.toBeNull(); - expect(result!.n_ctx).toBe(8192); - expect(result!.total_slots).toBe(1); - }); }); // ---- negative cache (single-shot) ------------------------------------------ diff --git a/apps/server/src/services/__tests__/sentinels.test.ts b/apps/server/src/services/__tests__/sentinels.test.ts new file mode 100644 index 0000000..16cb790 --- /dev/null +++ b/apps/server/src/services/__tests__/sentinels.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { SENTINEL_KINDS, isAnySentinel, isCapHitSentinel, isDoomLoopSentinel, isMistakeRecoverySentinel } from '../inference/sentinels.js'; +import type { Message } from '../../types/api.js'; + +function makeSentinel(kind: string): Message { + return { + id: 'msg-1', + session_id: 's', + chat_id: 'c', + role: 'system', + content: '', + kind: 'message', + tool_calls: null, + tool_results: null, + status: 'complete', + last_seq: 0, + tokens_used: null, + ctx_used: null, + ctx_max: null, + started_at: null, + finished_at: null, + created_at: new Date().toISOString(), + metadata: { kind } as unknown as import('../../types/api.js').MessageMetadata, + summary: false, + tail_start_id: null, + compacted_at: null, + }; +} + +describe('SENTINEL_KINDS — single source of truth', () => { + it('contains the three known sentinel kinds', () => { + expect(SENTINEL_KINDS.has('cap_hit')).toBe(true); + expect(SENTINEL_KINDS.has('doom_loop')).toBe(true); + expect(SENTINEL_KINDS.has('mistake_recovery')).toBe(true); + }); + + it('does not contain arbitrary strings', () => { + expect(SENTINEL_KINDS.has('user')).toBe(false); + expect(SENTINEL_KINDS.has('assistant')).toBe(false); + expect(SENTINEL_KINDS.has('')).toBe(false); + }); +}); + +describe('isAnySentinel', () => { + it('returns true for cap_hit', () => { + expect(isAnySentinel(makeSentinel('cap_hit'))).toBe(true); + }); + + it('returns true for doom_loop', () => { + expect(isAnySentinel(makeSentinel('doom_loop'))).toBe(true); + }); + + it('returns true for mistake_recovery', () => { + expect(isAnySentinel(makeSentinel('mistake_recovery'))).toBe(true); + }); + + it('returns false for non-system role', () => { + const m = { ...makeSentinel('cap_hit'), role: 'user' as const }; + expect(isAnySentinel(m)).toBe(false); + }); + + it('returns false for null metadata', () => { + const m = { ...makeSentinel('cap_hit'), metadata: null }; + expect(isAnySentinel(m)).toBe(false); + }); + + it('returns false for unknown kind', () => { + expect(isAnySentinel(makeSentinel('unknown_kind'))).toBe(false); + }); +}); + +describe('individual sentinel predicates still work', () => { + it('isCapHitSentinel matches cap_hit only', () => { + expect(isCapHitSentinel(makeSentinel('cap_hit'))).toBe(true); + expect(isCapHitSentinel(makeSentinel('doom_loop'))).toBe(false); + }); + + it('isDoomLoopSentinel matches doom_loop only', () => { + expect(isDoomLoopSentinel(makeSentinel('doom_loop'))).toBe(true); + expect(isDoomLoopSentinel(makeSentinel('cap_hit'))).toBe(false); + }); + + it('isMistakeRecoverySentinel matches mistake_recovery only', () => { + expect(isMistakeRecoverySentinel(makeSentinel('mistake_recovery'))).toBe(true); + expect(isMistakeRecoverySentinel(makeSentinel('cap_hit'))).toBe(false); + }); +}); diff --git a/apps/server/src/services/__tests__/step-decision.test.ts b/apps/server/src/services/__tests__/step-decision.test.ts new file mode 100644 index 0000000..51512bb --- /dev/null +++ b/apps/server/src/services/__tests__/step-decision.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { resolveTurnConfig, MAX_STEPS } from '../inference/turn-config.js'; +import { decideStep, decidePostToolAction } from '../inference/step-decision.js'; +import { DOOM_LOOP_THRESHOLD } from '../inference/sentinels.js'; +import type { MistakeState } from '../inference/mistake-tracker.js'; +import type { Agent, ToolCall } from '../../types/api.js'; + +const BASE_AGENT: Agent = { + id: 'test-agent', + name: 'Test', + description: 'test', + system_prompt: '', + temperature: 0.7, + top_p: null, + top_k: null, + min_p: null, + presence_penalty: null, + top_n_sigma: null, + dry_multiplier: null, + dry_base: null, + dry_allowed_length: null, + dry_penalty_last_n: null, + tools: ['view_file'], + model: null, + source: 'global', + max_tool_calls: null, + steps: null, + llama_extra_args: null, +}; + +function call(name: string, args: Record = {}): ToolCall { + return { id: `tc-${name}-${JSON.stringify(args)}`, name, args }; +} + +describe('resolveTurnConfig', () => { + it('no agent → budget 100, cap MAX_STEPS, not text-only', () => { + expect(resolveTurnConfig(null)).toEqual({ + effectiveCap: MAX_STEPS, + budget: 100, + isTextOnly: false, + }); + }); + + it('steps: 0 → effectiveCap 0 and isTextOnly true', () => { + expect(resolveTurnConfig({ ...BASE_AGENT, steps: 0 })).toEqual({ + effectiveCap: 0, + budget: 100, + isTextOnly: true, + }); + }); + + it('steps below MAX_STEPS → effectiveCap is the agent value', () => { + expect(resolveTurnConfig({ ...BASE_AGENT, steps: 5 }).effectiveCap).toBe(5); + }); + + it('steps above MAX_STEPS → effectiveCap clamps to MAX_STEPS', () => { + expect(resolveTurnConfig({ ...BASE_AGENT, steps: 9999 }).effectiveCap).toBe(MAX_STEPS); + }); + + it('max_tool_calls overrides the budget', () => { + expect(resolveTurnConfig({ ...BASE_AGENT, max_tool_calls: 12 }).budget).toBe(12); + }); +}); + +describe('decideStep (top-of-loop gate)', () => { + it('returns stream when no doom loop and under budget', () => { + expect(decideStep({ recentToolCalls: [], toolsUsed: 0, budget: 30 })).toEqual({ kind: 'stream' }); + }); + + it('returns budget when toolsUsed has reached the budget', () => { + expect(decideStep({ recentToolCalls: [], toolsUsed: 30, budget: 30 })).toEqual({ kind: 'budget' }); + }); + + it('returns doom (with the looping call) on identical-repeat tail', () => { + const recent = Array.from({ length: DOOM_LOOP_THRESHOLD }, () => call('view_file', { path: '/a' })); + const d = decideStep({ recentToolCalls: recent, toolsUsed: 1, budget: 30 }); + expect(d.kind).toBe('doom'); + if (d.kind === 'doom') { + expect(d.loop.name).toBe('view_file'); + expect(d.loop.args).toEqual({ path: '/a' }); + } + }); + + it('doom takes precedence over budget when both would trip', () => { + const recent = Array.from({ length: DOOM_LOOP_THRESHOLD }, () => call('grep', { q: 'x' })); + expect(decideStep({ recentToolCalls: recent, toolsUsed: 30, budget: 30 }).kind).toBe('doom'); + }); +}); + +describe('decidePostToolAction (post-tool decision)', () => { + const clean: MistakeState = { run: [], nudges: 0 }; + + it('non-continue actions stop the loop without consulting the tracker', () => { + expect(decidePostToolAction('paused', { run: ['exec_error', 'exec_error', 'exec_error'], nudges: 0 })).toBe('stop'); + expect(decidePostToolAction('synthesis_done', clean)).toBe('stop'); + }); + + it('continue with a clean tracker → continue', () => { + expect(decidePostToolAction('continue', clean)).toBe('continue'); + }); + + it('continue with a threshold streak and no prior nudge → nudge', () => { + const tracker: MistakeState = { run: ['zod_reject', 'tool_not_found', 'exec_error'], nudges: 0 }; + expect(decidePostToolAction('continue', tracker)).toBe('nudge'); + }); + + it('continue with a threshold streak after a nudge already fired → escalate', () => { + const tracker: MistakeState = { run: ['zod_reject', 'tool_not_found', 'exec_error'], nudges: 1 }; + expect(decidePostToolAction('continue', tracker)).toBe('escalate'); + }); +}); diff --git a/apps/server/src/services/__tests__/tools-registry.test.ts b/apps/server/src/services/__tests__/tools-registry.test.ts new file mode 100644 index 0000000..dd347fb --- /dev/null +++ b/apps/server/src/services/__tests__/tools-registry.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { + ALL_TOOLS, + TOOLS_BY_NAME, + appendMcpTools, + toolJsonSchemas, + type ToolDef, +} from '../tools.js'; + +// Parity test for the register-through MCP-discovery contract (Phase 6 split). +// `ALL_TOOLS` / `TOOLS_BY_NAME` are `let`-bound in tools/registry.ts and +// reassigned by appendMcpTools() at startup; this barrel re-exports them. +// apps/coder relies on this exact behavior: it imports `appendMcpTools` + the +// live `ALL_TOOLS` binding from @boocode/server/tools, calls appendMcpTools() +// once, then reads ALL_TOOLS. ESM live bindings must carry the mutation +// through the barrel re-export — if the split ever snapshots the array instead +// of re-exporting the live binding, these assertions fail. Each test file gets +// an isolated module instance (vitest default), so mutating the registry here +// does not leak into tools.test.ts. +function makeFakeMcpTool(name: string): ToolDef { + return { + name, + description: `fake mcp tool ${name}`, + inputSchema: z.object({}) as z.ZodType, + jsonSchema: { + type: 'function', + function: { + name, + description: `fake mcp tool ${name}`, + parameters: { type: 'object', properties: {}, additionalProperties: false }, + }, + }, + async execute() { + return { ok: true }; + }, + }; +} + +describe('appendMcpTools register-through contract', () => { + it('is a no-op for an empty array', () => { + const before = ALL_TOOLS.length; + appendMcpTools([]); + expect(ALL_TOOLS.length).toBe(before); + }); + + it('mutates the live ALL_TOOLS / TOOLS_BY_NAME bindings observable through the barrel', () => { + const before = ALL_TOOLS.length; + // Names chosen so insertion lands away from the array ends, proving the + // re-sort runs (a naive concat would leave them at the tail). + const a = makeFakeMcpTool('mcp__alpha__probe'); + const z2 = makeFakeMcpTool('mcp__zeta__probe'); + appendMcpTools([z2, a]); + + expect(ALL_TOOLS.length).toBe(before + 2); + expect(TOOLS_BY_NAME['mcp__alpha__probe']).toBe(a); + expect(TOOLS_BY_NAME['mcp__zeta__probe']).toBe(z2); + + // Still alpha-sorted after the append (prompt-cache stability invariant). + const names = ALL_TOOLS.map((t) => t.name); + expect(names).toEqual([...names].sort((x, y) => x.localeCompare(y))); + + // toolJsonSchemas() reads through the same live binding. + const schemaNames = toolJsonSchemas().map((s) => s.function.name); + expect(schemaNames).toContain('mcp__alpha__probe'); + expect(schemaNames).toContain('mcp__zeta__probe'); + }); +}); diff --git a/apps/server/src/services/agents.ts b/apps/server/src/services/agents.ts index e873991..e4f0697 100644 --- a/apps/server/src/services/agents.ts +++ b/apps/server/src/services/agents.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js'; import { ALL_TOOLS, resolveToolTier } from './tools.js'; import { validateExtraArgs } from './inference/llama-args-validator.js'; +import { stripQuotes } from '../utils/string-utils.js'; // v1.8.1: global agents live at /data/AGENTS.md inside the container // (./data:/data:ro mount on the host). Per-project AGENTS.md at the project @@ -107,17 +108,50 @@ interface ParsedFrontmatter { llama_extra_args?: string[]; } -function stripQuotes(s: string): string { - if ( - s.length >= 2 && - (s[0] === '"' || s[0] === "'") && - s[0] === s[s.length - 1] - ) { - return s.slice(1, -1); - } - return s; +// P5: table-driven validation for the "soft-range" numeric frontmatter fields. +// Each was a near-identical Number() + finite/integer + range-warn + push-error +// block. "Soft-range" = the value is STORED whenever the type checks out; an +// out-of-range value only emits a console.warn (it is NOT skipped). A type +// mismatch hard-fails the block. The range descriptor in the warn message is +// `min-max` when both bounds exist, else `(≥min)` — matching the original +// hand-written strings byte-for-byte. +// +// max_tool_calls and steps are deliberately NOT in this table: they are +// "hard-range" (store ONLY if in range; an in-type-but-out-of-range value is +// warned AND skipped) with bespoke messages, so they stay explicit below. +type NumericFieldKey = + | 'temperature' + | 'top_p' + | 'top_k' + | 'min_p' + | 'presence_penalty' + | 'top_n_sigma' + | 'dry_multiplier' + | 'dry_base' + | 'dry_allowed_length' + | 'dry_penalty_last_n'; + +interface NumericFieldSpec { + key: NumericFieldKey; + isInt: boolean; + min?: number; + max?: number; } +const NUMERIC_FIELDS: readonly NumericFieldSpec[] = [ + { key: 'temperature', isInt: false }, + { key: 'top_p', isInt: false, min: 0, max: 1 }, + { key: 'top_k', isInt: true, min: 0, max: 200 }, + { key: 'min_p', isInt: false, min: 0, max: 1 }, + { key: 'presence_penalty', isInt: false, min: -2, max: 2 }, + // v2.6 sampling-streamjson-tokens (#11): llama.cpp sampler extensions. + { key: 'top_n_sigma', isInt: false, min: 0 }, + { key: 'dry_multiplier', isInt: false, min: 0 }, + { key: 'dry_base', isInt: false, min: 0 }, + { key: 'dry_allowed_length', isInt: true, min: 0 }, + { key: 'dry_penalty_last_n', isInt: true, min: -1 }, +]; + function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: string[] } { const data: ParsedFrontmatter = {}; const errors: string[] = []; @@ -140,108 +174,33 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri const key = line.slice(0, colonIdx).trim(); const valueRaw = line.slice(colonIdx + 1).trim(); - if (key === 'temperature') { + const numericSpec = NUMERIC_FIELDS.find((f) => f.key === key); + if (numericSpec) { const n = Number(valueRaw); - if (Number.isFinite(n)) data.temperature = n; - else errors.push(`temperature must be a number (got "${valueRaw}")`); - } else if (key === 'top_p') { - const n = Number(valueRaw); - if (Number.isFinite(n)) { - data.top_p = n; - if (n < 0 || n > 1) { - console.warn(`agents: top_p ${n} out of range 0-1, ignoring (falling back to default)`); + const typeOk = numericSpec.isInt ? Number.isInteger(n) : Number.isFinite(n); + if (typeOk) { + // Soft-range: store regardless of range; out-of-range only warns. + data[numericSpec.key] = n; + const below = numericSpec.min !== undefined && n < numericSpec.min; + const above = numericSpec.max !== undefined && n > numericSpec.max; + if (below || above) { + const range = + numericSpec.max !== undefined + ? `${numericSpec.min}-${numericSpec.max}` + : `(≥${numericSpec.min})`; + console.warn( + `agents: ${numericSpec.key} ${n} out of range ${range}, ignoring (falling back to default)`, + ); } } else { - errors.push(`top_p must be a number (got "${valueRaw}")`); + errors.push( + `${numericSpec.key} must be ${numericSpec.isInt ? 'an integer' : 'a number'} (got "${valueRaw}")`, + ); } - } else if (key === 'top_k') { - const n = Number(valueRaw); - if (Number.isInteger(n)) { - data.top_k = n; - if (n < 0 || n > 200) { - console.warn(`agents: top_k ${n} out of range 0-200, ignoring (falling back to default)`); - } - } else { - errors.push(`top_k must be an integer (got "${valueRaw}")`); - } - } else if (key === 'min_p') { - const n = Number(valueRaw); - if (Number.isFinite(n)) { - data.min_p = n; - if (n < 0 || n > 1) { - console.warn(`agents: min_p ${n} out of range 0-1, ignoring (falling back to default)`); - } - } else { - errors.push(`min_p must be a number (got "${valueRaw}")`); - } - } else if (key === 'presence_penalty') { - const n = Number(valueRaw); - if (Number.isFinite(n)) { - data.presence_penalty = n; - if (n < -2 || n > 2) { - console.warn(`agents: presence_penalty ${n} out of range -2-2, ignoring (falling back to default)`); - } - } else { - errors.push(`presence_penalty must be a number (got "${valueRaw}")`); - } - } else if (key === 'top_n_sigma') { - // v2.6 #11: llama.cpp top-n-sigma sampler. Float ≥ 0 (typical 0-3). - // Mirrors top_p/min_p: store then warn on out-of-range (non-numeric - // hard-fails the block). - const n = Number(valueRaw); - if (Number.isFinite(n)) { - data.top_n_sigma = n; - if (n < 0) { - console.warn(`agents: top_n_sigma ${n} out of range (≥0), ignoring (falling back to default)`); - } - } else { - errors.push(`top_n_sigma must be a number (got "${valueRaw}")`); - } - } else if (key === 'dry_multiplier') { - // v2.6 #11: DRY repetition-penalty multiplier. Float ≥ 0 (0 disables DRY). - const n = Number(valueRaw); - if (Number.isFinite(n)) { - data.dry_multiplier = n; - if (n < 0) { - console.warn(`agents: dry_multiplier ${n} out of range (≥0), ignoring (falling back to default)`); - } - } else { - errors.push(`dry_multiplier must be a number (got "${valueRaw}")`); - } - } else if (key === 'dry_base') { - // v2.6 #11: DRY penalty growth base. Float ≥ 0. - const n = Number(valueRaw); - if (Number.isFinite(n)) { - data.dry_base = n; - if (n < 0) { - console.warn(`agents: dry_base ${n} out of range (≥0), ignoring (falling back to default)`); - } - } else { - errors.push(`dry_base must be a number (got "${valueRaw}")`); - } - } else if (key === 'dry_allowed_length') { - // v2.6 #11: DRY max sequence length not penalized. Integer ≥ 0. - const n = Number(valueRaw); - if (Number.isInteger(n)) { - data.dry_allowed_length = n; - if (n < 0) { - console.warn(`agents: dry_allowed_length ${n} out of range (≥0), ignoring (falling back to default)`); - } - } else { - errors.push(`dry_allowed_length must be an integer (got "${valueRaw}")`); - } - } else if (key === 'dry_penalty_last_n') { - // v2.6 #11: DRY lookback window. Integer ≥ -1 (-1 = whole context, 0 = off). - const n = Number(valueRaw); - if (Number.isInteger(n)) { - data.dry_penalty_last_n = n; - if (n < -1) { - console.warn(`agents: dry_penalty_last_n ${n} out of range (≥-1), ignoring (falling back to default)`); - } - } else { - errors.push(`dry_penalty_last_n must be an integer (got "${valueRaw}")`); - } - } else if (key === 'tools') { + continue; + } + + if (key === 'tools') { if (valueRaw === '') { data.tools = []; arrayKey = 'tools'; @@ -478,14 +437,6 @@ interface CacheEntry { // corresponding mtime so the next read sees a miss without a watcher. const cache = new Map(); -export function invalidateAgentsCache(projectPath?: string): void { - if (projectPath === undefined) { - cache.clear(); - } else { - cache.delete(projectPath); - } -} - // v1.13.8: cache-read accessor for the system-prompt prefix-fingerprint log. // Returns the AGENTS.md mtimes that getAgentsForProject() observed on its // last cache fill for this projectPath. Both fields are null when the cache diff --git a/apps/server/src/services/auto_name.ts b/apps/server/src/services/auto_name.ts index d058e82..cbad140 100644 --- a/apps/server/src/services/auto_name.ts +++ b/apps/server/src/services/auto_name.ts @@ -19,8 +19,6 @@ function cleanTitle(raw: string): string { return name; } -// TODO: wire suggestTags after task model validation - export async function maybeAutoNameChat( ctx: InferenceContext, chatId: string, diff --git a/apps/server/src/services/codecontext_client.ts b/apps/server/src/services/codecontext_client.ts index 040ab78..185c299 100644 --- a/apps/server/src/services/codecontext_client.ts +++ b/apps/server/src/services/codecontext_client.ts @@ -113,7 +113,7 @@ export async function callCodecontext( fetcher: typeof fetch = fetch, ): Promise { // Step 1: realpath the project root, then realpath the requested target_dir - // (defaulting to projectPath when the caller didn't pass one — the 8 wrappers + // (defaulting to projectPath when the caller didn't pass one — the 12 wrappers // never pass target_dir; tests can override). A non-existent target_dir // throws before we hit the network so the model gets a sharp error. const resolvedProject = await realpath(req.projectPath); diff --git a/apps/server/src/services/compaction.ts b/apps/server/src/services/compaction.ts index 1c784ae..b283f7b 100644 --- a/apps/server/src/services/compaction.ts +++ b/apps/server/src/services/compaction.ts @@ -22,6 +22,8 @@ import type { Config } from '../config.js'; import type { Broker } from './broker.js'; import { SUMMARY_TEMPLATE } from './compaction-prompt.js'; import * as modelContextLookup from './model-context.js'; +import { SENTINEL_KINDS } from './inference/sentinels.js'; +import type { OpenAiMessage } from './inference/payload.js'; // v1.13.9: ratio-only overflow trigger. Fires compaction at 85% of ctx_max // (opencode session/overflow.ts pattern). Replaces the v1.11.0-era @@ -256,24 +258,9 @@ export function buildPrompt( // would silently drop pre-legacy-compact history before the LLM sees it. // Compaction wants to send the entire head, full stop.) === -// v1.13.6: exported for unit-test access (reasoning render coverage). -export interface OpenAiMessage { - role: 'system' | 'user' | 'assistant' | 'tool'; - content: string | null; - tool_calls?: Array<{ - id: string; - type: 'function'; - function: { name: string; arguments: string }; - }>; - tool_call_id?: string; -} - -// #12: mirror inference/sentinels.ts:isAnySentinel over the CompactionMessage -// shape (which carries metadata as { kind?: string } | null, not the full -// Message type isAnySentinel expects). All UI-only sentinels are stripped from -// the head payload — they never go to the summarizer LLM. Keep the kind list in -// sync with isAnySentinel in sentinels.ts. -const SENTINEL_KINDS = new Set(['cap_hit', 'doom_loop', 'mistake_recovery']); +// #12: SENTINEL_KINDS imported from inference/sentinels.ts (single source). +// OpenAiMessage imported from inference/payload.ts (structurally compatible — +// compaction's head payload doesn't need the optional reasoning? field). function isAnySentinel(m: CompactionMessage): boolean { return ( m.role === 'system' && diff --git a/apps/server/src/services/file_ops.ts b/apps/server/src/services/file_ops.ts index 9f75231..7234e1e 100644 --- a/apps/server/src/services/file_ops.ts +++ b/apps/server/src/services/file_ops.ts @@ -200,7 +200,7 @@ export async function grep( export async function findFiles( projectRoot: string, pattern?: string, - opts?: { type?: 'file' | 'dir'; max_results?: number; path?: string; extra_roots?: readonly string[] } + opts?: { max_results?: number; path?: string; extra_roots?: readonly string[] } ): Promise { const limit = Math.min( Math.max(opts?.max_results ?? DEFAULT_FIND_RESULTS, 1), diff --git a/apps/server/src/services/git_meta.ts b/apps/server/src/services/git_meta.ts index 29e160f..1623b7d 100644 --- a/apps/server/src/services/git_meta.ts +++ b/apps/server/src/services/git_meta.ts @@ -83,10 +83,3 @@ export async function getGitMeta(rootPath: string): Promise { return value; } -export function invalidateGitMetaCache(rootPath?: string): void { - if (rootPath) { - cache.delete(rootPath); - } else { - cache.clear(); - } -} diff --git a/apps/server/src/services/inference/budget.ts b/apps/server/src/services/inference/budget.ts index eedd9ed..0ad70f0 100644 --- a/apps/server/src/services/inference/budget.ts +++ b/apps/server/src/services/inference/budget.ts @@ -1,32 +1,10 @@ import type { Agent } from '../../types/api.js'; -import { READ_ONLY_TOOL_NAMES } from '../tools.js'; - -// v1.8.2: tool-call budget defaults. Resolved per-turn by resolveToolBudget. -// - Agent with explicit max_tool_calls: that value. -// - Agent with read-only-only tools: BUDGET_READ_ONLY (50). -// - Agent with any non-read-only tool: BUDGET_NON_READ_ONLY (10). -// - No agent (raw chat): BUDGET_NO_AGENT (50). -// v1.13.7: bumped BUDGET_NO_AGENT 15→30 to match BUDGET_READ_ONLY. Every tool -// in ALL_TOOLS today is read-only (see services/tools.ts comment at -// READ_ONLY_TOOL_NAMES); the cautious 15-cap was a forward-looking guard for -// write tools that haven't landed yet. No-agent mode gets the same toolset as -// an all-read-only agent at runtime, so they should share the same budget. -// v1.13.12: bumped read-only caps 30→50. Real recon sessions were hitting 30 -// with ~3 turns wasted on codecontext parse failures (empty node_modules -// files); legitimate need was ~27, and Architect-class system overviews want -// deeper recon than a 30-cap permits. Headroom of 20 absorbs failure-retry -// turns + deeper exploration without changing the safety floor materially — -// the doom-loop guard (3 identical calls → abort) catches the actual failure -// mode this cap was guarding against. -export const BUDGET_READ_ONLY = 100; -export const BUDGET_NON_READ_ONLY = 100; -export const BUDGET_NO_AGENT = 100; - -const READ_ONLY_SET: ReadonlySet = new Set(READ_ONLY_TOOL_NAMES); +// Tool-call budget. All three historical tiers (read-only, non-read-only, +// no-agent) converged to 100 as of v1.13.12, collapsing the tier logic. +// The only remaining override is per-agent max_tool_calls from AGENTS.md +// frontmatter. Flat default of 100; doom-loop guard in sentinels.ts catches +// pathological cases well before the cap is reached. export function resolveToolBudget(agent: Agent | null): number { - if (agent?.max_tool_calls != null) return agent.max_tool_calls; - if (!agent) return BUDGET_NO_AGENT; - const allReadOnly = agent.tools.every((t) => READ_ONLY_SET.has(t)); - return allReadOnly ? BUDGET_READ_ONLY : BUDGET_NON_READ_ONLY; + return agent?.max_tool_calls ?? 100; } diff --git a/apps/server/src/services/inference/content-flusher.ts b/apps/server/src/services/inference/content-flusher.ts new file mode 100644 index 0000000..81a42dd --- /dev/null +++ b/apps/server/src/services/inference/content-flusher.ts @@ -0,0 +1,64 @@ +// P5: the debounced DB content-flush timer, extracted from the verbatim copy +// that lived in executeStreamPhase + the three sentinel summaries (4 sites). +// Each site streamed deltas into a local `accumulated`/`state.accumulated` +// string and threw an UPDATE at the row at most once per DB_FLUSH_INTERVAL_MS +// to bound write rate under heavy streaming. +// +// The accumulated string stays owned by the caller (stream-phase keeps it on +// the shared StreamPhaseState; the summaries keep a local) — the flusher reads +// it through a `getContent` thunk at fire time, snapshotting the latest value +// exactly as the inline `const snapshot = accumulated` did. No final flush is +// performed on drain (matches the originals): every caller writes the full +// content itself in its terminal UPDATE, so drain only cancels the pending +// timer and awaits whatever write is already chained. + +import type { Sql } from '../../db.js'; +import { DB_FLUSH_INTERVAL_MS } from './types.js'; + +export interface ContentFlusher { + // Arm a debounced flush. No-op if one is already pending (the in-flight timer + // will pick up the latest content via getContent when it fires). + scheduleFlush: () => void; + // Cancel any pending timer and await the in-flight write chain. Does NOT + // perform a final flush — the caller's terminal UPDATE owns the final write. + drain: () => Promise; +} + +export function createContentFlusher( + sql: Sql, + messageId: string, + getContent: () => string, + intervalMs: number = DB_FLUSH_INTERVAL_MS, +): ContentFlusher { + let pendingFlushTimer: NodeJS.Timeout | null = null; + let flushPromise: Promise = Promise.resolve(); + + const flushNow = () => { + if (pendingFlushTimer) { + clearTimeout(pendingFlushTimer); + pendingFlushTimer = null; + } + const snapshot = getContent(); + flushPromise = flushPromise.then(() => + sql`UPDATE messages SET content = ${snapshot} WHERE id = ${messageId}` + ); + }; + + const scheduleFlush = () => { + if (pendingFlushTimer) return; + pendingFlushTimer = setTimeout(() => { + pendingFlushTimer = null; + flushNow(); + }, intervalMs); + }; + + const drain = async () => { + if (pendingFlushTimer) { + clearTimeout(pendingFlushTimer); + pendingFlushTimer = null; + } + await flushPromise; + }; + + return { scheduleFlush, drain }; +} diff --git a/apps/server/src/services/inference/error-handler.ts b/apps/server/src/services/inference/error-handler.ts index 584ab9a..4bd438d 100644 --- a/apps/server/src/services/inference/error-handler.ts +++ b/apps/server/src/services/inference/error-handler.ts @@ -10,7 +10,7 @@ import { maybeFlagForCompaction } from './payload.js'; import { insertParts, partsFromAssistantMessage } from './parts.js'; import type { PartInsert } from './parts.js'; import { stripToolMarkup } from './tool-call-parser.js'; -import type { InferenceContext, StreamResult, TurnArgs } from './turn.js'; +import type { InferenceContext, StreamResult, TurnArgs } from './types.js'; export async function handleAbortOrError( ctx: InferenceContext, @@ -95,6 +95,90 @@ export async function handleAbortOrError( } } +// P5: the success-finalize atom shared by the wrap-up summaries +// (sentinel-summaries.ts) and the synthesis pass (synthesisPipeline.ts). Both +// previously hand-rolled this exact ceremony — n_ctx lookup, the complete +// UPDATE (content/status/tokens/ctx/ctx_max/finished_at; NO model column), and +// the message_complete frame with the full token fields. Single-sourcing it +// means a message_complete frame-contract change lands in one place instead of +// silently skipping the summary/synthesis paths. +// +// `beforeComplete` runs AFTER the UPDATE and BEFORE the message_complete frame +// — synthesis uses it to write its kind='synthesis' part in the original order +// (UPDATE → insertParts → message_complete), preserving timing exactly. +// +// NOTE: finalizeCompletion does NOT use this — it additionally writes the +// `model` column, the text/reasoning/html_artifact parts, the compaction flag, +// and the session_updated bump, which this atom deliberately omits (the summary +// and synthesis paths handle those — or not — themselves). +export async function finalizeStreamedRow( + ctx: InferenceContext, + opts: { + sessionId: string; + chatId: string; + messageId: string; + model: string; + content: string; + completionTokens: number | null; + promptTokens: number | null; + startedAt: string | null; + beforeComplete?: () => Promise; + }, +): Promise { + // v1.11.3: see executeToolPhase for the rationale. + const mctx = await modelContext.getModelContext(opts.model); + const nCtx = mctx?.n_ctx ?? null; + const [updated] = await ctx.sql< + { tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[] + >` + UPDATE messages + SET content = ${opts.content}, + status = 'complete', + tokens_used = ${opts.completionTokens}, + ctx_used = ${opts.promptTokens}, + ctx_max = ${nCtx}, + finished_at = clock_timestamp() + WHERE id = ${opts.messageId} + RETURNING tokens_used, ctx_used, ctx_max, finished_at + `; + if (opts.beforeComplete) await opts.beforeComplete(); + ctx.publish(opts.sessionId, { + type: 'message_complete', + message_id: opts.messageId, + chat_id: opts.chatId, + tokens_used: updated?.tokens_used ?? null, + ctx_used: updated?.ctx_used ?? null, + ctx_max: updated?.ctx_max ?? null, + started_at: opts.startedAt, + finished_at: updated?.finished_at ?? null, + model: opts.model, + }); +} + +// P5: minimal empty-finalize for the mistake-escalate path. The escalate +// branch in runAssistantTurn stops the turn cap-hit-style; the next assistant +// row is still 'streaming', so it's finalized as an empty complete row (no +// tokens, no parts, no session bump — the escalate branch handles the sentinel +// + chat_status itself). Centralizing the status-column write + message_complete +// frame here keeps it next to the other finalize paths so a status-column +// change is found in one place. +export async function finalizeEmpty( + ctx: InferenceContext, + args: TurnArgs, +): Promise { + const { sessionId, chatId, assistantMessageId } = args; + await ctx.sql` + UPDATE messages + SET content = '', status = 'complete', finished_at = clock_timestamp() + WHERE id = ${assistantMessageId} + `; + ctx.publish(sessionId, { + type: 'message_complete', + message_id: assistantMessageId, + chat_id: chatId, + }); +} + export async function finalizeCompletion( ctx: InferenceContext, args: TurnArgs, diff --git a/apps/server/src/services/inference/index.ts b/apps/server/src/services/inference/index.ts index 8e3152f..219d024 100644 --- a/apps/server/src/services/inference/index.ts +++ b/apps/server/src/services/inference/index.ts @@ -7,26 +7,17 @@ export { createInferenceRunner, MAX_STEPS, - runAssistantTurn, runInference, } from './turn.js'; +// P5: the shared pipeline types moved from turn.ts to types.ts (breaking the +// hub-and-leaf near-cycle). Re-exported here so the public surface is unchanged. export type { FramePublisher, InferenceContext, InferenceFrame, StreamResult, TurnArgs, -} from './turn.js'; +} from './types.js'; export type { ToolPhaseResult } from './tool-phase.js'; export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js'; -export { - detectMistakePattern, - freshMistakeState, - recordStep, - MISTAKE_THRESHOLD, - MISTAKE_RECOVERY_NOTE, -} from './mistake-tracker.js'; -export type { FailureKind, MistakeState } from './mistake-tracker.js'; export { buildMessagesPayload } from './payload.js'; -export { generateToolUseSummary } from './tool-summaries.js'; -export type { ToolInfo } from './tool-summaries.js'; diff --git a/apps/server/src/services/inference/parts.ts b/apps/server/src/services/inference/parts.ts index d9c7f99..f8640c5 100644 --- a/apps/server/src/services/inference/parts.ts +++ b/apps/server/src/services/inference/parts.ts @@ -1,11 +1,10 @@ import type { Sql } from '../../db.js'; import type { ToolCall, ToolResult } from '../../types/api.js'; -// v1.13.0: dual-write helper. Every site that writes the legacy -// messages.tool_calls / messages.tool_results JSON columns calls into here -// to mirror the same data into message_parts rows. Reads still go to the -// JSON columns; the swap to parts-as-source-of-truth happens in a later -// v1.13 dispatch alongside the AI SDK streamText migration. +// v1.13.0: message_parts write helpers. v1.13.20: legacy tool_calls/ +// tool_results JSON columns dropped; message_parts is the sole source of +// truth. All writes go through insertParts / partsFromAssistantMessage / +// partsFromToolMessage. Reads use the messages_with_parts view. // v1.13.13: 'synthesis' added. Schema CHECK constraint is updated in lockstep // (schema.sql adds 'synthesis' to message_parts_kind_chk on startup). The diff --git a/apps/server/src/services/inference/payload.ts b/apps/server/src/services/inference/payload.ts index d203165..7778e83 100644 --- a/apps/server/src/services/inference/payload.ts +++ b/apps/server/src/services/inference/payload.ts @@ -10,7 +10,8 @@ import * as compaction from '../compaction.js'; import { buildSystemPromptWithFingerprint } from '../system-prompt.js'; import { isAnySentinel } from './sentinels.js'; import { PRUNE_TRIGGER_TOKENS, prune } from './prune.js'; -import type { InferenceContext } from './turn.js'; +import type { InferenceContext } from './types.js'; +import { INFERENCE_MESSAGE_COLUMNS } from '../message-columns.js'; export interface OpenAiMessage { role: 'system' | 'user' | 'assistant' | 'tool'; @@ -205,9 +206,7 @@ export async function loadContext( // v1.13.1-C: also pull reasoning_parts so assistant messages from // reasoning models can be replayed with their reasoning context preserved. const history = await sql` - SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, - tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata, - reasoning_parts + SELECT ${sql.unsafe(INFERENCE_MESSAGE_COLUMNS)} FROM messages_with_parts WHERE chat_id = ${chatId} AND compacted_at IS NULL ORDER BY created_at ASC, id ASC diff --git a/apps/server/src/services/inference/sentinel-summaries.ts b/apps/server/src/services/inference/sentinel-summaries.ts index bf274d3..a2c1920 100644 --- a/apps/server/src/services/inference/sentinel-summaries.ts +++ b/apps/server/src/services/inference/sentinel-summaries.ts @@ -5,16 +5,16 @@ import type { Project, Session, } from '../../types/api.js'; -import * as modelContext from '../model-context.js'; import { buildMessagesPayload } from './payload.js'; import { DOOM_LOOP_THRESHOLD } from './sentinels.js'; -import { streamCompletion } from './stream-phase.js'; -import { DB_FLUSH_INTERVAL_MS } from './types.js'; +import { streamCompletion, samplerOptsFromAgent } from './stream-phase.js'; +import { createContentFlusher } from './content-flusher.js'; +import { finalizeStreamedRow } from './error-handler.js'; import type { InferenceContext, StreamResult, TurnArgs, -} from './turn.js'; +} from './types.js'; // Synthetic system note appended to the cap-hit summary call. Verbatim from // the v1.8.2 spec — do not paraphrase: the model is more reliable when the @@ -25,21 +25,50 @@ const CAP_HIT_SUMMARY_NOTE = (limit: number) => const DOOM_LOOP_NOTE = (name: string) => `You called ${name} with the same arguments ${DOOM_LOOP_THRESHOLD} times in a row. Stop calling it. Produce the best answer you can with what you have.`; -export async function runCapHitSummary( +// v1.14.0: step-cap wrap-up note. Names the step limit rather than the tool +// budget. The sentinel reuses metadata.kind = 'cap_hit' so the frontend +// CapHitSentinel component renders it without changes. +const STEP_CAP_NOTE = (steps: number, cap: number) => + `You've reached the step limit (${steps}/${cap} steps). Produce the best answer you can with what you have. Do not call more tools.`; + +// P5: the ONE generic wrap-up flow shared by the three sentinel summaries +// (cap-hit, doom-loop, step-cap). Each reuses the in-flight assistant slot to +// stream a short tools-disabled summary, finalizes via the same 3-outcome +// branch (complete / cancelled / failed), bumps the session, then drops a +// sentinel and the chat_status. The three differ only in: +// - `note`: the synthetic system instruction appended to the summary call. +// - `errorText`: the fallback used in the failed-status metadata + error frame. +// - sentinel timing: cap-hit inserts BEFORE the stream (`beforeStream`); +// doom-loop + step-cap insert AFTER the session bump (`afterSession`). +// - `logMsg` / `logFields`: per-kind log line + extra fields. +// All three use error_reason / chat_status reason = 'summary_after_cap_failed' +// (doom-loop reuses it deliberately — the user-visible failure mode is the +// same "model gave up mid-summary"; the ErrorReason union is shared and the UI +// surfaces a generic "summary failed" line for every sentinel path). +interface WrapUpOpts { + note: string; + errorText: string; + logMsg: string; + logFields: Record; + beforeStream?: () => Promise; + afterSession?: () => Promise; +} + +async function runWrapUpSummary( ctx: InferenceContext, args: TurnArgs, session: Session, project: Project, history: Message[], agent: Agent | null, - budget: number, + opts: WrapUpOpts, ): Promise { const { sessionId, chatId, assistantMessageId, signal } = args; - await insertCapHitSentinel(ctx, sessionId, chatId, agent, budget); + if (opts.beforeStream) await opts.beforeStream(); const messages = await buildMessagesPayload(session, project, history, agent, ctx.log); - messages.push({ role: 'system', content: CAP_HIT_SUMMARY_NOTE(budget) }); + messages.push({ role: 'system', content: opts.note }); const startedRow = await ctx.sql<{ started_at: string }[]>` UPDATE messages @@ -57,25 +86,7 @@ export async function runCapHitSummary( }); let accumulated = ''; - let pendingFlushTimer: NodeJS.Timeout | null = null; - let flushPromise: Promise = Promise.resolve(); - const flushNow = () => { - if (pendingFlushTimer) { - clearTimeout(pendingFlushTimer); - pendingFlushTimer = null; - } - const snapshot = accumulated; - flushPromise = flushPromise.then(() => - ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}` - ); - }; - const scheduleFlush = () => { - if (pendingFlushTimer) return; - pendingFlushTimer = setTimeout(() => { - pendingFlushTimer = null; - flushNow(); - }, DB_FLUSH_INTERVAL_MS); - }; + const flusher = createContentFlusher(ctx.sql, assistantMessageId, () => accumulated); let summaryOk = false; let summarySoftCancelled = false; @@ -86,7 +97,7 @@ export async function runCapHitSummary( ctx, session.model, messages, - { tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined, top_n_sigma: agent?.top_n_sigma ?? undefined, dry_multiplier: agent?.dry_multiplier ?? undefined, dry_base: agent?.dry_base ?? undefined, dry_allowed_length: agent?.dry_allowed_length ?? undefined, dry_penalty_last_n: agent?.dry_penalty_last_n ?? undefined }, + { tools: null, ...samplerOptsFromAgent(agent) }, (delta) => { accumulated += delta; ctx.publish(sessionId, { @@ -95,7 +106,7 @@ export async function runCapHitSummary( chat_id: chatId, content: delta, }); - scheduleFlush(); + flusher.scheduleFlush(); }, undefined, signal, @@ -108,44 +119,23 @@ export async function runCapHitSummary( summaryError = err instanceof Error ? err.message : String(err); } } finally { - if (pendingFlushTimer) { - clearTimeout(pendingFlushTimer); - pendingFlushTimer = null; - } - await flushPromise; + await flusher.drain(); } - // Finalize the summary message based on the three outcomes. The sentinel - // is inserted regardless so the user always has the Continue affordance — - // even on a partial / failed summary the chat history shows where the - // budget was hit. + // Finalize the summary message based on the three outcomes. The sentinel is + // inserted regardless (before or after, per opts) so the user always has the + // appropriate affordance — even on a partial / failed summary the chat + // history shows where the loop stopped. if (summaryOk && result) { - // v1.11.3: see executeToolPhase for the rationale. - const mctx = await modelContext.getModelContext(session.model); - const nCtx = mctx?.n_ctx ?? null; - const [updated] = await ctx.sql< - { tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[] - >` - UPDATE messages - SET content = ${result.content}, - status = 'complete', - tokens_used = ${result.completionTokens}, - ctx_used = ${result.promptTokens}, - ctx_max = ${nCtx}, - finished_at = clock_timestamp() - WHERE id = ${assistantMessageId} - RETURNING tokens_used, ctx_used, ctx_max, finished_at - `; - ctx.publish(sessionId, { - type: 'message_complete', - message_id: assistantMessageId, - chat_id: chatId, - tokens_used: updated?.tokens_used ?? null, - ctx_used: updated?.ctx_used ?? null, - ctx_max: updated?.ctx_max ?? null, - started_at: startedAt, - finished_at: updated?.finished_at ?? null, + await finalizeStreamedRow(ctx, { + sessionId, + chatId, + messageId: assistantMessageId, model: session.model, + content: result.content, + completionTokens: result.completionTokens, + promptTokens: result.promptTokens, + startedAt, }); } else if (summarySoftCancelled) { await ctx.sql` @@ -164,7 +154,7 @@ export async function runCapHitSummary( const errMeta: MessageMetadata = { kind: 'error', error_reason: 'summary_after_cap_failed', - error_text: summaryError ?? 'summary failed', + error_text: summaryError ?? opts.errorText, }; await ctx.sql` UPDATE messages @@ -178,7 +168,7 @@ export async function runCapHitSummary( type: 'error', message_id: assistantMessageId, chat_id: chatId, - error: summaryError ?? 'summary failed', + error: summaryError ?? opts.errorText, reason: 'summary_after_cap_failed', }); } @@ -197,11 +187,11 @@ export async function runCapHitSummary( updated_at: sessRow!.updated_at, }); + if (opts.afterSession) await opts.afterSession(); + // Status frame fires last so the dot color reflects the terminal state. // Success → idle, abort → idle (user-driven stop), error → error+reason. - if (summaryOk) { - ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() }); - } else if (summarySoftCancelled) { + if (summaryOk || summarySoftCancelled) { ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() }); } else { ctx.publishUser({ @@ -214,11 +204,113 @@ export async function runCapHitSummary( } ctx.log.info( - { sessionId, chatId, assistantMessageId, budget, summaryOk, summaryCancelled: summarySoftCancelled }, - 'inference cap-hit summary finished', + { sessionId, chatId, assistantMessageId, ...opts.logFields, summaryOk, summaryCancelled: summarySoftCancelled }, + opts.logMsg, ); } +// v1.8.2: cap-hit summary flow. Called instead of erroring when the loop hits +// its budget. The cap-hit sentinel is inserted FIRST (before the summary +// stream) so the UI shows the Continue affordance regardless of summary +// outcome. +export async function runCapHitSummary( + ctx: InferenceContext, + args: TurnArgs, + session: Session, + project: Project, + history: Message[], + agent: Agent | null, + budget: number, +): Promise { + await runWrapUpSummary(ctx, args, session, project, history, agent, { + note: CAP_HIT_SUMMARY_NOTE(budget), + errorText: 'summary failed', + logMsg: 'inference cap-hit summary finished', + logFields: { budget }, + beforeStream: () => insertCapHitSentinel(ctx, args.sessionId, args.chatId, agent, budget), + }); +} + +// v1.11.6: doom-loop wrap-up. The doom-loop sentinel is inserted AFTER the +// session bump (no Continue affordance — continuing would re-trigger the loop +// with the same tools available; the user needs to restate or switch agents). +export async function runDoomLoopSummary( + ctx: InferenceContext, + args: TurnArgs, + session: Session, + project: Project, + history: Message[], + agent: Agent | null, + loop: { name: string; args: Record }, +): Promise { + await runWrapUpSummary(ctx, args, session, project, history, agent, { + note: DOOM_LOOP_NOTE(loop.name), + errorText: 'doom-loop summary failed', + logMsg: 'inference doom-loop summary finished', + logFields: { loopedTool: loop.name }, + afterSession: () => insertDoomLoopSentinel(ctx, args.sessionId, args.chatId, loop), + }); +} + +// v1.14.0: step-cap wrap-up. Reuses the cap_hit sentinel (inserted AFTER the +// session bump) so the frontend CapHitSentinel component renders it without +// changes; the content text distinguishes step cap from budget. +export async function runStepCapSummary( + ctx: InferenceContext, + args: TurnArgs, + session: Session, + project: Project, + history: Message[], + agent: Agent | null, + steps: number, + cap: number, +): Promise { + await runWrapUpSummary(ctx, args, session, project, history, agent, { + note: STEP_CAP_NOTE(steps, cap), + errorText: 'step-cap summary failed', + logMsg: 'inference step-cap summary finished', + logFields: { steps, cap }, + afterSession: () => insertCapHitSentinel(ctx, args.sessionId, args.chatId, agent, cap), + }); +} + +// P5: the ONE INSERT + message_started → delta → message_complete frame +// sequence shared by every sentinel inserter. The sentinel row is a +// role='system', status='complete' message; the static content rides the same +// streaming-frame path useSessionStream's reducer uses for assistant messages +// (the delta carries the full text in one chunk). +async function insertSentinel( + ctx: InferenceContext, + sessionId: string, + chatId: string, + metadata: MessageMetadata, + content: string, +): Promise { + const [row] = await ctx.sql<{ id: string }[]>` + INSERT INTO messages (session_id, chat_id, role, content, status, created_at, metadata) + VALUES (${sessionId}, ${chatId}, 'system', ${content}, 'complete', clock_timestamp(), ${ctx.sql.json(metadata as never)}) + RETURNING id + `; + ctx.publish(sessionId, { + type: 'message_started', + message_id: row!.id, + chat_id: chatId, + role: 'system', + }); + ctx.publish(sessionId, { + type: 'delta', + message_id: row!.id, + chat_id: chatId, + content, + }); + ctx.publish(sessionId, { + type: 'message_complete', + message_id: row!.id, + chat_id: chatId, + metadata, + }); +} + async function insertCapHitSentinel( ctx: InferenceContext, sessionId: string, @@ -246,430 +338,7 @@ async function insertCapHitSentinel( can_continue: canContinue, }; const content = `Reached tool budget (${budget}/${budget}). Continue to extend.`; - - const [row] = await ctx.sql<{ id: string }[]>` - INSERT INTO messages (session_id, chat_id, role, content, status, created_at, metadata) - VALUES (${sessionId}, ${chatId}, 'system', ${content}, 'complete', clock_timestamp(), ${ctx.sql.json(metadata as never)}) - RETURNING id - `; - - // The sentinel content is static, but we still walk the standard frame - // sequence (started → delta → complete) so useSessionStream's reducer - // appends it via the same path it uses for streaming assistant messages. - // The delta carries the full text in one chunk. - ctx.publish(sessionId, { - type: 'message_started', - message_id: row!.id, - chat_id: chatId, - role: 'system', - }); - ctx.publish(sessionId, { - type: 'delta', - message_id: row!.id, - chat_id: chatId, - content, - }); - ctx.publish(sessionId, { - type: 'message_complete', - message_id: row!.id, - chat_id: chatId, - metadata, - }); -} - -// v1.11.6: doom-loop wrap-up. Mirrors runCapHitSummary structurally — same -// in-flight-slot reuse, same tools-disabled streaming-summary call, same -// post-finalize sentinel insert + chat_status drop. Differences: -// - synthetic note text comes from DOOM_LOOP_NOTE (names the looping tool) -// - sentinel metadata is { kind: 'doom_loop', tool_name, args, threshold } -// and has no Continue affordance (manual retry would just re-loop) -// - chat_status error path uses reason: 'doom_loop_summary_failed' -// Kept as a clone rather than refactored into a shared helper because the -// two summary paths still differ in error reason + sentinel shape; a third -// sentinel would justify factoring out runWrapUpSummary(opts). -export async function runDoomLoopSummary( - ctx: InferenceContext, - args: TurnArgs, - session: Session, - project: Project, - history: Message[], - agent: Agent | null, - loop: { name: string; args: Record }, -): Promise { - const { sessionId, chatId, assistantMessageId, signal } = args; - - const messages = await buildMessagesPayload(session, project, history, agent, ctx.log); - messages.push({ role: 'system', content: DOOM_LOOP_NOTE(loop.name) }); - - const startedRow = await ctx.sql<{ started_at: string }[]>` - UPDATE messages - SET started_at = clock_timestamp() - WHERE id = ${assistantMessageId} - RETURNING started_at - `; - const startedAt = startedRow[0]?.started_at ?? null; - - ctx.publish(sessionId, { - type: 'message_started', - message_id: assistantMessageId, - chat_id: chatId, - role: 'assistant', - }); - - let accumulated = ''; - let pendingFlushTimer: NodeJS.Timeout | null = null; - let flushPromise: Promise = Promise.resolve(); - const flushNow = () => { - if (pendingFlushTimer) { - clearTimeout(pendingFlushTimer); - pendingFlushTimer = null; - } - const snapshot = accumulated; - flushPromise = flushPromise.then(() => - ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}` - ); - }; - const scheduleFlush = () => { - if (pendingFlushTimer) return; - pendingFlushTimer = setTimeout(() => { - pendingFlushTimer = null; - flushNow(); - }, DB_FLUSH_INTERVAL_MS); - }; - - let summaryOk = false; - let summarySoftCancelled = false; - let summaryError: string | null = null; - let result: StreamResult | null = null; - try { - result = await streamCompletion( - ctx, - session.model, - messages, - { tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined, top_n_sigma: agent?.top_n_sigma ?? undefined, dry_multiplier: agent?.dry_multiplier ?? undefined, dry_base: agent?.dry_base ?? undefined, dry_allowed_length: agent?.dry_allowed_length ?? undefined, dry_penalty_last_n: agent?.dry_penalty_last_n ?? undefined }, - (delta) => { - accumulated += delta; - ctx.publish(sessionId, { - type: 'delta', - message_id: assistantMessageId, - chat_id: chatId, - content: delta, - }); - scheduleFlush(); - }, - undefined, - signal, - ); - summaryOk = true; - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { - summarySoftCancelled = true; - } else { - summaryError = err instanceof Error ? err.message : String(err); - } - } finally { - if (pendingFlushTimer) { - clearTimeout(pendingFlushTimer); - pendingFlushTimer = null; - } - await flushPromise; - } - - if (summaryOk && result) { - const mctx = await modelContext.getModelContext(session.model); - const nCtx = mctx?.n_ctx ?? null; - const [updated] = await ctx.sql< - { tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[] - >` - UPDATE messages - SET content = ${result.content}, - status = 'complete', - tokens_used = ${result.completionTokens}, - ctx_used = ${result.promptTokens}, - ctx_max = ${nCtx}, - finished_at = clock_timestamp() - WHERE id = ${assistantMessageId} - RETURNING tokens_used, ctx_used, ctx_max, finished_at - `; - ctx.publish(sessionId, { - type: 'message_complete', - message_id: assistantMessageId, - chat_id: chatId, - tokens_used: updated?.tokens_used ?? null, - ctx_used: updated?.ctx_used ?? null, - ctx_max: updated?.ctx_max ?? null, - started_at: startedAt, - finished_at: updated?.finished_at ?? null, - model: session.model, - }); - } else if (summarySoftCancelled) { - await ctx.sql` - UPDATE messages - SET content = ${accumulated}, - status = 'cancelled', - finished_at = clock_timestamp() - WHERE id = ${assistantMessageId} - `; - ctx.publish(sessionId, { - type: 'message_complete', - message_id: assistantMessageId, - chat_id: chatId, - }); - } else { - // Doom-loop summary failure reuses the existing summary_after_cap_failed - // error reason — the ErrorReason union is shared between sentinel paths - // and the UI surfaces a generic "summary failed" line for both. We don't - // add a new reason code because the user-visible failure mode is the - // same (model gave up mid-summary). Sentinel below still fires. - const errMeta: MessageMetadata = { - kind: 'error', - error_reason: 'summary_after_cap_failed', - error_text: summaryError ?? 'doom-loop summary failed', - }; - await ctx.sql` - UPDATE messages - SET content = ${accumulated}, - status = 'failed', - finished_at = clock_timestamp(), - metadata = ${ctx.sql.json(errMeta as never)} - WHERE id = ${assistantMessageId} - `; - ctx.publish(sessionId, { - type: 'error', - message_id: assistantMessageId, - chat_id: chatId, - error: summaryError ?? 'doom-loop summary failed', - reason: 'summary_after_cap_failed', - }); - } - - const [sessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>` - UPDATE sessions SET updated_at = clock_timestamp() - WHERE id = ${sessionId} - RETURNING project_id, name, updated_at - `; - ctx.publishUser({ - type: 'session_updated', - session_id: sessionId, - project_id: sessRow!.project_id, - name: sessRow!.name, - updated_at: sessRow!.updated_at, - }); - - await insertDoomLoopSentinel(ctx, sessionId, chatId, loop); - - if (summaryOk || summarySoftCancelled) { - ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() }); - } else { - ctx.publishUser({ - type: 'chat_status', - chat_id: chatId, - status: 'error', - at: new Date().toISOString(), - reason: 'summary_after_cap_failed', - }); - } - - ctx.log.info( - { sessionId, chatId, assistantMessageId, loopedTool: loop.name, summaryOk, summaryCancelled: summarySoftCancelled }, - 'inference doom-loop summary finished', - ); -} - -// v1.14.0: step-cap wrap-up. Mirrors runCapHitSummary structurally — same -// in-flight-slot reuse, same tools-disabled streaming-summary call, same -// post-finalize sentinel insert + chat_status drop. Difference: the note -// text names the step limit rather than the tool budget. Sentinel reuses -// metadata.kind = 'cap_hit' so the frontend CapHitSentinel component -// renders it without changes. -const STEP_CAP_NOTE = (steps: number, cap: number) => - `You've reached the step limit (${steps}/${cap} steps). Produce the best answer you can with what you have. Do not call more tools.`; - -export async function runStepCapSummary( - ctx: InferenceContext, - args: TurnArgs, - session: Session, - project: Project, - history: Message[], - agent: Agent | null, - steps: number, - cap: number, -): Promise { - const { sessionId, chatId, assistantMessageId, signal } = args; - - const messages = await buildMessagesPayload(session, project, history, agent, ctx.log); - messages.push({ role: 'system', content: STEP_CAP_NOTE(steps, cap) }); - - const startedRow = await ctx.sql<{ started_at: string }[]>` - UPDATE messages - SET started_at = clock_timestamp() - WHERE id = ${assistantMessageId} - RETURNING started_at - `; - const startedAt = startedRow[0]?.started_at ?? null; - - ctx.publish(sessionId, { - type: 'message_started', - message_id: assistantMessageId, - chat_id: chatId, - role: 'assistant', - }); - - let accumulated = ''; - let pendingFlushTimer: NodeJS.Timeout | null = null; - let flushPromise: Promise = Promise.resolve(); - const flushNow = () => { - if (pendingFlushTimer) { - clearTimeout(pendingFlushTimer); - pendingFlushTimer = null; - } - const snapshot = accumulated; - flushPromise = flushPromise.then(() => - ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}` - ); - }; - const scheduleFlush = () => { - if (pendingFlushTimer) return; - pendingFlushTimer = setTimeout(() => { - pendingFlushTimer = null; - flushNow(); - }, DB_FLUSH_INTERVAL_MS); - }; - - let summaryOk = false; - let summarySoftCancelled = false; - let summaryError: string | null = null; - let result: StreamResult | null = null; - try { - result = await streamCompletion( - ctx, - session.model, - messages, - { tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined, top_n_sigma: agent?.top_n_sigma ?? undefined, dry_multiplier: agent?.dry_multiplier ?? undefined, dry_base: agent?.dry_base ?? undefined, dry_allowed_length: agent?.dry_allowed_length ?? undefined, dry_penalty_last_n: agent?.dry_penalty_last_n ?? undefined }, - (delta) => { - accumulated += delta; - ctx.publish(sessionId, { - type: 'delta', - message_id: assistantMessageId, - chat_id: chatId, - content: delta, - }); - scheduleFlush(); - }, - undefined, - signal, - ); - summaryOk = true; - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { - summarySoftCancelled = true; - } else { - summaryError = err instanceof Error ? err.message : String(err); - } - } finally { - if (pendingFlushTimer) { - clearTimeout(pendingFlushTimer); - pendingFlushTimer = null; - } - await flushPromise; - } - - if (summaryOk && result) { - const mctx = await modelContext.getModelContext(session.model); - const nCtx = mctx?.n_ctx ?? null; - const [updated] = await ctx.sql< - { tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[] - >` - UPDATE messages - SET content = ${result.content}, - status = 'complete', - tokens_used = ${result.completionTokens}, - ctx_used = ${result.promptTokens}, - ctx_max = ${nCtx}, - finished_at = clock_timestamp() - WHERE id = ${assistantMessageId} - RETURNING tokens_used, ctx_used, ctx_max, finished_at - `; - ctx.publish(sessionId, { - type: 'message_complete', - message_id: assistantMessageId, - chat_id: chatId, - tokens_used: updated?.tokens_used ?? null, - ctx_used: updated?.ctx_used ?? null, - ctx_max: updated?.ctx_max ?? null, - started_at: startedAt, - finished_at: updated?.finished_at ?? null, - model: session.model, - }); - } else if (summarySoftCancelled) { - await ctx.sql` - UPDATE messages - SET content = ${accumulated}, - status = 'cancelled', - finished_at = clock_timestamp() - WHERE id = ${assistantMessageId} - `; - ctx.publish(sessionId, { - type: 'message_complete', - message_id: assistantMessageId, - chat_id: chatId, - }); - } else { - const errMeta: MessageMetadata = { - kind: 'error', - error_reason: 'summary_after_cap_failed', - error_text: summaryError ?? 'step-cap summary failed', - }; - await ctx.sql` - UPDATE messages - SET content = ${accumulated}, - status = 'failed', - finished_at = clock_timestamp(), - metadata = ${ctx.sql.json(errMeta as never)} - WHERE id = ${assistantMessageId} - `; - ctx.publish(sessionId, { - type: 'error', - message_id: assistantMessageId, - chat_id: chatId, - error: summaryError ?? 'step-cap summary failed', - reason: 'summary_after_cap_failed', - }); - } - - const [sessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>` - UPDATE sessions SET updated_at = clock_timestamp() - WHERE id = ${sessionId} - RETURNING project_id, name, updated_at - `; - ctx.publishUser({ - type: 'session_updated', - session_id: sessionId, - project_id: sessRow!.project_id, - name: sessRow!.name, - updated_at: sessRow!.updated_at, - }); - - // Reuse cap_hit sentinel so the frontend CapHitSentinel component renders - // it without changes. The content text distinguishes step cap from budget. - await insertCapHitSentinel(ctx, sessionId, chatId, agent, cap); - - if (summaryOk || summarySoftCancelled) { - ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() }); - } else { - ctx.publishUser({ - type: 'chat_status', - chat_id: chatId, - status: 'error', - at: new Date().toISOString(), - reason: 'summary_after_cap_failed', - }); - } - - ctx.log.info( - { sessionId, chatId, assistantMessageId, steps, cap, summaryOk, summaryCancelled: summarySoftCancelled }, - 'inference step-cap summary finished', - ); + await insertSentinel(ctx, sessionId, chatId, metadata, content); } async function insertDoomLoopSentinel( @@ -689,39 +358,12 @@ async function insertDoomLoopSentinel( threshold: DOOM_LOOP_THRESHOLD, }; const content = `Detected ${DOOM_LOOP_THRESHOLD} identical calls to ${loop.name}. Stopping the tool-call loop. Produce the best answer you can with what you have.`; - - const [row] = await ctx.sql<{ id: string }[]>` - INSERT INTO messages (session_id, chat_id, role, content, status, created_at, metadata) - VALUES (${sessionId}, ${chatId}, 'system', ${content}, 'complete', clock_timestamp(), ${ctx.sql.json(metadata as never)}) - RETURNING id - `; - - // Standard frame sequence — same as cap-hit sentinel — so - // useSessionStream's reducer appends the row via the existing path. - ctx.publish(sessionId, { - type: 'message_started', - message_id: row!.id, - chat_id: chatId, - role: 'system', - }); - ctx.publish(sessionId, { - type: 'delta', - message_id: row!.id, - chat_id: chatId, - content, - }); - ctx.publish(sessionId, { - type: 'message_complete', - message_id: row!.id, - chat_id: chatId, - metadata, - }); + await insertSentinel(ctx, sessionId, chatId, metadata, content); } -// #12 MistakeTracker: heterogeneous-failure recovery sentinel. Mirrors -// insertDoomLoopSentinel structurally — a role='system', status='complete' row -// firing the standard message_started → delta → message_complete frame -// sequence. Two variants distinguished by `escalated`: +// #12 MistakeTracker: heterogeneous-failure recovery sentinel. A role='system', +// status='complete' row firing the standard sentinel frame sequence. Two +// variants distinguished by `escalated`: // - escalated:false → a nudge fired; recovery guidance was injected into the // model's next step and the loop continued. can_continue is true (the turn // is still live). @@ -744,30 +386,5 @@ export async function insertMistakeRecoverySentinel( const content = opts.escalated ? `Repeated different errors persisted after a recovery nudge (${opts.count} in a row). Stopping the tool-call loop.` : `Hit ${opts.count} different errors in a row. Injected recovery guidance and continuing.`; - - const [row] = await ctx.sql<{ id: string }[]>` - INSERT INTO messages (session_id, chat_id, role, content, status, created_at, metadata) - VALUES (${sessionId}, ${chatId}, 'system', ${content}, 'complete', clock_timestamp(), ${ctx.sql.json(metadata as never)}) - RETURNING id - `; - - // Standard frame sequence — same as cap-hit / doom-loop sentinels. - ctx.publish(sessionId, { - type: 'message_started', - message_id: row!.id, - chat_id: chatId, - role: 'system', - }); - ctx.publish(sessionId, { - type: 'delta', - message_id: row!.id, - chat_id: chatId, - content, - }); - ctx.publish(sessionId, { - type: 'message_complete', - message_id: row!.id, - chat_id: chatId, - metadata, - }); + await insertSentinel(ctx, sessionId, chatId, metadata, content); } diff --git a/apps/server/src/services/inference/sentinels.ts b/apps/server/src/services/inference/sentinels.ts index 9904876..8b0daea 100644 --- a/apps/server/src/services/inference/sentinels.ts +++ b/apps/server/src/services/inference/sentinels.ts @@ -27,6 +27,10 @@ export function detectDoomLoop( return { name: ref.name, args: ref.args }; } +// All sentinel kinds. isAnySentinel and compaction.ts's local predicate both +// consume this set — single source so a new kind can't be missed in one. +export const SENTINEL_KINDS = new Set(['cap_hit', 'doom_loop', 'mistake_recovery']); + export function isCapHitSentinel(m: Message): boolean { return ( m.role === 'system' && @@ -61,5 +65,10 @@ export function isMistakeRecoverySentinel(m: Message): boolean { } export function isAnySentinel(m: Message): boolean { - return isCapHitSentinel(m) || isDoomLoopSentinel(m) || isMistakeRecoverySentinel(m); + return ( + m.role === 'system' && + m.metadata !== null && + typeof m.metadata === 'object' && + SENTINEL_KINDS.has((m.metadata as { kind?: unknown }).kind as string) + ); } diff --git a/apps/server/src/services/inference/step-decision.ts b/apps/server/src/services/inference/step-decision.ts new file mode 100644 index 0000000..d02f4bc --- /dev/null +++ b/apps/server/src/services/inference/step-decision.ts @@ -0,0 +1,47 @@ +// P5 (SPLIT SKETCH 5): pure step-decision helpers for the runAssistantTurn +// loop. These COMPOSE the existing decision predicates (detectDoomLoop, +// detectMistakePattern) — they do not reimplement them — so the loop body in +// turn.ts becomes a thin driver and the branch logic is unit-testable without +// a DB, broker, or stream. + +import type { ToolCall } from '../../types/api.js'; +import { detectDoomLoop } from './sentinels.js'; +import { detectMistakePattern, type MistakeState } from './mistake-tracker.js'; +import type { ToolPhaseResult } from './tool-phase.js'; + +// Top-of-loop gate, evaluated before the stream phase. Order matters and +// matches the original inline checks exactly: doom-loop first (identical-repeat +// guard), then the cumulative tool-call budget, otherwise proceed to stream. +export type PreStepDecision = + | { kind: 'doom'; loop: { name: string; args: Record } } + | { kind: 'budget' } + | { kind: 'stream' }; + +export function decideStep(input: { + recentToolCalls: ToolCall[]; + toolsUsed: number; + budget: number; +}): PreStepDecision { + const loop = detectDoomLoop(input.recentToolCalls); + if (loop) return { kind: 'doom', loop }; + if (input.toolsUsed >= input.budget) return { kind: 'budget' }; + return { kind: 'stream' }; +} + +// Post-tool-phase decision, evaluated after the tool phase returns. 'stop' +// covers the tool-phase's own non-'continue' actions ('paused' for user input, +// 'synthesis_done'); on 'continue' the mistake-tracker pattern gates the +// nudge/escalate/continue choice (detectMistakePattern is only consulted on the +// 'continue' path, exactly as the original loop did). +export type PostToolDecision = 'continue' | 'nudge' | 'escalate' | 'stop'; + +export function decidePostToolAction( + action: ToolPhaseResult['action'], + mistakeTracker: MistakeState, +): PostToolDecision { + if (action !== 'continue') return 'stop'; + const mistake = detectMistakePattern(mistakeTracker); + if (mistake === 'nudge') return 'nudge'; + if (mistake === 'escalate') return 'escalate'; + return 'continue'; +} diff --git a/apps/server/src/services/inference/stream-phase-adapter.ts b/apps/server/src/services/inference/stream-phase-adapter.ts new file mode 100644 index 0000000..4aa9ba8 --- /dev/null +++ b/apps/server/src/services/inference/stream-phase-adapter.ts @@ -0,0 +1,405 @@ +// P5 (SPLIT SKETCH): the generic AI-SDK adapter, split out of stream-phase.ts. +// This module is the v1.13.1-A streamText adapter and nothing else — it has NO +// SQL, broker, or BooCode persistence dependencies (its only `ctx` access is +// config + log), so it can be unit-tested without standing up a DB or broker. +// stream-phase.ts (the I/O layer) re-exports the public names below so existing +// importers (`./stream-phase.js`) are unchanged. + +import type { FastifyBaseLogger } from 'fastify'; +import type { Config } from '../../config.js'; +import type { Agent, ToolCall } from '../../types/api.js'; +import type { ToolJsonSchema } from '../tools.js'; +import type { OpenAiMessage } from './payload.js'; +import { extractToolCallBlocks } from './tool-call-parser.js'; +import type { StreamResult } from './types.js'; +import { upstreamModel } from './provider.js'; +import { + jsonSchema, + streamText, + tool, + type JSONValue, + type ModelMessage, + type ToolCallRepairFunction, +} from 'ai'; + +// The slice of InferenceContext the adapter actually needs. Narrowing it here +// (instead of taking the full InferenceContext) keeps the adapter free of the +// SQL/broker/publish surface. InferenceContext structurally satisfies this, so +// callers pass their ctx unchanged. +export interface StreamAdapterContext { + config: Config; + log: FastifyBaseLogger; +} + +export interface StreamOptions { + // null = omit tools entirely (compact phase); [] = caller stripped all tools + // (rare; we still omit from the request body to avoid OpenAI 400). + tools: ToolJsonSchema[] | null; + temperature?: number; + top_p?: number | null; + top_k?: number | null; + min_p?: number | null; + presence_penalty?: number | null; + // v2.6 sampling-streamjson-tokens (#11): llama.cpp sampler extensions. These + // are NOT standard AI-SDK streamText options and are NOT serialized by the + // openai-compatible provider's standardized-settings path (topK is even + // explicitly dropped with an "unsupported feature: topK" warning). They reach + // llama-server only via providerOptions.openaiCompatible (see buildSamplerProviderOptions). + top_n_sigma?: number | null; + dry_multiplier?: number | null; + dry_base?: number | null; + dry_allowed_length?: number | null; + dry_penalty_last_n?: number | null; +} + +// P5: the 10-field sampler-options literal that was copy-pasted at 4 sites +// (the three sentinel summaries + executeStreamPhase). Builds the StreamOptions +// sampler subset from an agent's frontmatter knobs. `temperature` is +// `agent?.temperature` (already number|undefined); the nullable fields strip +// null → undefined so they're omitted from the request body when unset. Keep +// this in lockstep with the StreamOptions sampler fields — a new sampler knob +// (the v2.7.3 dry_* family did this) is added here once instead of at 4 sites. +export type SamplerOpts = Omit; + +export function samplerOptsFromAgent(agent: Agent | null): SamplerOpts { + return { + temperature: agent?.temperature, + top_p: agent?.top_p ?? undefined, + top_k: agent?.top_k ?? undefined, + min_p: agent?.min_p ?? undefined, + presence_penalty: agent?.presence_penalty ?? undefined, + top_n_sigma: agent?.top_n_sigma ?? undefined, + dry_multiplier: agent?.dry_multiplier ?? undefined, + dry_base: agent?.dry_base ?? undefined, + dry_allowed_length: agent?.dry_allowed_length ?? undefined, + dry_penalty_last_n: agent?.dry_penalty_last_n ?? undefined, + }; +} + +// v2.6 #11: build the providerOptions.openaiCompatible extraBody object for the +// llama.cpp sampler extensions. @ai-sdk/openai-compatible (2.0.47) merges every +// non-reserved key under providerOptions.openaiCompatible straight into the +// chat-completion request body (see its getArgs: the Object.fromEntries spread +// filtered against openaiCompatibleLanguageModelChatOptions.shape). This is the +// ONLY working passthrough for these params: +// - top_k / min_p were latently dropped before this: top_k was passed as the +// AI-SDK `topK` setting which the openai-compatible provider rejects as +// unsupported; min_p was never passed to streamText at all. +// - top_n_sigma + the dry_* family have no AI-SDK equivalent. +// Keys use llama-server's snake_case body names so they land verbatim. +function buildSamplerProviderOptions(opts: StreamOptions): Record | undefined { + const body: Record = {}; + if (typeof opts.top_k === 'number') body.top_k = opts.top_k; + if (typeof opts.min_p === 'number') body.min_p = opts.min_p; + if (typeof opts.top_n_sigma === 'number') body.top_n_sigma = opts.top_n_sigma; + if (typeof opts.dry_multiplier === 'number') body.dry_multiplier = opts.dry_multiplier; + if (typeof opts.dry_base === 'number') body.dry_base = opts.dry_base; + if (typeof opts.dry_allowed_length === 'number') body.dry_allowed_length = opts.dry_allowed_length; + if (typeof opts.dry_penalty_last_n === 'number') body.dry_penalty_last_n = opts.dry_penalty_last_n; + return Object.keys(body).length > 0 ? body : undefined; +} + +// v1.13.1-A: convert BooCode's OpenAI-shaped history into AI SDK +// ModelMessage[]. Tool result messages need a `toolName` field that the +// OpenAI shape doesn't carry; we look it up by scanning earlier assistant +// `tool_calls` entries for a matching id. +function toModelMessages(messages: OpenAiMessage[]): ModelMessage[] { + const toolNameById = new Map(); + for (const m of messages) { + if (m.role === 'assistant' && m.tool_calls) { + for (const tc of m.tool_calls) { + toolNameById.set(tc.id, tc.function.name); + } + } + } + const out: ModelMessage[] = []; + for (const m of messages) { + if (m.role === 'system' || m.role === 'user') { + out.push({ role: m.role, content: m.content ?? '' }); + continue; + } + if (m.role === 'assistant') { + const hasTools = m.tool_calls && m.tool_calls.length > 0; + const hasReasoning = typeof m.reasoning === 'string' && m.reasoning.length > 0; + if (!hasTools && !hasReasoning) { + // Bare text assistant (string content). null content + no tool_calls + // is degenerate but harmless to forward. + out.push({ role: 'assistant', content: m.content ?? '' }); + continue; + } + // v1.13.1-C: AI SDK ReasoningPart precedes text + tool-calls in the + // assistant content array. Reasoning models (qwen3.6) consume their + // prior reasoning context to resume mid-thought across tool boundaries. + const parts: Array< + | { type: 'reasoning'; text: string } + | { type: 'text'; text: string } + | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown } + > = []; + if (hasReasoning) { + parts.push({ type: 'reasoning', text: m.reasoning! }); + } + if (m.content && m.content.length > 0) { + parts.push({ type: 'text', text: m.content }); + } + for (const tc of m.tool_calls ?? []) { + let input: unknown = {}; + try { + input = tc.function.arguments.length > 0 ? JSON.parse(tc.function.arguments) : {}; + } catch { + // Malformed args from a prior turn: pass through as a raw blob so + // the model sees the same shape it emitted. Wraps the string under + // _raw to match the buildMessagesPayload upstream convention. + input = { _raw: tc.function.arguments }; + } + parts.push({ type: 'tool-call', toolCallId: tc.id, toolName: tc.function.name, input }); + } + out.push({ role: 'assistant', content: parts }); + continue; + } + if (m.role === 'tool') { + const toolCallId = m.tool_call_id ?? ''; + const toolName = toolNameById.get(toolCallId) ?? 'unknown'; + const raw = m.content ?? ''; + let output: { type: 'text'; value: string } | { type: 'json'; value: JSONValue }; + try { + // JSON.parse returns `any`; cast to JSONValue since the upstream + // tool_results column is already JSON-serializable by construction. + output = { type: 'json', value: JSON.parse(raw) as JSONValue }; + } catch { + output = { type: 'text', value: raw }; + } + out.push({ + role: 'tool', + content: [{ type: 'tool-result', toolCallId, toolName, output }], + }); + continue; + } + } + return out; +} + +// Build the AI SDK tools record from BooCode's JSON-schema tool definitions. +// No `execute` field: BooCode runs tools itself in tool-phase.ts; streamText +// surfaces the tool-call parts via fullStream and we capture them for the +// outer loop to dispatch. +function buildAiTools(schemas: ToolJsonSchema[]): Record> { + const out: Record> = {}; + for (const s of schemas) { + out[s.function.name] = tool({ + description: s.function.description, + inputSchema: jsonSchema(s.function.parameters), + }); + } + return out; +} + +// v1.10.5 Qwen-coder XML fallback. Some local models (notably qwen3-coder via +// llama-swap) emit tool calls as inline XML inside delta.content rather than +// the structured tool_calls field. We extract them out of the streamed text +// before flushing it to the client. +// +// Qwen shape: +// +// +// VALUE +// ... +// +// +// +// v1.13.16: also recognize Anthropic markup that qwen3.6-35b-a3b-mxfp4 +// drifts to (training-data residue from Claude Code documentation): +// +// VALUE +// +// Both formats share the synthetic xml_call_${idx} ID space; the counter +// increments across whichever opener appears first. Multiple blocks may +// appear back-to-back in either format and they never nest. +export async function streamCompletion( + ctx: StreamAdapterContext, + model: string, + messages: OpenAiMessage[], + opts: StreamOptions, + onDelta: (content: string) => void, + onUsage: ((prompt: number | null, completion: number | null) => void) | undefined, + signal?: AbortSignal, + agent?: Agent | null, +): Promise { + const aiMessages = toModelMessages(messages); + const hasTools = opts.tools !== null && opts.tools.length > 0; + const aiTools = hasTools ? buildAiTools(opts.tools!) : undefined; + + const startedAt = Date.now(); + // v1.13.1-C: accumulate reasoning text across reasoning-delta parts. + // qwen3.6 emits these on a separate channel from text content; we capture + // them per stream so finalizeCompletion can dual-write a 'reasoning' part. + // Replaces the v1.13.1-A counter-only diagnostic. + let reasoningAccumulated = ''; + + // v1.13.3: experimental_repairToolCall keeps the stream alive when the + // model emits a malformed tool call (bad JSON args, unknown name, etc.). + // Without a repair function streamText throws and the WHOLE stream dies; + // with one, the SDK invokes us and we route the bad call through normally. + // Strategy: pass through unmodified. executeToolPhase's existing error + // path (unknown tool name → "unknown tool: X" result; zod-reject → tool + // 'X' rejected — fieldname: required) already gives the model a clean + // recovery surface on the next turn. Logging gives us visibility into + // how often qwen3.6 actually emits broken calls. + const repairToolCall: ToolCallRepairFunction> = async ({ + toolCall, + error, + }) => { + ctx.log.warn( + { + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + error: error.message, + }, + 'malformed tool call surfaced via repairToolCall', + ); + return toolCall; + }; + + // v2.6 #11: llama.cpp sampler extensions (top_k, min_p, top_n_sigma, dry_*) + // ride providerOptions.openaiCompatible — they are NOT standardized streamText + // settings. NB: top_k used to be passed below as the AI-SDK `topK` setting; + // the openai-compatible provider dropped it with an "unsupported feature: topK" + // warning and min_p was never wired at all, so both were dead on the wire + // before this. They now go through the same extraBody path as the new params. + const samplerBody = buildSamplerProviderOptions(opts); + + const result = streamText({ + model: upstreamModel(ctx.config, model, agent ?? null), + messages: aiMessages, + ...(aiTools + ? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall } + : {}), + ...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}), + ...(typeof opts.top_p === 'number' ? { topP: opts.top_p } : {}), + ...(typeof opts.presence_penalty === 'number' ? { presencePenalty: opts.presence_penalty } : {}), + ...(samplerBody ? { providerOptions: { openaiCompatible: samplerBody } } : {}), + abortSignal: signal, + }); + + let content = ''; + let pendingBuffer = ''; + let finishReason: string | null = null; + // v1.13.1-A: AI SDK emits one `tool-call` part per fully-aggregated call, + // so we no longer need the OpenAI-index reassembly map the manual SSE + // parser used. XML tool calls extracted from text content go into the + // same flat list and keep the v1.10.5 synthetic id convention. + const toolCalls: ToolCall[] = []; + + for await (const part of result.fullStream) { + switch (part.type) { + case 'text-delta': { + pendingBuffer += part.text; + // v1.13.16: unified extraction. The helper finds the earliest-opening + // complete or block, flushes prose between/around + // them, holds any partial opener for the next chunk, and silently + // drops blocks that fail to parse (matches pre-v1.13.16 behavior). + const extracted = extractToolCallBlocks(pendingBuffer); + if (extracted.flushed.length > 0) { + content += extracted.flushed; + onDelta(extracted.flushed); + } + for (const call of extracted.calls) { + const synthIdx = toolCalls.length; + toolCalls.push({ + id: `xml_call_${synthIdx}`, + name: call.name, + args: call.args, + }); + } + pendingBuffer = extracted.remaining; + break; + } + case 'tool-call': { + // AI SDK has already parsed the input into an object. Match the + // ToolCall shape BooCode passes around in toolCallsBuffer downstream. + toolCalls.push({ + id: part.toolCallId, + name: part.toolName, + args: (part.input ?? {}) as Record, + }); + break; + } + case 'reasoning-delta': { + // v1.13.1-C: accumulate; finalizeCompletion / executeToolPhase + // dual-write the resulting text as a kind='reasoning' part. + if (typeof part.text === 'string') { + reasoningAccumulated += part.text; + } + break; + } + case 'finish': { + if (typeof part.finishReason === 'string') { + finishReason = part.finishReason; + } + break; + } + case 'error': { + const err = part.error; + throw err instanceof Error ? err : new Error(String(err)); + } + // Intentional no-op: start, start-step, text-start, text-end, + // reasoning-start, reasoning-end, source, file, tool-input-start, + // tool-input-delta, tool-input-end, tool-result, tool-error, + // finish-step, raw. We only care about the aggregated tool-call and + // text-delta paths above; the rest are AI SDK lifecycle/streaming + // breadcrumbs that don't change BooCode's persistence or WS contract. + default: + break; + } + } + + // v1.13.1-A: drain any buffered partial XML opener as plain text. The + // pre-AI-SDK path did this on stream end too — better to leak ` 0) { + content += pendingBuffer; + onDelta(pendingBuffer); + pendingBuffer = ''; + } + + // AI SDK v6 fullStream returns normally on abort; check signal explicitly. + // Without this throw the row would land as status='complete' with partial + // content instead of going through handleAbortOrError → status='cancelled'. + // Smoke D caught this in v1.13.1-A — don't refactor it away. + if (signal?.aborted) { + const abortErr = new Error('aborted'); + abortErr.name = 'AbortError'; + throw abortErr; + } + + // Usage lands as a promise on the result; awaiting after fullStream is + // drained is safe. AI SDK v6 names: `inputTokens` / `outputTokens`. + let promptTokens: number | null = null; + let completionTokens: number | null = null; + try { + const usage = await result.usage; + if (typeof usage.inputTokens === 'number') promptTokens = usage.inputTokens; + if (typeof usage.outputTokens === 'number') completionTokens = usage.outputTokens; + } catch { + // Some providers omit usage on partial streams; leave both null. + } + + if (onUsage && (promptTokens !== null || completionTokens !== null)) { + onUsage(promptTokens, completionTokens); + } + + if (reasoningAccumulated.length > 0) { + ctx.log.debug( + { reasoningChars: reasoningAccumulated.length, model, elapsed_ms: Date.now() - startedAt }, + 'streamCompletion: captured reasoning', + ); + } + + return { + finishReason, + content, + toolCalls, + promptTokens, + completionTokens, + reasoning: reasoningAccumulated, + }; +} diff --git a/apps/server/src/services/inference/stream-phase.ts b/apps/server/src/services/inference/stream-phase.ts index c2915c3..741a75c 100644 --- a/apps/server/src/services/inference/stream-phase.ts +++ b/apps/server/src/services/inference/stream-phase.ts @@ -1,377 +1,34 @@ -import type { - Agent, - Session, - ToolCall, -} from '../../types/api.js'; +// P5 (SPLIT SKETCH): stream-phase.ts is now the BooCode I/O layer for the +// stream phase — `executeStreamPhase` owns the row UPDATE, message_started +// frame, debounced content flush, throttled usage publish, model-context +// lookup, and tool-whitelist filter. The generic AI-SDK adapter +// (streamCompletion / toModelMessages / buildAiTools / sampler helpers) moved +// to ./stream-phase-adapter.ts, which has no SQL/broker/publish deps and is +// unit-testable on its own. The adapter's public names are re-exported below so +// existing importers of './stream-phase.js' (sentinel-summaries, synthesis +// pipeline, the helper tests) keep working unchanged. + +import type { Agent, Session } from '../../types/api.js'; import * as modelContext from '../model-context.js'; import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js'; import { matchToolGlob } from '../agents.js'; import type { OpenAiMessage } from './payload.js'; -import { extractToolCallBlocks } from './tool-call-parser.js'; -import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js'; +import { createContentFlusher } from './content-flusher.js'; import type { + StreamPhaseState, InferenceContext, StreamResult, TurnArgs, -} from './turn.js'; -import { upstreamModel } from './provider.js'; -import { - jsonSchema, - streamText, - tool, - type JSONValue, - type ModelMessage, - type ToolCallRepairFunction, -} from 'ai'; +} from './types.js'; +import { streamCompletion, samplerOptsFromAgent } from './stream-phase-adapter.js'; -interface StreamOptions { - // null = omit tools entirely (compact phase); [] = caller stripped all tools - // (rare; we still omit from the request body to avoid OpenAI 400). - tools: ToolJsonSchema[] | null; - temperature?: number; - top_p?: number | null; - top_k?: number | null; - min_p?: number | null; - presence_penalty?: number | null; - // v2.6 sampling-streamjson-tokens (#11): llama.cpp sampler extensions. These - // are NOT standard AI-SDK streamText options and are NOT serialized by the - // openai-compatible provider's standardized-settings path (topK is even - // explicitly dropped with an "unsupported feature: topK" warning). They reach - // llama-server only via providerOptions.openaiCompatible (see buildSamplerProviderOptions). - top_n_sigma?: number | null; - dry_multiplier?: number | null; - dry_base?: number | null; - dry_allowed_length?: number | null; - dry_penalty_last_n?: number | null; -} - -// v2.6 #11: build the providerOptions.openaiCompatible extraBody object for the -// llama.cpp sampler extensions. @ai-sdk/openai-compatible (2.0.47) merges every -// non-reserved key under providerOptions.openaiCompatible straight into the -// chat-completion request body (see its getArgs: the Object.fromEntries spread -// filtered against openaiCompatibleLanguageModelChatOptions.shape). This is the -// ONLY working passthrough for these params: -// - top_k / min_p were latently dropped before this: top_k was passed as the -// AI-SDK `topK` setting which the openai-compatible provider rejects as -// unsupported; min_p was never passed to streamText at all. -// - top_n_sigma + the dry_* family have no AI-SDK equivalent. -// Keys use llama-server's snake_case body names so they land verbatim. -function buildSamplerProviderOptions(opts: StreamOptions): Record | undefined { - const body: Record = {}; - if (typeof opts.top_k === 'number') body.top_k = opts.top_k; - if (typeof opts.min_p === 'number') body.min_p = opts.min_p; - if (typeof opts.top_n_sigma === 'number') body.top_n_sigma = opts.top_n_sigma; - if (typeof opts.dry_multiplier === 'number') body.dry_multiplier = opts.dry_multiplier; - if (typeof opts.dry_base === 'number') body.dry_base = opts.dry_base; - if (typeof opts.dry_allowed_length === 'number') body.dry_allowed_length = opts.dry_allowed_length; - if (typeof opts.dry_penalty_last_n === 'number') body.dry_penalty_last_n = opts.dry_penalty_last_n; - return Object.keys(body).length > 0 ? body : undefined; -} - -// v1.13.1-A: convert BooCode's OpenAI-shaped history into AI SDK -// ModelMessage[]. Tool result messages need a `toolName` field that the -// OpenAI shape doesn't carry; we look it up by scanning earlier assistant -// `tool_calls` entries for a matching id. -function toModelMessages(messages: OpenAiMessage[]): ModelMessage[] { - const toolNameById = new Map(); - for (const m of messages) { - if (m.role === 'assistant' && m.tool_calls) { - for (const tc of m.tool_calls) { - toolNameById.set(tc.id, tc.function.name); - } - } - } - const out: ModelMessage[] = []; - for (const m of messages) { - if (m.role === 'system' || m.role === 'user') { - out.push({ role: m.role, content: m.content ?? '' }); - continue; - } - if (m.role === 'assistant') { - const hasTools = m.tool_calls && m.tool_calls.length > 0; - const hasReasoning = typeof m.reasoning === 'string' && m.reasoning.length > 0; - if (!hasTools && !hasReasoning) { - // Bare text assistant (string content). null content + no tool_calls - // is degenerate but harmless to forward. - out.push({ role: 'assistant', content: m.content ?? '' }); - continue; - } - // v1.13.1-C: AI SDK ReasoningPart precedes text + tool-calls in the - // assistant content array. Reasoning models (qwen3.6) consume their - // prior reasoning context to resume mid-thought across tool boundaries. - const parts: Array< - | { type: 'reasoning'; text: string } - | { type: 'text'; text: string } - | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown } - > = []; - if (hasReasoning) { - parts.push({ type: 'reasoning', text: m.reasoning! }); - } - if (m.content && m.content.length > 0) { - parts.push({ type: 'text', text: m.content }); - } - for (const tc of m.tool_calls ?? []) { - let input: unknown = {}; - try { - input = tc.function.arguments.length > 0 ? JSON.parse(tc.function.arguments) : {}; - } catch { - // Malformed args from a prior turn: pass through as a raw blob so - // the model sees the same shape it emitted. Wraps the string under - // _raw to match the buildMessagesPayload upstream convention. - input = { _raw: tc.function.arguments }; - } - parts.push({ type: 'tool-call', toolCallId: tc.id, toolName: tc.function.name, input }); - } - out.push({ role: 'assistant', content: parts }); - continue; - } - if (m.role === 'tool') { - const toolCallId = m.tool_call_id ?? ''; - const toolName = toolNameById.get(toolCallId) ?? 'unknown'; - const raw = m.content ?? ''; - let output: { type: 'text'; value: string } | { type: 'json'; value: JSONValue }; - try { - // JSON.parse returns `any`; cast to JSONValue since the upstream - // tool_results column is already JSON-serializable by construction. - output = { type: 'json', value: JSON.parse(raw) as JSONValue }; - } catch { - output = { type: 'text', value: raw }; - } - out.push({ - role: 'tool', - content: [{ type: 'tool-result', toolCallId, toolName, output }], - }); - continue; - } - } - return out; -} - -// Build the AI SDK tools record from BooCode's JSON-schema tool definitions. -// No `execute` field: BooCode runs tools itself in tool-phase.ts; streamText -// surfaces the tool-call parts via fullStream and we capture them for the -// outer loop to dispatch. -function buildAiTools(schemas: ToolJsonSchema[]): Record> { - const out: Record> = {}; - for (const s of schemas) { - out[s.function.name] = tool({ - description: s.function.description, - inputSchema: jsonSchema(s.function.parameters), - }); - } - return out; -} - -// v1.10.5 Qwen-coder XML fallback. Some local models (notably qwen3-coder via -// llama-swap) emit tool calls as inline XML inside delta.content rather than -// the structured tool_calls field. We extract them out of the streamed text -// before flushing it to the client. -// -// Qwen shape: -// -// -// VALUE -// ... -// -// -// -// v1.13.16: also recognize Anthropic markup that qwen3.6-35b-a3b-mxfp4 -// drifts to (training-data residue from Claude Code documentation): -// -// VALUE -// -// Both formats share the synthetic xml_call_${idx} ID space; the counter -// increments across whichever opener appears first. Multiple blocks may -// appear back-to-back in either format and they never nest. -export async function streamCompletion( - ctx: InferenceContext, - model: string, - messages: OpenAiMessage[], - opts: StreamOptions, - onDelta: (content: string) => void, - onUsage: ((prompt: number | null, completion: number | null) => void) | undefined, - signal?: AbortSignal, - agent?: Agent | null, -): Promise { - const aiMessages = toModelMessages(messages); - const hasTools = opts.tools !== null && opts.tools.length > 0; - const aiTools = hasTools ? buildAiTools(opts.tools!) : undefined; - - const startedAt = Date.now(); - // v1.13.1-C: accumulate reasoning text across reasoning-delta parts. - // qwen3.6 emits these on a separate channel from text content; we capture - // them per stream so finalizeCompletion can dual-write a 'reasoning' part. - // Replaces the v1.13.1-A counter-only diagnostic. - let reasoningAccumulated = ''; - - // v1.13.3: experimental_repairToolCall keeps the stream alive when the - // model emits a malformed tool call (bad JSON args, unknown name, etc.). - // Without a repair function streamText throws and the WHOLE stream dies; - // with one, the SDK invokes us and we route the bad call through normally. - // Strategy: pass through unmodified. executeToolPhase's existing error - // path (unknown tool name → "unknown tool: X" result; zod-reject → tool - // 'X' rejected — fieldname: required) already gives the model a clean - // recovery surface on the next turn. Logging gives us visibility into - // how often qwen3.6 actually emits broken calls. - const repairToolCall: ToolCallRepairFunction> = async ({ - toolCall, - error, - }) => { - ctx.log.warn( - { - toolCallId: toolCall.toolCallId, - toolName: toolCall.toolName, - error: error.message, - }, - 'malformed tool call surfaced via repairToolCall', - ); - return toolCall; - }; - - // v2.6 #11: llama.cpp sampler extensions (top_k, min_p, top_n_sigma, dry_*) - // ride providerOptions.openaiCompatible — they are NOT standardized streamText - // settings. NB: top_k used to be passed below as the AI-SDK `topK` setting; - // the openai-compatible provider dropped it with an "unsupported feature: topK" - // warning and min_p was never wired at all, so both were dead on the wire - // before this. They now go through the same extraBody path as the new params. - const samplerBody = buildSamplerProviderOptions(opts); - - const result = streamText({ - model: upstreamModel(ctx.config, model, agent ?? null), - messages: aiMessages, - ...(aiTools - ? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall } - : {}), - ...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}), - ...(typeof opts.top_p === 'number' ? { topP: opts.top_p } : {}), - ...(typeof opts.presence_penalty === 'number' ? { presencePenalty: opts.presence_penalty } : {}), - ...(samplerBody ? { providerOptions: { openaiCompatible: samplerBody } } : {}), - abortSignal: signal, - }); - - let content = ''; - let pendingBuffer = ''; - let finishReason: string | null = null; - // v1.13.1-A: AI SDK emits one `tool-call` part per fully-aggregated call, - // so we no longer need the OpenAI-index reassembly map the manual SSE - // parser used. XML tool calls extracted from text content go into the - // same flat list and keep the v1.10.5 synthetic id convention. - const toolCalls: ToolCall[] = []; - - for await (const part of result.fullStream) { - switch (part.type) { - case 'text-delta': { - pendingBuffer += part.text; - // v1.13.16: unified extraction. The helper finds the earliest-opening - // complete or block, flushes prose between/around - // them, holds any partial opener for the next chunk, and silently - // drops blocks that fail to parse (matches pre-v1.13.16 behavior). - const extracted = extractToolCallBlocks(pendingBuffer); - if (extracted.flushed.length > 0) { - content += extracted.flushed; - onDelta(extracted.flushed); - } - for (const call of extracted.calls) { - const synthIdx = toolCalls.length; - toolCalls.push({ - id: `xml_call_${synthIdx}`, - name: call.name, - args: call.args, - }); - } - pendingBuffer = extracted.remaining; - break; - } - case 'tool-call': { - // AI SDK has already parsed the input into an object. Match the - // ToolCall shape BooCode passes around in toolCallsBuffer downstream. - toolCalls.push({ - id: part.toolCallId, - name: part.toolName, - args: (part.input ?? {}) as Record, - }); - break; - } - case 'reasoning-delta': { - // v1.13.1-C: accumulate; finalizeCompletion / executeToolPhase - // dual-write the resulting text as a kind='reasoning' part. - if (typeof part.text === 'string') { - reasoningAccumulated += part.text; - } - break; - } - case 'finish': { - if (typeof part.finishReason === 'string') { - finishReason = part.finishReason; - } - break; - } - case 'error': { - const err = part.error; - throw err instanceof Error ? err : new Error(String(err)); - } - // Intentional no-op: start, start-step, text-start, text-end, - // reasoning-start, reasoning-end, source, file, tool-input-start, - // tool-input-delta, tool-input-end, tool-result, tool-error, - // finish-step, raw. We only care about the aggregated tool-call and - // text-delta paths above; the rest are AI SDK lifecycle/streaming - // breadcrumbs that don't change BooCode's persistence or WS contract. - default: - break; - } - } - - // v1.13.1-A: drain any buffered partial XML opener as plain text. The - // pre-AI-SDK path did this on stream end too — better to leak ` 0) { - content += pendingBuffer; - onDelta(pendingBuffer); - pendingBuffer = ''; - } - - // AI SDK v6 fullStream returns normally on abort; check signal explicitly. - // Without this throw the row would land as status='complete' with partial - // content instead of going through handleAbortOrError → status='cancelled'. - // Smoke D caught this in v1.13.1-A — don't refactor it away. - if (signal?.aborted) { - const abortErr = new Error('aborted'); - abortErr.name = 'AbortError'; - throw abortErr; - } - - // Usage lands as a promise on the result; awaiting after fullStream is - // drained is safe. AI SDK v6 names: `inputTokens` / `outputTokens`. - let promptTokens: number | null = null; - let completionTokens: number | null = null; - try { - const usage = await result.usage; - if (typeof usage.inputTokens === 'number') promptTokens = usage.inputTokens; - if (typeof usage.outputTokens === 'number') completionTokens = usage.outputTokens; - } catch { - // Some providers omit usage on partial streams; leave both null. - } - - if (onUsage && (promptTokens !== null || completionTokens !== null)) { - onUsage(promptTokens, completionTokens); - } - - if (reasoningAccumulated.length > 0) { - ctx.log.debug( - { reasoningChars: reasoningAccumulated.length, model, elapsed_ms: Date.now() - startedAt }, - 'streamCompletion: captured reasoning', - ); - } - - return { - finishReason, - content, - toolCalls, - promptTokens, - completionTokens, - reasoning: reasoningAccumulated, - }; -} +export { + streamCompletion, + samplerOptsFromAgent, + type StreamOptions, + type SamplerOpts, + type StreamAdapterContext, +} from './stream-phase-adapter.js'; export async function executeStreamPhase( ctx: InferenceContext, @@ -401,27 +58,7 @@ export async function executeStreamPhase( role: 'assistant', }); - let pendingFlushTimer: NodeJS.Timeout | null = null; - let flushPromise: Promise = Promise.resolve(); - - const flushNow = () => { - if (pendingFlushTimer) { - clearTimeout(pendingFlushTimer); - pendingFlushTimer = null; - } - const snapshot = state.accumulated; - flushPromise = flushPromise.then(() => - ctx.sql`UPDATE messages SET content = ${snapshot} WHERE id = ${assistantMessageId}` - ); - }; - - const scheduleFlush = () => { - if (pendingFlushTimer) return; - pendingFlushTimer = setTimeout(() => { - pendingFlushTimer = null; - flushNow(); - }, DB_FLUSH_INTERVAL_MS); - }; + const flusher = createContentFlusher(ctx.sql, assistantMessageId, () => state.accumulated); // Tool whitelist: if an agent is set, filter the global tool list to only the // tool names it allows. v1.15.0-mcp-multi: uses matchToolGlob for glob @@ -434,17 +71,6 @@ export async function executeStreamPhase( ? toolJsonSchemas().filter((t) => matchToolGlob(t.function.name, agent.tools)) : toolJsonSchemas() ).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name)); - const effectiveTemperature = agent?.temperature; - const effectiveTopP = agent?.top_p ?? undefined; - const effectiveTopK = agent?.top_k ?? undefined; - const effectiveMinP = agent?.min_p ?? undefined; - const effectivePresencePenalty = agent?.presence_penalty ?? undefined; - // v2.6 #11: llama.cpp sampler extensions, threaded the same way as top_k/min_p. - const effectiveTopNSigma = agent?.top_n_sigma ?? undefined; - const effectiveDryMultiplier = agent?.dry_multiplier ?? undefined; - const effectiveDryBase = agent?.dry_base ?? undefined; - const effectiveDryAllowedLength = agent?.dry_allowed_length ?? undefined; - const effectiveDryPenaltyLastN = agent?.dry_penalty_last_n ?? undefined; // v1.12.2: ctx_max lookup is cached after the first hit per model, so this // is a Map probe in steady state. We capture nCtx once at the top of the @@ -484,16 +110,7 @@ export async function executeStreamPhase( messages, { tools: effectiveTools, - temperature: effectiveTemperature, - top_p: effectiveTopP, - top_k: effectiveTopK, - min_p: effectiveMinP, - presence_penalty: effectivePresencePenalty, - top_n_sigma: effectiveTopNSigma, - dry_multiplier: effectiveDryMultiplier, - dry_base: effectiveDryBase, - dry_allowed_length: effectiveDryAllowedLength, - dry_penalty_last_n: effectiveDryPenaltyLastN, + ...samplerOptsFromAgent(agent), }, (delta) => { state.accumulated += delta; @@ -504,7 +121,7 @@ export async function executeStreamPhase( content: delta, }); ctx.log.debug({ sessionId, delta }, 'inference delta'); - scheduleFlush(); + flusher.scheduleFlush(); }, (prompt, completion) => { pendingUsage = { p: prompt, c: completion }; @@ -522,14 +139,10 @@ export async function executeStreamPhase( agent, ); } finally { - if (pendingFlushTimer) { - clearTimeout(pendingFlushTimer); - pendingFlushTimer = null; - } if (usageTimer) { clearTimeout(usageTimer); usageTimer = null; } - await flushPromise; + await flusher.drain(); } } diff --git a/apps/server/src/services/inference/tool-phase.ts b/apps/server/src/services/inference/tool-phase.ts index a35ceb1..f0e8d44 100644 --- a/apps/server/src/services/inference/tool-phase.ts +++ b/apps/server/src/services/inference/tool-phase.ts @@ -22,7 +22,7 @@ import type { InferenceContext, StreamResult, TurnArgs, -} from './turn.js'; +} from './types.js'; // v1.13.13: synthesis pipeline — replaces the immediate recursive turn when // any of this batch's tool calls is in SYNTHESIS_TOOLS. Falls through to // recursion on synthesis failure (timeout / model error). See module header diff --git a/apps/server/src/services/inference/tool-summaries.ts b/apps/server/src/services/inference/tool-summaries.ts deleted file mode 100644 index aa6983f..0000000 --- a/apps/server/src/services/inference/tool-summaries.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * v2.0.5: Tool-use summary generation. - * - * After a batch of tool calls completes, fire a cheap LLM call to generate - * a "git-commit-subject-style" one-liner label describing what the tools - * accomplished. Ported from the Qwen Code source recon. - */ -import type { FastifyBaseLogger } from 'fastify'; - -const TOOL_SUMMARY_SYSTEM_PROMPT = `Write a short summary label describing what these tool calls accomplished. Think git-commit-subject, not sentence. Past tense, most distinctive noun. Max 30 characters. Output ONLY the label. - -Examples: -- Searched in auth/ -- Fixed NPE in UserService -- Created signup endpoint -- Read config.json -- Ran failing tests`; - -const INPUT_TRUNCATE = 300; -const MAX_SUMMARY_LENGTH = 100; - -export interface ToolInfo { - name: string; - input: string; - output: string; -} - -export async function generateToolUseSummary(opts: { - tools: ToolInfo[]; - llamaSwapUrl: string; - model: string; - log: FastifyBaseLogger; - signal?: AbortSignal; -}): Promise { - const { tools, llamaSwapUrl, model, log, signal } = opts; - if (tools.length === 0) return null; - if (signal?.aborted) return null; - - const toolText = tools - .map(t => `Tool: ${t.name}\nInput: ${t.input.slice(0, INPUT_TRUNCATE)}\nOutput: ${t.output.slice(0, INPUT_TRUNCATE)}`) - .join('\n\n'); - - try { - const res = await fetch(`${llamaSwapUrl}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model, - messages: [ - { role: 'system', content: TOOL_SUMMARY_SYSTEM_PROMPT }, - { role: 'user', content: toolText }, - ], - max_tokens: 30, - temperature: 0.2, - stream: false, - chat_template_kwargs: { enable_thinking: false }, - }), - signal, - }); - if (!res.ok) { - log.debug({ status: res.status }, 'tool-summary: LLM request failed'); - return null; - } - const data = await res.json() as { choices?: Array<{ message?: { content?: string } }> }; - const raw = data.choices?.[0]?.message?.content?.trim() ?? ''; - if (!raw) return null; - // Clean: strip quotes, "Label:" prefix, cap length - let cleaned = raw.split('\n')[0]?.trim() ?? ''; - cleaned = cleaned - .replace(/^[-*•]\s+/, '') - .replace(/^["'`‘’“”]|["'`‘’“”]$/g, '') - .replace(/^(label|summary)\s*:\s*/i, '') - .trim(); - return cleaned.length > MAX_SUMMARY_LENGTH - ? cleaned.slice(0, MAX_SUMMARY_LENGTH).trim() - : cleaned || null; - } catch (err) { - log.debug({ err: err instanceof Error ? err.message : String(err) }, 'tool-summary: error'); - return null; - } -} diff --git a/apps/server/src/services/inference/turn-config.ts b/apps/server/src/services/inference/turn-config.ts new file mode 100644 index 0000000..cea5950 --- /dev/null +++ b/apps/server/src/services/inference/turn-config.ts @@ -0,0 +1,33 @@ +// P5 (SPLIT SKETCH 5): pure per-turn configuration resolved once at the top of +// runAssistantTurn. No I/O — just the cap math + budget lookup so it can be +// unit-tested without a DB or broker. + +import type { Agent } from '../../types/api.js'; +import { resolveToolBudget } from './budget.js'; + +// v1.14.0: hard ceiling on the number of stream-and-tool iterations per +// user-message turn. Per-agent cap via agent.steps is the primary knob; +// MAX_STEPS is the safety ceiling. 200 is 4x the effective budget ceiling +// (50 tool calls) — in practice budget fires first unless the model makes +// many 0-tool-call iterations (which exit the loop via the non-tool finish +// path anyway). +export const MAX_STEPS = 200; + +export interface TurnConfig { + // min(agent.steps ?? Infinity, MAX_STEPS). The while loop runs while + // stepNumber < effectiveCap. + effectiveCap: number; + // cumulative tool-call budget for the turn (resolveToolBudget). + budget: number; + // effectiveCap === 0 → the model responds text-only (no tool execution). + isTextOnly: boolean; +} + +export function resolveTurnConfig(agent: Agent | null): TurnConfig { + const budget = resolveToolBudget(agent); + // v1.14.0: effectiveCap = min(agent.steps ?? Infinity, MAX_STEPS). + // steps: 0 means "no tool calls allowed" — the first stream phase runs but + // any tool calls it emits are not executed (finalize as text-only). + const effectiveCap = Math.min(agent?.steps ?? Infinity, MAX_STEPS); + return { effectiveCap, budget, isTextOnly: effectiveCap === 0 }; +} diff --git a/apps/server/src/services/inference/turn.ts b/apps/server/src/services/inference/turn.ts index 4b857ff..e6582c4 100644 --- a/apps/server/src/services/inference/turn.ts +++ b/apps/server/src/services/inference/turn.ts @@ -1,33 +1,21 @@ -import type { FastifyBaseLogger } from 'fastify'; -import type { Sql } from '../../db.js'; -import type { Config } from '../../config.js'; import type { Agent, - ErrorReason, Message, - MessageMetadata, Project, Session, - ToolCall, UserStreamFrame, } from '../../types/api.js'; -import { ALL_TOOLS } from '../tools.js'; import { resolveProjectRoot } from '../path_guard.js'; import { maybeAutoNameChat } from '../auto_name.js'; import { rewriteSearchQuery } from '../task-search-rewrite.js'; import { getAgentById } from '../agents.js'; import * as compaction from '../compaction.js'; -import type { Broker } from '../broker.js'; -import { resolveToolBudget } from './budget.js'; +import { resolveTurnConfig } from './turn-config.js'; +import { decideStep, decidePostToolAction } from './step-decision.js'; import { - detectDoomLoop, -} from './sentinels.js'; -import { - detectMistakePattern, freshMistakeState, recordStep, MISTAKE_RECOVERY_NOTE, - type MistakeState, } from './mistake-tracker.js'; import { buildMessagesPayload, @@ -35,13 +23,19 @@ import { } from './payload.js'; import { finalizeCompletion, + finalizeEmpty, handleAbortOrError, } from './error-handler.js'; import { executeStreamPhase, } from './stream-phase.js'; import { executeToolPhase, type ToolPhaseResult } from './tool-phase.js'; -import type { StreamPhaseState } from './types.js'; +import type { + InferenceContext, + StreamPhaseState, + StreamResult, + TurnArgs, +} from './types.js'; import { runCapHitSummary, runDoomLoopSummary, @@ -49,121 +43,24 @@ import { insertMistakeRecoverySentinel, } from './sentinel-summaries.js'; -// v1.14.0: hard ceiling on the number of stream-and-tool iterations per -// user-message turn. Per-agent cap via agent.steps is the primary knob; -// MAX_STEPS is the safety ceiling. 200 is 4x the effective budget ceiling -// (50 tool calls) — in practice budget fires first unless the model makes -// many 0-tool-call iterations (which exit the loop via the non-tool finish -// path anyway). -export const MAX_STEPS = 200; +// P5: MAX_STEPS moved to ./turn-config.ts (with resolveTurnConfig). Re-exported +// here so the public surface (index.ts → './turn.js') is unchanged. +export { MAX_STEPS } from './turn-config.js'; // v1.12.4: re-exported so external callers (tests, future consumers) keep // importing from services/inference.js as the public surface. export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js'; export { buildMessagesPayload } from './payload.js'; -export interface InferenceFrame { - type: - | 'message_started' - | 'delta' - | 'tool_call' - | 'tool_result' - | 'message_complete' - | 'usage' - | 'messages_deleted' - | 'session_renamed' - | 'chat_renamed' - | 'error'; - message_id?: string; - message_ids?: string[]; - chat_id?: string; - tool_message_id?: string; - tool_call_id?: string; - // v1.8.2: 'system' added so cap-hit sentinel messages can announce themselves - // through the normal message_started → delta → message_complete sequence. - role?: 'assistant' | 'tool' | 'user' | 'system'; - content?: string; - tool_call?: ToolCall; - output?: unknown; - truncated?: boolean; - error?: string; - // v1.8.2: structured error reason. Set on `type: 'error'` so the UI can - // surface a specific message; `error` stays the human-readable text. - reason?: ErrorReason; - // v1.8.2: piggybacks on `message_complete` so static or terminally-resolved - // messages can carry their persisted metadata to the live stream without a - // refetch (sentinels carry { kind: 'cap_hit', ... }; failed messages carry - // { kind: 'error', ... }). - metadata?: MessageMetadata | null; - tokens_used?: number | null; - ctx_used?: number | null; - ctx_max?: number | null; - completion_tokens?: number | null; - started_at?: string | null; - finished_at?: string | null; - model?: string; - session_id?: string; - name?: string; -} - -export type FramePublisher = (sessionId: string, frame: InferenceFrame) => void; - -export interface InferenceContext { - sql: Sql; - config: Config; - log: FastifyBaseLogger; - publish: FramePublisher; - publishUser: (frame: UserStreamFrame) => void; - // v1.11: passed through so compaction.process can publish 'compacted' - // frames on the same session WS channel useSessionStream subscribes to. - // Compaction is the only path that needs the raw broker handle (regular - // inference goes through `publish`); keeping a separate field avoids - // tempting other code paths into bypassing the session-id binding. - broker: Broker; -} - // v1.12.4: payload assembly extracted to ./inference/payload.ts (tests // import buildMessagesPayload from this module, so a re-export below // preserves the public surface). Stream + tool phases extracted to // ./inference/stream-phase.ts and ./inference/tool-phase.ts. - -export interface StreamResult { - finishReason: string | null; - content: string; - toolCalls: ToolCall[]; - promptTokens: number | null; - completionTokens: number | null; - // v1.13.1-C: reasoning text accumulated across reasoning-delta parts. - // Empty string when the model doesn't emit reasoning (most cases). - reasoning: string; -} - - -export interface TurnArgs { - sessionId: string; - chatId: string; - assistantMessageId: string; - // v1.8.2: cumulative tool calls executed this run. Compared against the - // resolved budget at the top of each turn. Replaces the older `depth` - // counter (which counted iterations, not invocations). - toolsUsed: number; - // v1.11.6: ordered tool calls executed in this user-message turn (across - // recursive runAssistantTurn invocations). Reset to [] at user-message - // boundaries by runInference, same as toolsUsed. Doom-loop check at the - // top of runAssistantTurn slices the last DOOM_LOOP_THRESHOLD entries. - recentToolCalls: ToolCall[]; - // v#12 MistakeTracker: heterogeneous-failure recovery state. Loop-local, - // reset per runInference (user-message boundary) like recentToolCalls. Folds - // tool-phase outcomes via recordStep each iteration; detectMistakePattern - // gates the nudge/escalate decision. - mistakeTracker: MistakeState; - // v#12: transient model-facing recovery note set when a nudge fires. Consumed - // (appended as a role:'system' message + cleared) on the NEXT payload build. - // Never persisted — mirrors how the cap-hit/doom-loop notes live only inside - // the summary call's messages array. - pendingRecoveryNote?: string; - signal: AbortSignal | undefined; -} +// +// P5: the shared pipeline types (InferenceFrame / FramePublisher / +// InferenceContext / StreamResult / TurnArgs) moved to ./types.js to break the +// turn.ts type-hub-and-leaf near-cycle. They are re-exported from there via +// inference/index.ts for the public surface. export async function runAssistantTurn( @@ -184,17 +81,13 @@ export async function runAssistantTurn( const agent = session.agent_id ? await getAgentById(project.path, session.agent_id) : null; - const budget = resolveToolBudget(agent); - - // v1.14.0: effectiveCap = min(agent.steps ?? Infinity, MAX_STEPS). - // steps: 0 means "no tool calls allowed" — the first stream phase runs - // but if it emits tool calls they are not executed (finalize as text-only). - const effectiveCap = Math.min(agent?.steps ?? Infinity, MAX_STEPS); + // P5: pure per-turn config (budget + cap math + text-only flag). + const { effectiveCap, budget, isTextOnly } = resolveTurnConfig(agent); // steps: 0 special case — model responds text-only. The while loop would // never enter (effectiveCap === 0), so we handle it explicitly before the // loop. The model always gets at least one chance to respond with text. - if (effectiveCap === 0) { + if (isTextOnly) { const loaded = await loadContext(ctx.sql, sessionId, chatId); if (loaded) { await runTextOnlyTurn(ctx, args, loaded.session, loaded.project, loaded.history, agent); @@ -214,20 +107,18 @@ export async function runAssistantTurn( let pendingRecoveryNote: string | undefined = args.pendingRecoveryNote; while (stepNumber < effectiveCap) { - // ---- doom-loop check (moved from top-of-function) ---- - const loop = detectDoomLoop(recentToolCalls); - if (loop) { + // ---- top-of-loop gate: doom-loop, then budget (pure decision) ---- + const decision = decideStep({ recentToolCalls, toolsUsed, budget }); + if (decision.kind === 'doom') { // Need fresh history for the summary. const loaded = await loadContext(ctx.sql, sessionId, chatId); if (loaded) { const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal }; - await runDoomLoopSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, loop); + await runDoomLoopSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, decision.loop); } break; } - - // ---- budget check (moved from top-of-function) ---- - if (toolsUsed >= budget) { + if (decision.kind === 'budget') { const loaded = await loadContext(ctx.sql, sessionId, chatId); if (loaded) { const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal }; @@ -235,6 +126,7 @@ export async function runAssistantTurn( } break; } + // decision.kind === 'stream' → proceed with compaction + stream + tools. // ---- compaction check ---- // v1.11: if the prior turn flagged this chat for compaction, run it @@ -345,19 +237,17 @@ export async function runAssistantTurn( recordStep(mistakeTracker, o); } - if (toolPhaseResult.action !== 'continue') { - // 'paused' (user input) or 'synthesis_done' — stop the loop. The turn is - // already ending, so neither a nudge nor an escalate would change the - // control flow; we skip the mistake decision here. + // v#12 MistakeTracker: post-tool decision (pure). 'stop' = the tool phase + // returned a non-'continue' action ('paused' for user input, or + // 'synthesis_done') — neither a nudge nor an escalate would change the + // control flow, so the mistake check is skipped. On 'continue' the + // heterogeneous-failure pattern gates nudge/escalate/continue. Complements + // the doom-loop gate above, which only catches *identical* repeats. + const post = decidePostToolAction(toolPhaseResult.action, mistakeTracker); + if (post === 'stop') { break; } - - // v#12 MistakeTracker: heterogeneous-failure decision. Only evaluated on - // the 'continue' path (the only case where the loop would otherwise - // proceed to another step). Complements the doom-loop check above, which - // only catches *identical* repeats. - const mistake = detectMistakePattern(mistakeTracker); - if (mistake === 'nudge') { + if (post === 'nudge') { // Soft intervention: inject model-facing recovery guidance into the NEXT // step's payload, drop a UI sentinel, bump nudges, reset the streak, and // continue. The note is consumed (and cleared) at the top of the next @@ -379,23 +269,16 @@ export async function runAssistantTurn( assistantMessageId = toolPhaseResult.nextAssistantId!; continue; } - if (mistake === 'escalate') { + if (post === 'escalate') { // The nudge didn't break the failure run — stop the turn (cap-hit-style) // to avoid burning the whole step budget on heterogeneous failures. The - // next assistant row is still 'streaming'; finalize it as a short note so - // the slot doesn't dangle, then drop the escalate sentinel. + // next assistant row is still 'streaming'; finalize it as an empty + // complete row so the slot doesn't dangle, then drop the escalate + // sentinel. const failureKinds = [...mistakeTracker.run]; assistantMessageId = toolPhaseResult.nextAssistantId!; - await ctx.sql` - UPDATE messages - SET content = '', status = 'complete', finished_at = clock_timestamp() - WHERE id = ${assistantMessageId} - `; - ctx.publish(sessionId, { - type: 'message_complete', - message_id: assistantMessageId, - chat_id: chatId, - }); + const escalateArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal }; + await finalizeEmpty(ctx, escalateArgs); await insertMistakeRecoverySentinel(ctx, sessionId, chatId, { failureKinds, count: failureKinds.length, @@ -562,4 +445,3 @@ export function createInferenceRunner( }; } -export const _toolNames = ALL_TOOLS.map((t) => t.name); diff --git a/apps/server/src/services/inference/types.ts b/apps/server/src/services/inference/types.ts index 2b2a3b8..7139b9f 100644 --- a/apps/server/src/services/inference/types.ts +++ b/apps/server/src/services/inference/types.ts @@ -1,6 +1,25 @@ // v1.12.4: shared inter-phase types/constants for the extracted phase files. // Lives here so stream-phase, tool-phase, and the summary functions still in // inference.ts can all reference the same definitions without circular imports. +// +// P5: the shared pipeline types (InferenceContext / TurnArgs / StreamResult / +// InferenceFrame / FramePublisher) moved here from turn.ts. turn.ts was both the +// type hub (every phase imported these from './turn.js') AND the orchestration +// leaf (it imports functions back from payload/stream-phase/tool-phase/ +// error-handler/sentinel-summaries) — a hub-and-leaf near-cycle. Hosting the +// shared types here (this module imports no inference functions) breaks it. + +import type { FastifyBaseLogger } from 'fastify'; +import type { Sql } from '../../db.js'; +import type { Config } from '../../config.js'; +import type { + ErrorReason, + MessageMetadata, + ToolCall, + UserStreamFrame, +} from '../../types/api.js'; +import type { Broker } from '../broker.js'; +import type { MistakeState } from './mistake-tracker.js'; export interface StreamPhaseState { accumulated: string; @@ -11,3 +30,100 @@ export interface StreamPhaseState { // executeStreamPhase, runCapHitSummary, and runDoomLoopSummary — every site // that does a debounced content flush during streaming. export const DB_FLUSH_INTERVAL_MS = 500; + +export interface InferenceFrame { + type: + | 'message_started' + | 'delta' + | 'tool_call' + | 'tool_result' + | 'message_complete' + | 'usage' + | 'messages_deleted' + | 'session_renamed' + | 'chat_renamed' + | 'error'; + message_id?: string; + message_ids?: string[]; + chat_id?: string; + tool_message_id?: string; + tool_call_id?: string; + // v1.8.2: 'system' added so cap-hit sentinel messages can announce themselves + // through the normal message_started → delta → message_complete sequence. + role?: 'assistant' | 'tool' | 'user' | 'system'; + content?: string; + tool_call?: ToolCall; + output?: unknown; + truncated?: boolean; + error?: string; + // v1.8.2: structured error reason. Set on `type: 'error'` so the UI can + // surface a specific message; `error` stays the human-readable text. + reason?: ErrorReason; + // v1.8.2: piggybacks on `message_complete` so static or terminally-resolved + // messages can carry their persisted metadata to the live stream without a + // refetch (sentinels carry { kind: 'cap_hit', ... }; failed messages carry + // { kind: 'error', ... }). + metadata?: MessageMetadata | null; + tokens_used?: number | null; + ctx_used?: number | null; + ctx_max?: number | null; + completion_tokens?: number | null; + started_at?: string | null; + finished_at?: string | null; + model?: string; + session_id?: string; + name?: string; +} + +export type FramePublisher = (sessionId: string, frame: InferenceFrame) => void; + +export interface InferenceContext { + sql: Sql; + config: Config; + log: FastifyBaseLogger; + publish: FramePublisher; + publishUser: (frame: UserStreamFrame) => void; + // v1.11: passed through so compaction.process can publish 'compacted' + // frames on the same session WS channel useSessionStream subscribes to. + // Compaction is the only path that needs the raw broker handle (regular + // inference goes through `publish`); keeping a separate field avoids + // tempting other code paths into bypassing the session-id binding. + broker: Broker; +} + +export interface StreamResult { + finishReason: string | null; + content: string; + toolCalls: ToolCall[]; + promptTokens: number | null; + completionTokens: number | null; + // v1.13.1-C: reasoning text accumulated across reasoning-delta parts. + // Empty string when the model doesn't emit reasoning (most cases). + reasoning: string; +} + +export interface TurnArgs { + sessionId: string; + chatId: string; + assistantMessageId: string; + // v1.8.2: cumulative tool calls executed this run. Compared against the + // resolved budget at the top of each turn. Replaces the older `depth` + // counter (which counted iterations, not invocations). + toolsUsed: number; + // v1.11.6: ordered tool calls executed in this user-message turn (across + // recursive runAssistantTurn invocations). Reset to [] at user-message + // boundaries by runInference, same as toolsUsed. Doom-loop check at the + // top of runAssistantTurn slices the last DOOM_LOOP_THRESHOLD entries. + recentToolCalls: ToolCall[]; + // v#12 MistakeTracker: heterogeneous-failure recovery state. Loop-local, + // reset per runInference (user-message boundary) like recentToolCalls. Folds + // tool-phase outcomes via recordStep each iteration; detectMistakePattern + // gates the nudge/escalate decision. + mistakeTracker: MistakeState; + // v#12: transient model-facing recovery note set when a nudge fires. Consumed + // (appended as a role:'system' message + cleared) on the NEXT payload build. + // Never persisted — mirrors how the cap-hit/doom-loop notes live only inside + // the summary call's messages array. + pendingRecoveryNote?: string; + signal: AbortSignal | undefined; +} diff --git a/apps/server/src/services/message-columns.ts b/apps/server/src/services/message-columns.ts new file mode 100644 index 0000000..46b503e --- /dev/null +++ b/apps/server/src/services/message-columns.ts @@ -0,0 +1,15 @@ +// Shared column projections for queries against the messages_with_parts view. +// All sites that read the full Message wire shape for route responses use +// MESSAGE_COLUMNS. The inference load path uses INFERENCE_MESSAGE_COLUMNS — +// it adds reasoning_parts but omits the compaction-display fields +// (summary, tail_start_id, compacted_at, model) that only the UI needs. + +export const MESSAGE_COLUMNS = + 'id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, ' + + 'tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata, ' + + 'summary, tail_start_id, compacted_at, model'; + +export const INFERENCE_MESSAGE_COLUMNS = + 'id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq, ' + + 'tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata, ' + + 'reasoning_parts'; diff --git a/apps/server/src/services/model-context.ts b/apps/server/src/services/model-context.ts index 66594b3..d390bfe 100644 --- a/apps/server/src/services/model-context.ts +++ b/apps/server/src/services/model-context.ts @@ -18,8 +18,6 @@ export interface ModelContext { n_ctx: number; - total_slots: number; - fetched_at: number; } const NEGATIVE_TTL_MS = 60_000; @@ -77,19 +75,13 @@ export async function getModelContext(model: string): Promise 0 ? body.total_slots : 1; - const entry: ModelContext = { n_ctx, total_slots, fetched_at: Date.now() }; + const entry: ModelContext = { n_ctx }; positiveCache.set(model, entry); // Clear any stale negative entry so a future query sees the positive // hit cleanly (otherwise the negative TTL never expires from the map). diff --git a/apps/server/src/services/read_tab_by_number.ts b/apps/server/src/services/read_tab_by_number.ts index 73aa7a7..8647b91 100644 --- a/apps/server/src/services/read_tab_by_number.ts +++ b/apps/server/src/services/read_tab_by_number.ts @@ -3,7 +3,7 @@ // stored in the session's workspace_panes envelope (WorkspaceState.tabNumbers), // keyed by chat id. Lives in its own file (not appended to tools.ts) so tests // can import the executor directly without dragging in the whole tool registry. -// Registered in tools.ts ALL_TOOLS + READ_ONLY_TOOL_NAMES. +// Registered in tools.ts ALL_TOOLS. import { z } from 'zod'; import type { Sql } from '../db.js'; diff --git a/apps/server/src/services/skills.ts b/apps/server/src/services/skills.ts index d48eb8e..413a60d 100644 --- a/apps/server/src/services/skills.ts +++ b/apps/server/src/services/skills.ts @@ -1,6 +1,7 @@ import { promises as fs } from 'node:fs'; import { join, isAbsolute, basename } from 'node:path'; import { pathGuard, PathScopeError } from './path_guard.js'; +import { stripQuotes } from '../utils/string-utils.js'; // Batch 9.6: read-only skill library. Folders under /data/skills/// // contain a SKILL.md with YAML frontmatter (name + description) and a markdown @@ -44,13 +45,6 @@ interface Frontmatter { description?: string; } -function stripQuotes(s: string): string { - if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[0] === s[s.length - 1]) { - return s.slice(1, -1); - } - return s; -} - function parseFrontmatter(yaml: string): Frontmatter { const fm: Frontmatter = {}; for (const raw of yaml.split('\n')) { diff --git a/apps/server/src/services/synthesisPipeline.ts b/apps/server/src/services/synthesisPipeline.ts index 98901f0..7b31da0 100644 --- a/apps/server/src/services/synthesisPipeline.ts +++ b/apps/server/src/services/synthesisPipeline.ts @@ -24,12 +24,12 @@ import { TOOLS_BY_NAME } from './tools.js'; import { streamCompletion } from './inference/stream-phase.js'; import { SYNTHESIS_SYSTEM_PROMPT } from './synthesisPrompt.js'; import { insertParts } from './inference/parts.js'; -import * as modelContext from './model-context.js'; +import { finalizeStreamedRow } from './inference/error-handler.js'; import { readTruncation } from './truncate.js'; import type { Session } from '../types/api.js'; import type { OpenAiMessage } from './inference/payload.js'; -import type { InferenceContext, TurnArgs } from './inference/turn.js'; +import type { InferenceContext, TurnArgs } from './inference/types.js'; export const SYNTHESIS_TOOLS: ReadonlySet = new Set([ 'get_codebase_overview', @@ -192,44 +192,28 @@ export async function runSynthesisPass(p: SynthesisParams): Promise { combinedSignal, ); - const mctx = await modelContext.getModelContext(p.session.model); - const nCtx = mctx?.n_ctx ?? null; - const [updated] = await p.ctx.sql< - { - tokens_used: number | null; - ctx_used: number | null; - ctx_max: number | null; - finished_at: string | null; - }[] - >` - UPDATE messages - SET content = ${streamResult.content}, - status = 'complete', - tokens_used = ${streamResult.completionTokens}, - ctx_used = ${streamResult.promptTokens}, - ctx_max = ${nCtx}, - finished_at = clock_timestamp() - WHERE id = ${synthMessageId} - RETURNING tokens_used, ctx_used, ctx_max, finished_at - `; - await insertParts(p.ctx.sql, [ - { - message_id: synthMessageId, - sequence: 0, - kind: 'synthesis', - payload: { text: streamResult.content }, - }, - ]); - p.ctx.publish(p.args.sessionId, { - type: 'message_complete', - message_id: synthMessageId, - chat_id: p.args.chatId, - tokens_used: updated?.tokens_used ?? null, - ctx_used: updated?.ctx_used ?? null, - ctx_max: updated?.ctx_max ?? null, - started_at: startedAt, - finished_at: updated?.finished_at ?? null, + // P5: the n_ctx lookup + complete UPDATE + message_complete frame are the + // shared success-finalize atom (finalizeStreamedRow). beforeComplete writes + // the kind='synthesis' part in the original order (UPDATE → insertParts → + // message_complete), preserving timing exactly. + await finalizeStreamedRow(p.ctx, { + sessionId: p.args.sessionId, + chatId: p.args.chatId, + messageId: synthMessageId, model: p.session.model, + content: streamResult.content, + completionTokens: streamResult.completionTokens, + promptTokens: streamResult.promptTokens, + startedAt, + beforeComplete: () => + insertParts(p.ctx.sql, [ + { + message_id: synthMessageId!, + sequence: 0, + kind: 'synthesis', + payload: { text: streamResult.content }, + }, + ]), }); p.ctx.publishUser({ type: 'chat_status', diff --git a/apps/server/src/services/task-summary.ts b/apps/server/src/services/task-summary.ts deleted file mode 100644 index f7d6aea..0000000 --- a/apps/server/src/services/task-summary.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { taskModelCompletion } from './task-model.js'; - -const SYSTEM_PROMPT = - 'Summarize this conversation in one sentence, 15 words max. No quotes, no prefix.'; - -const MAX_INPUT_CHARS = 1000; - -export async function oneLineSummary( - messages: Array<{ role: string; content: string }>, -): Promise { - const lastPairs = messages.slice(-6); - let input = lastPairs - .map((m) => `${m.role}: ${m.content}`) - .join('\n'); - if (input.length > MAX_INPUT_CHARS) { - input = input.slice(0, MAX_INPUT_CHARS); - } - return taskModelCompletion({ - system: SYSTEM_PROMPT, - user: input, - maxTokens: 30, - temperature: 0.3, - }); -} diff --git a/apps/server/src/services/task-tags.ts b/apps/server/src/services/task-tags.ts deleted file mode 100644 index 2197e98..0000000 --- a/apps/server/src/services/task-tags.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { taskModelCompletion } from './task-model.js'; - -const SYSTEM_PROMPT = - 'You tag chat sessions. Reply with 1 to 3 lowercase tags separated by commas. Tags should describe the topic. No explanation. Examples: "docker, deployment", "python, debugging", "react, styling".'; - -export async function suggestTags( - userMessage: string, - assistantReply: string, -): Promise { - const input = `User: ${userMessage.slice(0, 300)}\nAssistant: ${assistantReply.slice(0, 300)}`; - const result = await taskModelCompletion({ - system: SYSTEM_PROMPT, - user: input, - maxTokens: 30, - temperature: 0.3, - }); - if (result.length === 0) return []; - return result - .split(',') - .map((t) => t.trim().toLowerCase()) - .filter((t) => t.length > 0 && t.length <= 30); -} diff --git a/apps/server/src/services/tools.ts b/apps/server/src/services/tools.ts index 000db3b..5de9c1c 100644 --- a/apps/server/src/services/tools.ts +++ b/apps/server/src/services/tools.ts @@ -1,844 +1,46 @@ -import { readFile, readdir, stat } from 'node:fs/promises'; -import { resolve, basename, relative } from 'node:path'; -import { z } from 'zod'; -import type { Sql } from '../db.js'; -import { pathGuard, PathScopeError } from './path_guard.js'; -import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.js'; -import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js'; -import { getGitMeta } from './git_meta.js'; -import { findSkills, getSkillBody, getSkillResource } from './skills.js'; -import { webSearch } from './web_search.js'; -import { webFetch } from './web_fetch.js'; -import { readTruncation, truncateIfNeeded } from './truncate.js'; -// v1.12 Track B.2: codecontext tools. 8 wrappers re-exported from -// tools/codecontext/index.ts. Each calls into services/codecontext_client.ts -// which talks to the codecontext sidecar at http://codecontext:8080. -import { - getCodebaseOverview, - getFileAnalysis, - getSymbolInfo, - searchSymbols, - getDependencies, - watchChanges, - getSemanticNeighborhoods, - getFrameworkAnalysis, - getBlastRadius, - getHotFiles, - getRoutes, - getMiddleware, -} from './tools/codecontext/index.js'; -// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired -// with the pause-on-pending-grant branch in inference/tool-phase.ts and the -// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts. -import { requestReadAccess } from './request_read_access.js'; -// v2.6.x: read-only tool that reads a tab's transcript by its session-scoped -// tab number. Needs DB/session context (ToolExecCtx 4th arg). -import { readTabByNumber } from './read_tab_by_number.js'; - -const MAX_FILE_BYTES = 5 * 1024 * 1024; -const DEFAULT_VIEW_LINES = 200; -const MAX_GREP_RESULTS = 200; -const DEFAULT_GREP_RESULTS = 100; -const MAX_FIND_RESULTS = 200; -const DEFAULT_FIND_RESULTS = 100; -const MAX_DIR_ENTRIES = 500; - -export interface ToolJsonSchema { - type: 'function'; - function: { - name: string; - description: string; - parameters: Record; - }; -} - -// v2.6.x: optional DB/session context threaded into a tool's execute(). Only -// tools that need to read session-scoped DB state (e.g. read_tab_by_number) -// use it; every other tool ignores the 4th arg. Kept optional so existing -// 3-arg execute() implementations stay assignable (apps/coder consumes this -// type from the compiled dist — the optional param keeps it backward-compatible). -export interface ToolExecCtx { - sql: Sql; - sessionId: string; -} - -export interface ToolDef { - name: string; - description: string; - inputSchema: z.ZodType; - jsonSchema: ToolJsonSchema; - // v1.13.17-cross-repo-reads: extraRoots is the session's - // allowed_read_paths, threaded through executeToolCall in tool-phase.ts. - // Only the filesystem tools (view_file, list_dir, grep, find_files, - // view_truncated_output) forward it to pathGuard; other tools accept the - // arg and ignore it. The execute signature stays compatible with - // pre-v1.13.17 callsites because the parameter is optional. - // v2.6.x: optional 4th param toolCtx carries DB/session context for tools - // that read session-scoped state (read_tab_by_number). Optional so 3-arg - // implementations remain assignable. - execute( - input: TInput, - projectRoot: string, - extraRoots?: readonly string[], - toolCtx?: ToolExecCtx, - ): Promise; -} - -const ViewFileInput = z.object({ - path: z.string().min(1), - start_line: z.number().int().positive().optional(), - end_line: z.number().int().positive().optional(), -}); -type ViewFileInputT = z.infer; - -export const viewFile: ToolDef = { - name: 'view_file', - description: - "Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused. Output is truncated if longer than the slice; the response indicates truncation.", - inputSchema: ViewFileInput, - jsonSchema: { - type: 'function', - function: { - name: 'view_file', - description: - "Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused.", - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'absolute or project-relative path' }, - start_line: { type: 'integer', description: 'first line (1-indexed)' }, - end_line: { type: 'integer', description: 'last line (1-indexed, inclusive)' }, - }, - required: ['path'], - additionalProperties: false, - }, - }, - }, - async execute(input, projectRoot, extraRoots) { - const real = await pathGuard(projectRoot, input.path, extraRoots); - // v1.11.7: secret-file deny check. Test the project-relative path - // (matches the form continue.dev's patterns expect: basenames + dir - // segments). Throw a typed error so executeToolCall in inference.ts - // surfaces a clear "blocked" message to the LLM instead of silently - // returning content the user wanted hidden. - // v1.13.17: when the resolved path is outside the primary projectRoot - // (i.e. via an allowed_read_paths grant), `relative()` returns "../…" - // which won't match secret-file basename patterns. Re-anchor on the - // file's basename so the secret deny still fires across all grant roots. - const rel = relative(projectRoot, real); - const relPath = rel && !rel.startsWith('..') ? rel : basename(real); - if (isSecretPath(relPath)) { - throw new SecretBlockedError(relPath); - } - const s = await stat(real); - if (!s.isFile()) { - throw new PathScopeError(`not a file: ${input.path}`); - } - if (s.size > MAX_FILE_BYTES) { - throw new Error(`file too large (${s.size} bytes, max ${MAX_FILE_BYTES})`); - } - const raw = await readFile(real, 'utf8'); - const lines = raw.split('\n'); - const total = lines.length; - let start = input.start_line ?? 1; - let end = input.end_line ?? Math.min(total, start + DEFAULT_VIEW_LINES - 1); - if (input.start_line == null && input.end_line == null) { - end = Math.min(total, DEFAULT_VIEW_LINES); - } - if (start < 1) start = 1; - if (end > total) end = total; - if (end < start) end = start; - const slice = lines.slice(start - 1, end); - const content = slice.join('\n'); - const truncated = total > end || start > 1; - // v1.13.5: stash the full file on tmpfs so the model can retrieve more - // via view_truncated_output(id) without re-reading the file (which it - // may not have project-relative-path access to in future agent setups). - // raw is bounded by MAX_FILE_BYTES (5MB), within truncateIfNeeded's cap. - const wrapped = await truncateIfNeeded({ - fullContent: raw, - slicedContent: content, - wasTruncated: truncated, - }); - return { - path: relative(projectRoot, real) || basename(real), - content: wrapped.content, - total_lines: total, - returned_lines: [start, end], - truncated: wrapped.truncated, - ...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}), - }; - }, -}; - -const ListDirInput = z.object({ - path: z.string().min(1), - show_hidden: z.boolean().optional(), -}); -type ListDirInputT = z.infer; - -export const listDir: ToolDef = { - name: 'list_dir', - description: 'List entries in a directory (up to 500). Hidden files excluded unless show_hidden=true.', - inputSchema: ListDirInput, - jsonSchema: { - type: 'function', - function: { - name: 'list_dir', - description: - 'List entries in a directory (up to 500). Hidden files (dot-prefixed) excluded unless show_hidden=true.', - parameters: { - type: 'object', - properties: { - path: { type: 'string' }, - show_hidden: { type: 'boolean' }, - }, - required: ['path'], - additionalProperties: false, - }, - }, - }, - async execute(input, projectRoot, extraRoots) { - const real = await pathGuard(projectRoot, input.path, extraRoots); - const s = await stat(real); - if (!s.isDirectory()) { - throw new PathScopeError(`not a directory: ${input.path}`); - } - const entries = await readdir(real, { withFileTypes: true }); - const filtered = input.show_hidden - ? entries - : entries.filter((e) => !e.name.startsWith('.')); - const total = filtered.length; - const wasTruncated = total > MAX_DIR_ENTRIES; - const relDir = relative(projectRoot, real) || '.'; - // v1.13.5: when we'd truncate, render the FULL list to tmpfs so - // view_truncated_output can serve it. Stat sizes for all entries when - // truncating so the stored view matches the visible shape; this is the - // one extra cost for big directories, bounded by total entries (which - // is itself bounded by filesystem behavior). - const processOne = async (e: typeof filtered[number]) => { - const child = resolve(real, e.name); - let size: number | undefined; - if (e.isFile()) { - try { - const cs = await stat(child); - size = cs.size; - } catch { /* ignore */ } - } - return { - name: e.name, - type: e.isDirectory() ? ('dir' as const) : ('file' as const), - ...(size != null ? { size } : {}), - }; - }; - const slice = filtered.slice(0, MAX_DIR_ENTRIES); - const out = await Promise.all(slice.map(processOne)); - // v1.11.7: filter entries whose project-relative path matches a secret - // pattern. The same filter applies to the full-list snapshot below so - // the stashed file never holds entries the slice would have hidden. - const secretFilter = filterSecretEntries(out, (e) => - relDir === '.' ? e.name : `${relDir}/${e.name}`, - ); - let outputPath: string | undefined; - if (wasTruncated) { - const fullProcessed = await Promise.all(filtered.map(processOne)); - const fullFiltered = filterSecretEntries(fullProcessed, (e) => - relDir === '.' ? e.name : `${relDir}/${e.name}`, - ); - // One line per entry, view_truncated_output's line slicing semantics - // map cleanly. Format: "\t[\tsize=N]". Header documents - // the shape so the model can grep / regex without prior schema lookup. - const header = `# list_dir ${relDir} — ${fullFiltered.kept.length} entries`; - const lines = [header, ...fullFiltered.kept.map((e) => { - const sz = 'size' in e && e.size != null ? `\tsize=${e.size}` : ''; - return `${e.type}\t${e.name}${sz}`; - })]; - const wrapped = await truncateIfNeeded({ - fullContent: lines.join('\n'), - slicedContent: '', - wasTruncated: true, - }); - outputPath = wrapped.outputPath; - } - return { - path: relDir, - entries: secretFilter.kept, - total: secretFilter.kept.length, - truncated: wasTruncated, - ...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}), - ...(outputPath ? { outputPath } : {}), - }; - }, -}; - -const GrepInput = z.object({ - pattern: z.string().min(1), - path: z.string().optional(), - case_sensitive: z.boolean().optional(), - max_results: z.number().int().positive().optional(), - hidden: z.boolean().optional(), -}); -type GrepInputT = z.infer; - -export const grep: ToolDef = { - name: 'grep', - description: - 'Search file contents with ripgrep. Default path is project root. Max 100 results (200 cap).', - inputSchema: GrepInput, - jsonSchema: { - type: 'function', - function: { - name: 'grep', - description: - 'Search file contents with ripgrep. Returns up to 100 matches (cap 200). Set hidden=true to include dot-prefixed files.', - parameters: { - type: 'object', - properties: { - pattern: { type: 'string' }, - path: { type: 'string' }, - case_sensitive: { type: 'boolean' }, - max_results: { type: 'integer' }, - hidden: { type: 'boolean' }, - }, - required: ['pattern'], - additionalProperties: false, - }, - }, - }, - async execute(input, projectRoot, extraRoots) { - const limit = Math.min( - Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1), - MAX_GREP_RESULTS - ); - // Delegate to file_ops.grep; reshape match objects to preserve LLM output format - // (file_ops uses {path, line, text}; tool output uses {path, line, content}) - const result = await fileOpsGrep(projectRoot, input.pattern, { - path: input.path, - max_matches: limit, - case_sensitive: input.case_sensitive, - hidden: input.hidden, - extra_roots: extraRoots, - }); - const reshaped = result.matches.map((m) => ({ - path: m.path, - line: m.line, - content: m.text, - })); - // v1.11.7: drop matches whose source file is a known-secret pattern. - // file_ops.grep returns project-relative paths, so we feed them straight - // into isSecretPath. Multiple matches in the same secret file each get - // dropped individually — they all count in the hidden tally. - const secretFilter = filterSecretEntries(reshaped, (m) => m.path); - return { - matches: secretFilter.kept, - total: secretFilter.kept.length, - truncated: result.truncated, - ...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}), - }; - }, -}; - -const FindFilesInput = z.object({ - pattern: z.string().min(1), - path: z.string().optional(), - max_results: z.number().int().positive().optional(), -}); -type FindFilesInputT = z.infer; - -export const findFiles: ToolDef = { - name: 'find_files', - description: 'Glob for filenames. Default path is project root. Max 100 results (200 cap).', - inputSchema: FindFilesInput, - jsonSchema: { - type: 'function', - function: { - name: 'find_files', - description: - 'Glob for filenames under a directory. Default path is project root. Max 100 results (cap 200). Pattern uses standard glob (e.g. "**/*.ts").', - parameters: { - type: 'object', - properties: { - pattern: { type: 'string' }, - path: { type: 'string' }, - max_results: { type: 'integer' }, - }, - required: ['pattern'], - additionalProperties: false, - }, - }, - }, - async execute(input, projectRoot, extraRoots) { - const limit = Math.min( - Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1), - MAX_FIND_RESULTS - ); - // Delegate to file_ops.findFiles; reshape { files, total, truncated } to - // preserve the LLM-visible output format { paths, total, truncated } - const result = await fileOpsFindFiles(projectRoot, input.pattern, { - path: input.path, - max_results: limit, - extra_roots: extraRoots, - }); - // v1.11.7: drop paths matching secret patterns. The original `total` - // from file_ops includes pre-truncation count; we report the visible - // count post-filter so the LLM can't infer hidden-count by subtraction. - const secretFilter = filterSecretEntries(result.files, (p) => p); - return { - paths: secretFilter.kept, - total: secretFilter.kept.length, - truncated: result.truncated, - ...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}), - }; - }, -}; - -// v1.13.5: retrieves the full content of a previously-truncated tool output -// via the opaque id stamped on the original tool_result. Line-based slicing -// matches view_file's mental model so the model uses the same affordances. -// Tmpfs-backed, 7-day TTL (see services/truncate.ts). -const VIEW_TRUNCATED_DEFAULT_LINES = 200; - -const ViewTruncatedOutputInput = z.object({ - id: z.string().regex(/^tr_[0-9a-v]{12}$/), - start_line: z.number().int().positive().optional(), - end_line: z.number().int().positive().optional(), -}); -type ViewTruncatedOutputInputT = z.infer; - -export const viewTruncatedOutput: ToolDef = { - name: 'view_truncated_output', - description: `Retrieve the full content of a previously-truncated tool output by its outputPath id. When a tool returns { truncated: true, outputPath: "tr_..." }, call this to view the full content. Defaults to the first ${VIEW_TRUNCATED_DEFAULT_LINES} lines. Use start_line and end_line (1-indexed, inclusive) to slice. Stored for 7 days.`, - inputSchema: ViewTruncatedOutputInput, - jsonSchema: { - type: 'function', - function: { - name: 'view_truncated_output', - description: `Retrieve the full content of a previously-truncated tool output by its outputPath id. Returns the first ${VIEW_TRUNCATED_DEFAULT_LINES} lines by default; use start_line/end_line to slice. Stored for 7 days.`, - parameters: { - type: 'object', - properties: { - id: { type: 'string', description: 'The outputPath value from an earlier truncated tool result (e.g. "tr_abc123def456").' }, - start_line: { type: 'integer', description: 'First line (1-indexed). Default 1.' }, - end_line: { type: 'integer', description: `Last line (1-indexed, inclusive). Default ${VIEW_TRUNCATED_DEFAULT_LINES} lines past start.` }, - }, - required: ['id'], - additionalProperties: false, - }, - }, - }, - // view_truncated_output doesn't touch the filesystem — it pulls from tmpfs - // by opaque id. extraRoots is irrelevant here; declared for signature parity - // with the v1.13.17 ToolDef contract. - async execute(input, _projectRoot, _extraRoots) { - const content = await readTruncation(input.id); - if (content === null) { - return { - id: input.id, - content: '', - truncated: false, - error: `No truncation found for id "${input.id}". It may have been pruned (7-day TTL) or never existed.`, - }; - } - const lines = content.split('\n'); - const total = lines.length; - let start = input.start_line ?? 1; - let end = input.end_line ?? Math.min(total, start + VIEW_TRUNCATED_DEFAULT_LINES - 1); - if (start < 1) start = 1; - if (end > total) end = total; - if (end < start) end = start; - const slice = lines.slice(start - 1, end).join('\n'); - // Re-slicing this view isn't truncation in the dual-write sense — the - // model already has the id; no point stashing the slice again. - const truncated = total > end || start > 1; - return { - id: input.id, - content: slice, - total_lines: total, - returned_lines: [start, end], - truncated, - }; - }, -}; - -// v1.8 Level 1 branch awareness: gives the model a read-only view of the -// project's git state. No path input — operates on the inference-resolved -// project root via getGitMeta. Subprocess runs with a 2s timeout (see git_meta). -const GitStatusInput = z.object({}).strict(); -type GitStatusInputT = z.infer; - -export const gitStatus: ToolDef = { - name: 'git_status', - description: - "Returns the current git branch, whether the working tree is dirty, and ahead/behind counts vs upstream. Read-only. Use when you need to know which branch the user is currently working on.", - inputSchema: GitStatusInput, - jsonSchema: { - type: 'function', - function: { - name: 'git_status', - description: - 'Returns the current git branch, dirty flag, and ahead/behind counts vs upstream. Read-only.', - parameters: { - type: 'object', - properties: {}, - additionalProperties: false, - }, - }, - }, - async execute(_input, projectRoot) { - const meta = await getGitMeta(projectRoot); - if (meta === null) { - return { repo: false, branch: null, is_dirty: false, ahead: 0, behind: 0 }; - } - return { repo: true, ...meta }; - }, -}; - -// Batch 9.6: skill_find, skill_use, skill_resource. Lazy-loaded markdown -// playbooks at /data/skills/. Three tools rather than one to keep each call -// cheap — the model lists, then loads, then optionally pulls support files. - -const SkillFindInput = z.object({ - query: z.string().optional(), -}); -type SkillFindInputT = z.infer; - -export const skillFind: ToolDef = { - name: 'skill_find', - description: - 'Find skills (markdown playbooks under /data/skills) by name or description. Returns up to 5 matches. Empty query or "*" returns all available skills. Call this first to discover what skills are available.', - inputSchema: SkillFindInput, - jsonSchema: { - type: 'function', - function: { - name: 'skill_find', - description: - 'Find skills by name or description. Returns up to 5 matches. Empty or "*" returns all.', - parameters: { - type: 'object', - properties: { - query: { type: 'string', description: 'substring matched against skill name and description' }, - }, - additionalProperties: false, - }, - }, - }, - async execute(input) { - return await findSkills(input.query ?? ''); - }, -}; - -const SkillUseInput = z.object({ - name: z.string().min(1), -}); -type SkillUseInputT = z.infer; - -export const skillUse: ToolDef = { - name: 'skill_use', - description: - "Load the full body of a skill's SKILL.md by name. Returns the markdown playbook to follow. Discover names via skill_find. Errors: unknown_skill.", - inputSchema: SkillUseInput, - jsonSchema: { - type: 'function', - function: { - name: 'skill_use', - description: "Load the full body of a skill's SKILL.md by name.", - parameters: { - type: 'object', - properties: { - name: { type: 'string', description: 'skill name from skill_find' }, - }, - required: ['name'], - additionalProperties: false, - }, - }, - }, - async execute(input) { - const body = await getSkillBody(input.name); - if (body === null) { - return { error: 'unknown_skill', message: `unknown skill: ${input.name}` }; - } - return { body }; - }, -}; - -const SkillResourceInput = z.object({ - name: z.string().min(1), - path: z.string().min(1), -}); -type SkillResourceInputT = z.infer; - -export const skillResource: ToolDef = { - name: 'skill_resource', - description: - "Read a support file inside a skill's folder (e.g. references/root-cause-tracing.md). Path is relative to the skill folder. Use skill_use to read SKILL.md itself. Errors: unknown_skill, unknown_resource, path_escape.", - inputSchema: SkillResourceInput, - jsonSchema: { - type: 'function', - function: { - name: 'skill_resource', - description: "Read a support file inside a skill's folder. Path is relative to the skill folder.", - parameters: { - type: 'object', - properties: { - name: { type: 'string', description: 'skill name' }, - path: { type: 'string', description: 'relative path under the skill folder' }, - }, - required: ['name', 'path'], - additionalProperties: false, - }, - }, - }, - async execute(input) { - const result = await getSkillResource(input.name, input.path); - if (!result.ok) { - return { error: result.code, message: result.message }; - } - return { content: result.content }; - }, -}; - -// Batch 9.7: ask_user_input. Interactive elicitation. The model emits a tool -// call with 1-3 structured questions; the inference loop PAUSES (does not -// execute the tool server-side, does not recurse) and waits for the frontend -// to POST /api/chats/:id/answer_user_input with the user's selections. See -// routes/messages.ts for the resume path and services/inference.ts for the -// pause branch in executeToolPhase. -const AskUserInputInput = z.object({ - questions: z - .array( - z.object({ - question: z.string().min(1).max(200), - type: z.enum(['single_select', 'multi_select']), - options: z.array(z.string().min(1).max(80)).min(2).max(6), - }), - ) - .min(1) - .max(3), -}); -type AskUserInputInputT = z.infer; - -export const askUserInput: ToolDef = { - name: 'ask_user_input', - description: - "Ask the user 1-3 structured questions through an inline picker UI. Use when you genuinely need a choice the user must make (e.g. scope, options, preferences) before continuing. Each question has 2-6 options and accepts free-text answers in addition. The tool call pauses the conversation until the user submits — the next assistant turn sees their answers as the tool result. Do not use for trivial yes/no clarifications you could infer; prefer it over multi-paragraph speculation about what the user might want.", - inputSchema: AskUserInputInput, - jsonSchema: { - type: 'function', - function: { - name: 'ask_user_input', - description: - 'Ask the user 1-3 structured questions through an inline picker. Pauses the conversation until the user answers; the next turn sees their selections.', - parameters: { - type: 'object', - properties: { - questions: { - type: 'array', - minItems: 1, - maxItems: 3, - items: { - type: 'object', - properties: { - question: { type: 'string', description: '<=200 chars, shown to the user' }, - type: { - type: 'string', - enum: ['single_select', 'multi_select'], - description: 'single_select = at most one option; multi_select = any subset', - }, - options: { - type: 'array', - minItems: 2, - maxItems: 6, - items: { type: 'string' }, - description: '2-6 strings, each <=80 chars; free-text input is always available alongside', - }, - }, - required: ['question', 'type', 'options'], - additionalProperties: false, - }, - }, - }, - required: ['questions'], - additionalProperties: false, - }, - }, - }, - // Server-side no-op. The "execution" of ask_user_input is the user's - // response, captured client-side and posted to /api/chats/:id/answer_user_input. - // The inference loop detects this tool by name and pauses before reaching - // executeToolCall — this fallback only runs if something bypasses that - // branch, in which case the pending sentinel matches the pause-path shape. - async execute(input) { - return { _pending: true, questions: input.questions }; - }, -}; - -// v1.13.3: alpha-sorted by tool.name at module load. llama.cpp's prompt -// cache hits on byte-identical prefixes; the tool list lives near the top -// of the system prompt, so any order drift would invalidate every cached -// turn. Single source of truth for ordering lives here — toolJsonSchemas() -// and TOOLS_BY_NAME inherit it. -// v1.14.1-mcp-poc: changed from ReadonlyArray to let-bound mutable array -// so appendMcpTools() can push MCP-discovered tools at startup. -export let ALL_TOOLS: ToolDef[] = [ - viewFile as ToolDef, - viewTruncatedOutput as ToolDef, - listDir as ToolDef, - grep as ToolDef, - findFiles as ToolDef, - gitStatus as ToolDef, - skillFind as ToolDef, - skillUse as ToolDef, - skillResource as ToolDef, - askUserInput as ToolDef, - // v1.11.8: web tools. Gated per-chat via session.web_search_enabled - // (with project default fallback) — see effectiveTools filter in - // services/inference.ts. - webSearch as ToolDef, - webFetch as ToolDef, - // v1.12 Track B.2: codecontext tools. Backed by the codecontext sidecar - // container. All read-only. target_dir is resolved server-side from the - // project root in codecontext_client.ts (the LLM never supplies it). - getCodebaseOverview as ToolDef, - getFileAnalysis as ToolDef, - getSymbolInfo as ToolDef, - searchSymbols as ToolDef, - getDependencies as ToolDef, - watchChanges as ToolDef, - getSemanticNeighborhoods as ToolDef, - getFrameworkAnalysis as ToolDef, - // v1.16: codesight-merge tools. Backed by the same codecontext sidecar. - getBlastRadius as ToolDef, - getHotFiles as ToolDef, - getRoutes as ToolDef, - getMiddleware as ToolDef, - // v1.13.17-cross-repo-reads: paired with the pause-on-pending-grant - // branch in tool-phase.ts. Read-only — only ever READS files; the only - // state change is appending to sessions.allowed_read_paths via the - // grant endpoint, gated by user consent. - requestReadAccess as ToolDef, - // v2.6.x: read a tab's transcript by its session-scoped tab number. - // Read-only; uses the ToolExecCtx 4th arg for DB/session access. - readTabByNumber as ToolDef, -].sort((a, b) => a.name.localeCompare(b.name)); - -// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is -// fully contained in this set gets a generous default tool budget (30); -// anything outside means the agent can mutate state and gets a tighter -// default (10). Every tool in v1.8.2 happens to be read-only, so the -// non-RO branch only takes effect once BooCoder lands write tools. -// Batch 9.6: skill_* added; all still read-only. -// Batch 9.7: ask_user_input added — it pauses execution but doesn't mutate -// project state, so it belongs in the read-only set for budget purposes. -export const READ_ONLY_TOOL_NAMES = [ - 'view_file', - 'view_truncated_output', - 'list_dir', - 'grep', - 'find_files', - 'git_status', - 'skill_find', - 'skill_use', - 'skill_resource', - 'ask_user_input', - // v1.11.8: web tools don't mutate project state; counted as read-only - // for the budget-tier calculation (BUDGET_READ_ONLY=30) when an agent's - // toolset is fully contained in this list. - 'web_search', - 'web_fetch', - // v1.12 Track B.2: codecontext tools. Read-only — they call the - // codecontext sidecar which only analyzes files (never writes). - 'get_codebase_overview', - 'get_file_analysis', - 'get_symbol_info', - 'search_symbols', - 'get_dependencies', - 'watch_changes', - 'get_semantic_neighborhoods', - 'get_framework_analysis', - // v1.13.17-cross-repo-reads: pauses execution but doesn't mutate project - // state directly (the grant endpoint appends to sessions.allowed_read_paths - // only with user consent). Belongs in the read-only budget tier. - 'request_read_access', - // v2.6.x: reads a tab's transcript from session-scoped DB state; never - // writes. Belongs in the read-only budget tier. - 'read_tab_by_number', -] as const; - -export let TOOLS_BY_NAME: Record> = Object.fromEntries( - ALL_TOOLS.map((t) => [t.name, t]) -); - -// v1.14.1-mcp-poc: append MCP-discovered tools at startup. Called once -// from index.ts after mcpClient.initialize(). Re-sorts ALL_TOOLS and -// rebuilds TOOLS_BY_NAME. READ_ONLY_TOOL_NAMES is not rebuilt because -// it's a const tuple used only for budget-tier checks; MCP tools are -// individually checked via their category at budget resolution time — -// they are all read_only by construction (the read-only guard in -// mcp-client.ts rejects any tool with readOnlyHint: false). -export function appendMcpTools(mcpTools: ToolDef[]): void { - if (mcpTools.length === 0) return; - ALL_TOOLS = [...ALL_TOOLS, ...mcpTools].sort((a, b) => a.name.localeCompare(b.name)); - TOOLS_BY_NAME = Object.fromEntries(ALL_TOOLS.map((t) => [t.name, t])); -} - -// v1.13.15-tools: tiered tool loading. BOOCODE_TOOLS env var (`core` | -// `standard` | `all`) filters the agent's tool whitelist before LLM dispatch. -// Daily-driver token win on qwen3.6-35b-a3b — the 35B-A3B MoE benefits from -// any prompt-cache stability win (fewer tools = shorter, more stable tool -// schemas in the system prompt). Pattern lift from eyaltoledano/claude-task- -// master (MIT + Commons Clause — pattern only, no code lift). +// Tool registry barrel. The implementation was split into focused modules +// under ./tools/ (Sketch 4) while this file stays the stable public surface: +// every import of './tools.js' and the @boocode/server/tools subpath (consumed +// by apps/coder) resolves through here unchanged. The exports-map path +// (dist/services/tools.js) is preserved. // -// The env var is a CEILING. It only narrows; never expands an agent's -// declared whitelist. Default behavior (var unset) is unchanged: all tools. -export const CORE_TOOL_NAMES = [ - 'view_file', - 'list_dir', - 'grep', - 'find_files', -] as const; +// ./tools/types.ts — ToolDef / ToolJsonSchema / ToolExecCtx +// ./tools/fs-tools.ts — filesystem ToolDefs (view_file/list_dir/grep/ +// find_files/view_truncated_output) +// ./tools/misc-tools.ts— git_status/skill_*/ask_user_input ToolDefs +// ./tools/registry.ts — ALL_TOOLS/TOOLS_BY_NAME (register-through let +// bindings), appendMcpTools, toolJsonSchemas +// ./tools/tiers.ts — CORE/STANDARD names + module-load validation + +// resolveToolTier +// +// Re-exporting the `let`-bound ALL_TOOLS / TOOLS_BY_NAME preserves the +// register-through MCP-discovery contract: appendMcpTools() reassigns the +// bindings in registry.ts and ESM live bindings make the mutation visible +// through this barrel to every consumer (incl. apps/coder). -export const STANDARD_TOOL_NAMES = [ - ...CORE_TOOL_NAMES, - 'web_search', - 'web_fetch', - 'git_status', - 'get_codebase_overview', - 'get_file_analysis', - 'get_symbol_info', - 'search_symbols', - 'get_dependencies', - 'watch_changes', - 'get_semantic_neighborhoods', - 'get_framework_analysis', -] as const; - -// Module-load validation: every name in CORE / STANDARD must exist in -// TOOLS_BY_NAME. Catches typos and stale tier definitions before they reach -// production; server boot fails loudly rather than silently filtering valid -// tools out of agent whitelists. -for (const name of CORE_TOOL_NAMES) { - if (!TOOLS_BY_NAME[name]) { - throw new Error(`CORE_TOOL_NAMES references unknown tool: '${name}'`); - } -} -for (const name of STANDARD_TOOL_NAMES) { - if (!TOOLS_BY_NAME[name]) { - throw new Error(`STANDARD_TOOL_NAMES references unknown tool: '${name}'`); - } -} - -export function resolveToolTier(tier: string | undefined): readonly string[] { - switch ((tier ?? 'all').toLowerCase()) { - case 'core': - return CORE_TOOL_NAMES; - case 'standard': - return STANDARD_TOOL_NAMES; - case 'all': - default: - return ALL_TOOLS.map((t) => t.name); - } -} - -export function toolJsonSchemas(): ToolJsonSchema[] { - return ALL_TOOLS.map((t) => t.jsonSchema); -} +export type { ToolDef, ToolJsonSchema, ToolExecCtx } from './tools/types.js'; +export { + viewFile, + listDir, + grep, + findFiles, + viewTruncatedOutput, +} from './tools/fs-tools.js'; +export { + gitStatus, + skillFind, + skillUse, + skillResource, + askUserInput, +} from './tools/misc-tools.js'; +export { + ALL_TOOLS, + TOOLS_BY_NAME, + appendMcpTools, + toolJsonSchemas, +} from './tools/registry.js'; +export { + CORE_TOOL_NAMES, + STANDARD_TOOL_NAMES, + resolveToolTier, +} from './tools/tiers.js'; diff --git a/apps/server/src/services/tools/codecontext/factory.ts b/apps/server/src/services/tools/codecontext/factory.ts new file mode 100644 index 0000000..c9df00a --- /dev/null +++ b/apps/server/src/services/tools/codecontext/factory.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; +import type { ToolDef } from '../types.js'; +import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; + +// Shared factory for the 12 codecontext shim ToolDefs. +// Each shim provides name/schema/description/jsonParameters/mapArgs; the +// factory builds the ToolDef and returns both the ToolDef and the standalone +// execute function (used by tests that inject a custom fetcher). +export function makeCodecontextTool(opts: { + name: string; + schema: z.ZodType; + description: string; + jsonParameters: Record; + mapArgs: (input: TInput) => Record; +}): { + toolDef: ToolDef; + execute: (input: TInput, projectPath: string, fetcher?: typeof fetch) => Promise; +} { + const { name, schema, description, jsonParameters, mapArgs } = opts; + + async function execute( + input: TInput, + projectPath: string, + fetcher: typeof fetch = fetch, + ): Promise { + return callCodecontext({ toolName: name, args: mapArgs(input), projectPath }, fetcher); + } + + const toolDef: ToolDef = { + name, + description, + inputSchema: schema, + jsonSchema: { + type: 'function', + function: { name, description, parameters: jsonParameters }, + }, + async execute(input, projectRoot) { + return execute(input, projectRoot); + }, + }; + + return { toolDef, execute }; +} diff --git a/apps/server/src/services/tools/codecontext/get_blast_radius.ts b/apps/server/src/services/tools/codecontext/get_blast_radius.ts index cdfbbe6..1f6fa7d 100644 --- a/apps/server/src/services/tools/codecontext/get_blast_radius.ts +++ b/apps/server/src/services/tools/codecontext/get_blast_radius.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; -import type { ToolDef } from '../../tools.js'; -import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; +import { makeCodecontextTool } from './factory.js'; export const GetBlastRadiusInput = z.object({ file_path: z.string().trim().min(1), @@ -12,40 +11,23 @@ const DESCRIPTION = 'Use to assess the impact of changing a file — "what breaks if I modify this?" ' + 'Traverses the import graph in reverse via BFS. Results sorted by distance (closest dependents first).'; -export async function executeGetBlastRadius( - input: GetBlastRadiusInputT, - projectPath: string, - fetcher: typeof fetch = fetch, -): Promise { - return callCodecontext( - { toolName: 'get_blast_radius', args: { file_path: input.file_path }, projectPath }, - fetcher, - ); -} - -export const getBlastRadius: ToolDef = { - name: 'get_blast_radius', - description: DESCRIPTION, - inputSchema: GetBlastRadiusInput, - jsonSchema: { - type: 'function', - function: { - name: 'get_blast_radius', - description: DESCRIPTION, - parameters: { - type: 'object', - properties: { - file_path: { - type: 'string', - description: 'Absolute or project-relative path to the file to analyze.', - }, +const { toolDef: getBlastRadius, execute: executeGetBlastRadius } = + makeCodecontextTool({ + name: 'get_blast_radius', + schema: GetBlastRadiusInput, + description: DESCRIPTION, + jsonParameters: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'Absolute or project-relative path to the file to analyze.', }, - required: ['file_path'], - additionalProperties: false, }, + required: ['file_path'], + additionalProperties: false, }, - }, - async execute(input, projectRoot) { - return await executeGetBlastRadius(input, projectRoot); - }, -}; + mapArgs: (input) => ({ file_path: input.file_path }), + }); + +export { getBlastRadius, executeGetBlastRadius }; diff --git a/apps/server/src/services/tools/codecontext/get_codebase_overview.ts b/apps/server/src/services/tools/codecontext/get_codebase_overview.ts index c624c09..4ede615 100644 --- a/apps/server/src/services/tools/codecontext/get_codebase_overview.ts +++ b/apps/server/src/services/tools/codecontext/get_codebase_overview.ts @@ -1,10 +1,5 @@ -// v1.12 Track B.2: codecontext wrapper — get_codebase_overview. -// Pattern mirrors services/web_search.ts: pure executor + ToolDef wrapper. -// target_dir is supplied by callCodecontext from the resolved project root. - import { z } from 'zod'; -import type { ToolDef } from '../../tools.js'; -import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; +import { makeCodecontextTool } from './factory.js'; export const GetCodebaseOverviewInput = z.object({ include_stats: z.boolean().optional(), @@ -17,43 +12,22 @@ const DESCRIPTION = 'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript symbols are approximate (uses JS grammar). ' + 'PHP and SQL are not supported — fall back to view_file/grep for those.'; -export async function executeGetCodebaseOverview( - input: GetCodebaseOverviewInputT, - projectPath: string, - fetcher: typeof fetch = fetch, -): Promise { - return callCodecontext( - { - toolName: 'get_codebase_overview', - args: { include_stats: input.include_stats ?? true }, - projectPath, - }, - fetcher, - ); -} - -export const getCodebaseOverview: ToolDef = { - name: 'get_codebase_overview', - description: DESCRIPTION, - inputSchema: GetCodebaseOverviewInput, - jsonSchema: { - type: 'function', - function: { - name: 'get_codebase_overview', - description: DESCRIPTION, - parameters: { - type: 'object', - properties: { - include_stats: { - type: 'boolean', - description: 'Include file count, symbol count, language stats. Defaults to true.', - }, +const { toolDef: getCodebaseOverview, execute: executeGetCodebaseOverview } = + makeCodecontextTool({ + name: 'get_codebase_overview', + schema: GetCodebaseOverviewInput, + description: DESCRIPTION, + jsonParameters: { + type: 'object', + properties: { + include_stats: { + type: 'boolean', + description: 'Include file count, symbol count, language stats. Defaults to true.', }, - additionalProperties: false, }, + additionalProperties: false, }, - }, - async execute(input, projectRoot) { - return await executeGetCodebaseOverview(input, projectRoot); - }, -}; + mapArgs: (input) => ({ include_stats: input.include_stats ?? true }), + }); + +export { getCodebaseOverview, executeGetCodebaseOverview }; diff --git a/apps/server/src/services/tools/codecontext/get_dependencies.ts b/apps/server/src/services/tools/codecontext/get_dependencies.ts index 3a86c5c..25e630e 100644 --- a/apps/server/src/services/tools/codecontext/get_dependencies.ts +++ b/apps/server/src/services/tools/codecontext/get_dependencies.ts @@ -1,8 +1,5 @@ -// v1.12 Track B.2: codecontext wrapper — get_dependencies. - import { z } from 'zod'; -import type { ToolDef } from '../../tools.js'; -import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; +import { makeCodecontextTool } from './factory.js'; export const GetDependenciesInput = z.object({ file_path: z.string().trim().optional(), @@ -16,45 +13,31 @@ const DESCRIPTION = 'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript dependencies are approximate. ' + 'PHP and SQL are not supported.'; -export async function executeGetDependencies( - input: GetDependenciesInputT, - projectPath: string, - fetcher: typeof fetch = fetch, -): Promise { - const args: Record = { - direction: input.direction ?? 'both', - }; - if (input.file_path) args['file_path'] = input.file_path; - return callCodecontext({ toolName: 'get_dependencies', args, projectPath }, fetcher); -} - -export const getDependencies: ToolDef = { - name: 'get_dependencies', - description: DESCRIPTION, - inputSchema: GetDependenciesInput, - jsonSchema: { - type: 'function', - function: { - name: 'get_dependencies', - description: DESCRIPTION, - parameters: { - type: 'object', - properties: { - file_path: { - type: 'string', - description: 'Narrow to a single file. Omit for a project-wide graph.', - }, - direction: { - type: 'string', - enum: ['incoming', 'outgoing', 'both'], - description: 'Which edges to include. Defaults to "both".', - }, +const { toolDef: getDependencies, execute: executeGetDependencies } = + makeCodecontextTool({ + name: 'get_dependencies', + schema: GetDependenciesInput, + description: DESCRIPTION, + jsonParameters: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'Narrow to a single file. Omit for a project-wide graph.', + }, + direction: { + type: 'string', + enum: ['incoming', 'outgoing', 'both'], + description: 'Which edges to include. Defaults to "both".', }, - additionalProperties: false, }, + additionalProperties: false, }, - }, - async execute(input, projectRoot) { - return await executeGetDependencies(input, projectRoot); - }, -}; + mapArgs: (input) => { + const args: Record = { direction: input.direction ?? 'both' }; + if (input.file_path) args['file_path'] = input.file_path; + return args; + }, + }); + +export { getDependencies, executeGetDependencies }; diff --git a/apps/server/src/services/tools/codecontext/get_file_analysis.ts b/apps/server/src/services/tools/codecontext/get_file_analysis.ts index 3c64899..daf90d3 100644 --- a/apps/server/src/services/tools/codecontext/get_file_analysis.ts +++ b/apps/server/src/services/tools/codecontext/get_file_analysis.ts @@ -1,8 +1,5 @@ -// v1.12 Track B.2: codecontext wrapper — get_file_analysis. - import { z } from 'zod'; -import type { ToolDef } from '../../tools.js'; -import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; +import { makeCodecontextTool } from './factory.js'; export const GetFileAnalysisInput = z.object({ file_path: z.string().trim().min(1), @@ -15,44 +12,23 @@ const DESCRIPTION = 'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript symbols are approximate. ' + 'PHP and SQL are not supported — fall back to view_file for those.'; -export async function executeGetFileAnalysis( - input: GetFileAnalysisInputT, - projectPath: string, - fetcher: typeof fetch = fetch, -): Promise { - return callCodecontext( - { - toolName: 'get_file_analysis', - args: { file_path: input.file_path }, - projectPath, - }, - fetcher, - ); -} - -export const getFileAnalysis: ToolDef = { - name: 'get_file_analysis', - description: DESCRIPTION, - inputSchema: GetFileAnalysisInput, - jsonSchema: { - type: 'function', - function: { - name: 'get_file_analysis', - description: DESCRIPTION, - parameters: { - type: 'object', - properties: { - file_path: { - type: 'string', - description: 'Absolute or project-relative path to the file.', - }, +const { toolDef: getFileAnalysis, execute: executeGetFileAnalysis } = + makeCodecontextTool({ + name: 'get_file_analysis', + schema: GetFileAnalysisInput, + description: DESCRIPTION, + jsonParameters: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'Absolute or project-relative path to the file.', }, - required: ['file_path'], - additionalProperties: false, }, + required: ['file_path'], + additionalProperties: false, }, - }, - async execute(input, projectRoot) { - return await executeGetFileAnalysis(input, projectRoot); - }, -}; + mapArgs: (input) => ({ file_path: input.file_path }), + }); + +export { getFileAnalysis, executeGetFileAnalysis }; diff --git a/apps/server/src/services/tools/codecontext/get_framework_analysis.ts b/apps/server/src/services/tools/codecontext/get_framework_analysis.ts index 8126e90..b23bf5a 100644 --- a/apps/server/src/services/tools/codecontext/get_framework_analysis.ts +++ b/apps/server/src/services/tools/codecontext/get_framework_analysis.ts @@ -1,8 +1,5 @@ -// v1.12 Track B.2: codecontext wrapper — get_framework_analysis. - import { z } from 'zod'; -import type { ToolDef } from '../../tools.js'; -import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; +import { makeCodecontextTool } from './factory.js'; export const GetFrameworkAnalysisInput = z.object({ framework: z.string().optional(), @@ -16,43 +13,31 @@ const DESCRIPTION = 'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript is approximate. ' + 'PHP and SQL are not supported.'; -export async function executeGetFrameworkAnalysis( - input: GetFrameworkAnalysisInputT, - projectPath: string, - fetcher: typeof fetch = fetch, -): Promise { - const args: Record = {}; - if (input.framework) args['framework'] = input.framework; - if (input.include_stats !== undefined) args['include_stats'] = input.include_stats; - return callCodecontext({ toolName: 'get_framework_analysis', args, projectPath }, fetcher); -} - -export const getFrameworkAnalysis: ToolDef = { - name: 'get_framework_analysis', - description: DESCRIPTION, - inputSchema: GetFrameworkAnalysisInput, - jsonSchema: { - type: 'function', - function: { - name: 'get_framework_analysis', - description: DESCRIPTION, - parameters: { - type: 'object', - properties: { - framework: { - type: 'string', - description: 'Framework name. Auto-detected if omitted.', - }, - include_stats: { - type: 'boolean', - description: 'Include component/hook/service counts.', - }, +const { toolDef: getFrameworkAnalysis, execute: executeGetFrameworkAnalysis } = + makeCodecontextTool({ + name: 'get_framework_analysis', + schema: GetFrameworkAnalysisInput, + description: DESCRIPTION, + jsonParameters: { + type: 'object', + properties: { + framework: { + type: 'string', + description: 'Framework name. Auto-detected if omitted.', + }, + include_stats: { + type: 'boolean', + description: 'Include component/hook/service counts.', }, - additionalProperties: false, }, + additionalProperties: false, }, - }, - async execute(input, projectRoot) { - return await executeGetFrameworkAnalysis(input, projectRoot); - }, -}; + mapArgs: (input) => { + const args: Record = {}; + if (input.framework) args['framework'] = input.framework; + if (input.include_stats !== undefined) args['include_stats'] = input.include_stats; + return args; + }, + }); + +export { getFrameworkAnalysis, executeGetFrameworkAnalysis }; diff --git a/apps/server/src/services/tools/codecontext/get_hot_files.ts b/apps/server/src/services/tools/codecontext/get_hot_files.ts index 337a4d0..cb62d4f 100644 --- a/apps/server/src/services/tools/codecontext/get_hot_files.ts +++ b/apps/server/src/services/tools/codecontext/get_hot_files.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; -import type { ToolDef } from '../../tools.js'; -import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; +import { makeCodecontextTool } from './factory.js'; export const GetHotFilesInput = z.object({ limit: z.number().int().min(1).max(100).optional(), @@ -12,39 +11,22 @@ const DESCRIPTION = 'Hot files are high-risk change targets — many other files depend on them. ' + 'Use to identify core modules and assess refactoring risk.'; -export async function executeGetHotFiles( - input: GetHotFilesInputT, - projectPath: string, - fetcher: typeof fetch = fetch, -): Promise { - return callCodecontext( - { toolName: 'get_hot_files', args: input.limit != null ? { limit: input.limit } : {}, projectPath }, - fetcher, - ); -} - -export const getHotFiles: ToolDef = { - name: 'get_hot_files', - description: DESCRIPTION, - inputSchema: GetHotFilesInput, - jsonSchema: { - type: 'function', - function: { - name: 'get_hot_files', - description: DESCRIPTION, - parameters: { - type: 'object', - properties: { - limit: { - type: 'number', - description: 'Maximum number of files to return (default 20, max 100).', - }, +const { toolDef: getHotFiles, execute: executeGetHotFiles } = + makeCodecontextTool({ + name: 'get_hot_files', + schema: GetHotFilesInput, + description: DESCRIPTION, + jsonParameters: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Maximum number of files to return (default 20, max 100).', }, - additionalProperties: false, }, + additionalProperties: false, }, - }, - async execute(input, projectRoot) { - return await executeGetHotFiles(input, projectRoot); - }, -}; + mapArgs: (input) => (input.limit != null ? { limit: input.limit } : {}), + }); + +export { getHotFiles, executeGetHotFiles }; diff --git a/apps/server/src/services/tools/codecontext/get_middleware.ts b/apps/server/src/services/tools/codecontext/get_middleware.ts index 3ced203..b07eddd 100644 --- a/apps/server/src/services/tools/codecontext/get_middleware.ts +++ b/apps/server/src/services/tools/codecontext/get_middleware.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; -import type { ToolDef } from '../../tools.js'; -import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; +import { makeCodecontextTool } from './factory.js'; export const GetMiddlewareInput = z.object({}); export type GetMiddlewareInputT = z.infer; @@ -11,31 +10,17 @@ const DESCRIPTION = 'import names (@fastify/cors, helmet, etc.) and registration patterns ' + '(app.register, app.addHook, app.setErrorHandler).'; -export async function executeGetMiddleware( - _input: GetMiddlewareInputT, - projectPath: string, - fetcher: typeof fetch = fetch, -): Promise { - return callCodecontext({ toolName: 'get_middleware', args: {}, projectPath }, fetcher); -} - -export const getMiddleware: ToolDef = { - name: 'get_middleware', - description: DESCRIPTION, - inputSchema: GetMiddlewareInput, - jsonSchema: { - type: 'function', - function: { - name: 'get_middleware', - description: DESCRIPTION, - parameters: { - type: 'object', - properties: {}, - additionalProperties: false, - }, +const { toolDef: getMiddleware, execute: executeGetMiddleware } = + makeCodecontextTool({ + name: 'get_middleware', + schema: GetMiddlewareInput, + description: DESCRIPTION, + jsonParameters: { + type: 'object', + properties: {}, + additionalProperties: false, }, - }, - async execute(input, projectRoot) { - return await executeGetMiddleware(input, projectRoot); - }, -}; + mapArgs: () => ({}), + }); + +export { getMiddleware, executeGetMiddleware }; diff --git a/apps/server/src/services/tools/codecontext/get_routes.ts b/apps/server/src/services/tools/codecontext/get_routes.ts index ebaaff1..5758a74 100644 --- a/apps/server/src/services/tools/codecontext/get_routes.ts +++ b/apps/server/src/services/tools/codecontext/get_routes.ts @@ -1,6 +1,5 @@ import { z } from 'zod'; -import type { ToolDef } from '../../tools.js'; -import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; +import { makeCodecontextTool } from './factory.js'; export const GetRoutesInput = z.object({ framework: z.string().trim().optional(), @@ -13,38 +12,26 @@ const DESCRIPTION = 'with method, path, file, line number, and inferred tags (db, auth, cache). ' + 'Optional framework filter narrows to "fastify" or "express".'; -export async function executeGetRoutes( - input: GetRoutesInputT, - projectPath: string, - fetcher: typeof fetch = fetch, -): Promise { - const args: Record = {}; - if (input.framework) args.framework = input.framework; - return callCodecontext({ toolName: 'get_routes', args, projectPath }, fetcher); -} - -export const getRoutes: ToolDef = { - name: 'get_routes', - description: DESCRIPTION, - inputSchema: GetRoutesInput, - jsonSchema: { - type: 'function', - function: { - name: 'get_routes', - description: DESCRIPTION, - parameters: { - type: 'object', - properties: { - framework: { - type: 'string', - description: 'Filter to a specific framework: "fastify" or "express". Omit for all.', - }, +const { toolDef: getRoutes, execute: executeGetRoutes } = + makeCodecontextTool({ + name: 'get_routes', + schema: GetRoutesInput, + description: DESCRIPTION, + jsonParameters: { + type: 'object', + properties: { + framework: { + type: 'string', + description: 'Filter to a specific framework: "fastify" or "express". Omit for all.', }, - additionalProperties: false, }, + additionalProperties: false, }, - }, - async execute(input, projectRoot) { - return await executeGetRoutes(input, projectRoot); - }, -}; + mapArgs: (input) => { + const args: Record = {}; + if (input.framework) args.framework = input.framework; + return args; + }, + }); + +export { getRoutes, executeGetRoutes }; diff --git a/apps/server/src/services/tools/codecontext/get_semantic_neighborhoods.ts b/apps/server/src/services/tools/codecontext/get_semantic_neighborhoods.ts index ffe845b..3e2558b 100644 --- a/apps/server/src/services/tools/codecontext/get_semantic_neighborhoods.ts +++ b/apps/server/src/services/tools/codecontext/get_semantic_neighborhoods.ts @@ -1,8 +1,5 @@ -// v1.12 Track B.2: codecontext wrapper — get_semantic_neighborhoods. - import { z } from 'zod'; -import type { ToolDef } from '../../tools.js'; -import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; +import { makeCodecontextTool } from './factory.js'; export const GetSemanticNeighborhoodsInput = z.object({ file_path: z.string().trim().optional(), @@ -20,54 +17,42 @@ const DESCRIPTION = const DEFAULT_MAX_RESULTS = 10; -export async function executeGetSemanticNeighborhoods( - input: GetSemanticNeighborhoodsInputT, - projectPath: string, - fetcher: typeof fetch = fetch, -): Promise { - const args: Record = { - max_results: input.max_results ?? DEFAULT_MAX_RESULTS, - }; - if (input.file_path) args['file_path'] = input.file_path; - if (input.include_basic !== undefined) args['include_basic'] = input.include_basic; - if (input.include_quality !== undefined) args['include_quality'] = input.include_quality; - return callCodecontext({ toolName: 'get_semantic_neighborhoods', args, projectPath }, fetcher); -} - -export const getSemanticNeighborhoods: ToolDef = { - name: 'get_semantic_neighborhoods', - description: DESCRIPTION, - inputSchema: GetSemanticNeighborhoodsInput, - jsonSchema: { - type: 'function', - function: { - name: 'get_semantic_neighborhoods', - description: DESCRIPTION, - parameters: { - type: 'object', - properties: { - file_path: { - type: 'string', - description: 'Anchor file for the neighborhood query. Omit for a project-wide view.', - }, - include_basic: { - type: 'boolean', - description: 'Include the basic (import-based) neighborhood. Default true.', - }, - include_quality: { - type: 'boolean', - description: 'Include code-quality metrics for the neighborhood. Default false.', - }, - max_results: { - type: 'integer', - description: `Cap on neighborhoods returned. Defaults to ${DEFAULT_MAX_RESULTS}.`, - }, +const { toolDef: getSemanticNeighborhoods, execute: executeGetSemanticNeighborhoods } = + makeCodecontextTool({ + name: 'get_semantic_neighborhoods', + schema: GetSemanticNeighborhoodsInput, + description: DESCRIPTION, + jsonParameters: { + type: 'object', + properties: { + file_path: { + type: 'string', + description: 'Anchor file for the neighborhood query. Omit for a project-wide view.', + }, + include_basic: { + type: 'boolean', + description: 'Include the basic (import-based) neighborhood. Default true.', + }, + include_quality: { + type: 'boolean', + description: 'Include code-quality metrics for the neighborhood. Default false.', + }, + max_results: { + type: 'integer', + description: `Cap on neighborhoods returned. Defaults to ${DEFAULT_MAX_RESULTS}.`, }, - additionalProperties: false, }, + additionalProperties: false, }, - }, - async execute(input, projectRoot) { - return await executeGetSemanticNeighborhoods(input, projectRoot); - }, -}; + mapArgs: (input) => { + const args: Record = { + max_results: input.max_results ?? DEFAULT_MAX_RESULTS, + }; + if (input.file_path) args['file_path'] = input.file_path; + if (input.include_basic !== undefined) args['include_basic'] = input.include_basic; + if (input.include_quality !== undefined) args['include_quality'] = input.include_quality; + return args; + }, + }); + +export { getSemanticNeighborhoods, executeGetSemanticNeighborhoods }; diff --git a/apps/server/src/services/tools/codecontext/get_symbol_info.ts b/apps/server/src/services/tools/codecontext/get_symbol_info.ts index ccaf793..85e8491 100644 --- a/apps/server/src/services/tools/codecontext/get_symbol_info.ts +++ b/apps/server/src/services/tools/codecontext/get_symbol_info.ts @@ -1,8 +1,5 @@ -// v1.12 Track B.2: codecontext wrapper — get_symbol_info. - import { z } from 'zod'; -import type { ToolDef } from '../../tools.js'; -import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; +import { makeCodecontextTool } from './factory.js'; export const GetSymbolInfoInput = z.object({ symbol_name: z.string().min(1), @@ -16,48 +13,36 @@ const DESCRIPTION = 'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript symbols are approximate (uses JS grammar). ' + 'PHP and SQL are not supported — fall back to grep for those.'; -export async function executeGetSymbolInfo( - input: GetSymbolInfoInputT, - projectPath: string, - fetcher: typeof fetch = fetch, -): Promise { - const args: Record = { symbol_name: input.symbol_name }; - if (input.file_path) args['file_path'] = input.file_path; - if (input.framework_type) args['framework_type'] = input.framework_type; - return callCodecontext({ toolName: 'get_symbol_info', args, projectPath }, fetcher); -} - -export const getSymbolInfo: ToolDef = { - name: 'get_symbol_info', - description: DESCRIPTION, - inputSchema: GetSymbolInfoInput, - jsonSchema: { - type: 'function', - function: { - name: 'get_symbol_info', - description: DESCRIPTION, - parameters: { - type: 'object', - properties: { - symbol_name: { - type: 'string', - description: 'The symbol name to look up (case-sensitive).', - }, - file_path: { - type: 'string', - description: 'Narrow to a specific file when the symbol name is ambiguous.', - }, - framework_type: { - type: 'string', - description: 'Hint for framework-specific extraction (react|vue|svelte|django|fastapi|express|nest|…).', - }, +const { toolDef: getSymbolInfo, execute: executeGetSymbolInfo } = + makeCodecontextTool({ + name: 'get_symbol_info', + schema: GetSymbolInfoInput, + description: DESCRIPTION, + jsonParameters: { + type: 'object', + properties: { + symbol_name: { + type: 'string', + description: 'The symbol name to look up (case-sensitive).', + }, + file_path: { + type: 'string', + description: 'Narrow to a specific file when the symbol name is ambiguous.', + }, + framework_type: { + type: 'string', + description: 'Hint for framework-specific extraction (react|vue|svelte|django|fastapi|express|nest|…).', }, - required: ['symbol_name'], - additionalProperties: false, }, + required: ['symbol_name'], + additionalProperties: false, }, - }, - async execute(input, projectRoot) { - return await executeGetSymbolInfo(input, projectRoot); - }, -}; + mapArgs: (input) => { + const args: Record = { symbol_name: input.symbol_name }; + if (input.file_path) args['file_path'] = input.file_path; + if (input.framework_type) args['framework_type'] = input.framework_type; + return args; + }, + }); + +export { getSymbolInfo, executeGetSymbolInfo }; diff --git a/apps/server/src/services/tools/codecontext/search_symbols.ts b/apps/server/src/services/tools/codecontext/search_symbols.ts index b5db808..52a95dc 100644 --- a/apps/server/src/services/tools/codecontext/search_symbols.ts +++ b/apps/server/src/services/tools/codecontext/search_symbols.ts @@ -1,8 +1,5 @@ -// v1.12 Track B.2: codecontext wrapper — search_symbols. - import { z } from 'zod'; -import type { ToolDef } from '../../tools.js'; -import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; +import { makeCodecontextTool } from './factory.js'; export const SearchSymbolsInput = z.object({ query: z.string().min(1), @@ -21,57 +18,45 @@ const DESCRIPTION = const DEFAULT_LIMIT = 20; -export async function executeSearchSymbols( - input: SearchSymbolsInputT, - projectPath: string, - fetcher: typeof fetch = fetch, -): Promise { - const args: Record = { - query: input.query, - limit: input.limit ?? DEFAULT_LIMIT, - }; - if (input.file_type) args['file_type'] = input.file_type; - if (input.symbol_type) args['symbol_type'] = input.symbol_type; - if (input.framework_type) args['framework_type'] = input.framework_type; - return callCodecontext({ toolName: 'search_symbols', args, projectPath }, fetcher); -} - -export const searchSymbols: ToolDef = { - name: 'search_symbols', - description: DESCRIPTION, - inputSchema: SearchSymbolsInput, - jsonSchema: { - type: 'function', - function: { - name: 'search_symbols', - description: DESCRIPTION, - parameters: { - type: 'object', - properties: { - query: { type: 'string', description: 'Substring or name fragment to match.' }, - file_type: { - type: 'string', - description: 'Filter by file extension or language (e.g. "ts", "py", "go").', - }, - symbol_type: { - type: 'string', - description: 'Filter by kind: function|class|method|variable|type|interface.', - }, - framework_type: { - type: 'string', - description: 'Filter by framework context (react|vue|svelte|…).', - }, - limit: { - type: 'integer', - description: `Max matches to return. Defaults to ${DEFAULT_LIMIT}.`, - }, +const { toolDef: searchSymbols, execute: executeSearchSymbols } = + makeCodecontextTool({ + name: 'search_symbols', + schema: SearchSymbolsInput, + description: DESCRIPTION, + jsonParameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Substring or name fragment to match.' }, + file_type: { + type: 'string', + description: 'Filter by file extension or language (e.g. "ts", "py", "go").', + }, + symbol_type: { + type: 'string', + description: 'Filter by kind: function|class|method|variable|type|interface.', + }, + framework_type: { + type: 'string', + description: 'Filter by framework context (react|vue|svelte|…).', + }, + limit: { + type: 'integer', + description: `Max matches to return. Defaults to ${DEFAULT_LIMIT}.`, }, - required: ['query'], - additionalProperties: false, }, + required: ['query'], + additionalProperties: false, }, - }, - async execute(input, projectRoot) { - return await executeSearchSymbols(input, projectRoot); - }, -}; + mapArgs: (input) => { + const args: Record = { + query: input.query, + limit: input.limit ?? DEFAULT_LIMIT, + }; + if (input.file_type) args['file_type'] = input.file_type; + if (input.symbol_type) args['symbol_type'] = input.symbol_type; + if (input.framework_type) args['framework_type'] = input.framework_type; + return args; + }, + }); + +export { searchSymbols, executeSearchSymbols }; diff --git a/apps/server/src/services/tools/codecontext/watch_changes.ts b/apps/server/src/services/tools/codecontext/watch_changes.ts index 437f1c0..2c936c1 100644 --- a/apps/server/src/services/tools/codecontext/watch_changes.ts +++ b/apps/server/src/services/tools/codecontext/watch_changes.ts @@ -1,8 +1,5 @@ -// v1.12 Track B.2: codecontext wrapper — watch_changes. - import { z } from 'zod'; -import type { ToolDef } from '../../tools.js'; -import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js'; +import { makeCodecontextTool } from './factory.js'; export const WatchChangesInput = z.object({ enable: z.boolean(), @@ -10,48 +7,27 @@ export const WatchChangesInput = z.object({ export type WatchChangesInputT = z.infer; const DESCRIPTION = - 'Turn codecontext\'s file watcher on or off for this project. ' + + "Turn codecontext's file watcher on or off for this project. " + 'When on, codecontext re-analyzes files in the background as they change (debounced). Default is on. ' + - 'Disable temporarily if you\'re doing bulk edits and want to avoid analysis churn.'; + "Disable temporarily if you're doing bulk edits and want to avoid analysis churn."; -export async function executeWatchChanges( - input: WatchChangesInputT, - projectPath: string, - fetcher: typeof fetch = fetch, -): Promise { - return callCodecontext( - { - toolName: 'watch_changes', - args: { enable: input.enable }, - projectPath, - }, - fetcher, - ); -} - -export const watchChanges: ToolDef = { - name: 'watch_changes', - description: DESCRIPTION, - inputSchema: WatchChangesInput, - jsonSchema: { - type: 'function', - function: { - name: 'watch_changes', - description: DESCRIPTION, - parameters: { - type: 'object', - properties: { - enable: { - type: 'boolean', - description: 'true = enable the watcher; false = disable.', - }, +const { toolDef: watchChanges, execute: executeWatchChanges } = + makeCodecontextTool({ + name: 'watch_changes', + schema: WatchChangesInput, + description: DESCRIPTION, + jsonParameters: { + type: 'object', + properties: { + enable: { + type: 'boolean', + description: 'true = enable the watcher; false = disable.', }, - required: ['enable'], - additionalProperties: false, }, + required: ['enable'], + additionalProperties: false, }, - }, - async execute(input, projectRoot) { - return await executeWatchChanges(input, projectRoot); - }, -}; + mapArgs: (input) => ({ enable: input.enable }), + }); + +export { watchChanges, executeWatchChanges }; diff --git a/apps/server/src/services/tools/fs-tools.ts b/apps/server/src/services/tools/fs-tools.ts new file mode 100644 index 0000000..c42ac66 --- /dev/null +++ b/apps/server/src/services/tools/fs-tools.ts @@ -0,0 +1,392 @@ +import { readFile, readdir, stat } from 'node:fs/promises'; +import { resolve, basename, relative } from 'node:path'; +import { z } from 'zod'; +import { pathGuard, PathScopeError } from '../path_guard.js'; +import { isSecretPath, SecretBlockedError, filterSecretEntries } from '../secret_guard.js'; +import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from '../file_ops.js'; +import { readTruncation, truncateIfNeeded } from '../truncate.js'; +import type { ToolDef } from './types.js'; + +const MAX_FILE_BYTES = 5 * 1024 * 1024; +const DEFAULT_VIEW_LINES = 200; +const MAX_GREP_RESULTS = 200; +const DEFAULT_GREP_RESULTS = 100; +const MAX_FIND_RESULTS = 200; +const DEFAULT_FIND_RESULTS = 100; +const MAX_DIR_ENTRIES = 500; + +const ViewFileInput = z.object({ + path: z.string().min(1), + start_line: z.number().int().positive().optional(), + end_line: z.number().int().positive().optional(), +}); +type ViewFileInputT = z.infer; + +export const viewFile: ToolDef = { + name: 'view_file', + description: + "Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused. Output is truncated if longer than the slice; the response indicates truncation.", + inputSchema: ViewFileInput, + jsonSchema: { + type: 'function', + function: { + name: 'view_file', + description: + "Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused.", + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'absolute or project-relative path' }, + start_line: { type: 'integer', description: 'first line (1-indexed)' }, + end_line: { type: 'integer', description: 'last line (1-indexed, inclusive)' }, + }, + required: ['path'], + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot, extraRoots) { + const real = await pathGuard(projectRoot, input.path, extraRoots); + // v1.11.7: secret-file deny check. Test the project-relative path + // (matches the form continue.dev's patterns expect: basenames + dir + // segments). Throw a typed error so executeToolCall in inference.ts + // surfaces a clear "blocked" message to the LLM instead of silently + // returning content the user wanted hidden. + // v1.13.17: when the resolved path is outside the primary projectRoot + // (i.e. via an allowed_read_paths grant), `relative()` returns "../…" + // which won't match secret-file basename patterns. Re-anchor on the + // file's basename so the secret deny still fires across all grant roots. + const rel = relative(projectRoot, real); + const relPath = rel && !rel.startsWith('..') ? rel : basename(real); + if (isSecretPath(relPath)) { + throw new SecretBlockedError(relPath); + } + const s = await stat(real); + if (!s.isFile()) { + throw new PathScopeError(`not a file: ${input.path}`); + } + if (s.size > MAX_FILE_BYTES) { + throw new Error(`file too large (${s.size} bytes, max ${MAX_FILE_BYTES})`); + } + const raw = await readFile(real, 'utf8'); + const lines = raw.split('\n'); + const total = lines.length; + let start = input.start_line ?? 1; + let end = input.end_line ?? Math.min(total, start + DEFAULT_VIEW_LINES - 1); + if (input.start_line == null && input.end_line == null) { + end = Math.min(total, DEFAULT_VIEW_LINES); + } + if (start < 1) start = 1; + if (end > total) end = total; + if (end < start) end = start; + const slice = lines.slice(start - 1, end); + const content = slice.join('\n'); + const truncated = total > end || start > 1; + // v1.13.5: stash the full file on tmpfs so the model can retrieve more + // via view_truncated_output(id) without re-reading the file (which it + // may not have project-relative-path access to in future agent setups). + // raw is bounded by MAX_FILE_BYTES (5MB), within truncateIfNeeded's cap. + const wrapped = await truncateIfNeeded({ + fullContent: raw, + slicedContent: content, + wasTruncated: truncated, + }); + return { + path: relative(projectRoot, real) || basename(real), + content: wrapped.content, + total_lines: total, + returned_lines: [start, end], + truncated: wrapped.truncated, + ...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}), + }; + }, +}; + +const ListDirInput = z.object({ + path: z.string().min(1), + show_hidden: z.boolean().optional(), +}); +type ListDirInputT = z.infer; + +export const listDir: ToolDef = { + name: 'list_dir', + description: 'List entries in a directory (up to 500). Hidden files excluded unless show_hidden=true.', + inputSchema: ListDirInput, + jsonSchema: { + type: 'function', + function: { + name: 'list_dir', + description: + 'List entries in a directory (up to 500). Hidden files (dot-prefixed) excluded unless show_hidden=true.', + parameters: { + type: 'object', + properties: { + path: { type: 'string' }, + show_hidden: { type: 'boolean' }, + }, + required: ['path'], + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot, extraRoots) { + const real = await pathGuard(projectRoot, input.path, extraRoots); + const s = await stat(real); + if (!s.isDirectory()) { + throw new PathScopeError(`not a directory: ${input.path}`); + } + const entries = await readdir(real, { withFileTypes: true }); + const filtered = input.show_hidden + ? entries + : entries.filter((e) => !e.name.startsWith('.')); + const total = filtered.length; + const wasTruncated = total > MAX_DIR_ENTRIES; + const relDir = relative(projectRoot, real) || '.'; + // v1.13.5: when we'd truncate, render the FULL list to tmpfs so + // view_truncated_output can serve it. Stat sizes for all entries when + // truncating so the stored view matches the visible shape; this is the + // one extra cost for big directories, bounded by total entries (which + // is itself bounded by filesystem behavior). + const processOne = async (e: typeof filtered[number]) => { + const child = resolve(real, e.name); + let size: number | undefined; + if (e.isFile()) { + try { + const cs = await stat(child); + size = cs.size; + } catch { /* ignore */ } + } + return { + name: e.name, + type: e.isDirectory() ? ('dir' as const) : ('file' as const), + ...(size != null ? { size } : {}), + }; + }; + const slice = filtered.slice(0, MAX_DIR_ENTRIES); + const out = await Promise.all(slice.map(processOne)); + // v1.11.7: filter entries whose project-relative path matches a secret + // pattern. The same filter applies to the full-list snapshot below so + // the stashed file never holds entries the slice would have hidden. + const secretFilter = filterSecretEntries(out, (e) => + relDir === '.' ? e.name : `${relDir}/${e.name}`, + ); + let outputPath: string | undefined; + if (wasTruncated) { + const fullProcessed = await Promise.all(filtered.map(processOne)); + const fullFiltered = filterSecretEntries(fullProcessed, (e) => + relDir === '.' ? e.name : `${relDir}/${e.name}`, + ); + // One line per entry, view_truncated_output's line slicing semantics + // map cleanly. Format: "\t[\tsize=N]". Header documents + // the shape so the model can grep / regex without prior schema lookup. + const header = `# list_dir ${relDir} — ${fullFiltered.kept.length} entries`; + const lines = [header, ...fullFiltered.kept.map((e) => { + const sz = 'size' in e && e.size != null ? `\tsize=${e.size}` : ''; + return `${e.type}\t${e.name}${sz}`; + })]; + const wrapped = await truncateIfNeeded({ + fullContent: lines.join('\n'), + slicedContent: '', + wasTruncated: true, + }); + outputPath = wrapped.outputPath; + } + return { + path: relDir, + entries: secretFilter.kept, + total: secretFilter.kept.length, + truncated: wasTruncated, + ...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}), + ...(outputPath ? { outputPath } : {}), + }; + }, +}; + +const GrepInput = z.object({ + pattern: z.string().min(1), + path: z.string().optional(), + case_sensitive: z.boolean().optional(), + max_results: z.number().int().positive().optional(), + hidden: z.boolean().optional(), +}); +type GrepInputT = z.infer; + +export const grep: ToolDef = { + name: 'grep', + description: + 'Search file contents with ripgrep. Default path is project root. Max 100 results (200 cap).', + inputSchema: GrepInput, + jsonSchema: { + type: 'function', + function: { + name: 'grep', + description: + 'Search file contents with ripgrep. Returns up to 100 matches (cap 200). Set hidden=true to include dot-prefixed files.', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + path: { type: 'string' }, + case_sensitive: { type: 'boolean' }, + max_results: { type: 'integer' }, + hidden: { type: 'boolean' }, + }, + required: ['pattern'], + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot, extraRoots) { + const limit = Math.min( + Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1), + MAX_GREP_RESULTS + ); + // Delegate to file_ops.grep; reshape match objects to preserve LLM output format + // (file_ops uses {path, line, text}; tool output uses {path, line, content}) + const result = await fileOpsGrep(projectRoot, input.pattern, { + path: input.path, + max_matches: limit, + case_sensitive: input.case_sensitive, + hidden: input.hidden, + extra_roots: extraRoots, + }); + const reshaped = result.matches.map((m) => ({ + path: m.path, + line: m.line, + content: m.text, + })); + // v1.11.7: drop matches whose source file is a known-secret pattern. + // file_ops.grep returns project-relative paths, so we feed them straight + // into isSecretPath. Multiple matches in the same secret file each get + // dropped individually — they all count in the hidden tally. + const secretFilter = filterSecretEntries(reshaped, (m) => m.path); + return { + matches: secretFilter.kept, + total: secretFilter.kept.length, + truncated: result.truncated, + ...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}), + }; + }, +}; + +const FindFilesInput = z.object({ + pattern: z.string().min(1), + path: z.string().optional(), + max_results: z.number().int().positive().optional(), +}); +type FindFilesInputT = z.infer; + +export const findFiles: ToolDef = { + name: 'find_files', + description: 'Glob for filenames. Default path is project root. Max 100 results (200 cap).', + inputSchema: FindFilesInput, + jsonSchema: { + type: 'function', + function: { + name: 'find_files', + description: + 'Glob for filenames under a directory. Default path is project root. Max 100 results (cap 200). Pattern uses standard glob (e.g. "**/*.ts").', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + path: { type: 'string' }, + max_results: { type: 'integer' }, + }, + required: ['pattern'], + additionalProperties: false, + }, + }, + }, + async execute(input, projectRoot, extraRoots) { + const limit = Math.min( + Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1), + MAX_FIND_RESULTS + ); + // Delegate to file_ops.findFiles; reshape { files, total, truncated } to + // preserve the LLM-visible output format { paths, total, truncated } + const result = await fileOpsFindFiles(projectRoot, input.pattern, { + path: input.path, + max_results: limit, + extra_roots: extraRoots, + }); + // v1.11.7: drop paths matching secret patterns. The original `total` + // from file_ops includes pre-truncation count; we report the visible + // count post-filter so the LLM can't infer hidden-count by subtraction. + const secretFilter = filterSecretEntries(result.files, (p) => p); + return { + paths: secretFilter.kept, + total: secretFilter.kept.length, + truncated: result.truncated, + ...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}), + }; + }, +}; + +// v1.13.5: retrieves the full content of a previously-truncated tool output +// via the opaque id stamped on the original tool_result. Line-based slicing +// matches view_file's mental model so the model uses the same affordances. +// Tmpfs-backed, 7-day TTL (see services/truncate.ts). +const VIEW_TRUNCATED_DEFAULT_LINES = 200; + +const ViewTruncatedOutputInput = z.object({ + id: z.string().regex(/^tr_[0-9a-v]{12}$/), + start_line: z.number().int().positive().optional(), + end_line: z.number().int().positive().optional(), +}); +type ViewTruncatedOutputInputT = z.infer; + +export const viewTruncatedOutput: ToolDef = { + name: 'view_truncated_output', + description: `Retrieve the full content of a previously-truncated tool output by its outputPath id. When a tool returns { truncated: true, outputPath: "tr_..." }, call this to view the full content. Defaults to the first ${VIEW_TRUNCATED_DEFAULT_LINES} lines. Use start_line and end_line (1-indexed, inclusive) to slice. Stored for 7 days.`, + inputSchema: ViewTruncatedOutputInput, + jsonSchema: { + type: 'function', + function: { + name: 'view_truncated_output', + description: `Retrieve the full content of a previously-truncated tool output by its outputPath id. Returns the first ${VIEW_TRUNCATED_DEFAULT_LINES} lines by default; use start_line/end_line to slice. Stored for 7 days.`, + parameters: { + type: 'object', + properties: { + id: { type: 'string', description: 'The outputPath value from an earlier truncated tool result (e.g. "tr_abc123def456").' }, + start_line: { type: 'integer', description: 'First line (1-indexed). Default 1.' }, + end_line: { type: 'integer', description: `Last line (1-indexed, inclusive). Default ${VIEW_TRUNCATED_DEFAULT_LINES} lines past start.` }, + }, + required: ['id'], + additionalProperties: false, + }, + }, + }, + // view_truncated_output doesn't touch the filesystem — it pulls from tmpfs + // by opaque id. extraRoots is irrelevant here; declared for signature parity + // with the v1.13.17 ToolDef contract. + async execute(input, _projectRoot, _extraRoots) { + const content = await readTruncation(input.id); + if (content === null) { + return { + id: input.id, + content: '', + truncated: false, + error: `No truncation found for id "${input.id}". It may have been pruned (7-day TTL) or never existed.`, + }; + } + const lines = content.split('\n'); + const total = lines.length; + let start = input.start_line ?? 1; + let end = input.end_line ?? Math.min(total, start + VIEW_TRUNCATED_DEFAULT_LINES - 1); + if (start < 1) start = 1; + if (end > total) end = total; + if (end < start) end = start; + const slice = lines.slice(start - 1, end).join('\n'); + // Re-slicing this view isn't truncation in the dual-write sense — the + // model already has the id; no point stashing the slice again. + const truncated = total > end || start > 1; + return { + id: input.id, + content: slice, + total_lines: total, + returned_lines: [start, end], + truncated, + }; + }, +}; diff --git a/apps/server/src/services/tools/misc-tools.ts b/apps/server/src/services/tools/misc-tools.ts new file mode 100644 index 0000000..e313d27 --- /dev/null +++ b/apps/server/src/services/tools/misc-tools.ts @@ -0,0 +1,216 @@ +import { z } from 'zod'; +import { getGitMeta } from '../git_meta.js'; +import { findSkills, getSkillBody, getSkillResource } from '../skills.js'; +import type { ToolDef } from './types.js'; + +// v1.8 Level 1 branch awareness: gives the model a read-only view of the +// project's git state. No path input — operates on the inference-resolved +// project root via getGitMeta. Subprocess runs with a 2s timeout (see git_meta). +const GitStatusInput = z.object({}).strict(); +type GitStatusInputT = z.infer; + +export const gitStatus: ToolDef = { + name: 'git_status', + description: + "Returns the current git branch, whether the working tree is dirty, and ahead/behind counts vs upstream. Read-only. Use when you need to know which branch the user is currently working on.", + inputSchema: GitStatusInput, + jsonSchema: { + type: 'function', + function: { + name: 'git_status', + description: + 'Returns the current git branch, dirty flag, and ahead/behind counts vs upstream. Read-only.', + parameters: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + }, + async execute(_input, projectRoot) { + const meta = await getGitMeta(projectRoot); + if (meta === null) { + return { repo: false, branch: null, is_dirty: false, ahead: 0, behind: 0 }; + } + return { repo: true, ...meta }; + }, +}; + +// Batch 9.6: skill_find, skill_use, skill_resource. Lazy-loaded markdown +// playbooks at /data/skills/. Three tools rather than one to keep each call +// cheap — the model lists, then loads, then optionally pulls support files. + +const SkillFindInput = z.object({ + query: z.string().optional(), +}); +type SkillFindInputT = z.infer; + +export const skillFind: ToolDef = { + name: 'skill_find', + description: + 'Find skills (markdown playbooks under /data/skills) by name or description. Returns up to 5 matches. Empty query or "*" returns all available skills. Call this first to discover what skills are available.', + inputSchema: SkillFindInput, + jsonSchema: { + type: 'function', + function: { + name: 'skill_find', + description: + 'Find skills by name or description. Returns up to 5 matches. Empty or "*" returns all.', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'substring matched against skill name and description' }, + }, + additionalProperties: false, + }, + }, + }, + async execute(input) { + return await findSkills(input.query ?? ''); + }, +}; + +const SkillUseInput = z.object({ + name: z.string().min(1), +}); +type SkillUseInputT = z.infer; + +export const skillUse: ToolDef = { + name: 'skill_use', + description: + "Load the full body of a skill's SKILL.md by name. Returns the markdown playbook to follow. Discover names via skill_find. Errors: unknown_skill.", + inputSchema: SkillUseInput, + jsonSchema: { + type: 'function', + function: { + name: 'skill_use', + description: "Load the full body of a skill's SKILL.md by name.", + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'skill name from skill_find' }, + }, + required: ['name'], + additionalProperties: false, + }, + }, + }, + async execute(input) { + const body = await getSkillBody(input.name); + if (body === null) { + return { error: 'unknown_skill', message: `unknown skill: ${input.name}` }; + } + return { body }; + }, +}; + +const SkillResourceInput = z.object({ + name: z.string().min(1), + path: z.string().min(1), +}); +type SkillResourceInputT = z.infer; + +export const skillResource: ToolDef = { + name: 'skill_resource', + description: + "Read a support file inside a skill's folder (e.g. references/root-cause-tracing.md). Path is relative to the skill folder. Use skill_use to read SKILL.md itself. Errors: unknown_skill, unknown_resource, path_escape.", + inputSchema: SkillResourceInput, + jsonSchema: { + type: 'function', + function: { + name: 'skill_resource', + description: "Read a support file inside a skill's folder. Path is relative to the skill folder.", + parameters: { + type: 'object', + properties: { + name: { type: 'string', description: 'skill name' }, + path: { type: 'string', description: 'relative path under the skill folder' }, + }, + required: ['name', 'path'], + additionalProperties: false, + }, + }, + }, + async execute(input) { + const result = await getSkillResource(input.name, input.path); + if (!result.ok) { + return { error: result.code, message: result.message }; + } + return { content: result.content }; + }, +}; + +// Batch 9.7: ask_user_input. Interactive elicitation. The model emits a tool +// call with 1-3 structured questions; the inference loop PAUSES (does not +// execute the tool server-side, does not recurse) and waits for the frontend +// to POST /api/chats/:id/answer_user_input with the user's selections. See +// routes/messages.ts for the resume path and services/inference.ts for the +// pause branch in executeToolPhase. +const AskUserInputInput = z.object({ + questions: z + .array( + z.object({ + question: z.string().min(1).max(200), + type: z.enum(['single_select', 'multi_select']), + options: z.array(z.string().min(1).max(80)).min(2).max(6), + }), + ) + .min(1) + .max(3), +}); +type AskUserInputInputT = z.infer; + +export const askUserInput: ToolDef = { + name: 'ask_user_input', + description: + "Ask the user 1-3 structured questions through an inline picker UI. Use when you genuinely need a choice the user must make (e.g. scope, options, preferences) before continuing. Each question has 2-6 options and accepts free-text answers in addition. The tool call pauses the conversation until the user submits — the next assistant turn sees their answers as the tool result. Do not use for trivial yes/no clarifications you could infer; prefer it over multi-paragraph speculation about what the user might want.", + inputSchema: AskUserInputInput, + jsonSchema: { + type: 'function', + function: { + name: 'ask_user_input', + description: + 'Ask the user 1-3 structured questions through an inline picker. Pauses the conversation until the user answers; the next turn sees their selections.', + parameters: { + type: 'object', + properties: { + questions: { + type: 'array', + minItems: 1, + maxItems: 3, + items: { + type: 'object', + properties: { + question: { type: 'string', description: '<=200 chars, shown to the user' }, + type: { + type: 'string', + enum: ['single_select', 'multi_select'], + description: 'single_select = at most one option; multi_select = any subset', + }, + options: { + type: 'array', + minItems: 2, + maxItems: 6, + items: { type: 'string' }, + description: '2-6 strings, each <=80 chars; free-text input is always available alongside', + }, + }, + required: ['question', 'type', 'options'], + additionalProperties: false, + }, + }, + }, + required: ['questions'], + additionalProperties: false, + }, + }, + }, + // Server-side no-op. The "execution" of ask_user_input is the user's + // response, captured client-side and posted to /api/chats/:id/answer_user_input. + // The inference loop detects this tool by name and pauses before reaching + // executeToolCall — this fallback only runs if something bypasses that + // branch, in which case the pending sentinel matches the pause-path shape. + async execute(input) { + return { _pending: true, questions: input.questions }; + }, +}; diff --git a/apps/server/src/services/tools/registry.ts b/apps/server/src/services/tools/registry.ts new file mode 100644 index 0000000..a351602 --- /dev/null +++ b/apps/server/src/services/tools/registry.ts @@ -0,0 +1,96 @@ +import type { ToolDef, ToolJsonSchema } from './types.js'; +import { viewFile, listDir, grep, findFiles, viewTruncatedOutput } from './fs-tools.js'; +import { gitStatus, skillFind, skillUse, skillResource, askUserInput } from './misc-tools.js'; +import { webSearch } from '../web_search.js'; +import { webFetch } from '../web_fetch.js'; +// v1.12 Track B.2: codecontext tools. 8 wrappers re-exported from +// tools/codecontext/index.ts. Each calls into services/codecontext_client.ts +// which talks to the codecontext sidecar at http://codecontext:8080. +import { + getCodebaseOverview, + getFileAnalysis, + getSymbolInfo, + searchSymbols, + getDependencies, + watchChanges, + getSemanticNeighborhoods, + getFrameworkAnalysis, + getBlastRadius, + getHotFiles, + getRoutes, + getMiddleware, +} from './codecontext/index.js'; +// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired +// with the pause-on-pending-grant branch in inference/tool-phase.ts and the +// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts. +import { requestReadAccess } from '../request_read_access.js'; +// v2.6.x: read-only tool that reads a tab's transcript by its session-scoped +// tab number. Needs DB/session context (ToolExecCtx 4th arg). +import { readTabByNumber } from '../read_tab_by_number.js'; + +// v1.13.3: alpha-sorted by tool.name at module load. llama.cpp's prompt +// cache hits on byte-identical prefixes; the tool list lives near the top +// of the system prompt, so any order drift would invalidate every cached +// turn. Single source of truth for ordering lives here — toolJsonSchemas() +// and TOOLS_BY_NAME inherit it. +// v1.14.1-mcp-poc: changed from ReadonlyArray to let-bound mutable array +// so appendMcpTools() can push MCP-discovered tools at startup. +export let ALL_TOOLS: ToolDef[] = [ + viewFile as ToolDef, + viewTruncatedOutput as ToolDef, + listDir as ToolDef, + grep as ToolDef, + findFiles as ToolDef, + gitStatus as ToolDef, + skillFind as ToolDef, + skillUse as ToolDef, + skillResource as ToolDef, + askUserInput as ToolDef, + // v1.11.8: web tools. Gated per-chat via session.web_search_enabled + // (with project default fallback) — see effectiveTools filter in + // services/inference.ts. + webSearch as ToolDef, + webFetch as ToolDef, + // v1.12 Track B.2: codecontext tools. Backed by the codecontext sidecar + // container. All read-only. target_dir is resolved server-side from the + // project root in codecontext_client.ts (the LLM never supplies it). + getCodebaseOverview as ToolDef, + getFileAnalysis as ToolDef, + getSymbolInfo as ToolDef, + searchSymbols as ToolDef, + getDependencies as ToolDef, + watchChanges as ToolDef, + getSemanticNeighborhoods as ToolDef, + getFrameworkAnalysis as ToolDef, + // v1.16: codesight-merge tools. Backed by the same codecontext sidecar. + getBlastRadius as ToolDef, + getHotFiles as ToolDef, + getRoutes as ToolDef, + getMiddleware as ToolDef, + // v1.13.17-cross-repo-reads: paired with the pause-on-pending-grant + // branch in tool-phase.ts. Read-only — only ever READS files; the only + // state change is appending to sessions.allowed_read_paths via the + // grant endpoint, gated by user consent. + requestReadAccess as ToolDef, + // v2.6.x: read a tab's transcript by its session-scoped tab number. + // Read-only; uses the ToolExecCtx 4th arg for DB/session access. + readTabByNumber as ToolDef, +].sort((a, b) => a.name.localeCompare(b.name)); + +export let TOOLS_BY_NAME: Record> = Object.fromEntries( + ALL_TOOLS.map((t) => [t.name, t]) +); + +// v1.14.1-mcp-poc: append MCP-discovered tools at startup. Called once +// from index.ts after mcpClient.initialize(). Re-sorts ALL_TOOLS and +// rebuilds TOOLS_BY_NAME. MCP tools are all read-only by construction +// (the read-only guard in mcp-client.ts rejects readOnlyHint: false). +export function appendMcpTools(mcpTools: ToolDef[]): void { + if (mcpTools.length === 0) return; + ALL_TOOLS = [...ALL_TOOLS, ...mcpTools].sort((a, b) => a.name.localeCompare(b.name)); + TOOLS_BY_NAME = Object.fromEntries(ALL_TOOLS.map((t) => [t.name, t])); +} + +export function toolJsonSchemas(): ToolJsonSchema[] { + return ALL_TOOLS.map((t) => t.jsonSchema); +} diff --git a/apps/server/src/services/tools/tiers.ts b/apps/server/src/services/tools/tiers.ts new file mode 100644 index 0000000..0ab609c --- /dev/null +++ b/apps/server/src/services/tools/tiers.ts @@ -0,0 +1,59 @@ +import { ALL_TOOLS, TOOLS_BY_NAME } from './registry.js'; + +// v1.13.15-tools: tiered tool loading. BOOCODE_TOOLS env var (`core` | +// `standard` | `all`) filters the agent's tool whitelist before LLM dispatch. +// Daily-driver token win on qwen3.6-35b-a3b — the 35B-A3B MoE benefits from +// any prompt-cache stability win (fewer tools = shorter, more stable tool +// schemas in the system prompt). Pattern lift from eyaltoledano/claude-task- +// master (MIT + Commons Clause — pattern only, no code lift). +// +// The env var is a CEILING. It only narrows; never expands an agent's +// declared whitelist. Default behavior (var unset) is unchanged: all tools. +export const CORE_TOOL_NAMES = [ + 'view_file', + 'list_dir', + 'grep', + 'find_files', +] as const; + +export const STANDARD_TOOL_NAMES = [ + ...CORE_TOOL_NAMES, + 'web_search', + 'web_fetch', + 'git_status', + 'get_codebase_overview', + 'get_file_analysis', + 'get_symbol_info', + 'search_symbols', + 'get_dependencies', + 'watch_changes', + 'get_semantic_neighborhoods', + 'get_framework_analysis', +] as const; + +// Module-load validation: every name in CORE / STANDARD must exist in +// TOOLS_BY_NAME. Catches typos and stale tier definitions before they reach +// production; server boot fails loudly rather than silently filtering valid +// tools out of agent whitelists. +for (const name of CORE_TOOL_NAMES) { + if (!TOOLS_BY_NAME[name]) { + throw new Error(`CORE_TOOL_NAMES references unknown tool: '${name}'`); + } +} +for (const name of STANDARD_TOOL_NAMES) { + if (!TOOLS_BY_NAME[name]) { + throw new Error(`STANDARD_TOOL_NAMES references unknown tool: '${name}'`); + } +} + +export function resolveToolTier(tier: string | undefined): readonly string[] { + switch ((tier ?? 'all').toLowerCase()) { + case 'core': + return CORE_TOOL_NAMES; + case 'standard': + return STANDARD_TOOL_NAMES; + case 'all': + default: + return ALL_TOOLS.map((t) => t.name); + } +} diff --git a/apps/server/src/services/tools/types.ts b/apps/server/src/services/tools/types.ts new file mode 100644 index 0000000..dfe9469 --- /dev/null +++ b/apps/server/src/services/tools/types.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; +import type { Sql } from '../../db.js'; + +export interface ToolJsonSchema { + type: 'function'; + function: { + name: string; + description: string; + parameters: Record; + }; +} + +// v2.6.x: optional DB/session context threaded into a tool's execute(). Only +// tools that need to read session-scoped DB state (e.g. read_tab_by_number) +// use it; every other tool ignores the 4th arg. Kept optional so existing +// 3-arg execute() implementations stay assignable (apps/coder consumes this +// type from the compiled dist — the optional param keeps it backward-compatible). +export interface ToolExecCtx { + sql: Sql; + sessionId: string; +} + +export interface ToolDef { + name: string; + description: string; + inputSchema: z.ZodType; + jsonSchema: ToolJsonSchema; + // v1.13.17-cross-repo-reads: extraRoots is the session's + // allowed_read_paths, threaded through executeToolCall in tool-phase.ts. + // Only the filesystem tools (view_file, list_dir, grep, find_files, + // view_truncated_output) forward it to pathGuard; other tools accept the + // arg and ignore it. The execute signature stays compatible with + // pre-v1.13.17 callsites because the parameter is optional. + // v2.6.x: optional 4th param toolCtx carries DB/session context for tools + // that read session-scoped state (read_tab_by_number). Optional so 3-arg + // implementations remain assignable. + execute( + input: TInput, + projectRoot: string, + extraRoots?: readonly string[], + toolCtx?: ToolExecCtx, + ): Promise; +} diff --git a/apps/server/src/types/api.ts b/apps/server/src/types/api.ts index ce03d45..ec299c2 100644 --- a/apps/server/src/types/api.ts +++ b/apps/server/src/types/api.ts @@ -182,8 +182,7 @@ export type MessageRole = typeof MESSAGE_ROLES[number]; export const MESSAGE_STATUSES = ['streaming', 'complete', 'failed', 'cancelled'] as const; export type MessageStatus = typeof MESSAGE_STATUSES[number]; -export const MESSAGE_KINDS = ['message', 'compact'] as const; -export type MessageKind = typeof MESSAGE_KINDS[number]; +export type MessageKind = 'message' | 'compact'; export interface ToolCall { id: string; @@ -303,41 +302,6 @@ export interface SidebarResponse { projects: SidebarProject[]; } -export type PaneKind = 'chat' | 'file_browser'; - -export interface FileBrowserPaneState { - open_file?: string | null; - filter?: string; - expanded_dirs?: string[]; -} - -// chat panes have no state for now -export type ChatPaneState = Record; - -export type PaneState = ChatPaneState | FileBrowserPaneState; - -interface PaneBase { - id: string; - session_id: string; - position: number; - created_at: string; -} - -export type Pane = PaneBase & ( - | { kind: 'chat'; state: ChatPaneState } - | { kind: 'file_browser'; state: FileBrowserPaneState } -); - -export interface PaneCreateRequest { - kind: PaneKind; - position?: number; // optional; if omitted, append at end -} - -export interface PaneUpdateRequest { - state?: PaneState; - position?: number; -} - // User-stream frames (broadcast on /ws/user channel) export interface ProjectCreatedFrame { type: 'project_created'; diff --git a/apps/server/src/utils/__tests__/string-utils.test.ts b/apps/server/src/utils/__tests__/string-utils.test.ts new file mode 100644 index 0000000..dfe62a4 --- /dev/null +++ b/apps/server/src/utils/__tests__/string-utils.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { stripQuotes } from '../string-utils.js'; + +describe('stripQuotes', () => { + it('strips matching double quotes', () => { + expect(stripQuotes('"hello"')).toBe('hello'); + }); + + it('strips matching single quotes', () => { + expect(stripQuotes("'world'")).toBe('world'); + }); + + it('leaves unquoted strings unchanged', () => { + expect(stripQuotes('no quotes')).toBe('no quotes'); + }); + + it('leaves mismatched quotes unchanged', () => { + expect(stripQuotes('"mismatched\'')).toBe('"mismatched\''); + }); + + it('leaves a single-char string unchanged', () => { + expect(stripQuotes('"')).toBe('"'); + }); + + it('handles empty string', () => { + expect(stripQuotes('')).toBe(''); + }); + + it('strips quotes around a value with spaces', () => { + expect(stripQuotes('"a description with spaces"')).toBe('a description with spaces'); + }); +}); diff --git a/apps/server/src/utils/string-utils.ts b/apps/server/src/utils/string-utils.ts new file mode 100644 index 0000000..41ea754 --- /dev/null +++ b/apps/server/src/utils/string-utils.ts @@ -0,0 +1,9 @@ +// Strips a matching pair of leading/trailing single or double quotes from a +// YAML value string produced by the minimal frontmatter parsers in agents.ts +// and skills.ts. Returns the string unchanged when no matching pair is found. +export function stripQuotes(s: string): string { + if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[0] === s[s.length - 1]) { + return s.slice(1, -1); + } + return s; +} diff --git a/apps/web/components.json b/apps/web/components.json index 5eb55ce..9abfc85 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -4,7 +4,6 @@ "rsc": false, "tsx": true, "tailwind": { - "config": "tailwind.config.ts", "css": "src/styles/globals.css", "baseColor": "neutral", "cssVariables": true, diff --git a/apps/web/package.json b/apps/web/package.json index e434849..57adfbd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,19 +15,16 @@ "@xterm/addon-fit": "0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/addon-web-links": "0.11.0", - "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "5.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.16.0", - "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "react-router-dom": "^6.26.0", "remark-gfm": "^4.0.1", - "shadcn": "^4.7.0", "shiki": "^1.29.2", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", @@ -40,6 +37,7 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "shadcn": "^4.7.0", "tailwindcss": "^4.3.0", "typescript": "^5.5.0", "vite": "^5.3.4" diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 8a071d4..76ba306 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -11,7 +11,6 @@ import type { AgentsResponse, GitMeta, Skill, - AskUserAnswer, ToolCostStat, ProviderSnapshotEntry, CoderProvidersFile, @@ -284,17 +283,6 @@ export const api = { method: 'POST', body: JSON.stringify({ skill_name: skillName, user_message: userMessage }), }), - // Batch 9.7: submit answers for a paused ask_user_input call. Server - // validates against the question shape, UPDATEs the pending tool row, - // publishes the deferred tool_result frame, and enqueues the next turn. - answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) => - request<{ tool_message_id: string; assistant_message_id: string }>( - `/api/chats/${chatId}/answer_user_input`, - { - method: 'POST', - body: JSON.stringify({ tool_call_id: toolCallId, answers }), - }, - ), // v1.13.17-cross-repo-reads: resume a paused request_read_access. On // 'allow' the server re-resolves the grant root and appends it to // sessions.allowed_read_paths; the returned list reflects the post- diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 462f084..24e74aa 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -417,36 +417,6 @@ export interface ViewFileResult { bytes_returned: number; } -export type PaneKind = 'chat' | 'file_browser'; - -export interface FileBrowserPaneState { - open_file?: string | null; - filter?: string; - expanded_dirs?: string[]; -} -export type ChatPaneState = Record; -export type PaneState = ChatPaneState | FileBrowserPaneState; - -interface PaneBase { - id: string; - session_id: string; - position: number; - created_at: string; -} -export type Pane = PaneBase & ( - | { kind: 'chat'; state: ChatPaneState } - | { kind: 'file_browser'; state: FileBrowserPaneState } -); - -export interface PaneCreateRequest { - kind: PaneKind; - position?: number; -} -export interface PaneUpdateRequest { - state?: PaneState; - position?: number; -} - // v1.8 mobile-tabs: shape returned by GET /api/projects/:id/git. Mirrors // services/git_meta.ts on the server. branch=null means "not a git repo". export interface GitMeta { diff --git a/apps/web/src/components/AgentPicker.tsx b/apps/web/src/components/AgentPicker.tsx index ef7d99a..3c00dc3 100644 --- a/apps/web/src/components/AgentPicker.tsx +++ b/apps/web/src/components/AgentPicker.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import { formatAgo } from '@/lib/format'; import { Check, ChevronDown } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api/client'; @@ -191,12 +192,3 @@ function formatK(n: number): string { return `${Math.round(n / 1000)}k`; } -function formatAgo(iso: string): string { - const then = new Date(iso).getTime(); - if (Number.isNaN(then)) return '—'; - const diff = Date.now() - then; - if (diff < 60_000) return 'just now'; - if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`; - if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`; - return `${Math.round(diff / 86_400_000)}d ago`; -} diff --git a/apps/web/src/components/ArtifactPaneHeader.tsx b/apps/web/src/components/ArtifactPaneHeader.tsx new file mode 100644 index 0000000..44e6554 --- /dev/null +++ b/apps/web/src/components/ArtifactPaneHeader.tsx @@ -0,0 +1,63 @@ +import { Check, Copy, Download, X } from 'lucide-react'; + +interface Props { + title: string; + defaultTitle: string; + onDownload: () => void; + downloadDisabled: boolean; + onClose: () => void; + // Optional copy button (Markdown pane only; HTML pane omits it). + onCopy?: () => void; + justCopied?: boolean; + copyDisabled?: boolean; +} + +export function ArtifactPaneHeader({ + title, + defaultTitle, + onDownload, + downloadDisabled, + onClose, + onCopy, + justCopied, + copyDisabled, +}: Props) { + return ( +
+ + {title || defaultTitle} + + {onCopy && ( + + )} + + +
+ ); +} diff --git a/apps/web/src/components/FileViewerOverlay.tsx b/apps/web/src/components/FileViewerOverlay.tsx index 8babf72..582ced4 100644 --- a/apps/web/src/components/FileViewerOverlay.tsx +++ b/apps/web/src/components/FileViewerOverlay.tsx @@ -9,9 +9,7 @@ interface Props { path: string; content: string; lang: string | null; - projectId: string; onClose: () => void; - onNavigate: (path: string) => void; } const SHIKI_THEME = 'github-dark'; @@ -21,7 +19,11 @@ function splitShikiLines(html: string): string[] { if (!match) return []; const inner = match[1]!; const lines = inner.split(/(?=)/); - return lines.filter(l => l.trim().length > 0); + // Keep every line segment (including blank `` + // entries) so lineHtmls.length matches the raw line count and line-number + // indexing stays in sync. The first element before the first line span is + // discarded (it's always empty or whitespace). + return lines.filter(l => l.startsWith('')); } function basename(path: string): string { diff --git a/apps/web/src/components/HtmlArtifactPane.tsx b/apps/web/src/components/HtmlArtifactPane.tsx index b0d717e..de87950 100644 --- a/apps/web/src/components/HtmlArtifactPane.tsx +++ b/apps/web/src/components/HtmlArtifactPane.tsx @@ -10,10 +10,10 @@ // sessions.workspace_panes jsonb stays small and message_parts.payload is the // single source of truth. import { useEffect, useState } from 'react'; -import { Download, X } from 'lucide-react'; -import { toast } from 'sonner'; import { api } from '@/api/client'; import type { HtmlArtifactState } from '@/api/types'; +import { ArtifactPaneHeader } from './ArtifactPaneHeader'; +import { useArtifactDownload } from '@/hooks/useArtifactDownload'; interface Props { chatId: string; @@ -22,9 +22,9 @@ interface Props { } export function HtmlArtifactPane({ chatId, state, onClose }: Props) { - const [downloading, setDownloading] = useState(false); const [htmlContent, setHtmlContent] = useState(null); const [loadError, setLoadError] = useState(null); + const { downloading, download } = useArtifactDownload(chatId, state.message_id, 'html'); useEffect(() => { let cancelled = false; @@ -45,53 +45,15 @@ export function HtmlArtifactPane({ chatId, state, onClose }: Props) { }; }, [chatId, state.message_id]); - async function download() { - if (downloading) return; - setDownloading(true); - try { - const { url, path } = await api.messages.downloadArtifact( - chatId, - state.message_id, - 'html', - ); - const a = document.createElement('a'); - a.href = url; - a.rel = 'noopener'; - a.click(); - toast.success(`Saved to ${path}`); - } catch (err) { - toast.error(err instanceof Error ? err.message : 'download failed'); - } finally { - setDownloading(false); - } - } - return (
-
- - {state.title || 'HTML artifact'} - - - -
+ void download()} + downloadDisabled={downloading || htmlContent === null} + onClose={onClose} + />
{loadError ? (
Failed to load: {loadError}
diff --git a/apps/web/src/components/MarkdownArtifactPane.tsx b/apps/web/src/components/MarkdownArtifactPane.tsx index 862dbbd..8837685 100644 --- a/apps/web/src/components/MarkdownArtifactPane.tsx +++ b/apps/web/src/components/MarkdownArtifactPane.tsx @@ -8,11 +8,12 @@ // the matching message_id. This keeps sessions.workspace_panes jsonb small // and the assistant message row remains the single source of truth. import { useEffect, useState } from 'react'; -import { Check, Copy, Download, X } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api/client'; import type { MarkdownArtifactState } from '@/api/types'; import { MarkdownRenderer } from './MarkdownRenderer'; +import { ArtifactPaneHeader } from './ArtifactPaneHeader'; +import { useArtifactDownload } from '@/hooks/useArtifactDownload'; interface Props { chatId: string; @@ -22,9 +23,9 @@ interface Props { export function MarkdownArtifactPane({ chatId, state, onClose }: Props) { const [justCopied, setJustCopied] = useState(false); - const [downloading, setDownloading] = useState(false); const [content, setContent] = useState(null); const [loadError, setLoadError] = useState(null); + const { downloading, download } = useArtifactDownload(chatId, state.message_id, 'md'); useEffect(() => { let cancelled = false; @@ -64,65 +65,18 @@ export function MarkdownArtifactPane({ chatId, state, onClose }: Props) { } } - async function download() { - if (downloading) return; - setDownloading(true); - try { - const { url, path } = await api.messages.downloadArtifact( - chatId, - state.message_id, - 'md', - ); - // Trigger browser download from the returned URL. The endpoint stamps - // Content-Disposition: attachment so the click lands as a save. - const a = document.createElement('a'); - a.href = url; - a.rel = 'noopener'; - a.click(); - toast.success(`Saved to ${path}`); - } catch (err) { - toast.error(err instanceof Error ? err.message : 'download failed'); - } finally { - setDownloading(false); - } - } - return (
-
- - {state.title || 'Markdown artifact'} - - - - -
+ void download()} + downloadDisabled={downloading || content === null} + onClose={onClose} + onCopy={() => void copy()} + justCopied={justCopied} + copyDisabled={content === null} + />
{loadError ? (
Failed to load: {loadError}
diff --git a/apps/web/src/components/MarkdownRenderer.tsx b/apps/web/src/components/MarkdownRenderer.tsx index 555e897..f8716db 100644 --- a/apps/web/src/components/MarkdownRenderer.tsx +++ b/apps/web/src/components/MarkdownRenderer.tsx @@ -2,53 +2,13 @@ // in-chat bubble renderer and the MarkdownArtifactPane share the same Shiki + // remark-gfm + path-linkifier pipeline. Behavior preserved byte-for-byte from // the original MessageBubble.MarkdownBody helper (and its linkify helpers). -import { Children, cloneElement, isValidElement } from 'react'; +import { memo, Children, cloneElement, isValidElement } from 'react'; import type { ReactElement, ReactNode } from 'react'; import Markdown from 'react-markdown'; +import type { Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { CodeBlock } from './CodeBlock'; -import { sessionEvents } from '@/hooks/sessionEvents'; - -// Match path-shaped substrings ending in `.ext`. Additionally require a `/` -// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't -// match, but `src/foo.ts` will). False positives at the edges are accepted -// per Sam's design decision (2026-05-14). -const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g; - -function isPathLike(s: string): boolean { - return s.includes('/'); -} - -function emitOpenFile(path: string): void { - sessionEvents.emit({ type: 'open_file_in_browser', path }); -} - -function linkifyPaths(text: string, keyPrefix: string): ReactNode { - const out: ReactNode[] = []; - let lastIdx = 0; - let idx = 0; - for (const match of text.matchAll(PATH_REGEX)) { - const matchedText = match[0]; - const start = match.index ?? 0; - if (!isPathLike(matchedText)) continue; - if (start > lastIdx) out.push(text.slice(lastIdx, start)); - out.push( - - ); - lastIdx = start + matchedText.length; - idx += 1; - } - if (out.length === 0) return text; - if (lastIdx < text.length) out.push(text.slice(lastIdx)); - return out; -} +import { linkifyPaths } from '@/lib/linkify-paths'; function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode { const arr = Children.toArray(children); @@ -92,57 +52,58 @@ const codeRenderer = (props: { children?: unknown; className?: string }) => { ); }; -export function MarkdownRenderer({ content }: { content: string }) { - return ( - <>{children}, - code: codeRenderer, - a: ({ children, href }) => ( - - {children} - - ), - ul: ({ children }) => ( -
    {children}
- ), - ol: ({ children }) => ( -
    {children}
- ), - li: ({ children }) =>
  • {linkifyChildren(children)}
  • , - p: ({ children }) => ( -

    {linkifyChildren(children)}

    - ), - h1: ({ children }) =>

    {children}

    , - h2: ({ children }) =>

    {children}

    , - h3: ({ children }) =>

    {children}

    , - blockquote: ({ children }) => ( -
    - {children} -
    - ), - table: ({ children }) => ( -
    - {children}
    -
    - ), - th: ({ children }) => ( - {children} - ), - td: ({ children }) => ( - - {linkifyChildren(children)} - - ), - }} +// Hoisted to module level — closes over nothing render-scoped, so a stable +// reference avoids react-markdown reconstructing its vdom on every token delta. +const MARKDOWN_COMPONENTS: Components = { + pre: ({ children }) => <>{children}, + code: codeRenderer, + a: ({ children, href }) => ( + + {children} + + ), + ul: ({ children }) => ( +
      {children}
    + ), + ol: ({ children }) => ( +
      {children}
    + ), + li: ({ children }) =>
  • {linkifyChildren(children)}
  • , + p: ({ children }) => ( +

    {linkifyChildren(children)}

    + ), + h1: ({ children }) =>

    {children}

    , + h2: ({ children }) =>

    {children}

    , + h3: ({ children }) =>

    {children}

    , + blockquote: ({ children }) => ( +
    + {children} +
    + ), + table: ({ children }) => ( +
    + {children}
    +
    + ), + th: ({ children }) => ( + {children} + ), + td: ({ children }) => ( + + {linkifyChildren(children)} + + ), +}; + +export const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) { + return ( + {content} ); -} +}); diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index 72ba442..44b3f10 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { memo, useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain, History, AlertCircle } from 'lucide-react'; import { toast } from 'sonner'; @@ -708,7 +708,7 @@ function MistakeRecoverySentinel({ message }: { message: Message }) { ); } -export function MessageBubble({ +export const MessageBubble = memo(function MessageBubble({ message, sessionChats, capHitInfo, @@ -717,7 +717,7 @@ export function MessageBubble({ hasCheckpoint, restoreDisabled, }: Props) { - const hiddenSet = new Set(hideActions ?? []); + const hiddenSet = useMemo(() => new Set(hideActions ?? []), [hideActions]); // v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact' // branch because summary=true never coexists with kind='compact' (new // compactions emit role='assistant' rows with kind='message'+summary=true). @@ -846,4 +846,4 @@ export function MessageBubble({ )}
    ); -} +}); diff --git a/apps/web/src/components/ProjectSidebar.tsx b/apps/web/src/components/ProjectSidebar.tsx index 8968f31..9b2291a 100644 --- a/apps/web/src/components/ProjectSidebar.tsx +++ b/apps/web/src/components/ProjectSidebar.tsx @@ -29,6 +29,7 @@ import { usePullToRefresh } from '@/hooks/usePullToRefresh'; import type { SidebarProject, WorktreeRiskReport } from '@/api/types'; import { giteaUrlFor } from '@/lib/projectUrls'; import { isCoderSessionName } from '@/lib/coder-session'; +import { relTime } from '@/lib/format'; import { cn } from '@/lib/utils'; const EXPANDED_KEY = 'boocode.sidebar.expanded'; @@ -54,22 +55,6 @@ function writeExpanded(ids: Set): void { } } -function relTime(iso: string): string { - const now = Date.now(); - const t = Date.parse(iso); - if (Number.isNaN(t)) return ''; - const sec = Math.max(0, Math.floor((now - t) / 1000)); - if (sec < 60) return `${sec}s`; - const min = Math.floor(sec / 60); - if (min < 60) return `${min}m`; - const hr = Math.floor(min / 60); - if (hr < 24) return `${hr}h`; - const day = Math.floor(hr / 24); - if (day < 30) return `${day}d`; - const mo = Math.floor(day / 30); - if (mo < 12) return `${mo}mo`; - return `${Math.floor(mo / 12)}y`; -} function activeProjectId( pathname: string, diff --git a/apps/web/src/components/RightRail.tsx b/apps/web/src/components/RightRail.tsx index 59ae508..545a933 100644 --- a/apps/web/src/components/RightRail.tsx +++ b/apps/web/src/components/RightRail.tsx @@ -274,9 +274,7 @@ export function RightRail({ projectId, sessionId }: Props) { path={viewerFile.path} content={viewerFile.content} lang={inferLanguage(viewerFile.path)} - projectId={projectId} onClose={() => setViewerFile(null)} - onNavigate={(path) => void openFile(path)} /> )} diff --git a/apps/web/src/components/SessionLandingPage.tsx b/apps/web/src/components/SessionLandingPage.tsx index c6900fb..f8d8ce3 100644 --- a/apps/web/src/components/SessionLandingPage.tsx +++ b/apps/web/src/components/SessionLandingPage.tsx @@ -12,6 +12,7 @@ import { } from '@/components/ui/dialog'; import { api } from '@/api/client'; import type { Chat } from '@/api/types'; +import { formatRelative } from '@/lib/format'; interface Props { projectId: string; @@ -34,19 +35,6 @@ interface Props { onDeleteChat: (chatId: string) => Promise; } -function formatRelative(iso: string): string { - const then = new Date(iso).getTime(); - if (Number.isNaN(then)) return ''; - const s = Math.max(0, Math.round((Date.now() - then) / 1000)); - if (s < 60) return 'just now'; - const m = Math.round(s / 60); - if (m < 60) return `${m}m ago`; - const h = Math.round(m / 60); - if (h < 24) return `${h}h ago`; - const d = Math.round(h / 24); - if (d < 7) return `${d}d ago`; - return new Date(iso).toLocaleDateString(); -} function byRecent(a: Chat, b: Chat): number { return (b.updated_at ?? '').localeCompare(a.updated_at ?? ''); diff --git a/apps/web/src/components/SwipeablePaneTab.tsx b/apps/web/src/components/SwipeablePaneTab.tsx deleted file mode 100644 index 5e894d6..0000000 --- a/apps/web/src/components/SwipeablePaneTab.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useRef, useState } from 'react'; -import type { TouchEvent } from 'react'; -import { cn } from '@/lib/utils'; - -interface Props { - label: string; - isActive: boolean; - onTap: () => void; - onClose: () => void; - canClose: boolean; -} - -const CLOSE_THRESHOLD = 60; -const MAX_TRAVEL = 120; -const VERTICAL_BAIL = 30; - -// Pane tab with horizontal swipe-to-close (mobile only). Tracks horizontal -// finger movement; if vertical exceeds VERTICAL_BAIL the gesture is cancelled -// (so vertical scroll still works). On release past CLOSE_THRESHOLD, the -// onClose callback fires. Otherwise the tab snaps back. Hand-rolled per spec. -export function SwipeablePaneTab({ label, isActive, onTap, onClose, canClose }: Props) { - const [translateX, setTranslateX] = useState(0); - const [dragging, setDragging] = useState(false); - const startRef = useRef<{ x: number; y: number; bailed: boolean } | null>(null); - - const onTouchStart = (e: TouchEvent) => { - if (!canClose) return; - const t = e.touches[0]; - if (!t) return; - startRef.current = { x: t.clientX, y: t.clientY, bailed: false }; - setDragging(true); - }; - - const onTouchMove = (e: TouchEvent) => { - const start = startRef.current; - if (!start || start.bailed) return; - const t = e.touches[0]; - if (!t) return; - const dx = t.clientX - start.x; - const dy = t.clientY - start.y; - if (Math.abs(dy) > VERTICAL_BAIL) { - start.bailed = true; - setTranslateX(0); - setDragging(false); - return; - } - if (dx < 0) { - setTranslateX(Math.max(dx, -MAX_TRAVEL)); - } else { - setTranslateX(0); - } - }; - - const onTouchEnd = () => { - const start = startRef.current; - startRef.current = null; - setDragging(false); - if (!start || start.bailed) { - setTranslateX(0); - return; - } - const tx = translateX; - if (tx <= -CLOSE_THRESHOLD) { - onClose(); - // Don't reset translateX; the parent will unmount this tab. - } else { - setTranslateX(0); - } - }; - - // Opacity fades from 1 -> 0.4 as the tab approaches the close threshold. - const opacity = - translateX < 0 - ? Math.max(0.4, 1 - (Math.abs(translateX) / CLOSE_THRESHOLD) * 0.6) - : 1; - - return ( - - ); -} diff --git a/apps/web/src/components/ToolCallLine.tsx b/apps/web/src/components/ToolCallLine.tsx index a769e5c..2b31641 100644 --- a/apps/web/src/components/ToolCallLine.tsx +++ b/apps/web/src/components/ToolCallLine.tsx @@ -1,8 +1,7 @@ import { useState } from 'react'; -import type { ReactNode } from 'react'; import { Check, ChevronRight, Loader2, X } from 'lucide-react'; import type { ToolCall, ToolResult } from '@/api/types'; -import { sessionEvents } from '@/hooks/sessionEvents'; +import { linkifyPaths } from '@/lib/linkify-paths'; // v1.8.2: cap on the inline arg-summary length. Expanded view shows full // args + full result, so this is purely a single-line render budget. @@ -22,7 +21,7 @@ function truncate(s: string, n: number): string { // Per-tool argument summary mapping from the v1.8.2 spec. Goal is a single // scannable line that surfaces the *what* (path / pattern) without // overwhelming the chat with full JSON. -export function formatToolArgs(name: string, args: Record): string { +function formatToolArgs(name: string, args: Record): string { if (name === 'view_file') { const path = String(args.path ?? ''); const start = args.start_line; @@ -98,36 +97,8 @@ export function runStatus(run: ToolRun): 'pending' | 'success' | 'error' { return 'success'; } -// Path-shaped paths in tool output text get a click handler so users can -// jump to the file. Same heuristic as MessageBubble.linkifyPaths. -const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g; -function linkifyOutput(text: string): ReactNode[] { - const out: ReactNode[] = []; - let lastIdx = 0; - let idx = 0; - for (const match of text.matchAll(PATH_REGEX)) { - const matchedText = match[0]; - const start = match.index ?? 0; - if (!matchedText.includes('/')) continue; - if (start > lastIdx) out.push(text.slice(lastIdx, start)); - out.push( - - ); - lastIdx = start + matchedText.length; - idx += 1; - } - if (lastIdx < text.length) out.push(text.slice(lastIdx)); - return out.length > 0 ? out : [text]; -} +// Path-shaped substrings in tool output get a click handler via the shared +// linkifyPaths util (lib/linkify-paths.tsx). interface Props { run: ToolRun; @@ -189,7 +160,7 @@ export function ToolCallLine({ run, insideGroup }: Props) { {run.result.error ? ( {run.result.error} ) : ( - linkifyOutput( + linkifyPaths( typeof run.result.output === 'string' ? run.result.output : JSON.stringify(run.result.output, null, 2) diff --git a/apps/web/src/components/Workspace.tsx b/apps/web/src/components/Workspace.tsx index ab00368..29253b0 100644 --- a/apps/web/src/components/Workspace.tsx +++ b/apps/web/src/components/Workspace.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Terminal, Clipboard } from 'lucide-react'; import { api } from '@/api/client'; import type { Chat, Project, Session, WorkspacePane } from '@/api/types'; -import { MAX_PANES, activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes'; +import { activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes'; import type { UseSessionChatsResult } from '@/hooks/useSessionChats'; import { useViewport } from '@/hooks/useViewport'; import { terminalsRegistry } from '@/lib/events'; @@ -32,7 +32,6 @@ interface Props { project: Project | null; /** New BooCode opens a fresh coder session; chat/terminal split in-place. */ onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void; - onCoderConnectedChange?: (paneId: string, connected: boolean) => void; } export function Workspace({ @@ -44,7 +43,6 @@ export function Workspace({ chatsHook, session, project, - onCoderConnectedChange, onAddPane, }: Props) { const { @@ -141,10 +139,6 @@ export function Workspace({ return out; }, [panes]); - // Per-coder-pane WS connection (status dot lives in the pane header). - const [coderConnected, setCoderConnected] = useState>({}); - const [coderLabels, setCoderLabels] = useState>({}); - return (
    1 ? handlePaneDragStart(idx) : undefined} onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined} > - {/* Hidden on mobile per v1.8; settings + terminal panes own - their own header (no chats, so no ChatTabBar). */} - {!isMobile && !isChromeless && ( + {/* Hidden on mobile; settings/terminal/artifact panes own their + own header. Chat and coder panes share this strip — tabKind + and onNewTab differ between the two. */} + {!isMobile && (isCoder || !isChromeless) && ( switchTab(idx, tabIdx)} onRemoveTab={(chatId) => removeTab(idx, chatId)} onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)} onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)} onCloseAll={() => closeAllTabs(idx)} - onNewTab={() => void createChat(idx)} - onSplitPane={(kind) => onAddPane(kind)} - onReopenPane={hasClosedPanes ? reopenPane : undefined} - onShowHistory={() => showLandingPage(idx)} - onRename={renameChat} - onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} - /> - )} - {/* Coder panes host BooCode tabs (one chat = one agent context, - all sharing the session worktree). "+" adds a tab; the split - button adds a pane. Same tab strip as chat panes (tabKind). */} - {isCoder && !isMobile && ( - switchTab(idx, tabIdx)} - onRemoveTab={(chatId) => removeTab(idx, chatId)} - onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)} - onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)} - onCloseAll={() => closeAllTabs(idx)} - onNewTab={() => void createCoderTab(idx)} + onNewTab={isCoder ? () => void createCoderTab(idx) : () => void createChat(idx)} onSplitPane={(kind) => onAddPane(kind)} onReopenPane={hasClosedPanes ? reopenPane : undefined} onShowHistory={() => showLandingPage(idx)} @@ -296,17 +270,6 @@ export function Workspace({ chatId={activePaneChatId(pane)} chatPending={isPaneChatPending(pane.id)} projectPath={project?.path} - onConnectedChange={(connected) => { - setCoderConnected((prev) => - prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected }, - ); - onCoderConnectedChange?.(pane.id, connected); - }} - onAgentLabelChange={(label) => - setCoderLabels((prev) => - prev[pane.id] === label ? prev : { ...prev, [pane.id]: label }, - ) - } /> ) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? ( (null); - const [queue, setQueue] = useState([]); + const [queue, setQueue] = useState<{ id: string; text: string }[]>([]); + const queueIdRef = useRef(0); const processingRef = useRef(false); useEffect(() => { @@ -84,7 +85,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, processingRef.current = true; const next = queue[0]!; setQueue((prev) => prev.slice(1)); - api.messages.send(chatId, next) + api.messages.send(chatId, next.text) .catch((err) => toast.error(err instanceof Error ? err.message : 'queue send failed')) .finally(() => { processingRef.current = false; }); }, [streaming, queue, chatId]); @@ -101,7 +102,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, return; } if (streaming) { - setQueue((prev) => [...prev, trimmed]); + setQueue((prev) => [...prev, { id: String(++queueIdRef.current), text: trimmed }]); return; } await api.messages.send(chatId, trimmed); @@ -185,18 +186,18 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, // into ChatInput via sendToChat. ChatInput appends (or sets, if empty) and // focuses; user re-sends, which re-queues if streaming is still active. function editQueued(idx: number) { - const msg = queue[idx]; - if (!msg) return; + const item = queue[idx]; + if (!item) return; setQueue((prev) => prev.filter((_, i) => i !== idx)); - sendToChat.emit({ chat_id: chatId, text: msg }); + sendToChat.emit({ chat_id: chatId, text: item.text }); } async function forceSendQueued(idx: number) { - const msg = queue[idx]; - if (!msg) return; + const item = queue[idx]; + if (!item) return; setQueue((prev) => prev.filter((_, i) => i !== idx)); try { - await api.chats.forceSend(chatId, msg); + await api.chats.forceSend(chatId, item.text); } catch (err) { toast.error(err instanceof Error ? err.message : 'force send failed'); } @@ -211,10 +212,10 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, {queue.length > 0 && (
    - {queue.map((msg, i) => ( -
    + {queue.map((item, i) => ( +
    Queued: - {msg} + {item.text}
    -

    - Plumbed for Batch 8 (web_search tool). No effect yet. -

    @@ -532,7 +529,7 @@ function ProjectSection({ project }: { project: Project }) { />

    - Applies to new sessions only. Plumbed for Batch 8. + Applies to new sessions only.

    diff --git a/apps/web/src/components/panes/TerminalPane.tsx b/apps/web/src/components/panes/TerminalPane.tsx index ee9ae31..9520a99 100644 --- a/apps/web/src/components/panes/TerminalPane.tsx +++ b/apps/web/src/components/panes/TerminalPane.tsx @@ -1,27 +1,24 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { SearchAddon } from '@xterm/addon-search'; import { WebLinksAddon } from '@xterm/addon-web-links'; import '@xterm/xterm/css/xterm.css'; -import { ChevronDown, ChevronUp, Maximize2, RefreshCw, X } from 'lucide-react'; -import { toast } from 'sonner'; -import { api } from '@/api/client'; -import { - chatInputsRegistry, - sendToChat, - sendToTerminal, - terminalsRegistry, - type ChatInputRegistration, -} from '@/lib/events'; -import { cn } from '@/lib/utils'; +import { RefreshCw } from 'lucide-react'; +import { useTerminalFit } from '@/hooks/terminal/useTerminalFit'; +import { useTerminalSocket } from '@/hooks/terminal/useTerminalSocket'; +import { useTerminalSelection } from '@/hooks/terminal/useTerminalSelection'; +import { TerminalHotkeyBar } from '@/components/panes/terminal/TerminalHotkeyBar'; +import { FloatingMenu } from '@/components/panes/terminal/FloatingMenu'; +import { SearchBar } from '@/components/panes/terminal/SearchBar'; +import { TERM_BG, XTERM_THEME } from '@/components/panes/terminal/theme'; // Architecture invariants (do not regress without reading the comments // next to each): // // - Resize is in-band on the WebSocket as a JSON text frame. The HTTP // /resize endpoint had a race with PTY-map registration; WS frames -// don't. +// don't. (lib/terminal-protocol.ts owns the wire encoding.) // - Server `init` text frame triggers term.clear() so a stale buffer // can't leak through the capture-pane replay. // - DOM renderer only (no @xterm/addon-webgl). The WebGL atlas bakes @@ -33,7 +30,19 @@ import { cn } from '@/lib/utils'; // fallback per-character — boolab pattern, just works. // - Refit on document.fonts.ready (post-open) so xterm's cell metrics // don't bake against the system fallback. Boolab pattern: rAF inside -// fonts.ready, plus a 150ms safety-net setTimeout. +// fonts.ready, plus a 150ms safety-net setTimeout. (useTerminalFit.) +// +// Decomposition (v2 Phase 9): this orchestrator constructs/disposes the +// Terminal (spine effect) and composes three hooks — +// - useTerminalFit: measurement + the pure-fit refit triggers. +// - useTerminalSocket: WS lifecycle, term I/O handlers, bootstrap, +// visibility reconnect, sticky Ctrl. +// - useTerminalSelection: touch long-press, context menu, clipboard, +// custom keys, registries, menu actions. +// The spine effect is registered FIRST so termRef is populated before the +// hooks' effects run on mount. (On unmount that means term.dispose() runs +// before the hooks' cleanups — verified safe: no hook cleanup touches the +// terminal, and the WS message handler null-guards on termRef.) interface Props { sessionId: string; @@ -42,159 +51,21 @@ interface Props { active?: boolean; } -// Terminal background matches the pane container's `bg-[#0b0f14]` so any -// sub-cell rounding remainder is invisible. Update both if the theme changes. -const TERM_BG = '#0b0f14'; - -const XTERM_THEME = { - background: TERM_BG, - foreground: '#d6deeb', - cursor: '#82aaff', - selectionBackground: '#1d3b53', - black: '#011627', - red: '#ef5350', - green: '#22da6e', - yellow: '#c5e478', - blue: '#82aaff', - magenta: '#c792ea', - cyan: '#7fdbca', - white: '#d6deeb', - brightBlack: '#575656', - brightRed: '#ef5350', - brightGreen: '#22da6e', - brightYellow: '#ffeb95', - brightBlue: '#82aaff', - brightMagenta: '#c792ea', - brightCyan: '#7fdbca', - brightWhite: '#ffffff', -}; - -type ConnState = 'connecting' | 'open' | 'reconnecting' | 'disconnected'; - -const MAX_RECONNECT_ATTEMPTS = 5; -const RECONNECT_DELAYS_MS = [500, 1000, 2000, 4000, 8000]; - -const LONG_PRESS_MS = 500; -const LONG_PRESS_TOLERANCE_PX = 10; - -// xterm 5 ships no public dimensions API — `_core._renderService.dimensions` -// is internal but stable across the package versions we ship. -function cellSize(term: Terminal, container: HTMLElement): { w: number; h: number } { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const dims = (term as any)._core?._renderService?.dimensions?.css?.cell; - if (dims && dims.width > 0 && dims.height > 0) { - return { w: dims.width, h: dims.height }; - } - const rect = container.getBoundingClientRect(); - return { w: rect.width / Math.max(term.cols, 1), h: rect.height / Math.max(term.rows, 1) }; -} - -function pointToCell( - term: Terminal, - container: HTMLElement, - clientX: number, - clientY: number, -): { col: number; bufferRow: number } { - const rect = container.getBoundingClientRect(); - const { w, h } = cellSize(term, container); - const localX = Math.max(0, clientX - rect.left); - const localY = Math.max(0, clientY - rect.top); - const col = Math.min(term.cols - 1, Math.floor(localX / Math.max(w, 1))); - const screenRow = Math.min(term.rows - 1, Math.floor(localY / Math.max(h, 1))); - const bufferRow = term.buffer.active.viewportY + screenRow; - return { col, bufferRow }; -} - -const WORD_RE = /[\w.~$\-/]+/g; -function wordRangeAt(line: string, col: number): { start: number; end: number } | null { - for (const m of line.matchAll(WORD_RE)) { - const start = m.index ?? 0; - const end = start + m[0].length; - if (col >= start && col < end) return { start, end }; - if (start > col) return null; - } - return null; -} - export function TerminalPane({ sessionId, paneId, label, active = false }: Props) { const containerRef = useRef(null); const termRef = useRef(null); const searchRef = useRef(null); - const wsRef = useRef(null); - const reconnectTimerRef = useRef | null>(null); - const reconnectAttemptRef = useRef(0); - const closedByUserRef = useRef(false); - // Last size pushed through term.onResize. Mirrors xterm's authoritative - // cols/rows; consulted on every WS open so the resize frame carries the - // currently-measured size, not a stale lastSize from before the latest fit. - const lastSizeRef = useRef<{ cols: number; rows: number }>({ cols: 80, rows: 24 }); - const reconnectFnRef = useRef<() => void>(() => {}); - - const [connState, setConnState] = useState('connecting'); - const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); - const [searchOpen, setSearchOpen] = useState(false); - const [chatInputs, setChatInputs] = useState([]); - const setMenuRef = useRef(setMenu); - setMenuRef.current = setMenu; - const setSearchOpenRef = useRef(setSearchOpen); - setSearchOpenRef.current = setSearchOpen; - const setChatInputsRef = useRef(setChatInputs); - setChatInputsRef.current = setChatInputs; - - // v1.10.8d: sticky Ctrl for the on-screen hotkey bar (boolab pattern). - // ctrlArmedRef is read synchronously inside term.onData (per keystroke); - // ctrlArmed state mirror exists so the Ctrl button can highlight in React. - // Auto-disarms after 5s so a stray tap doesn't quietly mangle the next - // keystroke minutes later. - const [ctrlArmed, setCtrlArmed] = useState(false); - const ctrlArmedRef = useRef(false); - const ctrlTimerRef = useRef | null>(null); - const setCtrlArmedSync = useCallback((armed: boolean) => { - ctrlArmedRef.current = armed; - setCtrlArmed(armed); - if (ctrlTimerRef.current) { - clearTimeout(ctrlTimerRef.current); - ctrlTimerRef.current = null; - } - if (armed) { - ctrlTimerRef.current = setTimeout(() => { - ctrlArmedRef.current = false; - setCtrlArmed(false); - ctrlTimerRef.current = null; - }, 5000); - } - }, []); - const armCtrl = useCallback(() => { - setCtrlArmedSync(!ctrlArmedRef.current); - }, [setCtrlArmedSync]); - // sendInput: write to the WS as a binary frame (server-side discriminator - // routes binary to PTY, text to JSON control). Used by the hotkey bar. - const sendInput = useCallback((text: string) => { - if (!text) return; - const ws = wsRef.current; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - try { - ws.send(new TextEncoder().encode(text)); - } catch { - /* ignore */ - } - }, []); - // fitFull lives inside the main useEffect (closure over `disposed`/`ctr`); - // expose via a ref so the hotkey bar's fit button and the visualViewport - // resize handler can reach it. - const fitFullRef = useRef<() => void>(() => {}); - const triggerFit = useCallback(() => { - fitFullRef.current(); - }, []); + // Spine: construct + dispose the Terminal. Registered before the hooks so + // termRef/searchRef are set when their effects run on mount. NOTE: `label` + // is intentionally NOT a dependency — it is positional ("Terminal N") and + // renumbers on pane add/remove; keeping it out of these deps stops the live + // terminal from tearing down + reconnecting on a cosmetic renumber. The + // terminal registry's label is refreshed by useTerminalSelection's + // [paneId, label] effect instead. useEffect(() => { const container = containerRef.current; if (!container) return; - const ctr: HTMLDivElement = container; - - let disposed = false; - closedByUserRef.current = false; - reconnectAttemptRef.current = 0; // On mobile viewports drop to 11px so opencode/claude get enough cols. const fontSize = typeof window !== 'undefined' && window.innerWidth < 768 ? 11 : 13; @@ -217,526 +88,15 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props theme: XTERM_THEME, }); termRef.current = term; - const fit = new FitAddon(); + const fitAddon = new FitAddon(); const search = new SearchAddon(); searchRef.current = search; - term.loadAddon(fit); + term.loadAddon(fitAddon); term.loadAddon(search); term.loadAddon(new WebLinksAddon()); - term.open(ctr); - - // Bypass FitAddon's proposeDimensions(), which subtracts the native - // scrollbar's reserved width even after CSS hides it (boolab fix). We - // compute cols/rows directly from host.clientWidth/Height ÷ cell metrics. - // Falls through (no-op) if cell metrics aren't measured yet — a later - // refit (fonts.ready / setTimeout / ResizeObserver) will catch it. - // - // v1.10.8d: row count uses the VISIBLE height (clipped against the - // visualViewport) rather than raw clientHeight. h-dvh on the root only - // shrinks for the URL bar — not the iOS keyboard — so without the clip - // we allocate rows for the area hidden behind the keyboard, and bash's - // cursor (pushed down by MOTD on SSH login) ends up below the fold. - const fitFull = (): void => { - if (disposed) return; - const t = termRef.current; - if (!t) return; - if (!t.element || !(t.element as HTMLElement).offsetParent) return; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const core: any = (t as any)._core; - const cellW: number | undefined = core?._renderService?.dimensions?.css?.cell?.width; - const cellH: number | undefined = core?._renderService?.dimensions?.css?.cell?.height; - if (!cellW || !cellH || cellW <= 0 || cellH <= 0) return; - let visibleHeight = ctr.clientHeight; - const vp2 = typeof window !== 'undefined' ? window.visualViewport : null; - if (vp2) { - const rect = ctr.getBoundingClientRect(); - // visualViewport.height is the layout viewport minus the keyboard. - // rect.bottom is in layout-viewport coordinates. clip to whichever - // is smaller. offsetTop/pageTop adjustments aren't needed at zoom=1. - const visibleBottom = Math.min(rect.bottom, vp2.height); - const clipped = Math.max(0, visibleBottom - rect.top); - if (clipped > 0) visibleHeight = clipped; - } - const cols = Math.max(2, Math.floor(ctr.clientWidth / cellW)); - const rows = Math.max(1, Math.floor(visibleHeight / cellH)); - if (cols !== t.cols || rows !== t.rows) { - try { - t.resize(cols, rows); - } catch { - /* ignore */ - } - } - }; - fitFullRef.current = fitFull; - - // boolab pattern: term.onResize fires synchronously inside term.resize() - // and on init. We always update lastSizeRef so a fresh WS connect (or - // reconnect) carries the current measured size; we send a resize frame - // when the WS is open. No gating by an "is-started" flag — the WS itself - // is the gate (it ignores writes when not open). - term.onResize(({ cols, rows }) => { - lastSizeRef.current = { cols, rows }; - if (cols < 2 || rows < 1) return; - const ws = wsRef.current; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - try { - ws.send(JSON.stringify({ type: 'resize', cols, rows })); - } catch { - /* ignore */ - } - }); - - // Keystrokes go out as a BINARY frame so the server can disambiguate them - // from JSON control frames. TextEncoder is in every modern browser. - // - // Sticky Ctrl: when armed (via the hotkey bar), apply Ctrl to the next - // single ASCII printable (0x40-0x7e — '@', A-Z, [, \, ], ^, _, `, a-z, - // {, |, }, ~) and disarm. Multi-byte sequences (arrows, Esc, F-keys) pass - // through untouched and disarm so a stuck arm doesn't poison them. - term.onData((data) => { - const ws = wsRef.current; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - let out = data; - if (ctrlArmedRef.current) { - if (data.length === 1) { - const c = data.charCodeAt(0); - if (c >= 0x40 && c <= 0x7e) { - out = String.fromCharCode(c & 0x1f); - } - } - setCtrlArmedSync(false); - } - try { - ws.send(new TextEncoder().encode(out)); - } catch { - /* ignore */ - } - }); - - function buildWsUrl(): string { - const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const { cols, rows } = lastSizeRef.current; - return `${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}?cols=${cols}&rows=${rows}`; - } - - function scheduleReconnect(): void { - if (disposed || closedByUserRef.current) return; - const attempt = reconnectAttemptRef.current; - if (attempt >= MAX_RECONNECT_ATTEMPTS) { - setConnState('disconnected'); - return; - } - const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)]!; - reconnectAttemptRef.current = attempt + 1; - if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); - reconnectTimerRef.current = setTimeout(connect, delay); - } - - function connect(): void { - if (disposed || closedByUserRef.current) return; - const existing = wsRef.current; - if (existing && existing.readyState !== WebSocket.CLOSED) return; - let ws: WebSocket; - try { - ws = new WebSocket(buildWsUrl()); - } catch { - scheduleReconnect(); - return; - } - ws.binaryType = 'arraybuffer'; - wsRef.current = ws; - - ws.addEventListener('open', () => { - if (disposed) return; - reconnectAttemptRef.current = 0; - setConnState('open'); - // boolab pattern: send our currently-measured size as a resize frame - // on every WS open. Server's PTY adopts it immediately; no race - // window between attach and resize. - const { cols, rows } = lastSizeRef.current; - if (cols >= 2 && rows >= 1) { - try { - ws.send(JSON.stringify({ type: 'resize', cols, rows })); - } catch { - /* ignore */ - } - } - }); - - ws.addEventListener('message', (e) => { - const t = termRef.current; - if (!t) return; - if (typeof e.data === 'string') { - try { - const parsed = JSON.parse(e.data) as { type?: string; code?: number }; - if (parsed.type === 'init') { - // boolab pattern: wipe any pre-attach buffer state, then the - // server's next frame (a single binary capture-pane replay) - // will paint the current tmux pane state in cleanly. Re-send - // our measured size in case the server's init size (from the - // WS query string) is stale relative to a fit that landed - // between opening and now. - try { - t.clear(); - } catch { - /* ignore */ - } - const { cols, rows } = lastSizeRef.current; - if (cols >= 2 && rows >= 1) { - try { - ws.send(JSON.stringify({ type: 'resize', cols, rows })); - } catch { - /* ignore */ - } - } - return; - } - if (parsed.type === 'exit') { - t.write(`\r\n\x1b[2m[process exited with code ${parsed.code ?? 0}]\x1b[0m\r\n`); - return; - } - } catch { - /* not JSON — fall through and write as text */ - } - t.write(e.data); - } else { - t.write(new Uint8Array(e.data as ArrayBuffer)); - } - }); - - ws.addEventListener('close', () => { - if (disposed) return; - wsRef.current = null; - if (closedByUserRef.current) return; - setConnState('reconnecting'); - scheduleReconnect(); - }); - - ws.addEventListener('error', () => { - // close fires after; let close own the retry. - }); - } - - function manualReconnect(): void { - if (disposed) return; - closedByUserRef.current = false; - reconnectAttemptRef.current = 0; - if (reconnectTimerRef.current) { - clearTimeout(reconnectTimerRef.current); - reconnectTimerRef.current = null; - } - try { - wsRef.current?.close(); - } catch { - /* ignore */ - } - setConnState('reconnecting'); - setTimeout(connect, 50); - } - reconnectFnRef.current = manualReconnect; - - // Init: rAF-defer one frame so xterm's render service can populate - // _renderService.dimensions, then fitFull → /start (idempotent, sizes - // the tmux session) → connect WS. The WS handler also ensureSession's - // as belt-and-suspenders, so a /start failure is non-fatal. - const initRaf = requestAnimationFrame(() => { - if (disposed) return; - fitFull(); - const { cols, rows } = lastSizeRef.current; - api.terminals - .start(sessionId, paneId, cols, rows) - .catch(() => { - /* WS handler will ensureSession itself — non-fatal */ - }) - .finally(() => { - if (disposed) return; - connect(); - }); - }); - - // Boolab pattern: term measures cell metrics on open() against whatever - // font is registered at that instant; if the font hasn't loaded yet we - // get a fallback measurement and the wrong cols. Refit when - // document.fonts.ready resolves. - let fontRaf: number | null = null; - if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) { - document.fonts.ready - .then(() => { - if (disposed) return; - fontRaf = requestAnimationFrame(() => fitFull()); - }) - .catch(() => { - /* ignore */ - }); - } - - // Second-pass refit: mobile browsers can settle layout slightly late, - // causing the rAF fit to measure a stale clientWidth. - const delayedFit = setTimeout(() => fitFull(), 150); - - // ResizeObserver: refit on any container size change. No debounce — - // term.onResize itself is the throttle (no-op when cols/rows unchanged). - const ro = new ResizeObserver(() => { - fitFull(); - }); - ro.observe(ctr); - - const onVis = (): void => { - if (document.visibilityState !== 'visible') return; - const ws = wsRef.current; - if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) { - manualReconnect(); - } else { - // Refit in case the layout changed while hidden. Single rAF so the - // measurement runs after the tab's first composited frame back in - // the foreground. - requestAnimationFrame(() => { - if (disposed) return; - fitFull(); - }); - } - }; - document.addEventListener('visibilitychange', onVis); - - // v1.10.8d: visualViewport.resize fires when the iOS keyboard opens or - // closes — the layout viewport stays full-height (so our h-dvh root - // doesn't shrink), but the visual viewport contracts above the keyboard. - // Refit so xterm's row count matches the visible area, then scroll to - // bottom so the cursor stays above the keyboard fold (boolab pattern). - let onVpResize: (() => void) | null = null; - const vp = typeof window !== 'undefined' ? window.visualViewport : null; - if (vp) { - onVpResize = (): void => { - if (disposed) return; - fitFull(); - const t = termRef.current; - if (t) { - try { t.scrollToBottom(); } catch { /* ignore */ } - } - }; - vp.addEventListener('resize', onVpResize); - } - - // ============================================================ - // v1.10.4 features (long-press menu, right-click, search, registry) - // Kept verbatim — independent of the WS/fit path that v1.10.8c fixes. - // ============================================================ - - function pasteFromClipboard(): void { - if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') { - toast.error('Paste blocked — long-press input area instead'); - return; - } - navigator.clipboard - .readText() - .then((text) => { - if (!text) return; - const ws = wsRef.current; - if (ws && ws.readyState === WebSocket.OPEN) { - try { - ws.send(new TextEncoder().encode(text)); - } catch { - /* ignore */ - } - } - }) - .catch(() => { - toast.error('Paste blocked — long-press input area instead'); - }); - } - - term.attachCustomKeyEventHandler((e) => { - if (e.type !== 'keydown') return true; - const mod = e.ctrlKey || e.metaKey; - if (!mod) return true; - const isC = e.key === 'c' || e.key === 'C'; - const isV = e.key === 'v' || e.key === 'V'; - const isF = e.key === 'f' || e.key === 'F'; - if (isC) { - if (term.hasSelection()) { - const sel = term.getSelection(); - if (sel) { - navigator.clipboard.writeText(sel).catch(() => { - toast.error('Clipboard write failed'); - }); - } - return false; - } - return !e.shiftKey; - } - if (isV) { - pasteFromClipboard(); - return false; - } - if (isF) { - setSearchOpenRef.current(true); - return false; - } - return true; - }); - - // Long-press selection + floating menu (touch). - let lpTimer: ReturnType | null = null; - let lpStart: { x: number; y: number } | null = null; - let lpAnchor: { col: number; bufferRow: number } | null = null; - let inSelection = false; - function clearLp(): void { - if (lpTimer !== null) { - clearTimeout(lpTimer); - lpTimer = null; - } - lpStart = null; - } - function selectWord(col: number, bufferRow: number): boolean { - const line = term.buffer.active.getLine(bufferRow); - if (!line) return false; - const text = line.translateToString(true); - const range = wordRangeAt(text, col); - if (!range) return false; - term.select(range.start, bufferRow, range.end - range.start); - return true; - } - function extendSelection( - anchor: { col: number; bufferRow: number }, - to: { col: number; bufferRow: number }, - ): void { - const a = anchor; - const b = to; - let s: { col: number; row: number }; - let e: { col: number; row: number }; - if (a.bufferRow < b.bufferRow || (a.bufferRow === b.bufferRow && a.col <= b.col)) { - s = { col: a.col, row: a.bufferRow }; - e = { col: b.col, row: b.bufferRow }; - } else { - s = { col: b.col, row: b.bufferRow }; - e = { col: a.col, row: a.bufferRow }; - } - const rowsBetween = e.row - s.row; - const length = rowsBetween * term.cols + (e.col - s.col) + 1; - term.select(s.col, s.row, length); - } - function onTouchStart(e: TouchEvent): void { - if (e.touches.length !== 1) return; - const t = e.touches[0]!; - if ((e.target as Element | null)?.closest('[data-term-menu]')) return; - lpStart = { x: t.clientX, y: t.clientY }; - lpAnchor = pointToCell(term, ctr, t.clientX, t.clientY); - inSelection = false; - lpTimer = setTimeout(() => { - if (disposed || !lpAnchor) return; - const ok = selectWord(lpAnchor.col, lpAnchor.bufferRow); - if (!ok) return; - inSelection = true; - setMenuRef.current({ x: t.clientX, y: Math.max(t.clientY - 50, 8) }); - }, LONG_PRESS_MS); - } - function onTouchMove(e: TouchEvent): void { - if (e.touches.length !== 1) return; - const t = e.touches[0]!; - if (inSelection && lpAnchor) { - e.preventDefault(); - const to = pointToCell(term, ctr, t.clientX, t.clientY); - extendSelection(lpAnchor, to); - return; - } - if (!lpStart) return; - const dx = t.clientX - lpStart.x; - const dy = t.clientY - lpStart.y; - if (Math.abs(dx) > LONG_PRESS_TOLERANCE_PX || Math.abs(dy) > LONG_PRESS_TOLERANCE_PX) { - clearLp(); - } - } - function onTouchEnd(): void { - if (!inSelection) clearLp(); - inSelection = false; - } - function onTouchCancel(): void { - clearLp(); - inSelection = false; - } - ctr.addEventListener('touchstart', onTouchStart, { passive: true }); - ctr.addEventListener('touchmove', onTouchMove, { passive: false }); - ctr.addEventListener('touchend', onTouchEnd, { passive: true }); - ctr.addEventListener('touchcancel', onTouchCancel, { passive: true }); - - function onContextMenu(e: MouseEvent): void { - e.preventDefault(); - setMenuRef.current({ x: e.clientX, y: e.clientY }); - } - ctr.addEventListener('contextmenu', onContextMenu); - - function onDocPointerDown(e: PointerEvent): void { - const t = e.target as Element | null; - if (t && t.closest('[data-term-menu]')) return; - setMenuRef.current(null); - } - document.addEventListener('pointerdown', onDocPointerDown); - - const unregister = terminalsRegistry.register( - paneId, - label, - () => { - try { - term.focus(); - } catch { - /* ignore */ - } - }, - () => setSearchOpenRef.current(true), - () => pasteFromClipboard(), - ); - const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => { - if (pane_id !== paneId) return; - const ws = wsRef.current; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - const payload = text.endsWith('\n') ? text : `${text}\n`; - try { - ws.send(new TextEncoder().encode(payload)); - } catch { - /* ignore */ - } - }); - setChatInputsRef.current(chatInputsRegistry.list()); - const unsubChats = chatInputsRegistry.subscribe(() => { - setChatInputsRef.current(chatInputsRegistry.list()); - }); + term.open(container); return () => { - disposed = true; - closedByUserRef.current = true; - cancelAnimationFrame(initRaf); - if (fontRaf !== null) cancelAnimationFrame(fontRaf); - clearTimeout(delayedFit); - if (reconnectTimerRef.current) { - clearTimeout(reconnectTimerRef.current); - reconnectTimerRef.current = null; - } - if (ctrlTimerRef.current) { - clearTimeout(ctrlTimerRef.current); - ctrlTimerRef.current = null; - } - if (lpTimer !== null) clearTimeout(lpTimer); - ro.disconnect(); - document.removeEventListener('visibilitychange', onVis); - if (vp && onVpResize) vp.removeEventListener('resize', onVpResize); - document.removeEventListener('pointerdown', onDocPointerDown); - ctr.removeEventListener('touchstart', onTouchStart); - ctr.removeEventListener('touchmove', onTouchMove); - ctr.removeEventListener('touchend', onTouchEnd); - ctr.removeEventListener('touchcancel', onTouchCancel); - ctr.removeEventListener('contextmenu', onContextMenu); - unsubscribe(); - unsubChats(); - unregister(); - const ws = wsRef.current; - if (ws) { - try { - ws.close(1000, 'unmount'); - } catch { - /* ignore */ - } - wsRef.current = null; - } try { term.dispose(); } catch { @@ -744,75 +104,38 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props } termRef.current = null; searchRef.current = null; - reconnectFnRef.current = () => {}; }; - }, [sessionId, paneId, label]); + }, [sessionId, paneId]); + + const fit = useTerminalFit({ termRef, containerRef, sessionId, paneId }); + const socket = useTerminalSocket({ + termRef, + sessionId, + paneId, + fit: fit.fit, + getSize: fit.getSize, + setSize: fit.setSize, + }); + const selection = useTerminalSelection({ + termRef, + containerRef, + sessionId, + paneId, + label, + send: socket.send, + }); // Refit when this pane becomes the visible/focused one (e.g. mobile pane - // switch, maximize toggle). term.onResize will send the resize WS frame if - // cols or rows actually changed. + // switch, maximize toggle). Routes through useTerminalFit().fit so the + // visualViewport keyboard clip is applied consistently (v2 Phase 9 dedup — + // previously this effect re-implemented the cell-metric→resize math WITHOUT + // the keyboard clip). term.onResize sends the resize WS frame if cols or + // rows actually changed. useEffect(() => { if (!active) return; - const raf = requestAnimationFrame(() => { - const t = termRef.current; - const host = containerRef.current; - if (!t || !host) return; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const core: any = (t as any)._core; - const cellW: number | undefined = core?._renderService?.dimensions?.css?.cell?.width; - const cellH: number | undefined = core?._renderService?.dimensions?.css?.cell?.height; - if (!cellW || !cellH || cellW <= 0 || cellH <= 0) return; - const cols = Math.max(2, Math.floor(host.clientWidth / cellW)); - const rows = Math.max(1, Math.floor(host.clientHeight / cellH)); - if (cols !== t.cols || rows !== t.rows) { - try { - t.resize(cols, rows); - } catch { - /* ignore */ - } - } - }); + const raf = requestAnimationFrame(() => fit.fit()); return () => cancelAnimationFrame(raf); - }, [active]); - - function actCopy(): void { - const term = termRef.current; - if (!term) return; - const sel = term.getSelection(); - if (!sel) { - setMenu(null); - return; - } - navigator.clipboard.writeText(sel).catch(() => toast.error('Clipboard write failed')); - term.clearSelection(); - setMenu(null); - } - function actPaste(): void { - const reg = terminalsRegistry.get(paneId); - reg?.paste(); - setMenu(null); - } - function actSelectAll(): void { - termRef.current?.selectAll(); - setMenu(null); - } - function actSearch(): void { - setSearchOpen(true); - setMenu(null); - } - function actSendToChat(chatId: string): void { - const term = termRef.current; - if (!term) return; - const sel = term.getSelection(); - if (!sel) { - setMenu(null); - return; - } - sendToChat.emit({ chat_id: chatId, text: sel }); - setMenu(null); - } - - const hasSelection = !!termRef.current?.getSelection(); + }, [active, fit.fit]); return (
    @@ -820,10 +143,10 @@ export function TerminalPane({ sessionId, paneId, label, active = false }: Props Putting them in an inline