Compare commits

..

3 Commits

Author SHA1 Message Date
cc4bd04aa4 Merge contracts-ssot-pkg: v2.7.13 single-source cross-app wire contracts in @boocode/contracts 2026-06-02 21:24:14 +00:00
649ce71eff feat: single-source cross-app wire contracts in @boocode/contracts (v2.7.13)
Move all hand-synced cross-app wire contracts into one built workspace
package, @boocode/contracts, consumed by server/web/coder/coder-web via
workspace:* + a per-subpath exports map. The ws-frames and provider-config
Zod schemas are schema-first (z.infer); MessageMetadata, ErrorReason,
AgentSessionConfig, the provider snapshot types, and WorktreeRiskReport are
each single-sourced. Deletes the byte-identical copies and their parity
tests, fixes a live AgentSessionConfig drift (coder dead copy removed,
unified to the web required/nullable shape), removes the dead pending_change
WS arms in the fallback SPA, and inverts the build order (contracts builds
first) across root build, Dockerfile, and the coder deploy docs. Reverses
the shared-package decision declined in v2.5.12.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:24:08 +00:00
2a05d2f9fe docs: archive shipped openspec batches; add feature/plan/research notes
Move 13 shipped openspec change docs under openspec/changes/archived/.
Add docs/features/git-diff-panel, docs/plans/post-review-backlog, and
docs/research/cross-app-contract-ssot.md (the research behind the
@boocode/contracts SSOT work). Update BOOCHAT.md, BOOCODER.md, and
boocode_roadmap.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:20:33 +00:00
209 changed files with 9101 additions and 7854 deletions

View File

@@ -28,6 +28,11 @@
- Prefer codecontext (`search_symbols`, `get_symbol_info`, `get_dependencies`) over `grep` for symbol-level questions. Fall back to `grep` / `view_file` when codecontext returns degraded or empty results — that signals an unsupported language or parse failure. - Prefer codecontext (`search_symbols`, `get_symbol_info`, `get_dependencies`) over `grep` for symbol-level questions. Fall back to `grep` / `view_file` when codecontext returns degraded or empty results — that signals an unsupported language or parse failure.
- Verify before reporting work complete: run the relevant test/build/smoke command and confirm output matches the claim. Evidence first, assertion second. - Verify before reporting work complete: run the relevant test/build/smoke command and confirm output matches the claim. Evidence first, assertion second.
## Recovery and context (v2.7)
- **Heed the recovery nudge.** Native inference tracks consecutive tool **failures** (`mistake-tracker.ts`): after 3 in a row with no successful step between, a `mistake_recovery` sentinel is injected telling you to re-read tool schemas, verify a path exists before acting, and try a *different* approach — not retry variations of the same failing call. Ignoring it (a second failure run with the nudge still outstanding) **escalates and stops the turn** to protect the step budget. This complements the doom-loop guard, which only catches *identical* repeats.
- **Files-read provenance survives compaction.** Paths you read via `view_file` / `grep` / `find_files` / `list_dir` are accumulated and merged into a cumulative `## Files Read` ledger in the rolling summary, so a file read long ago stays in context across compactions. You don't manage this — but it means you usually don't need to re-read a file just because the raw turn scrolled out of the window.
## Output format ## Output format
- Stay in Markdown by default for every reply, short or long. - Stay in Markdown by default for every reply, short or long.

View File

@@ -23,6 +23,8 @@ You are BooCoder, a write-capable coding agent. You can read AND modify files wi
Every file modification queues in `pending_changes` before touching disk. The user sees a diff preview and approves/rejects each change. Never bypass this queue — it is the safety boundary between inference and the filesystem. Every file modification queues in `pending_changes` before touching disk. The user sees a diff preview and approves/rejects each change. Never bypass this queue — it is the safety boundary between inference and the filesystem.
`edit_file`'s `old_string` match is **fuzzy** (`fuzzy-match.ts`, v2.7.1): an exact → per-line-whitespace → unicode-canonicalization (curly quotes/dashes/nbsp) → Levenshtein-≥0.66 ladder, so minor whitespace/indentation/unicode drift in `old_string` still lands on the right span. Two consequences: a near-miss `old_string` may still apply (verify the queued diff is what you intended), and an `old_string` matching **more than one** place is rejected as **ambiguous** rather than editing the first — add surrounding context to disambiguate. A genuine non-match returns a clear failure, not a thrown error.
## Behavior ## Behavior
- Show diffs clearly. Explain what you're changing and why. - Show diffs clearly. Explain what you're changing and why.
@@ -102,7 +104,7 @@ Either way, **adding to config does NOT install the binary.** Until the CLI is o
### Deploy + smoke ### Deploy + smoke
Two deploy targets: Two deploy targets:
- **Routes (host service):** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder` - **Routes (host service):** `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
- **Web UI (container):** `docker compose up --build -d boocode` - **Web UI (container):** `docker compose up --build -d boocode`
Green gate (verified across phases 15): `pnpm -C apps/coder test` (134 passing) `&& pnpm -C apps/coder build`. Green gate (verified across phases 15): `pnpm -C apps/coder test` (134 passing) `&& pnpm -C apps/coder build`.
@@ -115,3 +117,35 @@ curl http://100.114.205.53:9500/api/coder/providers/config # raw config, throu
# Settings → Providers: disable goose → it leaves the composer picker, stays in the tab # Settings → Providers: disable goose → it leaves the composer picker, stays in the tab
# POST refresh → models repopulate; Add a catalog entry → it appears after refresh (unavailable until its CLI is installed) # POST refresh → models repopulate; Add a catalog entry → it appears after refresh (unavailable until its CLI is installed)
``` ```
## Persistent agent sessions (v2.6)
When you `dispatch_external_agent` to a chat-tab provider, BooCoder keeps that agent **warm and resumable** instead of spawning a fresh process per turn. This is mostly transparent — but the model below explains why turn 2 is fast, why an external agent remembers earlier turns, and how edits flow.
### Backends and keying
- One live backend per **`(chat_id, agent)`** pair, owned by the `agent-pool` (`agent-pool.ts`). State lives in `agent_sessions` (the resumable session id) and `worktrees` (the per-chat working copy).
- **opencode** runs a long-lived `opencode serve` (`backends/opencode-server.ts`) with per-session SSE; turns after the first reuse the same session (memory intact, ~9× faster).
- **goose / qwen** run a warm ACP connection (`backends/warm-acp.ts`) — `initialize` + `session/new` once per `(chat,agent)`, then `session/prompt` per turn. Interrupt cancels the prompt (`session/cancel`), never the child.
- **claude** runs the Claude Agent SDK backend (`backends/claude-sdk.ts`) over a clean-room Postgres session store.
- Arena, MCP `new_task`, and one-shot dispatches still use the cold `runExternalAgent` path — warm reuse needs both a `session_id` and a `chat_id`.
### Worktrees
- External agents write **directly into a persistent per-chat worktree** (`/tmp/booworktrees/sess-<id>`), not into the project root via `pending_changes`. The worktree is created once, base commit captured, and **reused across turns and across agents in the same chat** — so opencode and goose in one chat share one worktree.
- Each turn's worktree diff supersedes the prior `pending_changes` row for that `(chat,agent)` (latest-wins) and is badged with the authoring agent in the DiffPanel.
- **Staging boundary:** a provider only sees another agent's edits once they are **applied**. Unapplied worktree edits from a different agent are invisible to you — the DiffPanel shows a muted hint when that's the case.
### Lifecycle (v2.6.10v2.6.11)
- **Idle eviction:** a backend idle past `AGENT_POOL_IDLE_TTL_MS` (default 30 min) is disposed; an LRU cap of `AGENT_POOL_MAX_LIVE` (default 10) bounds live backends. A busy backend is never evicted, and the next turn transparently re-attaches or re-creates from `agent_sessions`/`worktrees`.
- **Crash recovery:** a health monitor restarts a crashed server (opencode → fresh sessions; ACP → re-`session/new`) and reclaims its port.
- **Close cleanup:** closing/deleting a chat or session evicts its backends, archives the `worktrees` row, and removes the worktree. An hourly reaper sweeps orphaned worktrees (dirty/unpushed preflight before removal).
### Checkpoints (v2.7.1)
Because external agents write the worktree directly (outside `pending_changes`), a worktree **checkpoint** is shadow-committed before each external-agent turn (tracked + untracked, into `refs/boocode/checkpoints/<id>`), anchored to that turn's assistant message. The per-message **"Restore to here"** affordance resets the worktree (`reset --hard` + `clean -fd`), trims the transcript past that message, and resets the `(chat,agent)` backend session — so files, transcript, and agent context land consistent at the restore point. `rewind` still only reverses BooCoder's own applied `pending_changes`; checkpoints are what cover external-agent worktree edits.
### Normalized status (v2.6 / v2.7.6)
Turn boundaries publish a normalized per-`(chat,agent)` status — `working | blocked | idle | error` — to the UI (`agent_status_updated` frame), so blocked-on-permission and crash/idle are visible, not just WS liveness.

View File

@@ -2,9 +2,9 @@
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. 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 ## v2.7.13-contracts-ssot — 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`. Creates `@boocode/contracts` (`packages/contracts`), a new workspace package that becomes the single source of truth for every cross-app wire contract — reversing the decision recorded in `v2.5.12-provider-lifecycle-phase4` that declined a shared types package as not worth the Docker/build-order risk at solo scale; a live `AgentSessionConfig` drift that had since appeared between `apps/coder` and `apps/web` justified the investment. Six contracts are now defined exactly once: the `WsFrameSchema` Zod runtime schema, the provider snapshot types (`ProviderSnapshotEntry` and family), the Zod provider-config schemas, `MessageMetadata` + `ErrorReason`, `AgentSessionConfig`, and `WorktreeRiskReport`; both Zod-backed contracts use `z.infer` so validator and type derive from the same definition and cannot drift independently. All four consumers — `apps/server`, `apps/web`, `apps/coder`, and the fallback SPA `apps/coder/web` — import via `workspace:*` through a per-subpath exports map consuming built dist only (no tsconfig project references); the hand-synced copies and their parity tests (`provider-types-parity.test.ts`; the ws-frames byte-parity assertion) are deleted while the KNOWN_FRAME_TYPES drift test and broker fail-closed tests are preserved. Build order is inverted in the root build script, Dockerfile, and coder deploy docs; `apps/coder/web`'s migration also removed dead `pending_change_*` reducer arms (no frame publisher exists for these — pending changes are HTTP-delivered), closing a latent missing-default-arm crash, and reconciled field-type conflicts with the canonical `WsFrame`; zod is pinned to a single version across the workspace. Server 543 / coder 293 / contracts 11 tests passing; human smoke verified on the live stack 2026-06-02.
## v2.7.11-coder-model-snapshot — 2026-06-02 ## v2.7.11-coder-model-snapshot — 2026-06-02

View File

@@ -73,7 +73,7 @@ Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ...
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only add-existing scope), `BOOTSTRAP_ROOT` (/opt/projects, writable bootstrap mkdir target — host must `mkdir -p` it before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale; the public host is behind Authelia, unusable from server context), `BOOCODE_TOOLS` (`core`|`standard`|`all`, default `all`; a ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (default `/data/mcp.json`, opencode `mcpServers` shape; missing = no MCP), `CONTEXT7_API_KEY` (the Context7 MCP key, referenced from `data/mcp.json` as `"{env:CONTEXT7_API_KEY}"`). `data/mcp.json` is **gitignored** but no longer holds secrets — string values support opencode-style `{env:VAR}` substitution (`mcp-config.ts:substituteEnvVars`, applied before Zod validation; unset var → `''` + warn), so real keys live in `.env`; template `data/mcp.example.json`. A config-only edit there needs only `docker compose restart boocode` (data/ is bind-mounted); changing a referenced secret edits `.env`. MCP loads at server startup with per-server graceful degradation; the coder does NOT load MCP (BooChat only). Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only add-existing scope), `BOOTSTRAP_ROOT` (/opt/projects, writable bootstrap mkdir target — host must `mkdir -p` it before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale; the public host is behind Authelia, unusable from server context), `BOOCODE_TOOLS` (`core`|`standard`|`all`, default `all`; a ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (default `/data/mcp.json`, opencode `mcpServers` shape; missing = no MCP), `CONTEXT7_API_KEY` (the Context7 MCP key, referenced from `data/mcp.json` as `"{env:CONTEXT7_API_KEY}"`). `data/mcp.json` is **gitignored** but no longer holds secrets — string values support opencode-style `{env:VAR}` substitution (`mcp-config.ts:substituteEnvVars`, applied before Zod validation; unset var → `''` + warn), so real keys live in `.env`; template `data/mcp.example.json`. A config-only edit there needs only `docker compose restart boocode` (data/ is bind-mounted); changing a referenced secret edits `.env`. MCP loads at server startup with per-server graceful degradation; the coder does NOT load MCP (BooChat only).
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`. BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
- `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL. Set to a small llama-swap model (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls. - `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL. Set to a small llama-swap model (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls.
- Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — `-p` runs autonomously without prompts. ACP bridge is an HTTP daemon (not stdio); use PTY dispatch. - Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — `-p` runs autonomously without prompts. ACP bridge is an HTTP daemon (not stdio); use PTY dispatch.
@@ -113,9 +113,9 @@ Cross-cutting only. Per-app conventions live in the matching `apps/*/CLAUDE.md`.
- No app-layer auth. Authelia handles auth at the reverse proxy. All `broker.publishUser`/`subscribeUser` calls use `'default'` as the user key. - No app-layer auth. Authelia handles auth at the reverse proxy. All `broker.publishUser`/`subscribeUser` calls use `'default'` as the user key.
- TypeScript strict mode. Both apps share `tsconfig.base.json`. Server + coder use NodeNext module resolution (`.js` extensions in imports). - TypeScript strict mode. Both apps share `tsconfig.base.json`. Server + coder use NodeNext module resolution (`.js` extensions in imports).
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`). - Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
- **Adding a new WS frame type** (cross-app) requires updating BOTH the server's `InferenceFrame` (loose `type:` union + optional fields in `services/inference/turn.ts`) AND the web `WsFrame` (strict discriminated union in `apps/web/src/api/types.ts`). Server publish is permissive; the frontend type is the wire-format gate — missing the web side silently drops the frame at JSON-parse. - **Adding a new WS frame type** (cross-app): add it to `WsFrameSchema` in `packages/contracts/src/ws-frames.ts` (single source of truth; rebuild with `pnpm -C packages/contracts build`). The server's `InferenceFrame` loose union (`services/inference/turn.ts`) and the web's strict `WsFrame` discriminated union (`apps/web/src/api/types.ts`) still exist separately and also need updating. Server publish is permissive; the frontend type is the wire-format gate — missing the web side silently drops the frame at JSON-parse.
- **Sentinels** (cross-app) are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. A new kind requires arms in `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts`, plus a render branch in `apps/web/src/components/MessageBubble.tsx`. - **Sentinels** (cross-app) are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. `MessageMetadata` is single-sourced in `@boocode/contracts` (`packages/contracts/src/message-metadata.ts`). A new kind requires updating that file and rebuilding the package, plus a render branch in `apps/web/src/components/MessageBubble.tsx`.
- **Coder↔web provider-type parity** (`apps/coder/src/services/provider-types.ts` `apps/web/src/api/types.ts`): enforced by runtime `provider-types-parity.test.ts` (compile-time cross-import is blocked by TS6307 on web's composite tsconfig). Mirror of the ws-frames parity pattern — edit both copies together. - **Provider snapshot types** (`ProviderSnapshotEntry`, `ProviderModel`, `ProviderMode`, `ThinkingOption`, `AgentCommand`, `ProviderSnapshotStatus`) are single-sourced in `@boocode/contracts` (`packages/contracts/src/provider-snapshot.ts`); `apps/coder/src/services/provider-types.ts` re-exports them. Edit the package source; there is no hand-synced web copy to update.
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of an object/array). Pattern in `parts.ts`, `settings.ts`. - **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of an object/array). Pattern in `parts.ts`, `settings.ts`.
- Skills live in `data/skills/<vendor>/`; Sam's own namespace is `boocode/` (`committing-changes`, `using-worktrees`, `improving-boocode-guidance`, `systematic-debugging`) — `SKILL.md` + optional `eval.yaml` (gerund names; eval = `skill:` + `tasks:` of `prompt`+`grader`, incl. a negative-trigger task). `data/skills/` is canonical; a divergent mirror at `/opt/skills/` exists. - Skills live in `data/skills/<vendor>/`; Sam's own namespace is `boocode/` (`committing-changes`, `using-worktrees`, `improving-boocode-guidance`, `systematic-debugging`) — `SKILL.md` + optional `eval.yaml` (gerund names; eval = `skill:` + `tasks:` of `prompt`+`grader`, incl. a negative-trigger task). `data/skills/` is canonical; a divergent mirror at `/opt/skills/` exists.

View File

@@ -1,9 +1,10 @@
# Current focus # Current focus
Last updated: 2026-06-02 Last updated: 2026-05-26
- **Last shipped:** `v2.7.8-ember-coder-tabs-model-chips` (2026-06-01) - **Batch:** v2.3-provider-lifecycle (openspec drafted; not started)
- **Branch:** `codebase-audit-cleanup` (audit + cleanup epic, off main HEAD) - **Branch:** `main`
- **In progress:** Phase 3 — stale comments + docs refresh - **Blockers:** none
- **Last shipped:** `v2.2.2-xml-placeholder-reject`
See `CHANGELOG.md` for the full shipped history. That file is always authoritative; this file is a quick orientation pointer only. 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.

View File

@@ -5,11 +5,15 @@ RUN corepack enable
WORKDIR /build WORKDIR /build
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
COPY packages/contracts/package.json ./packages/contracts/
COPY apps/server/package.json ./apps/server/ COPY apps/server/package.json ./apps/server/
COPY apps/web/package.json ./apps/web/ COPY apps/web/package.json ./apps/web/
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# @boocode/contracts must be present before `pnpm build`, which builds it FIRST
# (root build script) so apps/web can resolve its compiled dist via the exports map.
COPY packages/contracts ./packages/contracts
COPY apps/server ./apps/server COPY apps/server ./apps/server
COPY apps/web ./apps/web COPY apps/web ./apps/web

View File

@@ -58,7 +58,7 @@ upstream and inject `Remote-User`. Postgres binds loopback only.
BooCoder runs as a **host systemd service** (`boocoder.service`, port `:9502`), not in Docker: BooCoder runs as a **host systemd service** (`boocoder.service`, port `:9502`), not in Docker:
```bash ```bash
pnpm -C apps/server build && pnpm -C apps/coder build pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build
sudo systemctl restart boocoder sudo systemctl restart boocoder
curl http://100.114.205.53:9502/api/health curl http://100.114.205.53:9502/api/health
``` ```

View File

@@ -15,6 +15,7 @@
"fastify": "^4.28.1", "fastify": "^4.28.1",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
"pg": "^8.13.0", "pg": "^8.13.0",
"tslib": "^2.6.3",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -9,7 +9,7 @@ const ConfigSchema = z.object({
TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'), TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'),
}); });
type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;
let cached: Config | null = null; let cached: Config | null = null;

View File

@@ -10,7 +10,7 @@ export function getPool(databaseUrl: string): pg.Pool {
return pool; return pool;
} }
interface SessionInfo { export interface SessionInfo {
id: string; id: string;
project_id: string; project_id: string;
project_path: string; project_path: string;

View File

@@ -1,7 +1,7 @@
import * as pty from 'node-pty'; import * as pty from 'node-pty';
import type { IPty } from 'node-pty'; import type { IPty } from 'node-pty';
interface AttachPtyOptions { export interface AttachPtyOptions {
sessionName: string; sessionName: string;
projectRoot: string; projectRoot: string;
cols: number; cols: number;

View File

@@ -13,7 +13,7 @@
## Build, deploy, dispatch ## Build, deploy, dispatch
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. **apps/server must build FIRST.** - **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. **apps/server must build FIRST.**
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`. - Build + deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
- After `pnpm -C apps/coder build` the host service keeps running the OLD process until `sudo systemctl restart boocoder` — a stale process shows **new routes 404 with `{error:'not found'}` while old routes still 200** (the `/api` not-found handler shape). Restart, don't re-debug. - After `pnpm -C apps/coder build` the host service keeps running the OLD process until `sudo systemctl restart boocoder` — a stale process shows **new routes 404 with `{error:'not found'}` while old routes still 200** (the `/api` not-found handler shape). Restart, don't re-debug.
- `:9502/api/health` is down ~1520s after a boocoder restart while the startup agent-probe scan runs — retry; an early connection-refused is not a failed deploy. - `:9502/api/health` is down ~1520s after a boocoder restart while the startup agent-probe scan runs — retry; an early connection-refused is not a failed deploy.
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`. - Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.

View File

@@ -13,6 +13,7 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@boocode/contracts": "workspace:*",
"@agentclientprotocol/sdk": "^0.22.1", "@agentclientprotocol/sdk": "^0.22.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.159", "@anthropic-ai/claude-agent-sdk": "^0.3.159",
"@boocode/server": "workspace:*", "@boocode/server": "workspace:*",

View File

@@ -16,7 +16,7 @@ import { createInferenceRunner } from '@boocode/server/inference';
import { createBroker } from '@boocode/server/broker'; import { createBroker } from '@boocode/server/broker';
import { appendMcpTools, ALL_TOOLS } from '@boocode/server/tools'; import { appendMcpTools, ALL_TOOLS } from '@boocode/server/tools';
import type { Config as ServerConfig } from '@boocode/server/config'; import type { Config as ServerConfig } from '@boocode/server/config';
import type { WsFrame } from '@boocode/server/ws-frames'; import type { WsFrame } from '@boocode/contracts/ws-frames';
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility. // v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
import { WRITE_TOOLS } from './services/tools/index.js'; import { WRITE_TOOLS } from './services/tools/index.js';
import { adaptWriteTool } from './services/tools/adapter.js'; import { adaptWriteTool } from './services/tools/adapter.js';

View File

@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker'; import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames'; import type { WsFrame } from '@boocode/contracts/ws-frames';
import { resolveChatId } from './chat-resolve.js'; import { resolveChatId } from './chat-resolve.js';
const AnswerUserInputBody = z.object({ const AnswerUserInputBody = z.object({

View File

@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker'; import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames'; import type { WsFrame } from '@boocode/contracts/ws-frames';
import { getSkillBody } from '@boocode/server/skills'; import { getSkillBody } from '@boocode/server/skills';
import { import {
buildSkillInvokeSyntheticFrames, buildSkillInvokeSyntheticFrames,

View File

@@ -95,7 +95,7 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
// GET /api/tasks/:id — single task detail // GET /api/tasks/:id — single task detail
app.get<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => { app.get<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => {
const rows = await sql` const rows = await sql`
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 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
FROM tasks FROM tasks
WHERE id = ${req.params.id} WHERE id = ${req.params.id}
`; `;

View File

@@ -9,7 +9,7 @@
*/ */
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import { checkWorktreeWorkAtRisk, stashWorktree } from '../services/worktree-risk.js'; import { checkWorktreeWorkAtRisk, stashWorktree } from '../services/worktrees.js';
export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): void { export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): void {
// GET risk for a session's worktree(s). One row per session today (PK on // GET risk for a session's worktree(s). One row per session today (PK on

View File

@@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS tasks (
agent TEXT, agent TEXT,
model TEXT, model TEXT,
execution_path TEXT, execution_path TEXT,
worktree_path TEXT,
cost_tokens INTEGER, cost_tokens INTEGER,
started_at TIMESTAMPTZ, started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ, ended_at TIMESTAMPTZ,
@@ -38,9 +39,9 @@ CREATE TABLE IF NOT EXISTS available_agents (
install_path TEXT, install_path TEXT,
version TEXT, version TEXT,
supports_acp BOOLEAN NOT NULL DEFAULT false, supports_acp BOOLEAN NOT NULL DEFAULT false,
supports_mcp_client BOOLEAN NOT NULL DEFAULT false,
last_probed_at TIMESTAMPTZ 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. -- 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); ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id UUID REFERENCES sessions(id);
@@ -73,10 +74,31 @@ ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS commands JSONB DEFAULT '[]
-- v2.2.0: Paseo-style session config on tasks. -- 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 mode_id TEXT;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
-- tasks.feature_values and tasks.worktree_path were never read or written by any ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
-- code path; drop them from existing DBs (fresh DBs never had them in the CREATE).
ALTER TABLE tasks DROP COLUMN IF EXISTS feature_values; -- v2.6: one shared worktree per session (all agents/panes in the session operate in it).
ALTER TABLE tasks DROP COLUMN IF EXISTS worktree_path; 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 $$;
-- v2.6: one backend session per (session, agent); resumed on switch-back. -- v2.6: one backend session per (session, agent); resumed on switch-back.
CREATE TABLE IF NOT EXISTS agent_sessions ( CREATE TABLE IF NOT EXISTS agent_sessions (
@@ -146,9 +168,12 @@ CREATE TABLE IF NOT EXISTS worktrees (
); );
CREATE UNIQUE INDEX IF NOT EXISTS worktrees_active_path_uidx ON worktrees(path) WHERE status='active'; CREATE UNIQUE INDEX IF NOT EXISTS worktrees_active_path_uidx ON worktrees(path) WHERE status='active';
-- session_worktrees was superseded by worktrees (v2.6/P1.5-b); all rows migrated -- Migrate any surviving session_worktrees rows → worktrees (idempotent; 0 rows
-- before P2 cleanup. Drop the dead table; no-op on fresh DBs that never had it. -- after the test-session delete, kept for generality / fresh-DB safety).
DROP TABLE IF EXISTS session_worktrees; 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');
-- Dispatch hint: which chat (tab) a task belongs to. The coder message route and -- 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, -- skills route set it from the frontend tab; session-less creators (arena, MCP,

View File

@@ -1,74 +0,0 @@
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' });
});
});

View File

@@ -1,102 +0,0 @@
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<string, unknown> }> } {
const frames: Array<{ sid: string; frame: Record<string, unknown> }> = [];
const broker = {
publishFrame: (sid: string, frame: unknown) => {
frames.push({ sid, frame: frame as Record<string, unknown> });
},
} 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.', '<dcp', '-message', '-id>m1</dcp', '-message-id>', ' 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 <dcp-message-id>m</dcp-message-id>' });
em.finalize();
// No stripping without a stripper — verbatim text (prior ACP-path behavior).
expect(em.output).toBe('raw <dcp-message-id>m</dcp-message-id>');
expect(frames).toHaveLength(1);
});
});

View File

@@ -0,0 +1,83 @@
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');
});
});
});

View File

@@ -1,64 +0,0 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Parity guard between the two copies of the provider snapshot types:
* apps/coder/src/services/provider-types.ts (backend source of truth)
* apps/web/src/api/types.ts (web wire copy)
*
* APPROACH: text-identity of each shared type block (mirrors the repo's existing
* ws-frames.test.ts byte-parity convention). A compile-time bidirectional-
* assignability check was attempted first (a web-side file importing coder's
* import-free provider-types.ts), but apps/web/tsconfig.app.json is a composite
* project and rejects out-of-include files with TS6307 — so cross-project type
* import is structurally blocked. This runtime guard FAILS on any field
* add/remove/rename/loosen in either copy, including the nested model/mode/
* command types that ProviderSnapshotEntry references. Single-source-of-truth
* (shared workspace package) is deferred as a Tier-2 follow-up.
*/
const here = dirname(fileURLToPath(import.meta.url));
const coderSrc = readFileSync(resolve(here, '../provider-types.ts'), 'utf8');
const webSrc = readFileSync(resolve(here, '../../../../web/src/api/types.ts'), 'utf8');
function extractBlock(src: string, name: string): string {
const iface = src.match(new RegExp(`export interface ${name} \\{[\\s\\S]*?\\n\\}`));
const alias = src.match(new RegExp(`export type ${name} =[^;]*;`));
const block = iface?.[0] ?? alias?.[0];
if (!block) throw new Error(`type block '${name}' not found`);
// Normalize to type structure: drop blank + comment lines (//, /* */, *),
// trim each line. Field add/remove/rename/loosen still changes a field line.
return block
.split('\n')
.map((l) => l.trim())
.filter(
(l) =>
l.length > 0 &&
!l.startsWith('//') &&
!l.startsWith('/*') &&
!l.startsWith('*'),
)
.join('\n');
}
describe('provider snapshot type parity (coder ↔ web)', () => {
// Includes the nested types ProviderSnapshotEntry references, so structural
// drift anywhere in the snapshot surface is caught.
const names = [
'ProviderSnapshotStatus',
'ProviderSnapshotEntry',
'ProviderModel',
'ProviderMode',
'ThinkingOption',
'AgentCommand',
];
for (const name of names) {
it(`${name} is identical in both copies`, () => {
expect(
extractBlock(webSrc, name),
`${name} drifted between apps/coder/src/services/provider-types.ts and apps/web/src/api/types.ts`,
).toBe(extractBlock(coderSrc, name));
});
}
});

View File

@@ -1,88 +0,0 @@
/**
* 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<void>;
}
/**
* 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<void> => {
const turn = resolveTurn();
if (!turn) return; // between turns — drop (no orphan settles a future turn)
await turn.onSessionUpdate(params);
},
requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
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<ReadTextFileResponse> => {
const content = await readWorktreeTextFile(worktreePath, params.path, params.line, params.limit);
return { content };
},
writeTextFile: async (params: WriteTextFileRequest): Promise<WriteTextFileResponse> => {
await writeWorktreeTextFile(worktreePath, params.path, params.content);
return {};
},
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
return { terminalId: 'noop' };
},
unstable_createElicitation: async (params: CreateElicitationRequest): Promise<CreateElicitationResponse> => {
const turn = resolveTurn();
if (turn && turn.taskId && turn.sessionId) {
return waitForElicitationResponse(turn.taskId, turn.sessionId, turn.agent, turn.modeId, params);
}
return { action: 'decline' };
},
};
}

View File

@@ -9,20 +9,35 @@ import {
ClientSideConnection, ClientSideConnection,
type Client, type Client,
type SessionNotification, 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 SessionConfigOption,
type ClientSideConnection as ConnectionType, type ClientSideConnection as ConnectionType,
} from '@agentclientprotocol/sdk'; } from '@agentclientprotocol/sdk';
import type { Broker } from '@boocode/server/broker'; import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { findThoughtLevelConfigId } from './acp-derive.js'; import { findThoughtLevelConfigId } from './acp-derive.js';
import { resolveLaunchSpec } from './acp-spawn.js'; import { resolveLaunchSpec } from './acp-spawn.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js'; import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
import { createAcpNdJsonStream } from './acp-stream.js'; import { createAcpNdJsonStream } from './acp-stream.js';
import { cancelPendingPermission } from './permission-waiter.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 { mapSessionUpdate } from './acp-event-map.js'; import { mapSessionUpdate } from './acp-event-map.js';
import { type AcpToolSnapshot, synthesizeCanceledSnapshots } from './acp-tool-snapshot.js'; import {
import { makeFrameEmitter, type FrameEmitter } from './frame-emitter.js'; type AcpToolSnapshot,
import { buildAcpClient } from './acp-client.js'; snapshotToWireToolCall,
synthesizeCanceledSnapshots,
} from './acp-tool-snapshot.js';
export interface AcpDispatchResult { export interface AcpDispatchResult {
exitCode: number; exitCode: number;
@@ -96,61 +111,144 @@ async function applySessionOverrides(
} }
class AcpStreamContext { class AcpStreamContext {
/** AgentEvent → WS-frame mapping + text/reasoning/tool accumulation (shared readonly textChunks: string[] = [];
* `makeFrameEmitter`). The one-shot path passes no `dcp` stripper, so text is readonly reasoningChunks: string[] = [];
* emitted verbatim — byte-identical to the prior inline switch. */ readonly toolSnapshots = new Map<string, AcpToolSnapshot>();
private readonly emitter: FrameEmitter; private aborted = false;
constructor( constructor(
opts: Pick<AcpDispatchOpts, 'broker' | 'sessionId' | 'chatId' | 'messageId' | 'taskId'>, private readonly opts: Pick<
AcpDispatchOpts,
'broker' | 'sessionId' | 'chatId' | 'messageId' | 'taskId'
>,
private readonly worktreePath: string, 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 { get reasoningText(): string {
return this.emitter.reasoningText; return this.reasoningChunks.join('');
} }
get output(): string { get output(): string {
return this.emitter.output; return this.textChunks.join('');
} }
get snapshots(): AcpToolSnapshot[] { get snapshots(): AcpToolSnapshot[] {
return this.emitter.snapshots; return [...this.toolSnapshots.values()];
} }
markAborted(): void { markAborted(): void {
// Synthesize 'canceled' updates for still-running tool calls so the UI doesn't this.aborted = true;
// leave them spinning, then emit them through the same frame path (tool_update for (const snap of synthesizeCanceledSnapshots(this.toolSnapshots.values())) {
// → the same `tool_call` wire frame the original published). this.toolSnapshots.set(snap.toolCallId, snap);
for (const snap of synthesizeCanceledSnapshots(this.emitter.toolSnapshots.values())) { this.publishToolSnapshot(snap);
this.emitter.onEvent({ type: 'tool_update', toolCall: snap });
} }
} }
handleSessionUpdate(params: SessionNotification): void { private canStream(): boolean {
// The merge accumulator (`this.emitter.toolSnapshots`) is the same Map the return !!(this.opts.broker && this.opts.sessionId && this.opts.chatId && this.opts.messageId);
// 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); 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<void> {
// 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;
}
} }
} }
buildClient(agent: string, modeId: string | undefined, taskId: string | undefined, sessionId: string | undefined): Client { buildClient(agent: string, modeId: string | undefined, taskId: string | undefined, sessionId: string | undefined): Client {
return buildAcpClient(this.worktreePath, () => ({ return {
taskId, sessionUpdate: (params) => this.handleSessionUpdate(params),
sessionId, requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
modeId, if (taskId && sessionId) {
agent, return waitForPermissionResponse(taskId, sessionId, agent, modeId, params);
onSessionUpdate: (params) => this.handleSessionUpdate(params), }
})); const firstOption = params.options[0];
if (firstOption) {
return { outcome: { outcome: 'selected', optionId: firstOption.optionId } };
}
return { outcome: { outcome: 'cancelled' } };
},
readTextFile: async (params: ReadTextFileRequest): Promise<ReadTextFileResponse> => {
const content = await readWorktreeTextFile(
this.worktreePath,
params.path,
params.line,
params.limit,
);
return { content };
},
writeTextFile: async (params: WriteTextFileRequest): Promise<WriteTextFileResponse> => {
await writeWorktreeTextFile(this.worktreePath, params.path, params.content);
return {};
},
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
return { terminalId: 'noop' };
},
unstable_createElicitation: async (params: CreateElicitationRequest): Promise<CreateElicitationResponse> => {
if (taskId && sessionId) {
return waitForElicitationResponse(taskId, sessionId, agent, modeId, params);
}
return { action: 'decline' };
},
};
} }
} }

View File

@@ -2,7 +2,7 @@ import { Readable, Writable } from 'node:stream';
import type { ChildProcess } from 'node:child_process'; import type { ChildProcess } from 'node:child_process';
import { ndJsonStream } from '@agentclientprotocol/sdk'; import { ndJsonStream } from '@agentclientprotocol/sdk';
function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableStream<Uint8Array> { export function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({ return new ReadableStream<Uint8Array>({
start(controller) { start(controller) {
nodeStream.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk))); nodeStream.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
@@ -17,7 +17,7 @@ function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableStream<Ui
}); });
} }
function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream<Uint8Array> { export function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream<Uint8Array> {
return new WritableStream<Uint8Array>({ return new WritableStream<Uint8Array>({
write(chunk) { write(chunk) {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {

View File

@@ -8,7 +8,7 @@
* (`AgentStatusUpdatedFrame`) and mirrored byte-identical in apps/web. * (`AgentStatusUpdatedFrame`) and mirrored byte-identical in apps/web.
*/ */
import type { Broker } from '@boocode/server/broker'; import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames'; import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { AgentStatus } from './normalize-agent-status.js'; import type { AgentStatus } from './normalize-agent-status.js';
// The exact slice of Broker we need — accepting just the bound method keeps call // The exact slice of Broker we need — accepting just the bound method keeps call

View File

@@ -1,173 +0,0 @@
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);
});
});

View File

@@ -1,226 +0,0 @@
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 <dcp-message-id>m1</dcp-message-id> there')).toBe('hi there');
});
it('leaves untagged text untouched', () => {
expect(stripDcpTags('plain text <div>')).toBe('plain text <div>');
});
});
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<typeof toolPartToSnapshot>[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<typeof toolPartToSnapshot>[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 <dcp-message-id>m</dcp-message-id>' }, 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: '<dcp-message-id>m</dcp-message-id>' }, 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> = {}): 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 <dcp-message-id>m</dcp-message-id>b' });
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}');
});
});

View File

@@ -1,203 +0,0 @@
/**
* 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<string>;
/** partID → 'text' | 'reasoning', so a delta with a non-'reasoning' field is still classed right. */
partTypeById: Map<string, string>;
}
/** 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>[^<]*<\/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<unknown> | 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);
}
}

View File

@@ -1,325 +0,0 @@
/**
* 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<void> | 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<void> | 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<void> {
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<void> {
// 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<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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);
});
}

View File

@@ -1,64 +1,91 @@
/** /**
* v2.6 Phase 1 — OpenCodeServerBackend (slimmed, v2.7 audit reshape). * v2.6 Phase 1 — OpenCodeServerBackend.
* *
* Warm, multi-turn backend for the `opencode` agent. One `opencode serve` HTTP * Warm, multi-turn backend for the `opencode` agent. One `opencode serve` HTTP
* server per BooCoder process; one opencode session per BooCode session (resumed * 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 * on switch-back); one SSE read loop PER session, each scoped to that session's
* worktree directory so sessions in different directories stream concurrently. * worktree directory so sessions in different directories stream concurrently
* * (P1.5-a — replaced the Phase-1 single-stream-last-directory model).
* 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 * Implements the Phase 0 `AgentBackend` interface. Emits transport-agnostic
* `AgentEvent`s; the dispatcher maps them to WS frames. * `AgentEvent`s the dispatcher (Phase 1.7, NOT wired in this batch) maps them
* to WS frames. No dispatcher/route references this file yet.
* *
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2 / §2a. * 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<GlobalEvent> }; 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 { createHash } from 'node:crypto';
import { createServer, connect as netConnect } from 'node:net';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import type { Event, AssistantMessage } from '@opencode-ai/sdk/v2/client'; 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 { Sql } from '../../db.js'; import type { Sql } from '../../db.js';
import type { AcpToolSnapshot } from '../acp-tool-snapshot.js';
import { armAbortGuard, noteTurnActivity, consumeTerminal } from './turn-guard.js'; import { armAbortGuard, noteTurnActivity, consumeTerminal } from './turn-guard.js';
import { stepEndedToUsage, type StepUsage } from './opencode-usage.js'; import { stepEndedToUsage, type StepUsage } from './opencode-usage.js';
import { OpenCodeServerSupervisor, type ServerDownInfo } from './opencode-server-process.js'; import { decideRestart, DEFAULT_HEALTH_FAILURE_THRESHOLD } from './lifecycle-decisions.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 { import type {
AgentBackend, AgentBackend,
AgentEvent,
AgentSessionHandle, AgentSessionHandle,
EnsureSessionOpts, EnsureSessionOpts,
PromptCtx, PromptCtx,
TurnResult, TurnResult,
} from '../agent-backend.js'; } 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 * 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 * deltas continuously while working, so "zero events for this long" means the turn
* is wedged or its terminal event (session.idle) was lost. Generous so a * is wedged or its terminal event (session.idle) was lost (see the reconnect race
* legitimately slow turn never trips it. * below). Generous so a legitimately slow turn never trips it.
*/ */
const TURN_INACTIVITY_MS = 180_000; 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<string>;
/** partID → 'text' | 'reasoning', so a delta with a non-'reasoning' field is still classed right. Cleared at turn end. */
partTypeById: Map<string, string>;
activeTurn: TurnState | null;
/** Inactivity backstop timer for the active turn; null when no turn in flight. */
watchdog: ReturnType<typeof setTimeout> | 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 { export interface OpenCodeServerBackendDeps {
sql: Sql; sql: Sql;
log: FastifyBaseLogger; log: FastifyBaseLogger;
@@ -71,32 +98,36 @@ export class OpenCodeServerBackend implements AgentBackend {
private readonly sql: Sql; private readonly sql: Sql;
private readonly log: FastifyBaseLogger; private readonly log: FastifyBaseLogger;
private readonly supervisor: OpenCodeServerSupervisor; 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<void> | 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<void> | null = null;
/** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */ /** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */
private readonly byOpencodeId = new Map<string, SessionState>(); private readonly byOpencodeId = new Map<string, SessionState>();
/** Coalesces concurrent ensureSession calls for the same (chat, agent) key. */
private readonly ensuring = new Map<string, Promise<AgentSessionHandle>>();
constructor(deps: OpenCodeServerBackendDeps) { constructor(deps: OpenCodeServerBackendDeps) {
this.sql = deps.sql; this.sql = deps.sql;
this.log = deps.log; this.log = deps.log;
this.supervisor = new OpenCodeServerSupervisor({ this.opencodeBinary = deps.opencodeBinary;
opencodeBinary: deps.opencodeBinary,
log: deps.log,
hooks: {
isBusy: () => this.isBusy(),
onServerDown: (info) => this.onServerDown(info),
},
});
} }
/** §2: liveness for the health endpoint + dispatcher fallback decision. */ /** §2: liveness for the health endpoint + dispatcher fallback decision. */
health(): 'up' | 'down' { health(): 'up' | 'down' {
return this.supervisor.health(); return this.up ? 'up' : 'down';
} }
/** Phase 3: busy iff ANY pooled opencode session has an in-flight turn. */ /** 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). */
isBusy(): boolean { isBusy(): boolean {
for (const st of this.byOpencodeId.values()) { for (const st of this.byOpencodeId.values()) {
if (st.activeTurn) return true; if (st.activeTurn) return true;
@@ -104,23 +135,72 @@ export class OpenCodeServerBackend implements AgentBackend {
return false; return false;
} }
/** Phase 3 proactive health probe + busy-aware self-restart, run by the pool's // ─── Server lifecycle (1.2: spawn once + client + ready; Phase 3 crash-restart) ──
* periodic sweep. Delegates to the supervisor. */
async tickHealth(now: number = Date.now()): Promise<void> { /**
await this.supervisor.tickHealth(now); * 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<void> {
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<void> {
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');
} }
/** /**
* Server down (crash-exit or forced restart): fail every in-flight turn so its * Crash handler (Phase 3, lift of openchamber's restart-on-exit path). The
* dispatcher unblocks, mark each session crashed so ensureSession won't resume a * server died with N live opencode sessions; we can't restart it here (the next
* now-dead native id, and tear down the SSE loops + demux state. Invoked by the * turn does, lazily — avoids a restart storm if the binary is broken). We:
* supervisor (it owns the process/port reset). Mirrors the original * 1. fail every in-flight turn so its dispatcher unblocks + publishes an error,
* handleServerCrash session-half byte-for-byte. * 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.
*/ */
private onServerDown(info: ServerDownInfo): void { private handleServerCrash(code: number | null, signal: NodeJS.Signals | null, port: number): void {
this.up = false;
const states = [...this.byOpencodeId.values()]; const states = [...this.byOpencodeId.values()];
this.log.warn( this.log.warn(
{ code: info.code, signal: info.signal, port: info.port, liveSessions: states.length }, { code, signal, port, liveSessions: states.length },
'opencode-server: child exited — recovering (fail in-flight, mark crashed, re-spawn next turn)', 'opencode-server: child exited — recovering (fail in-flight, mark crashed, re-spawn next turn)',
); );
@@ -139,6 +219,8 @@ export class OpenCodeServerBackend implements AgentBackend {
} }
// Drop the demux map: every session id is stale against a fresh server. // Drop the demux map: every session id is stale against a fresh server.
this.byOpencodeId.clear(); this.byOpencodeId.clear();
this.client = null;
this.serverStarting = null; // force a re-spawn on the next ensureServer
if (crashedIds.length > 0) { if (crashedIds.length > 0) {
this.sql` this.sql`
@@ -148,20 +230,146 @@ export class OpenCodeServerBackend implements AgentBackend {
this.log.warn({ err: errMsg(err) }, 'opencode-server: failed to mark crashed sessions (non-fatal)'); 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);
} }
// ─── SSE loop wiring ───────────────────────────────────────────────────────── /**
* 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<void> {
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;
/** The dependency bundle the per-session SSE loop reads. */ const healthy = await this.probeHealth();
private sseDeps(): SseLoopDeps { if (healthy) {
return { this.consecutiveHealthFailures = 0;
isUp: () => this.supervisor.isUp(), this.unhealthyBusySince = 0;
getClient: () => this.supervisor.client, return;
dispatchEvent: (ev) => this.dispatchEvent(ev), }
reconcile: (st) => this.reconcile(st), this.consecutiveHealthFailures += 1;
onReconnectGiveUp: (st) => this.onReconnectGiveUp(st), const busy = this.isBusy();
log: this.log, 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<boolean> {
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<void> {
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<void> {
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);
}
}
} }
/** Demux one event to the owning session's active turn. Unknown/between-turns → drop. */ /** Demux one event to the owning session's active turn. Unknown/between-turns → drop. */
@@ -190,7 +398,15 @@ export class OpenCodeServerBackend implements AgentBackend {
const st = this.byOpencodeId.get(p.sessionID); const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return; if (!st?.activeTurn) return;
this.bumpActivity(st); this.bumpActivity(st);
st.activeTurn.onEvent({ type: 'tool_call', toolCall: toolCalledSnapshot(p) }); 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 });
return; return;
} }
case 'session.next.tool.success': { case 'session.next.tool.success': {
@@ -198,7 +414,16 @@ export class OpenCodeServerBackend implements AgentBackend {
const st = this.byOpencodeId.get(p.sessionID); const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return; if (!st?.activeTurn) return;
this.bumpActivity(st); this.bumpActivity(st);
st.activeTurn.onEvent({ type: 'tool_update', toolCall: toolSuccessSnapshot(p) }); 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 });
return; return;
} }
case 'session.next.tool.failed': { case 'session.next.tool.failed': {
@@ -206,7 +431,15 @@ export class OpenCodeServerBackend implements AgentBackend {
const st = this.byOpencodeId.get(p.sessionID); const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return; if (!st?.activeTurn) return;
this.bumpActivity(st); this.bumpActivity(st);
st.activeTurn.onEvent({ type: 'tool_update', toolCall: toolFailedSnapshot(p) }); 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 });
return; return;
} }
// ─── per-step usage (U.6) — token/cost accounting for opencode sessions ── // ─── per-step usage (U.6) — token/cost accounting for opencode sessions ──
@@ -216,7 +449,8 @@ export class OpenCodeServerBackend implements AgentBackend {
if (!st?.activeTurn) return; if (!st?.activeTurn) return;
this.bumpActivity(st); this.bumpActivity(st);
// Accumulate this step's normalized usage onto the (chat_id, agent) row. // Accumulate this step's normalized usage onto the (chat_id, agent) row.
// Fire-and-forget: a DB hiccup must not stall the turn. // 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.
const usage = stepEndedToUsage(p); const usage = stepEndedToUsage(p);
void this.accumulateUsage(st, usage); void this.accumulateUsage(st, usage);
return; return;
@@ -227,8 +461,15 @@ export class OpenCodeServerBackend implements AgentBackend {
const st = this.byOpencodeId.get(p.sessionID); const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return; if (!st?.activeTurn) return;
this.bumpActivity(st); this.bumpActivity(st);
const e = classifyPartDelta(p, st); const isReasoning = p.field === 'reasoning' || st.partTypeById.get(p.partID) === 'reasoning';
if (e) st.activeTurn.onEvent(e); 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 });
}
return; return;
} }
case 'message.part.updated': { case 'message.part.updated': {
@@ -236,8 +477,7 @@ export class OpenCodeServerBackend implements AgentBackend {
const st = this.byOpencodeId.get(part.sessionID); const st = this.byOpencodeId.get(part.sessionID);
if (!st?.activeTurn) return; if (!st?.activeTurn) return;
this.bumpActivity(st); this.bumpActivity(st);
const e = classifyUpdatedPart(part, st); this.handleUpdatedPart(part, st);
if (e) st.activeTurn.onEvent(e);
return; return;
} }
// ─── lifecycle ───────────────────────────────────────────────────────── // ─── lifecycle ─────────────────────────────────────────────────────────
@@ -262,6 +502,40 @@ 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) ───────────── // ─── turn-completion resilience (watchdog + reconnect reconcile) ─────────────
/** Reset the inactivity backstop on any event routed to a session's active turn. */ /** Reset the inactivity backstop on any event routed to a session's active turn. */
@@ -276,8 +550,8 @@ export class OpenCodeServerBackend implements AgentBackend {
st.watchdog.unref?.(); st.watchdog.unref?.();
} }
/** Watchdog fired: reconcile once; if still-running we can't tell, so fail closed. /** 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. */ * Also mark the agent_sessions row crashed so a stale session isn't resumed next turn. */
private async onTurnStall(st: SessionState): Promise<void> { private async onTurnStall(st: SessionState): Promise<void> {
const settled = await this.reconcile(st); const settled = await this.reconcile(st);
if (!settled) { if (!settled) {
@@ -290,27 +564,16 @@ 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<void> {
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 * 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. * 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<boolean> { private async reconcile(st: SessionState): Promise<boolean> {
const turn = st.activeTurn; const turn = st.activeTurn;
const client = this.supervisor.client; if (!turn || !this.client) return false;
if (!turn || !client) return false;
try { try {
const res = await client.session.messages({ const res = await this.client.session.messages({
sessionID: st.agentSessionId, sessionID: st.agentSessionId,
directory: st.worktreePath, directory: st.worktreePath,
}); });
@@ -342,8 +605,10 @@ export class OpenCodeServerBackend implements AgentBackend {
/** /**
* Accumulate one `session.next.step.ended`'s normalized usage onto the session's * Accumulate one `session.next.step.ended`'s normalized usage onto the session's
* agent_sessions row. Running totals for the whole conversation context. Zero-delta * agent_sessions row, keyed by the resumed `agent_session_id` (unique per active
* steps are skipped. Errors are swallowed: usage telemetry must never fail a turn. * 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.
*/ */
private async accumulateUsage(st: SessionState, u: StepUsage): Promise<void> { private async accumulateUsage(st: SessionState, u: StepUsage): Promise<void> {
if (u.input === 0 && u.output === 0 && u.cost === 0) return; if (u.input === 0 && u.output === 0 && u.cost === 0) return;
@@ -366,29 +631,13 @@ export class OpenCodeServerBackend implements AgentBackend {
// ─── ensureSession: create-or-resume against agent_sessions (1.5) ──────────── // ─── ensureSession: create-or-resume against agent_sessions (1.5) ────────────
async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> { async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> {
// Coalesce concurrent first-turns for the same (chat, agent) so the SELECT… await this.ensureServer();
// create…upsert can't race into two opencode sessions (the second orphaning if (!this.client) throw new Error('opencode-server: client not ready after ensureServer');
// 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<AgentSessionHandle> {
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); const configHash = sessionConfigHash(opts.model);
// P1.5-b: agent_sessions is keyed (chat_id, agent) — the tab/chat is the // 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). // 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 }[]>` 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 SELECT agent_session_id, status, config_hash FROM agent_sessions
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent} WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
@@ -406,7 +655,7 @@ export class OpenCodeServerBackend implements AgentBackend {
'opencode-server: not resuming stale session, creating fresh'); 'opencode-server: not resuming stale session, creating fresh');
this.byOpencodeId.delete(agentSessionId); this.byOpencodeId.delete(agentSessionId);
} }
const created = await client.session.create({ directory: opts.worktreePath }); const created = await this.client.session.create({ directory: opts.worktreePath });
if (created.error || !created.data) { if (created.error || !created.data) {
throw new Error(`opencode-server: session.create failed: ${errToString(created.error)}`); throw new Error(`opencode-server: session.create failed: ${errToString(created.error)}`);
} }
@@ -415,7 +664,7 @@ export class OpenCodeServerBackend implements AgentBackend {
INSERT INTO agent_sessions INSERT INTO agent_sessions
(chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash) (chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
VALUES VALUES
(${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.supervisor.port}, 'active', clock_timestamp(), ${configHash}) (${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
ON CONFLICT (chat_id, agent) DO UPDATE SET ON CONFLICT (chat_id, agent) DO UPDATE SET
session_id = EXCLUDED.session_id, session_id = EXCLUDED.session_id,
worktree_id = EXCLUDED.worktree_id, worktree_id = EXCLUDED.worktree_id,
@@ -429,7 +678,7 @@ export class OpenCodeServerBackend implements AgentBackend {
} else { } else {
await this.sql` await this.sql`
UPDATE agent_sessions UPDATE agent_sessions
SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.supervisor.port}, config_hash = ${configHash} SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash}
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent} WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
`; `;
} }
@@ -444,13 +693,24 @@ export class OpenCodeServerBackend implements AgentBackend {
state.boocodeSessionId = sessionId; state.boocodeSessionId = sessionId;
state.worktreePath = opts.worktreePath; state.worktreePath = opts.worktreePath;
} else { } else {
state = this.makeSessionState(sessionId, ocSessionId, opts.worktreePath); state = {
boocodeSessionId: sessionId,
agentSessionId: ocSessionId,
worktreePath: opts.worktreePath,
streamedPartKeys: new Set(),
partTypeById: new Map(),
activeTurn: null,
watchdog: null,
sseAbort: null,
swallowNextTerminal: false,
};
this.byOpencodeId.set(ocSessionId, state); this.byOpencodeId.set(ocSessionId, state);
} }
// Start this session's own SSE loop, scoped to its worktree directory. Both // Start this session's own SSE loop, scoped to its worktree directory. Both
// fresh-create and resume reach here; idempotent. // fresh-create and resume reach here; idempotent, so a re-ensure (e.g. a
startSessionEventLoop(state, this.sseDeps()); // second turn) won't spawn a duplicate loop.
this.startSessionEventLoop(state);
return { return {
sessionId, sessionId,
@@ -459,53 +719,40 @@ export class OpenCodeServerBackend implements AgentBackend {
chatId: opts.chatId, chatId: opts.chatId,
worktreeId: opts.worktreeId, worktreeId: opts.worktreeId,
agentSessionId: ocSessionId, agentSessionId: ocSessionId,
serverPort: this.supervisor.port, serverPort: this.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) ───────────────────────────────────────────── // ─── prompt: send one turn (1.6) ─────────────────────────────────────────────
async prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult> { async prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult> {
const client = this.supervisor.client; if (!this.client) throw new Error('opencode-server: client not ready');
if (!client) throw new Error('opencode-server: client not ready');
const oc = handle.agentSessionId; const oc = handle.agentSessionId;
if (!oc) throw new Error('opencode-server: handle has no agentSessionId'); if (!oc) throw new Error('opencode-server: handle has no agentSessionId');
let state = this.byOpencodeId.get(oc); let state = this.byOpencodeId.get(oc);
if (!state) { if (!state) {
state = this.makeSessionState(handle.sessionId, oc, ctx.worktreePath); state = {
boocodeSessionId: handle.sessionId,
agentSessionId: oc,
worktreePath: ctx.worktreePath,
streamedPartKeys: new Set(),
partTypeById: new Map(),
activeTurn: null,
watchdog: null,
sseAbort: null,
swallowNextTerminal: false,
};
this.byOpencodeId.set(oc, state); this.byOpencodeId.set(oc, state);
} }
const session = 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. // Authoritative per-turn directory for SDK routing + reconcile.
session.worktreePath = ctx.worktreePath; session.worktreePath = ctx.worktreePath;
// Defensive: ensureSession normally starts the loop, but if prompt is reached // 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. // with a freshly-created state (no loop yet), start it so the turn streams.
startSessionEventLoop(session, this.sseDeps()); // Idempotent when ensureSession already started one.
this.startSessionEventLoop(session);
const client = this.client;
return await new Promise<TurnResult>((resolve) => { return await new Promise<TurnResult>((resolve) => {
let settled = false; let settled = false;
@@ -534,8 +781,7 @@ export class OpenCodeServerBackend implements AgentBackend {
settle({ ok: false, error: 'aborted' }); settle({ ok: false, error: 'aborted' });
}; };
const turn: TurnState = { onEvent: ctx.onEvent, settle }; session.activeTurn = { onEvent: ctx.onEvent, settle };
session.activeTurn = turn;
this.bumpActivity(session); // arm the inactivity backstop this.bumpActivity(session); // arm the inactivity backstop
if (ctx.signal.aborted) { if (ctx.signal.aborted) {
@@ -576,15 +822,39 @@ export class OpenCodeServerBackend implements AgentBackend {
} }
async dispose(): Promise<void> { async dispose(): Promise<void> {
this.up = false;
// Abort every per-session SSE loop so none survive the teardown. // Abort every per-session SSE loop so none survive the teardown.
for (const st of this.byOpencodeId.values()) st.sseAbort?.abort(); for (const st of this.byOpencodeId.values()) st.sseAbort?.abort();
const child = this.child;
this.child = null;
this.client = null;
this.byOpencodeId.clear(); this.byOpencodeId.clear();
await this.supervisor.dispose(); if (child && !child.killed) {
child.kill('SIGTERM');
const t = setTimeout(() => {
if (!child.killed) child.kill('SIGKILL');
}, 5_000);
t.unref();
}
} }
} }
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── 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}. */ /** BooCoder model string "provider/model" → opencode's structured {providerID, modelID}. */
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined { function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
if (!model || !model.trim()) return undefined; if (!model || !model.trim()) return undefined;
@@ -594,14 +864,199 @@ function parseModel(model: string | undefined): { providerID: string; modelID: s
return { providerID: trimmed.slice(0, idx), modelID: trimmed.slice(idx + 1) }; return { providerID: trimmed.slice(0, idx), modelID: trimmed.slice(idx + 1) };
} }
// No slash but non-empty → infer llama-swap (the only configured provider). // 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) { if (idx < 0 && trimmed.length > 0) {
return { providerID: 'llama-swap', modelID: trimmed }; return { providerID: 'llama-swap', modelID: trimmed };
} }
return undefined; 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<boolean> {
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<number> {
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<void> {
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<void> {
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>[^<]*<\/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 /** Hash of stable config — detects model changes across sessions without
* invalidating on ephemeral state like the random server port. */ * invalidating on ephemeral state like the random server port (which changes
* every BooCoder restart). */
function sessionConfigHash(model: string): string { function sessionConfigHash(model: string): string {
return createHash('sha256').update(`opencode_server|${model}`).digest('hex').slice(0, 16); return createHash('sha256').update(`opencode_server|${model}`).digest('hex').slice(0, 16);
} }

View File

@@ -1,181 +0,0 @@
/**
* 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<string>;
/** partID → 'text' | 'reasoning', so a delta with a non-'reasoning' field is still classed right. Cleared at turn end. */
partTypeById: Map<string, string>;
activeTurn: TurnState | null;
/** Inactivity backstop timer for the active turn; null when no turn in flight. */
watchdog: ReturnType<typeof setTimeout> | 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<boolean>;
/** Circuit-breaker: called once the backoff gives up; fail the active turn. */
onReconnectGiveUp: (state: SessionState) => Promise<void> | void;
log: FastifyBaseLogger;
/** Injectable for tests; defaults to a real timer sleep. */
sleep?: (ms: number) => Promise<void>;
policy?: ReconnectPolicy;
}
function defaultSleep(ms: number): Promise<void> {
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<void> {
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);
}
}
}

View File

@@ -36,15 +36,29 @@
*/ */
import { spawn, type ChildProcess } from 'node:child_process'; import { spawn, type ChildProcess } from 'node:child_process';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import { ClientSideConnection, type Client } from '@agentclientprotocol/sdk'; 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 type { Sql } from '../../db.js'; import type { Sql } from '../../db.js';
import { resolveLaunchSpec } from '../acp-spawn.js'; import { resolveLaunchSpec } from '../acp-spawn.js';
import { isTurnOkForStopReason } from './warm-acp-routing.js'; import { isTurnOkForStopReason } from './warm-acp-routing.js';
import { getResolvedRegistry, type ResolvedProviderDef } from '../provider-config-registry.js'; import { getResolvedRegistry, type ResolvedProviderDef } from '../provider-config-registry.js';
import { createAcpNdJsonStream } from '../acp-stream.js'; import { createAcpNdJsonStream } from '../acp-stream.js';
import { mapSessionUpdate } from '../acp-event-map.js'; import { mapSessionUpdate } from '../acp-event-map.js';
import { buildAcpClient } from '../acp-client.js'; import { readWorktreeTextFile, writeWorktreeTextFile } from '../acp-client-fs.js';
import { cancelPendingPermission } from '../permission-waiter.js'; import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from '../permission-waiter.js';
import { type AcpToolSnapshot, synthesizeCanceledSnapshots } from '../acp-tool-snapshot.js'; import { type AcpToolSnapshot, synthesizeCanceledSnapshots } from '../acp-tool-snapshot.js';
import type { import type {
AgentBackend, AgentBackend,
@@ -197,25 +211,47 @@ export class WarmAcpBackend implements AgentBackend {
); );
} }
/** Build the ACP Client callbacks ONCE per connection (shared `buildAcpClient`). /** Build the ACP Client callbacks ONCE per connection. They read `this.activeTurn`
* `resolveTurn` reads `this.activeTurn` at each callback so events/permissions * so each turn's events/permissions route to the right place — exactly the
* route to the live turn — exactly the prior behavior. The warm session always * opencode-server `activeTurn` pattern. Worktree-scoped FS like AcpStreamContext. */
* 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 { private buildClient(worktreePath: string): Client {
return buildAcpClient(worktreePath, () => { return {
const turn = this.activeTurn; sessionUpdate: async (params: SessionNotification): Promise<void> => {
if (!turn) return null; const turn = this.activeTurn;
return { if (!turn) return; // between turns — drop (no orphan settles a future turn)
taskId: turn.taskId, for (const event of mapSessionUpdate(params, turn.snapshots)) {
sessionId: turn.sessionId, turn.onEvent(event);
modeId: turn.modeId, }
agent: this.agent, },
onSessionUpdate: (params) => { requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
for (const event of mapSessionUpdate(params, turn.snapshots)) turn.onEvent(event); 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<ReadTextFileResponse> => {
const content = await readWorktreeTextFile(worktreePath, params.path, params.line, params.limit);
return { content };
},
writeTextFile: async (params: WriteTextFileRequest): Promise<WriteTextFileResponse> => {
await writeWorktreeTextFile(worktreePath, params.path, params.content);
return {};
},
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
return { terminalId: 'noop' };
},
unstable_createElicitation: async (params: CreateElicitationRequest): Promise<CreateElicitationResponse> => {
const turn = this.activeTurn;
if (turn?.taskId) {
return waitForElicitationResponse(turn.taskId, turn.sessionId, this.agent, turn.modeId, params);
}
return { action: 'decline' };
},
};
} }
// ─── ensureSession: create-or-reuse the warm session (2.1) ─────────────────── // ─── ensureSession: create-or-reuse the warm session (2.1) ───────────────────
@@ -267,14 +303,6 @@ export class WarmAcpBackend implements AgentBackend {
return { ok: false, error: 'warm-acp: no live ACP connection' }; 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<string, AcpToolSnapshot>(); const snapshots = new Map<string, AcpToolSnapshot>();
// taskId routes permission/elicitation prompts back to the UI. The dispatcher // 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. // passes it (plus mode) on the per-turn PromptCtx; permission-waiter keys on it.

View File

@@ -1,7 +1,7 @@
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import type { Broker } from '@boocode/server/broker'; import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames'; import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js'; import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
import { createCheckpoint } from './checkpoints.js'; import { createCheckpoint } from './checkpoints.js';

View File

@@ -1,142 +0,0 @@
/**
* 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<string, AcpToolSnapshot>;
/** 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<string, AcpToolSnapshot>();
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()];
},
};
}

View File

@@ -25,6 +25,13 @@ interface PendingRow {
session_id: string; session_id: string;
} }
interface WorktreeRow {
id: string;
worktree_path: string;
agent: string;
started_at: string;
}
interface ProjectPathRow { interface ProjectPathRow {
path: string; path: string;
} }
@@ -189,6 +196,28 @@ export async function startMcpServer(sql: Sql): Promise<void> {
}, },
); );
// 6. boocoder.list_worktrees
server.tool(
'boocoder.list_worktrees',
'List active worktrees from running tasks',
{},
async () => {
const rows = await sql<WorktreeRow[]>`
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 // Connect via stdio
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
await server.connect(transport); await server.connect(transport);

View File

@@ -1,88 +0,0 @@
/**
* 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<boolean> {
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<number> {
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')));
}
});
});
}

View File

@@ -21,3 +21,72 @@
*/ */
export type AgentStatus = 'working' | 'blocked' | 'idle' | 'error'; 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<readonly [AgentEventBucket, readonly string[]]>,
): Map<string, AgentEventBucket> {
const map = new Map<string, AgentEventBucket>();
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;
}

View File

@@ -21,8 +21,7 @@ import { readdir, stat } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import { WORKTREE_BASE } from './worktrees.js'; import { WORKTREE_BASE, checkWorktreeWorkAtRisk } from './worktrees.js';
import { checkWorktreeWorkAtRisk } from './worktree-risk.js';
import { hostExec } from './host-exec.js'; import { hostExec } from './host-exec.js';
import { import {
selectOrphanWorktreeTargets, selectOrphanWorktreeTargets,

View File

@@ -181,6 +181,10 @@ export async function rejectOne(sql: Sql, changeId: string): Promise<void> {
await sql`UPDATE pending_changes SET status = 'rejected' WHERE id = ${changeId} AND status = 'pending'`; await sql`UPDATE pending_changes SET status = 'rejected' WHERE id = ${changeId} AND status = 'pending'`;
} }
export async function rejectAll(sql: Sql, sessionId: string): Promise<void> {
await sql`UPDATE pending_changes SET status = 'rejected' WHERE session_id = ${sessionId} AND status = 'pending'`;
}
// --- Rewind functions -------------------------------------------------------- // --- Rewind functions --------------------------------------------------------
export async function rewindOne( export async function rewindOne(

View File

@@ -127,3 +127,7 @@ export function getResolvedRegistry(): Map<string, ResolvedProviderDef> {
return cachedRegistry ?? buildResolvedRegistry(PROVIDERS, { providers: {} }); return cachedRegistry ?? buildResolvedRegistry(PROVIDERS, { providers: {} });
} }
/** Resolved provider ids in registry order. */
export function getResolvedProviderIds(): string[] {
return [...getResolvedRegistry().keys()];
}

View File

@@ -5,42 +5,28 @@
* (see provider-config-registry.ts). Loading NEVER throws at startup (design.md * (see provider-config-registry.ts). Loading NEVER throws at startup (design.md
* §2.1): a missing file, invalid JSON, or schema mismatch all fall back to * §2.1): a missing file, invalid JSON, or schema mismatch all fall back to
* `{ providers: {} }` (built-ins only, all enabled). * `{ providers: {} }` (built-ins only, all enabled).
*
* Schemas are defined once in @boocode/contracts/provider-config and re-exported
* here so existing importers (routes, tests, registry) don't need path changes.
*/ */
import { readFileSync, writeFileSync } from 'node:fs'; import { readFileSync, writeFileSync } from 'node:fs';
import { z } from 'zod'; import {
ProviderOverrideSchema,
CoderProvidersFileSchema,
ProviderConfigPatchSchema,
type ProviderOverride,
type CoderProvidersFile,
type ProviderConfigPatch,
} from '@boocode/contracts/provider-config';
// Schemas verbatim from design.md §2.2. export {
export const ProviderOverrideSchema = z.object({ ProviderOverrideSchema,
extends: z.enum(['acp']).optional(), // v2.3: only 'acp' for custom; built-ins omit extends CoderProvidersFileSchema,
label: z.string().min(1).optional(), ProviderConfigPatchSchema,
description: z.string().optional(), type ProviderOverride,
command: z.array(z.string().min(1)).min(1).optional(), // [binary, ...args] type CoderProvidersFile,
env: z.record(z.string()).optional(), type ProviderConfigPatch,
enabled: z.boolean().optional(), // default true };
order: z.number().int().optional(), // UI sort key
models: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
additionalModels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
});
export const CoderProvidersFileSchema = z.object({
providers: z.record(ProviderOverrideSchema).default({}),
});
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
export type CoderProvidersFile = z.infer<typeof CoderProvidersFileSchema>;
/**
* PATCH body schema (design.md §6.2). A partial providers map where each value
* is either a full override object (REPLACES that id's override) or `null`
* (DELETES the override → revert to the built-in default). Ids absent from the
* patch are left untouched. The route validates the body against this first
* (malformed → 422) so a bad shape can never reach the merge/save step.
*/
export const ProviderConfigPatchSchema = z.object({
providers: z.record(ProviderOverrideSchema.nullable()).default({}),
});
export type ProviderConfigPatch = z.infer<typeof ProviderConfigPatchSchema>;
/** /**
* Shallow per-id merge (design.md §6.2 / Paseo `patchConfig`). Each key in * Shallow per-id merge (design.md §6.2 / Paseo `patchConfig`). Each key in

View File

@@ -1,61 +1,10 @@
/** Shared provider / snapshot types (Paseo-shaped, BooCoder-native). */ /** Provider snapshot types — re-exported from @boocode/contracts for local consumers. */
export interface ProviderMode { export type {
id: string; ProviderMode,
label: string; ThinkingOption,
description?: string; ProviderModel,
/** Auto-approve tool permissions when this mode is selected. */ ProviderSnapshotStatus,
isUnattended?: boolean; AgentCommand,
} ProviderSnapshotEntry,
} from '@boocode/contracts/provider-snapshot';
export interface ThinkingOption {
id: string;
label: string;
isDefault?: boolean;
}
export interface ProviderModel {
id: string;
label: string;
description?: string;
isDefault?: boolean;
thinkingOptions?: ThinkingOption[];
defaultThinkingOptionId?: string;
}
// v2.3 phase 2: 'loading' (cache-miss, probe in flight) + 'unavailable'
// (disabled or not installed) restored alongside the terminal 'ready' | 'error'.
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
export interface AgentCommand {
name: string;
description?: string;
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
// Drives the icon split in the coder slash menu. Undefined → command.
kind?: 'command' | 'skill';
}
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is
// enforced by __tests__/provider-types-parity.test.ts (fails on any field drift).
export interface ProviderSnapshotEntry {
name: string;
label: string;
description?: string;
transport: string;
status: ProviderSnapshotStatus;
enabled: boolean;
installed: boolean;
models: ProviderModel[];
modes: ProviderMode[];
defaultModeId: string | null;
commands: AgentCommand[];
error?: string;
fetchedAt?: string;
}
export interface AgentSessionConfig {
provider: string;
model?: string;
modeId?: string;
thinkingOptionId?: string;
}

View File

@@ -26,4 +26,9 @@ export const WRITE_TOOLS: readonly ToolDef<any>[] = [
checkTaskStatusTool, checkTaskStatusTool,
]; ];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const WRITE_TOOLS_BY_NAME: ReadonlyMap<string, ToolDef<any>> = new Map(
WRITE_TOOLS.map((t) => [t.name, t]),
);
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool }; export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool };

View File

@@ -1,175 +0,0 @@
/**
* 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-<id>` 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<string | null> {
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<RiskReport> {
// 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-<id>) 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, "'\\''") + "'";
}

View File

@@ -8,7 +8,7 @@
*/ */
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import { hostExec } from './host-exec.js'; import { hostExec } from './host-exec.js';
import { checkWorktreeWorkAtRisk } from './worktree-risk.js'; import type { WorktreeRiskReport } from '@boocode/contracts/worktree-risk';
export const WORKTREE_BASE = '/tmp/booworktrees'; export const WORKTREE_BASE = '/tmp/booworktrees';
@@ -379,6 +379,151 @@ export async function rebaselineWorktreeAfterApply(
return { rebaselined: true, newBaseCommit: newBase }; return { rebaselined: true, newBaseCommit: newBase };
} }
// ─── Session-delete work-loss guard ─────────────────────────────────────────
// WorktreeRiskReport single-sourced in @boocode/contracts — edit the package, not here.
export type { WorktreeRiskReport };
/**
* 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-<id>` 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<string | null> {
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<WorktreeRiskReport> {
// 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-<id>) 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). */ /** Minimal shell escape for paths (single-quote wrapping). */
function shellEscape(s: string): string { function shellEscape(s: string): string {
// Replace single quotes with escaped version, wrap in single quotes // Replace single quotes with escaped version, wrap in single quotes

View File

@@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@boocode/contracts": "workspace:*",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@@ -1,5 +1,22 @@
// Minimal types for the BooCoder frontend. // Minimal types for the BooCoder frontend.
// Shared DB entities (same schema as BooChat). // Shared DB entities (same schema as BooChat).
//
// WS wire contracts are single-sourced from @boocode/contracts (the canonical
// Zod-backed schema). The DB entity types below (Project/Session/Chat/Message/
// ToolCall/ToolResult/PendingChange) are an intentional minimal SPA-local subset
// and are NOT cross-app contracts — they stay defined here.
import type { WsFrame } from '@boocode/contracts/ws-frames';
// Re-export the canonical WebSocket frame union (single source of truth). The
// coder backend publishes the full frame set; this SPA's reducer handles the
// subset it renders and ignores the rest.
export type { WsFrame };
// The error frame's `reason`, single-sourced from the canonical schema's
// frame-level reason enum (derived from WsFrame so it cannot drift from the
// wire). Distinct from message-metadata's ErrorReason, which is a different set.
export type ErrorReason = NonNullable<Extract<WsFrame, { type: 'error' }>['reason']>;
export interface Project { export interface Project {
id: string; id: string;
@@ -39,7 +56,9 @@ export interface ToolResult {
tool_call_id: string; tool_call_id: string;
output: unknown; output: unknown;
truncated?: boolean; truncated?: boolean;
error?: boolean; // Canonical wire shape: the failure message string (present only on error),
// not a boolean. ToolResultBubble treats it as truthy → renders error styling.
error?: string;
} }
// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] } // Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
@@ -96,15 +115,3 @@ export interface PendingChange {
created_at: string; created_at: string;
applied_at: string | null; applied_at: string | null;
} }
// WebSocket frame types (subset of what the coder backend publishes)
export type WsFrame =
| { type: 'snapshot'; messages: Message[] }
| { type: 'message_started'; message_id: string; chat_id: string; role: Message['role'] }
| { type: 'delta'; message_id: string; chat_id: string; content: string }
| { type: 'tool_call'; message_id: string; chat_id: string; tool_call: ToolCall }
| { type: 'tool_result'; tool_message_id: string; chat_id: string; tool_call_id: string; output: string; truncated?: boolean; error?: boolean }
| { type: 'message_complete'; message_id: string; chat_id: string; tokens_used?: number; ctx_used?: number; ctx_max?: number; started_at?: string; finished_at?: string; metadata?: unknown }
| { type: 'error'; message_id?: string; error: string; reason?: string }
| { type: 'pending_change_added'; change: PendingChange }
| { type: 'pending_change_updated'; change: PendingChange };

View File

@@ -5,10 +5,9 @@ import { api } from '@/api/client';
interface Props { interface Props {
sessionId: string; sessionId: string;
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
} }
export function DiffPane({ sessionId, onPendingChange }: Props) { export function DiffPane({ sessionId }: Props) {
const [changes, setChanges] = useState<PendingChange[]>([]); const [changes, setChanges] = useState<PendingChange[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -24,27 +23,13 @@ export function DiffPane({ sessionId, onPendingChange }: Props) {
} }
}, [sessionId]); }, [sessionId]);
// Initial load // Initial load. Pending changes are delivered over HTTP (list + apply/reject/
// rewind below); there is no WS pending-change frame, so the list refreshes on
// mount, on the Refresh button, and optimistically as the user acts on it.
useEffect(() => { useEffect(() => {
fetchPending(); fetchPending();
}, [fetchPending]); }, [fetchPending]);
// Listen for WS pending change events
useEffect(() => {
const unsub = onPendingChange((change) => {
setChanges((prev) => {
const idx = prev.findIndex((c) => c.id === change.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = change;
return next;
}
return [...prev, change];
});
});
return unsub;
}, [onPendingChange]);
const pendingChanges = changes.filter((c) => c.status === 'pending'); const pendingChanges = changes.filter((c) => c.status === 'pending');
const resolvedChanges = changes.filter((c) => c.status !== 'pending'); const resolvedChanges = changes.filter((c) => c.status !== 'pending');

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { Message, WsFrame, PendingChange } from '@/api/types'; import type { Message, WsFrame } from '@/api/types';
interface State { interface State {
messages: Message[]; messages: Message[];
@@ -10,7 +10,9 @@ interface State {
function applyFrame(state: State, frame: WsFrame): State { function applyFrame(state: State, frame: WsFrame): State {
switch (frame.type) { switch (frame.type) {
case 'snapshot': { case 'snapshot': {
return { ...state, messages: frame.messages }; // Canonical SnapshotFrame.messages is opaque (z.array(z.unknown())); the
// coder backend sends Message-shaped rows, so cast to the SPA's local type.
return { ...state, messages: frame.messages as Message[] };
} }
case 'message_started': { case 'message_started': {
const exists = state.messages.some((m) => m.id === frame.message_id); const exists = state.messages.some((m) => m.id === frame.message_id);
@@ -18,7 +20,7 @@ function applyFrame(state: State, frame: WsFrame): State {
const newMsg: Message = { const newMsg: Message = {
id: frame.message_id, id: frame.message_id,
session_id: '', session_id: '',
chat_id: frame.chat_id, chat_id: frame.chat_id ?? '',
role: frame.role, role: frame.role,
content: '', content: '',
kind: 'message', kind: 'message',
@@ -72,7 +74,7 @@ function applyFrame(state: State, frame: WsFrame): State {
const newMsg: Message = { const newMsg: Message = {
id: frame.tool_message_id, id: frame.tool_message_id,
session_id: '', session_id: '',
chat_id: frame.chat_id, chat_id: frame.chat_id ?? '',
role: 'tool', role: 'tool',
content: '', content: '',
kind: 'message', kind: 'message',
@@ -119,9 +121,12 @@ function applyFrame(state: State, frame: WsFrame): State {
: state.messages; : state.messages;
return { ...state, messages: next, error: frame.error }; return { ...state, messages: next, error: frame.error };
} }
case 'pending_change_added': default:
case 'pending_change_updated': // The canonical WsFrame carries the full set of frames the coder backend
// These are handled by the pending changes listener, not the message state // can publish; this SPA only renders the subset handled above and safely
// ignores the rest (reasoning_delta, usage, permission_*, agent_*, and the
// per-user sidebar frames). pending_change_* frames have no publisher —
// pending changes are delivered over HTTP, so there is nothing to handle.
return state; return state;
} }
} }
@@ -134,14 +139,11 @@ interface SessionStreamResult {
connected: boolean; connected: boolean;
error: string | null; error: string | null;
isStreaming: boolean; isStreaming: boolean;
/** Listeners for pending change frames */
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
} }
export function useSessionStream(sessionId: string | undefined): SessionStreamResult { export function useSessionStream(sessionId: string | undefined): SessionStreamResult {
const [state, setState] = useState<State>({ messages: [], connected: false, error: null }); const [state, setState] = useState<State>({ messages: [], connected: false, error: null });
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const pendingListenersRef = useRef<Set<(change: PendingChange) => void>>(new Set());
useEffect(() => { useEffect(() => {
if (!sessionId) return; if (!sessionId) return;
@@ -172,13 +174,6 @@ export function useSessionStream(sessionId: string | undefined): SessionStreamRe
return; return;
} }
// Notify pending change listeners
if (frame.type === 'pending_change_added' || frame.type === 'pending_change_updated') {
for (const cb of pendingListenersRef.current) {
cb(frame.change);
}
}
setState((s) => applyFrame(s, frame)); setState((s) => applyFrame(s, frame));
}; };
@@ -213,18 +208,10 @@ export function useSessionStream(sessionId: string | undefined): SessionStreamRe
const isStreaming = state.messages.some((m) => m.status === 'streaming'); const isStreaming = state.messages.some((m) => m.status === 'streaming');
const onPendingChange = useCallback((cb: (change: PendingChange) => void) => {
pendingListenersRef.current.add(cb);
return () => {
pendingListenersRef.current.delete(cb);
};
}, []);
return { return {
messages: state.messages, messages: state.messages,
connected: state.connected, connected: state.connected,
error: state.error, error: state.error,
isStreaming, isStreaming,
onPendingChange,
}; };
} }

View File

@@ -14,8 +14,7 @@ export function Session() {
const [chat, setChat] = useState<Chat | null>(null); const [chat, setChat] = useState<Chat | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { messages, connected, isStreaming, onPendingChange } = const { messages, connected, isStreaming } = useSessionStream(sessionId);
useSessionStream(sessionId);
// Get or create a chat for this session // Get or create a chat for this session
useEffect(() => { useEffect(() => {
@@ -78,9 +77,7 @@ export function Session() {
connected={connected} connected={connected}
/> />
} }
diffPane={ diffPane={<DiffPane sessionId={sessionId} />}
<DiffPane sessionId={sessionId} onPendingChange={onPendingChange} />
}
/> />
); );
} }

2
apps/coder/web/vite.config.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@@ -0,0 +1,25 @@
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,
},
});

View File

@@ -21,7 +21,7 @@
- **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn. - **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn.
- **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up. - **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up.
- **Stale-streaming sweeps** (`apps/server/src/index.ts`): a boot-time pass after `applySchema()` and a periodic 60s `setInterval` both flip `messages.status='streaming'` older than 5 min to `failed` (publishing `chat_status='idle'`); the interval also runs `cleanupTruncations` (TTL + orphan reap of tmpfs truncation files). `onClose` hook clears the timer. Recovers from a container restart mid-stream. - **Stale-streaming sweeps** (`apps/server/src/index.ts`): a boot-time pass after `applySchema()` and a periodic 60s `setInterval` both flip `messages.status='streaming'` older than 5 min to `failed` (publishing `chat_status='idle'`); the interval also runs `cleanupTruncations` (TTL + orphan reap of tmpfs truncation files). `onClose` hook clears the timer. Recovers from a container restart mid-stream.
- **`services/broker.ts`** — In-memory pub/sub, two channel types: per-session (message streaming) and per-user (sidebar). No persistence; clients reconnect on restart. Every WS publish goes through `broker.publishFrame(sessionId, frame)` / `publishUserFrame(user, frame)` — both Zod-validate against `WsFrameSchema` (`types/ws-frames.ts`) and fail-closed (log + drop). Schema duplicated byte-identical at `apps/web/src/api/ws-frames.ts`; `ws-frames.test.ts` enforces parity. Don't add raw `broker.publish()`/`publishUser()` calls. - **`services/broker.ts`** — In-memory pub/sub, two channel types: per-session (message streaming) and per-user (sidebar). No persistence; clients reconnect on restart. Every WS publish goes through `broker.publishFrame(sessionId, frame)` / `publishUserFrame(user, frame)` — both Zod-validate against `WsFrameSchema` (`types/ws-frames.ts`) and fail-closed (log + drop). Schema single-sourced in `@boocode/contracts` (`packages/contracts/src/ws-frames.ts`); the package's `ws-frames.test.ts` enforces schema correctness. Don't add raw `broker.publish()`/`publishUser()` calls.
- **`services/tools.ts`** — Tool registry (`ALL_TOOLS`, `READ_ONLY_TOOL_NAMES`, `TOOLS_BY_NAME`). Filesystem tools (view_file/list_dir/grep/find_files) pass three guards: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). Web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (falls back to `project.default_web_search_enabled`) and filtered out of the LLM tool schema when false. Truncation: when a tool slice cuts content, `services/truncate.ts` stashes the full text on tmpfs (`BOOCODE_TRUNCATION_DIR`, default `/tmp/boocode-truncations`, 0o700) keyed by `tr_<12 base32>`; `view_truncated_output(id)` retrieves it. 5MB cap, 7-day TTL, reaped by the sweeper. Container restart loses retrieval — acceptable. - **`services/tools.ts`** — Tool registry (`ALL_TOOLS`, `READ_ONLY_TOOL_NAMES`, `TOOLS_BY_NAME`). Filesystem tools (view_file/list_dir/grep/find_files) pass three guards: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). Web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (falls back to `project.default_web_search_enabled`) and filtered out of the LLM tool schema when false. Truncation: when a tool slice cuts content, `services/truncate.ts` stashes the full text on tmpfs (`BOOCODE_TRUNCATION_DIR`, default `/tmp/boocode-truncations`, 0o700) keyed by `tr_<12 base32>`; `view_truncated_output(id)` retrieves it. 5MB cap, 7-day TTL, reaped by the sweeper. Container restart loses retrieval — acceptable.
- **`services/compaction.ts`** + **`services/model-context.ts`** — Anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself each compaction). Triggered when `chats.needs_compaction` is set after a turn exceeds `usable(ctx_max) = floor(0.85 × ctx_max)`. **`ctx_max` comes from `model-context.getModelContext()` fetching `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx`. First inferences after boot may have `ctx_max=NULL` if llama-swap hasn't loaded the model; negative cache TTL 60s, recovers next turn. `buildHeadPayload` embeds `reasoning_parts` as a `<reasoning>...</reasoning>` prose prefix on assistant `content` (OpenAI wire shape has no structured reasoning field); standalone tag when content is empty. `buildHeadPayload` + `OpenAiMessage` exported for tests — keep them exported. - **`services/compaction.ts`** + **`services/model-context.ts`** — Anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself each compaction). Triggered when `chats.needs_compaction` is set after a turn exceeds `usable(ctx_max) = floor(0.85 × ctx_max)`. **`ctx_max` comes from `model-context.getModelContext()` fetching `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx`. First inferences after boot may have `ctx_max=NULL` if llama-swap hasn't loaded the model; negative cache TTL 60s, recovers next turn. `buildHeadPayload` embeds `reasoning_parts` as a `<reasoning>...</reasoning>` prose prefix on assistant `content` (OpenAI wire shape has no structured reasoning field); standalone tag when content is empty. `buildHeadPayload` + `OpenAiMessage` exported for tests — keep them exported.
- **`services/system-prompt.ts`** — `buildSystemPrompt` is the string shim; `buildSystemPromptWithFingerprint` is the canonical impl returning `{prompt, fingerprint, drift}`. SHA-256 of the assembled prefix is logged per `buildMessagesPayload` (`prefix-fingerprint`, info); a `Map<sessionId, lastHash>` fires `prefix-drift` (warn) on change with a `changed_inputs` diff. The prefix is byte-stable in steady-state, so prefix caching is left to the input-layer mtime caches (BOOCHAT.md + AGENTS.md global/per-project in `agents.ts:safeStat`). - **`services/system-prompt.ts`** — `buildSystemPrompt` is the string shim; `buildSystemPromptWithFingerprint` is the canonical impl returning `{prompt, fingerprint, drift}`. SHA-256 of the assembled prefix is logged per `buildMessagesPayload` (`prefix-fingerprint`, info); a `Map<sessionId, lastHash>` fires `prefix-drift` (warn) on change with a `changed_inputs` diff. The prefix is byte-stable in steady-state, so prefix caching is left to the input-layer mtime caches (BOOCHAT.md + AGENTS.md global/per-project in `agents.ts:safeStat`).

View File

@@ -53,10 +53,6 @@
"types": "./dist/types/api.d.ts", "types": "./dist/types/api.d.ts",
"default": "./dist/types/api.js" "default": "./dist/types/api.js"
}, },
"./ws-frames": {
"types": "./dist/types/ws-frames.d.ts",
"default": "./dist/types/ws-frames.js"
},
"./db": { "./db": {
"types": "./dist/db.d.ts", "types": "./dist/db.d.ts",
"default": "./dist/db.js" "default": "./dist/db.js"
@@ -81,6 +77,7 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@boocode/contracts": "workspace:*",
"@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/openai-compatible": "^2.0.47",
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
"@fastify/websocket": "^10.0.1", "@fastify/websocket": "^10.0.1",

View File

@@ -18,6 +18,7 @@ const ConfigSchema = z.object({
GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'), GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'),
GITEA_USER: z.string().default('indifferentketchup'), GITEA_USER: z.string().default('indifferentketchup'),
GITEA_TOKEN: z.string().optional(), 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 // 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). // (bind-mounted alongside AGENTS.md). File missing = no MCP (opt-in).
MCP_CONFIG_PATH: z.string().optional(), MCP_CONFIG_PATH: z.string().optional(),

View File

@@ -140,7 +140,7 @@ async function main() {
publish: (sessionId, frame) => { publish: (sessionId, frame) => {
// v1.13.11-b: route through the typed publishFrame so the broker's // v1.13.11-b: route through the typed publishFrame so the broker's
// Zod gate validates every inference frame before delivery. // Zod gate validates every inference frame before delivery.
broker.publishFrame(sessionId, frame as unknown as import('./types/ws-frames.js').WsFrame); broker.publishFrame(sessionId, frame as unknown as import('@boocode/contracts/ws-frames').WsFrame);
}, },
// v1.11: broker handle for compaction.process to publish 'compacted' // v1.11: broker handle for compaction.process to publish 'compacted'
// frames on the per-session channel. Inference's regular publish path // frames on the per-session channel. Inference's regular publish path
@@ -149,7 +149,7 @@ async function main() {
broker, broker,
}, },
(user, frame) => { (user, frame) => {
broker.publishUserFrame(user, frame as unknown as import('./types/ws-frames.js').WsFrame); broker.publishUserFrame(user, frame as unknown as import('@boocode/contracts/ws-frames').WsFrame);
} }
); );
registerMessageRoutes(app, sql, config, broker, { registerMessageRoutes(app, sql, config, broker, {
@@ -194,7 +194,7 @@ async function main() {
}); });
}, },
publishSessionFrame: (sessionId, frame) => { publishSessionFrame: (sessionId, frame) => {
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame); broker.publishFrame(sessionId, frame as import('@boocode/contracts/ws-frames').WsFrame);
}, },
}); });
registerArtifactRoutes(app, sql); registerArtifactRoutes(app, sql);
@@ -222,7 +222,7 @@ async function main() {
}); });
}, },
publishSessionFrame: (sessionId, frame) => { publishSessionFrame: (sessionId, frame) => {
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame); broker.publishFrame(sessionId, frame as import('@boocode/contracts/ws-frames').WsFrame);
}, },
}); });
registerWebSocket(app, sql, broker); registerWebSocket(app, sql, broker);

View File

@@ -5,7 +5,6 @@ import type { Broker } from '../services/broker.js';
import type { Chat, Message } from '../types/api.js'; import type { Chat, Message } from '../types/api.js';
import { getModelContext } from '../services/model-context.js'; import { getModelContext } from '../services/model-context.js';
import { notifyCoderClose } from '../services/coder-notify.js'; import { notifyCoderClose } from '../services/coder-notify.js';
import { MESSAGE_COLUMNS } from '../services/message-columns.js';
const CreateBody = z.object({ const CreateBody = z.object({
name: z.string().min(1).max(200).optional(), name: z.string().min(1).max(200).optional(),
@@ -440,7 +439,9 @@ export function registerChatRoutes(
} }
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view. // v1.13.1-B: reads tool_calls/tool_results via the parts-merged view.
const rows = await sql<Message[]>` const rows = await sql<Message[]>`
SELECT ${sql.unsafe(MESSAGE_COLUMNS)} 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
FROM messages_with_parts FROM messages_with_parts
WHERE chat_id = ${req.params.id} WHERE chat_id = ${req.params.id}
ORDER BY created_at ASC, id ASC ORDER BY created_at ASC, id ASC

View File

@@ -8,81 +8,6 @@ import type { Chat, Message, Session, ToolCall } from '../types/api.js';
// decision time (not at request time) so concurrent project changes don't // decision time (not at request time) so concurrent project changes don't
// stale-bind the resolution. // stale-bind the resolution.
import { resolveGrantRoot } from '../services/grant_resolver.js'; 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<string, unknown> };
async function lookupPendingToolCall(
sql: Sql,
chatId: string,
tool_call_id: string,
expectedToolName: string,
wrongToolError: string,
): Promise<PendingToolLookupResult> {
// 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<string, 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 = '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({ const SendBody = z.object({
content: z.string().min(1).max(64_000), content: z.string().min(1).max(64_000),
@@ -191,7 +116,9 @@ export function registerMessageRoutes(
// see services/inference.ts loadContext + services/compaction.ts. // see services/inference.ts loadContext + services/compaction.ts.
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view. // v1.13.1-B: reads tool_calls/tool_results via the parts-merged view.
const rows = await sql<Message[]>` const rows = await sql<Message[]>`
SELECT ${sql.unsafe(MESSAGE_COLUMNS)} 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
FROM messages_with_parts FROM messages_with_parts
WHERE session_id = ${req.params.id} WHERE session_id = ${req.params.id}
ORDER BY created_at ASC, id ASC ORDER BY created_at ASC, id ASC
@@ -566,16 +493,40 @@ export function registerMessageRoutes(
const chat = chatRows[0]!; const chat = chatRows[0]!;
const sessionId = chat.session_id; const sessionId = chat.session_id;
// v1.13.1-C: resolve the originating tool_call + pending tool row. // v1.13.1-C: find the assistant's tool_call by indexing message_parts
// Pre-v1.13.0 history has no parts rows — those become unreachable (404). // directly on payload->>'id'. Scoped by chat_id + role via the JOIN.
const ctx = await lookupPendingToolCall( // Pre-v1.13.0 history has no parts rows — those tool_calls become
sql, chat.id, tool_call_id, 'ask_user_input', 'tool_call_not_ask_user_input', // unreachable here (404). Acceptable per the dispatch decision: any
); // pending elicitation from before v1.13.0 is long timed out by now;
if (!ctx.ok) { // promote to a hotfix with a JSON-column fallback if it ever surfaces.
reply.code(ctx.code); const callerRows = await sql<{
return ctx.body; message_id: string;
payload: { id: string; name: string; args: Record<string, 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 = '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' };
} }
const { foundCall, toolMessageId } = ctx;
// Validate the args themselves — the LLM could have emitted bad JSON. // Validate the args themselves — the LLM could have emitted bad JSON.
const argsParsed = AskUserInputArgs.safeParse(foundCall.args); const argsParsed = AskUserInputArgs.safeParse(foundCall.args);
@@ -618,6 +569,33 @@ 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 answerSet = { answers };
const newToolResults = { const newToolResults = {
tool_call_id, tool_call_id,
@@ -625,6 +603,7 @@ export function registerMessageRoutes(
truncated: false, truncated: false,
}; };
const toolMessageId = toolRow.message_id;
const result = await sql.begin(async (tx) => { const result = await sql.begin(async (tx) => {
// v1.13.20: parts-only. Replace the pending tool_result part inserted // v1.13.20: parts-only. Replace the pending tool_result part inserted
// at message creation (tool-phase.ts) with the answered one. Delete- // at message creation (tool-phase.ts) with the answered one. Delete-
@@ -702,15 +681,35 @@ export function registerMessageRoutes(
const chat = chatRows[0]!; const chat = chatRows[0]!;
const sessionId = chat.session_id; const sessionId = chat.session_id;
const grantCtx = await lookupPendingToolCall( // Mirror the /answer lookup: assistant tool_call by id via message_parts.
sql, chat.id, tool_call_id, 'request_read_access', 'tool_call_not_request_read_access', const callerRows = await sql<{
); message_id: string;
if (!grantCtx.ok) { payload: { id: string; name: string; args: Record<string, unknown> };
reply.code(grantCtx.code); }[]>`
return grantCtx.body; 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 { foundCall, toolMessageId } = grantCtx;
const argsParsed = RequestReadAccessArgs.safeParse(foundCall.args); const argsParsed = RequestReadAccessArgs.safeParse(foundCall.args);
if (!argsParsed.success) { if (!argsParsed.success) {
reply.code(400); reply.code(400);
@@ -718,6 +717,31 @@ export function registerMessageRoutes(
} }
const requestedPath = argsParsed.data.path; 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 // 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 // append to allowed_read_paths atomically. We don't need agent or
// history here — just the project path for the resolver. // history here — just the project path for the resolver.
@@ -766,6 +790,7 @@ export function registerMessageRoutes(
output: resultOutput, output: resultOutput,
truncated: false, truncated: false,
}; };
const toolMessageId = toolRow.message_id;
const dbResult = await sql.begin(async (tx) => { const dbResult = await sql.begin(async (tx) => {
// v1.13.20: parts-only. Same delete+insert dance as /answer — // v1.13.20: parts-only. Same delete+insert dance as /answer —
// UNIQUE (message_id, sequence) blocks plain UPDATE on append-style // UNIQUE (message_id, sequence) blocks plain UPDATE on append-style

View File

@@ -67,20 +67,6 @@ export async function resolveProjectPath(
return { real, name: basename(real) }; return { real, name: basename(real) };
} }
async function selectProject(sql: Sql, id: string): Promise<Project | null> {
const rows = await sql<Project[]>`
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<string | null> {
const rows = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${id}`;
return rows[0]?.path ?? null;
}
export function registerProjectRoutes( export function registerProjectRoutes(
app: FastifyInstance, app: FastifyInstance,
sql: Sql, sql: Sql,
@@ -213,12 +199,16 @@ export function registerProjectRoutes(
// v1.9: single-project fetch so the settings pane can refetch on // v1.9: single-project fetch so the settings pane can refetch on
// project_updated without pulling the whole project list. // project_updated without pulling the whole project list.
app.get<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => { app.get<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
const project = await selectProject(sql, req.params.id); const rows = await sql<Project[]>`
if (!project) { 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) {
reply.code(404); reply.code(404);
return { error: 'not found' }; return { error: 'not found' };
} }
return project; return rows[0];
}); });
app.patch<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => { app.patch<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
@@ -350,14 +340,18 @@ export function registerProjectRoutes(
const { id } = req.params; const { id } = req.params;
const relPath = req.query.path ?? '.'; const relPath = req.query.path ?? '.';
const projectPath = await selectProjectPath(sql, id); const rows = await sql<Project[]>`
if (projectPath === null) { SELECT id, name, path, added_at, last_session_id, status, gitea_remote
FROM projects WHERE id = ${id}
`;
if (rows.length === 0) {
reply.code(404); reply.code(404);
return { error: 'not found' }; return { error: 'not found' };
} }
const project = rows[0]!;
let projectRoot: string; let projectRoot: string;
try { try {
projectRoot = await resolveProjectRoot(projectPath); projectRoot = await resolveProjectRoot(project.path);
} catch (err) { } catch (err) {
if (err instanceof PathScopeError) { if (err instanceof PathScopeError) {
reply.code(404); reply.code(404);
@@ -391,14 +385,18 @@ export function registerProjectRoutes(
return { error: 'path is required' }; return { error: 'path is required' };
} }
const projectPath = await selectProjectPath(sql, id); const rows = await sql<Project[]>`
if (projectPath === null) { SELECT id, name, path, added_at, last_session_id, status, gitea_remote
FROM projects WHERE id = ${id}
`;
if (rows.length === 0) {
reply.code(404); reply.code(404);
return { error: 'not found' }; return { error: 'not found' };
} }
const project = rows[0]!;
let projectRoot: string; let projectRoot: string;
try { try {
projectRoot = await resolveProjectRoot(projectPath); projectRoot = await resolveProjectRoot(project.path);
} catch (err) { } catch (err) {
if (err instanceof PathScopeError) { if (err instanceof PathScopeError) {
reply.code(404); reply.code(404);
@@ -433,14 +431,18 @@ export function registerProjectRoutes(
'/api/projects/:id/git', '/api/projects/:id/git',
async (req, reply) => { async (req, reply) => {
const { id } = req.params; const { id } = req.params;
const projectPath = await selectProjectPath(sql, id); const rows = await sql<Project[]>`
if (projectPath === null) { SELECT id, name, path, added_at, last_session_id, status, gitea_remote
FROM projects WHERE id = ${id}
`;
if (rows.length === 0) {
reply.code(404); reply.code(404);
return { error: 'not found' }; return { error: 'not found' };
} }
const project = rows[0]!;
let projectRoot: string; let projectRoot: string;
try { try {
projectRoot = await resolveProjectRoot(projectPath); projectRoot = await resolveProjectRoot(project.path);
} catch (err) { } catch (err) {
if (err instanceof PathScopeError) { if (err instanceof PathScopeError) {
reply.code(404); reply.code(404);
@@ -459,14 +461,18 @@ export function registerProjectRoutes(
async (req, reply) => { async (req, reply) => {
const { id } = req.params; const { id } = req.params;
const projectPath = await selectProjectPath(sql, id); const rows = await sql<Project[]>`
if (projectPath === null) { SELECT id, name, path, added_at, last_session_id, status, gitea_remote
FROM projects WHERE id = ${id}
`;
if (rows.length === 0) {
reply.code(404); reply.code(404);
return { error: 'not found' }; return { error: 'not found' };
} }
const project = rows[0]!;
let projectRoot: string; let projectRoot: string;
try { try {
projectRoot = await resolveProjectRoot(projectPath); projectRoot = await resolveProjectRoot(project.path);
} catch (err) { } catch (err) {
if (err instanceof PathScopeError) { if (err instanceof PathScopeError) {
reply.code(404); reply.code(404);

View File

@@ -2,7 +2,6 @@ import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Broker } from '../services/broker.js'; import type { Broker } from '../services/broker.js';
import type { Message } from '../types/api.js'; import type { Message } from '../types/api.js';
import { MESSAGE_COLUMNS } from '../services/message-columns.js';
export function registerWebSocket( export function registerWebSocket(
app: FastifyInstance, app: FastifyInstance,
@@ -26,7 +25,9 @@ export function registerWebSocket(
// render the SummaryCard for summary=true rows on first connect. // render the SummaryCard for summary=true rows on first connect.
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view. // v1.13.1-B: reads tool_calls/tool_results via the parts-merged view.
const messages = await sql<Message[]>` const messages = await sql<Message[]>`
SELECT ${sql.unsafe(MESSAGE_COLUMNS)} 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
FROM messages_with_parts FROM messages_with_parts
WHERE session_id = ${sessionId} WHERE session_id = ${sessionId}
ORDER BY created_at ASC, id ASC ORDER BY created_at ASC, id ASC

View File

@@ -1,5 +1,5 @@
-- v1.13.3: statement_timeout is set at database level via: -- v1.13.3: statement_timeout is set at database level via:
-- ALTER DATABASE boochat SET statement_timeout = '30s'; -- ALTER DATABASE boocode SET statement_timeout = '30s';
-- ALTER DATABASE can't run inside a DO block, so this is an operational -- 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 -- 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 -- lives in pg_db which survives `docker compose up --build` but NOT a
@@ -30,6 +30,8 @@ CREATE TABLE IF NOT EXISTS messages (
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL, role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
tool_calls JSONB,
tool_results JSONB,
status TEXT NOT NULL DEFAULT 'complete', status TEXT NOT NULL DEFAULT 'complete',
last_seq INT NOT NULL DEFAULT 0, last_seq INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
@@ -37,10 +39,11 @@ CREATE TABLE IF NOT EXISTS messages (
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
-- v1.13.0: granular message parts table. v1.13.20: legacy tool_calls/ -- v1.13.0: granular message parts table for AI SDK migration. Old
-- tool_results columns dropped; message_parts is now the sole source of -- messages.content / tool_calls / tool_results columns stay authoritative
-- truth for tool calls, tool results, and reasoning. ON DELETE CASCADE -- for reads in v1.13.0; this table is dual-written so the swap can happen
-- means removing a message removes its parts in one go. -- in a later dispatch without a backfill window. ON DELETE CASCADE means
-- removing a message removes its parts in one go.
CREATE TABLE IF NOT EXISTS message_parts ( CREATE TABLE IF NOT EXISTS message_parts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE, message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
@@ -139,9 +142,10 @@ ALTER TABLE messages DROP COLUMN IF EXISTS tool_calls;
ALTER TABLE messages DROP COLUMN IF EXISTS tool_results; ALTER TABLE messages DROP COLUMN IF EXISTS tool_results;
-- v1.13.10: per-tool token cost rolling window. Derives from -- v1.13.10: per-tool token cost rolling window. Derives from
-- messages_with_parts (the v1.13.1-B view; v1.13.20 removed the legacy -- messages_with_parts (the v1.13.1-B view that COALESCEs message_parts over
-- JSON-column COALESCE fallback — parts are sole source). No new write -- the legacy JSON column) so this works whether the chat predates v1.13.0
-- site — all source data already lands via tool-phase.ts:94-95 UPDATE. -- 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.
-- --
-- Attribution model: equal split. A turn emitting N tool calls divides its -- Attribution model: equal split. A turn emitting N tool calls divides its
-- prompt/completion tokens by N before attribution. See v1.13.10 dispatch -- prompt/completion tokens by N before attribution. See v1.13.10 dispatch
@@ -348,7 +352,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_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 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 web_search_enabled BOOLEAN;
ALTER TABLE sessions DROP COLUMN IF EXISTS tags; ALTER TABLE sessions ADD COLUMN IF NOT EXISTS tags TEXT[] DEFAULT '{}';
-- v1.11: anchored rolling compaction. -- v1.11: anchored rolling compaction.
-- compacted_at — marks rows that are "behind the curtain" of the latest -- compacted_at — marks rows that are "behind the curtain" of the latest
@@ -387,7 +391,9 @@ CREATE TABLE IF NOT EXISTS tasks (
model TEXT, model TEXT,
mode_id TEXT, mode_id TEXT,
thinking_option_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')), execution_path TEXT CHECK (execution_path IS NULL OR execution_path IN ('native','acp','pty','qwen')),
worktree_path TEXT,
cost_tokens INTEGER, cost_tokens INTEGER,
started_at TIMESTAMPTZ, started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ, ended_at TIMESTAMPTZ,

View File

@@ -1,46 +0,0 @@
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);
});
});

View File

@@ -1,149 +0,0 @@
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);
});
});

View File

@@ -9,9 +9,12 @@ import {
const TEST_URL = 'http://llama-swap.test:8401'; const TEST_URL = 'http://llama-swap.test:8401';
function mockOkProps(n_ctx: number) { function mockOkProps(n_ctx: number, total_slots = 1) {
return new Response( return new Response(
JSON.stringify({ default_generation_settings: { n_ctx } }), JSON.stringify({
default_generation_settings: { n_ctx },
total_slots,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }, { status: 200, headers: { 'Content-Type': 'application/json' } },
); );
} }
@@ -30,10 +33,12 @@ afterEach(() => {
describe('getModelContext — positive cache', () => { describe('getModelContext — positive cache', () => {
it('returns the parsed body on a 200 with valid shape', async () => { it('returns the parsed body on a 200 with valid shape', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockOkProps(262_144)); const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(mockOkProps(262_144, 1));
const result = await getModelContext('qwen3.6'); const result = await getModelContext('qwen3.6');
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!.n_ctx).toBe(262_144); 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 // Verify the URL was constructed correctly — encodes the model name in
// case it contains characters that would break the path. // case it contains characters that would break the path.
expect(fetchSpy).toHaveBeenCalledExactlyOnceWith( expect(fetchSpy).toHaveBeenCalledExactlyOnceWith(
@@ -52,6 +57,19 @@ describe('getModelContext — positive cache', () => {
expect(fetchSpy).toHaveBeenCalledTimes(1); 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) ------------------------------------------ // ---- negative cache (single-shot) ------------------------------------------

View File

@@ -1,87 +0,0 @@
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);
});
});

View File

@@ -1,111 +0,0 @@
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<string, unknown> = {}): 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');
});
});

View File

@@ -1,68 +0,0 @@
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<unknown> {
return {
name,
description: `fake mcp tool ${name}`,
inputSchema: z.object({}) as z.ZodType<unknown>,
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');
});
});

View File

@@ -1,159 +1,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { import {
WsFrameSchema,
KNOWN_FRAME_TYPES,
type WsFrame, type WsFrame,
} from '../../types/ws-frames.js'; } from '@boocode/contracts/ws-frames';
import { createBroker } from '../broker.js'; import { createBroker } from '../broker.js';
const VALID_UUID_A = '00000000-0000-0000-0000-000000000001'; const VALID_UUID_A = '00000000-0000-0000-0000-000000000001';
const VALID_UUID_B = '00000000-0000-0000-0000-000000000002'; const VALID_UUID_B = '00000000-0000-0000-0000-000000000002';
const VALID_UUID_C = '00000000-0000-0000-0000-000000000003';
const VALID_TIMESTAMP = '2026-05-22T14:30:00.000Z'; const VALID_TIMESTAMP = '2026-05-22T14:30:00.000Z';
describe('WsFrameSchema (v1.13.11-a)', () => {
it('accepts a well-formed chat_status frame', () => {
const result = WsFrameSchema.safeParse({
type: 'chat_status',
chat_id: VALID_UUID_A,
status: 'streaming',
at: VALID_TIMESTAMP,
});
expect(result.success).toBe(true);
});
it('rejects an unknown frame type', () => {
const result = WsFrameSchema.safeParse({
type: 'cosmic_ray_strike',
chat_id: VALID_UUID_A,
});
expect(result.success).toBe(false);
});
it('rejects a chat_status frame with invalid status enum', () => {
// v1.12.1 dropped the legacy 'working' status. Any frame still emitting it
// should fail validation — that's a drift catcher.
const result = WsFrameSchema.safeParse({
type: 'chat_status',
chat_id: VALID_UUID_A,
status: 'working',
at: VALID_TIMESTAMP,
});
expect(result.success).toBe(false);
});
it('rejects a UUID field with a non-UUID string', () => {
const result = WsFrameSchema.safeParse({
type: 'chat_status',
chat_id: 'not-a-uuid',
status: 'idle',
at: VALID_TIMESTAMP,
});
expect(result.success).toBe(false);
});
it('rejects negative token counts in usage frame', () => {
const result = WsFrameSchema.safeParse({
type: 'usage',
message_id: VALID_UUID_A,
chat_id: VALID_UUID_B,
completion_tokens: -1,
ctx_used: 100,
ctx_max: 1000,
});
expect(result.success).toBe(false);
});
it('accepts a usage frame with nullable token counts (pre-v1.13.7 history)', () => {
const result = WsFrameSchema.safeParse({
type: 'usage',
message_id: VALID_UUID_A,
chat_id: VALID_UUID_B,
completion_tokens: null,
ctx_used: null,
ctx_max: null,
});
expect(result.success).toBe(true);
});
it('accepts a tool_result frame with non-UUID tool_call_id (model-emitted)', () => {
// Model-emitted tool_call_ids look like "call_abc123", not UUIDs.
const result = WsFrameSchema.safeParse({
type: 'tool_result',
tool_message_id: VALID_UUID_A,
chat_id: VALID_UUID_B,
tool_call_id: 'call_abc123',
output: { whatever: true },
truncated: false,
});
expect(result.success).toBe(true);
});
it('accepts a compacted frame', () => {
const result = WsFrameSchema.safeParse({
type: 'compacted',
session_id: VALID_UUID_A,
chat_id: VALID_UUID_B,
summary_message_id: VALID_UUID_C,
});
expect(result.success).toBe(true);
});
it('accepts a session_workspace_updated frame', () => {
const result = WsFrameSchema.safeParse({
type: 'session_workspace_updated',
session_id: VALID_UUID_A,
workspace_panes: [{ id: 'p1', kind: 'chat', chatIds: [], activeChatIdx: 0 }],
});
expect(result.success).toBe(true);
});
it('accepts a message_complete frame with a null model (external coder, no model selected)', () => {
// Regression guard: the dispatcher publishes `model: task.model` (string |
// null). When null, this MUST validate or publishFrame fail-closes and drops
// the whole frame, incl. the status:'complete' transition.
const result = WsFrameSchema.safeParse({
type: 'message_complete',
message_id: VALID_UUID_A,
chat_id: VALID_UUID_B,
model: null,
});
expect(result.success).toBe(true);
});
it('every KNOWN_FRAME_TYPES entry has a discriminated branch', () => {
// Probe each known type by attempting a minimal valid construction.
// Failure here means the union and the KNOWN_FRAME_TYPES list drifted.
for (const type of KNOWN_FRAME_TYPES) {
const probe = WsFrameSchema.safeParse({ type, __dummy__: true });
// We expect FAILURE on every type because we're missing required fields,
// but the failure must be ABOUT the missing fields, not about an unknown
// type. A "Invalid discriminator value" error means the type isn't in
// the union — that's a drift.
if (probe.success) continue;
const issues = probe.error.issues;
const hasInvalidDiscriminator = issues.some(
(i) => i.code === 'invalid_union_discriminator',
);
expect(hasInvalidDiscriminator, `frame type '${type}' is missing from the discriminated union`).toBe(false);
}
});
});
describe('ws-frames.ts file mirror parity', () => {
it('apps/server and apps/web copies are byte-identical', () => {
const here = fileURLToPath(import.meta.url);
const serverPath = resolve(here, '../../../types/ws-frames.ts');
const webPath = resolve(here, '../../../../../web/src/api/ws-frames.ts');
const serverContent = readFileSync(serverPath, 'utf8');
const webContent = readFileSync(webPath, 'utf8');
expect(webContent, 'apps/web/src/api/ws-frames.ts must be byte-identical to apps/server/src/types/ws-frames.ts').toBe(serverContent);
});
});
describe('broker.publishFrame / publishUserFrame fail-closed behavior', () => { describe('broker.publishFrame / publishUserFrame fail-closed behavior', () => {
let logErrors: Array<{ obj: unknown; msg: string }>; let logErrors: Array<{ obj: unknown; msg: string }>;
let mockLog: Parameters<typeof createBroker>[0]; let mockLog: Parameters<typeof createBroker>[0];

View File

@@ -3,7 +3,6 @@ import { join } from 'node:path';
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js'; import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
import { ALL_TOOLS, resolveToolTier } from './tools.js'; import { ALL_TOOLS, resolveToolTier } from './tools.js';
import { validateExtraArgs } from './inference/llama-args-validator.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 // 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 // (./data:/data:ro mount on the host). Per-project AGENTS.md at the project
@@ -108,50 +107,17 @@ interface ParsedFrontmatter {
llama_extra_args?: string[]; llama_extra_args?: string[];
} }
// P5: table-driven validation for the "soft-range" numeric frontmatter fields. function stripQuotes(s: string): string {
// Each was a near-identical Number() + finite/integer + range-warn + push-error if (
// block. "Soft-range" = the value is STORED whenever the type checks out; an s.length >= 2 &&
// out-of-range value only emits a console.warn (it is NOT skipped). A type (s[0] === '"' || s[0] === "'") &&
// mismatch hard-fails the block. The range descriptor in the warn message is s[0] === s[s.length - 1]
// `min-max` when both bounds exist, else `(≥min)` — matching the original ) {
// hand-written strings byte-for-byte. return s.slice(1, -1);
// }
// max_tool_calls and steps are deliberately NOT in this table: they are return s;
// "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[] } { function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: string[] } {
const data: ParsedFrontmatter = {}; const data: ParsedFrontmatter = {};
const errors: string[] = []; const errors: string[] = [];
@@ -174,33 +140,108 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
const key = line.slice(0, colonIdx).trim(); const key = line.slice(0, colonIdx).trim();
const valueRaw = line.slice(colonIdx + 1).trim(); const valueRaw = line.slice(colonIdx + 1).trim();
const numericSpec = NUMERIC_FIELDS.find((f) => f.key === key); if (key === 'temperature') {
if (numericSpec) {
const n = Number(valueRaw); const n = Number(valueRaw);
const typeOk = numericSpec.isInt ? Number.isInteger(n) : Number.isFinite(n); if (Number.isFinite(n)) data.temperature = n;
if (typeOk) { else errors.push(`temperature must be a number (got "${valueRaw}")`);
// Soft-range: store regardless of range; out-of-range only warns. } else if (key === 'top_p') {
data[numericSpec.key] = n; const n = Number(valueRaw);
const below = numericSpec.min !== undefined && n < numericSpec.min; if (Number.isFinite(n)) {
const above = numericSpec.max !== undefined && n > numericSpec.max; data.top_p = n;
if (below || above) { if (n < 0 || n > 1) {
const range = console.warn(`agents: top_p ${n} out of range 0-1, ignoring (falling back to default)`);
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 { } else {
errors.push( errors.push(`top_p must be a number (got "${valueRaw}")`);
`${numericSpec.key} must be ${numericSpec.isInt ? 'an integer' : 'a number'} (got "${valueRaw}")`,
);
} }
continue; } else if (key === 'top_k') {
} const n = Number(valueRaw);
if (Number.isInteger(n)) {
if (key === 'tools') { 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') {
if (valueRaw === '') { if (valueRaw === '') {
data.tools = []; data.tools = [];
arrayKey = 'tools'; arrayKey = 'tools';
@@ -437,6 +478,14 @@ interface CacheEntry {
// corresponding mtime so the next read sees a miss without a watcher. // corresponding mtime so the next read sees a miss without a watcher.
const cache = new Map<string, CacheEntry>(); const cache = new Map<string, CacheEntry>();
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. // v1.13.8: cache-read accessor for the system-prompt prefix-fingerprint log.
// Returns the AGENTS.md mtimes that getAgentsForProject() observed on its // Returns the AGENTS.md mtimes that getAgentsForProject() observed on its
// last cache fill for this projectPath. Both fields are null when the cache // last cache fill for this projectPath. Both fields are null when the cache

View File

@@ -19,6 +19,8 @@ function cleanTitle(raw: string): string {
return name; return name;
} }
// TODO: wire suggestTags after task model validation
export async function maybeAutoNameChat( export async function maybeAutoNameChat(
ctx: InferenceContext, ctx: InferenceContext,
chatId: string, chatId: string,

View File

@@ -1,5 +1,5 @@
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import { WsFrameSchema, type WsFrame } from '../types/ws-frames.js'; import { WsFrameSchema, type WsFrame } from '@boocode/contracts/ws-frames';
export type Frame = Record<string, unknown> & { type: string }; export type Frame = Record<string, unknown> & { type: string };
export type Listener = (frame: Frame) => void; export type Listener = (frame: Frame) => void;

View File

@@ -113,7 +113,7 @@ export async function callCodecontext(
fetcher: typeof fetch = fetch, fetcher: typeof fetch = fetch,
): Promise<CodecontextResponse> { ): Promise<CodecontextResponse> {
// Step 1: realpath the project root, then realpath the requested target_dir // Step 1: realpath the project root, then realpath the requested target_dir
// (defaulting to projectPath when the caller didn't pass one — the 12 wrappers // (defaulting to projectPath when the caller didn't pass one — the 8 wrappers
// never pass target_dir; tests can override). A non-existent target_dir // 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. // throws before we hit the network so the model gets a sharp error.
const resolvedProject = await realpath(req.projectPath); const resolvedProject = await realpath(req.projectPath);

View File

@@ -22,8 +22,6 @@ import type { Config } from '../config.js';
import type { Broker } from './broker.js'; import type { Broker } from './broker.js';
import { SUMMARY_TEMPLATE } from './compaction-prompt.js'; import { SUMMARY_TEMPLATE } from './compaction-prompt.js';
import * as modelContextLookup from './model-context.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 // 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 // (opencode session/overflow.ts pattern). Replaces the v1.11.0-era
@@ -258,9 +256,24 @@ export function buildPrompt(
// would silently drop pre-legacy-compact history before the LLM sees it. // would silently drop pre-legacy-compact history before the LLM sees it.
// Compaction wants to send the entire head, full stop.) === // Compaction wants to send the entire head, full stop.) ===
// #12: SENTINEL_KINDS imported from inference/sentinels.ts (single source). // v1.13.6: exported for unit-test access (reasoning render coverage).
// OpenAiMessage imported from inference/payload.ts (structurally compatible — export interface OpenAiMessage {
// compaction's head payload doesn't need the optional reasoning? field). 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']);
function isAnySentinel(m: CompactionMessage): boolean { function isAnySentinel(m: CompactionMessage): boolean {
return ( return (
m.role === 'system' && m.role === 'system' &&

View File

@@ -200,7 +200,7 @@ export async function grep(
export async function findFiles( export async function findFiles(
projectRoot: string, projectRoot: string,
pattern?: string, pattern?: string,
opts?: { max_results?: number; path?: string; extra_roots?: readonly string[] } opts?: { type?: 'file' | 'dir'; max_results?: number; path?: string; extra_roots?: readonly string[] }
): Promise<FindFilesResult> { ): Promise<FindFilesResult> {
const limit = Math.min( const limit = Math.min(
Math.max(opts?.max_results ?? DEFAULT_FIND_RESULTS, 1), Math.max(opts?.max_results ?? DEFAULT_FIND_RESULTS, 1),

View File

@@ -83,3 +83,10 @@ export async function getGitMeta(rootPath: string): Promise<GitMeta | null> {
return value; return value;
} }
export function invalidateGitMetaCache(rootPath?: string): void {
if (rootPath) {
cache.delete(rootPath);
} else {
cache.clear();
}
}

View File

@@ -1,10 +1,32 @@
import type { Agent } from '../../types/api.js'; 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<string> = 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 { export function resolveToolBudget(agent: Agent | null): number {
return agent?.max_tool_calls ?? 100; 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;
} }

View File

@@ -1,64 +0,0 @@
// 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<void>;
}
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<unknown> = 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 };
}

View File

@@ -10,7 +10,7 @@ import { maybeFlagForCompaction } from './payload.js';
import { insertParts, partsFromAssistantMessage } from './parts.js'; import { insertParts, partsFromAssistantMessage } from './parts.js';
import type { PartInsert } from './parts.js'; import type { PartInsert } from './parts.js';
import { stripToolMarkup } from './tool-call-parser.js'; import { stripToolMarkup } from './tool-call-parser.js';
import type { InferenceContext, StreamResult, TurnArgs } from './types.js'; import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
export async function handleAbortOrError( export async function handleAbortOrError(
ctx: InferenceContext, ctx: InferenceContext,
@@ -95,90 +95,6 @@ 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<void>;
},
): Promise<void> {
// 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<void> {
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( export async function finalizeCompletion(
ctx: InferenceContext, ctx: InferenceContext,
args: TurnArgs, args: TurnArgs,

View File

@@ -7,17 +7,26 @@
export { export {
createInferenceRunner, createInferenceRunner,
MAX_STEPS, MAX_STEPS,
runAssistantTurn,
runInference, runInference,
} from './turn.js'; } 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 { export type {
FramePublisher, FramePublisher,
InferenceContext, InferenceContext,
InferenceFrame, InferenceFrame,
StreamResult, StreamResult,
TurnArgs, TurnArgs,
} from './types.js'; } from './turn.js';
export type { ToolPhaseResult } from './tool-phase.js'; export type { ToolPhaseResult } from './tool-phase.js';
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.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 { buildMessagesPayload } from './payload.js';
export { generateToolUseSummary } from './tool-summaries.js';
export type { ToolInfo } from './tool-summaries.js';

View File

@@ -1,10 +1,11 @@
import type { Sql } from '../../db.js'; import type { Sql } from '../../db.js';
import type { ToolCall, ToolResult } from '../../types/api.js'; import type { ToolCall, ToolResult } from '../../types/api.js';
// v1.13.0: message_parts write helpers. v1.13.20: legacy tool_calls/ // v1.13.0: dual-write helper. Every site that writes the legacy
// tool_results JSON columns dropped; message_parts is the sole source of // messages.tool_calls / messages.tool_results JSON columns calls into here
// truth. All writes go through insertParts / partsFromAssistantMessage / // to mirror the same data into message_parts rows. Reads still go to the
// partsFromToolMessage. Reads use the messages_with_parts view. // 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.13: 'synthesis' added. Schema CHECK constraint is updated in lockstep // v1.13.13: 'synthesis' added. Schema CHECK constraint is updated in lockstep
// (schema.sql adds 'synthesis' to message_parts_kind_chk on startup). The // (schema.sql adds 'synthesis' to message_parts_kind_chk on startup). The

View File

@@ -10,8 +10,7 @@ import * as compaction from '../compaction.js';
import { buildSystemPromptWithFingerprint } from '../system-prompt.js'; import { buildSystemPromptWithFingerprint } from '../system-prompt.js';
import { isAnySentinel } from './sentinels.js'; import { isAnySentinel } from './sentinels.js';
import { PRUNE_TRIGGER_TOKENS, prune } from './prune.js'; import { PRUNE_TRIGGER_TOKENS, prune } from './prune.js';
import type { InferenceContext } from './types.js'; import type { InferenceContext } from './turn.js';
import { INFERENCE_MESSAGE_COLUMNS } from '../message-columns.js';
export interface OpenAiMessage { export interface OpenAiMessage {
role: 'system' | 'user' | 'assistant' | 'tool'; role: 'system' | 'user' | 'assistant' | 'tool';
@@ -206,7 +205,9 @@ export async function loadContext(
// v1.13.1-C: also pull reasoning_parts so assistant messages from // v1.13.1-C: also pull reasoning_parts so assistant messages from
// reasoning models can be replayed with their reasoning context preserved. // reasoning models can be replayed with their reasoning context preserved.
const history = await sql<Message[]>` const history = await sql<Message[]>`
SELECT ${sql.unsafe(INFERENCE_MESSAGE_COLUMNS)} 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
FROM messages_with_parts FROM messages_with_parts
WHERE chat_id = ${chatId} AND compacted_at IS NULL WHERE chat_id = ${chatId} AND compacted_at IS NULL
ORDER BY created_at ASC, id ASC ORDER BY created_at ASC, id ASC

View File

@@ -5,16 +5,16 @@ import type {
Project, Project,
Session, Session,
} from '../../types/api.js'; } from '../../types/api.js';
import * as modelContext from '../model-context.js';
import { buildMessagesPayload } from './payload.js'; import { buildMessagesPayload } from './payload.js';
import { DOOM_LOOP_THRESHOLD } from './sentinels.js'; import { DOOM_LOOP_THRESHOLD } from './sentinels.js';
import { streamCompletion, samplerOptsFromAgent } from './stream-phase.js'; import { streamCompletion } from './stream-phase.js';
import { createContentFlusher } from './content-flusher.js'; import { DB_FLUSH_INTERVAL_MS } from './types.js';
import { finalizeStreamedRow } from './error-handler.js';
import type { import type {
InferenceContext, InferenceContext,
StreamResult, StreamResult,
TurnArgs, TurnArgs,
} from './types.js'; } from './turn.js';
// Synthetic system note appended to the cap-hit summary call. Verbatim from // 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 // the v1.8.2 spec — do not paraphrase: the model is more reliable when the
@@ -25,50 +25,21 @@ const CAP_HIT_SUMMARY_NOTE = (limit: number) =>
const DOOM_LOOP_NOTE = (name: string) => 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.`; `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.`;
// v1.14.0: step-cap wrap-up note. Names the step limit rather than the tool export async function runCapHitSummary(
// 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<string, unknown>;
beforeStream?: () => Promise<void>;
afterSession?: () => Promise<void>;
}
async function runWrapUpSummary(
ctx: InferenceContext, ctx: InferenceContext,
args: TurnArgs, args: TurnArgs,
session: Session, session: Session,
project: Project, project: Project,
history: Message[], history: Message[],
agent: Agent | null, agent: Agent | null,
opts: WrapUpOpts, budget: number,
): Promise<void> { ): Promise<void> {
const { sessionId, chatId, assistantMessageId, signal } = args; const { sessionId, chatId, assistantMessageId, signal } = args;
if (opts.beforeStream) await opts.beforeStream(); await insertCapHitSentinel(ctx, sessionId, chatId, agent, budget);
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log); const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
messages.push({ role: 'system', content: opts.note }); messages.push({ role: 'system', content: CAP_HIT_SUMMARY_NOTE(budget) });
const startedRow = await ctx.sql<{ started_at: string }[]>` const startedRow = await ctx.sql<{ started_at: string }[]>`
UPDATE messages UPDATE messages
@@ -86,7 +57,25 @@ async function runWrapUpSummary(
}); });
let accumulated = ''; let accumulated = '';
const flusher = createContentFlusher(ctx.sql, assistantMessageId, () => accumulated); let pendingFlushTimer: NodeJS.Timeout | null = null;
let flushPromise: Promise<unknown> = 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 summaryOk = false;
let summarySoftCancelled = false; let summarySoftCancelled = false;
@@ -97,7 +86,7 @@ async function runWrapUpSummary(
ctx, ctx,
session.model, session.model,
messages, messages,
{ tools: null, ...samplerOptsFromAgent(agent) }, { 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) => { (delta) => {
accumulated += delta; accumulated += delta;
ctx.publish(sessionId, { ctx.publish(sessionId, {
@@ -106,7 +95,7 @@ async function runWrapUpSummary(
chat_id: chatId, chat_id: chatId,
content: delta, content: delta,
}); });
flusher.scheduleFlush(); scheduleFlush();
}, },
undefined, undefined,
signal, signal,
@@ -119,23 +108,44 @@ async function runWrapUpSummary(
summaryError = err instanceof Error ? err.message : String(err); summaryError = err instanceof Error ? err.message : String(err);
} }
} finally { } finally {
await flusher.drain(); if (pendingFlushTimer) {
clearTimeout(pendingFlushTimer);
pendingFlushTimer = null;
}
await flushPromise;
} }
// Finalize the summary message based on the three outcomes. The sentinel is // Finalize the summary message based on the three outcomes. The sentinel
// inserted regardless (before or after, per opts) so the user always has the // is inserted regardless so the user always has the Continue affordance —
// appropriate affordance — even on a partial / failed summary the chat // even on a partial / failed summary the chat history shows where the
// history shows where the loop stopped. // budget was hit.
if (summaryOk && result) { if (summaryOk && result) {
await finalizeStreamedRow(ctx, { // v1.11.3: see executeToolPhase for the rationale.
sessionId, const mctx = await modelContext.getModelContext(session.model);
chatId, const nCtx = mctx?.n_ctx ?? null;
messageId: assistantMessageId, 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, model: session.model,
content: result.content,
completionTokens: result.completionTokens,
promptTokens: result.promptTokens,
startedAt,
}); });
} else if (summarySoftCancelled) { } else if (summarySoftCancelled) {
await ctx.sql` await ctx.sql`
@@ -154,7 +164,7 @@ async function runWrapUpSummary(
const errMeta: MessageMetadata = { const errMeta: MessageMetadata = {
kind: 'error', kind: 'error',
error_reason: 'summary_after_cap_failed', error_reason: 'summary_after_cap_failed',
error_text: summaryError ?? opts.errorText, error_text: summaryError ?? 'summary failed',
}; };
await ctx.sql` await ctx.sql`
UPDATE messages UPDATE messages
@@ -168,7 +178,7 @@ async function runWrapUpSummary(
type: 'error', type: 'error',
message_id: assistantMessageId, message_id: assistantMessageId,
chat_id: chatId, chat_id: chatId,
error: summaryError ?? opts.errorText, error: summaryError ?? 'summary failed',
reason: 'summary_after_cap_failed', reason: 'summary_after_cap_failed',
}); });
} }
@@ -187,11 +197,11 @@ async function runWrapUpSummary(
updated_at: sessRow!.updated_at, updated_at: sessRow!.updated_at,
}); });
if (opts.afterSession) await opts.afterSession();
// Status frame fires last so the dot color reflects the terminal state. // Status frame fires last so the dot color reflects the terminal state.
// Success → idle, abort → idle (user-driven stop), error → error+reason. // Success → idle, abort → idle (user-driven stop), error → error+reason.
if (summaryOk || summarySoftCancelled) { if (summaryOk) {
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
} else if (summarySoftCancelled) {
ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() }); ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'idle', at: new Date().toISOString() });
} else { } else {
ctx.publishUser({ ctx.publishUser({
@@ -204,113 +214,11 @@ async function runWrapUpSummary(
} }
ctx.log.info( ctx.log.info(
{ sessionId, chatId, assistantMessageId, ...opts.logFields, summaryOk, summaryCancelled: summarySoftCancelled }, { sessionId, chatId, assistantMessageId, budget, summaryOk, summaryCancelled: summarySoftCancelled },
opts.logMsg, 'inference cap-hit summary finished',
); );
} }
// 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<void> {
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<string, unknown> },
): Promise<void> {
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<void> {
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<void> {
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( async function insertCapHitSentinel(
ctx: InferenceContext, ctx: InferenceContext,
sessionId: string, sessionId: string,
@@ -338,7 +246,430 @@ async function insertCapHitSentinel(
can_continue: canContinue, can_continue: canContinue,
}; };
const content = `Reached tool budget (${budget}/${budget}). Continue to extend.`; const content = `Reached tool budget (${budget}/${budget}). Continue to extend.`;
await insertSentinel(ctx, sessionId, chatId, metadata, content);
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<string, unknown> },
): Promise<void> {
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<unknown> = 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<void> {
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<unknown> = 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',
);
} }
async function insertDoomLoopSentinel( async function insertDoomLoopSentinel(
@@ -358,12 +689,39 @@ async function insertDoomLoopSentinel(
threshold: DOOM_LOOP_THRESHOLD, 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 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.`;
await insertSentinel(ctx, sessionId, chatId, metadata, content);
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,
});
} }
// #12 MistakeTracker: heterogeneous-failure recovery sentinel. A role='system', // #12 MistakeTracker: heterogeneous-failure recovery sentinel. Mirrors
// status='complete' row firing the standard sentinel frame sequence. Two // insertDoomLoopSentinel structurally — a role='system', status='complete' row
// variants distinguished by `escalated`: // firing the standard message_started → delta → message_complete frame
// sequence. Two variants distinguished by `escalated`:
// - escalated:false → a nudge fired; recovery guidance was injected into the // - 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 // model's next step and the loop continued. can_continue is true (the turn
// is still live). // is still live).
@@ -386,5 +744,30 @@ export async function insertMistakeRecoverySentinel(
const content = opts.escalated const content = opts.escalated
? `Repeated different errors persisted after a recovery nudge (${opts.count} in a row). Stopping the tool-call loop.` ? `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.`; : `Hit ${opts.count} different errors in a row. Injected recovery guidance and continuing.`;
await insertSentinel(ctx, sessionId, chatId, metadata, content);
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,
});
} }

View File

@@ -27,10 +27,6 @@ export function detectDoomLoop(
return { name: ref.name, args: ref.args }; 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 { export function isCapHitSentinel(m: Message): boolean {
return ( return (
m.role === 'system' && m.role === 'system' &&
@@ -65,10 +61,5 @@ export function isMistakeRecoverySentinel(m: Message): boolean {
} }
export function isAnySentinel(m: Message): boolean { export function isAnySentinel(m: Message): boolean {
return ( return isCapHitSentinel(m) || isDoomLoopSentinel(m) || isMistakeRecoverySentinel(m);
m.role === 'system' &&
m.metadata !== null &&
typeof m.metadata === 'object' &&
SENTINEL_KINDS.has((m.metadata as { kind?: unknown }).kind as string)
);
} }

View File

@@ -1,47 +0,0 @@
// 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<string, unknown> } }
| { 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';
}

View File

@@ -1,405 +0,0 @@
// 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<StreamOptions, 'tools'>;
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<string, number> | undefined {
const body: Record<string, number> = {};
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<string, string>();
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<string, ReturnType<typeof tool>> {
const out: Record<string, ReturnType<typeof tool>> = {};
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:
// <tool_call>
// <function=NAME>
// <parameter=KEY>VALUE</parameter>
// ...
// </function>
// </tool_call>
//
// v1.13.16: also recognize Anthropic <invoke> markup that qwen3.6-35b-a3b-mxfp4
// drifts to (training-data residue from Claude Code documentation):
// <invoke name="NAME">
// <parameter name="KEY">VALUE</parameter>
// </invoke>
// 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<StreamResult> {
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<NonNullable<typeof aiTools>> = 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 <tool_call> or <invoke> 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<string, unknown>,
});
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 `<tool_c`
// than vanish the text.
if (pendingBuffer.length > 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,
};
}

View File

@@ -1,34 +1,377 @@
// P5 (SPLIT SKETCH): stream-phase.ts is now the BooCode I/O layer for the import type {
// stream phase — `executeStreamPhase` owns the row UPDATE, message_started Agent,
// frame, debounced content flush, throttled usage publish, model-context Session,
// lookup, and tool-whitelist filter. The generic AI-SDK adapter ToolCall,
// (streamCompletion / toModelMessages / buildAiTools / sampler helpers) moved } from '../../types/api.js';
// 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 * as modelContext from '../model-context.js';
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js'; import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
import { matchToolGlob } from '../agents.js'; import { matchToolGlob } from '../agents.js';
import type { OpenAiMessage } from './payload.js'; import type { OpenAiMessage } from './payload.js';
import { createContentFlusher } from './content-flusher.js'; import { extractToolCallBlocks } from './tool-call-parser.js';
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
import type { import type {
StreamPhaseState,
InferenceContext, InferenceContext,
StreamResult, StreamResult,
TurnArgs, TurnArgs,
} from './types.js'; } from './turn.js';
import { streamCompletion, samplerOptsFromAgent } from './stream-phase-adapter.js'; import { upstreamModel } from './provider.js';
import {
jsonSchema,
streamText,
tool,
type JSONValue,
type ModelMessage,
type ToolCallRepairFunction,
} from 'ai';
export { interface StreamOptions {
streamCompletion, // null = omit tools entirely (compact phase); [] = caller stripped all tools
samplerOptsFromAgent, // (rare; we still omit from the request body to avoid OpenAI 400).
type StreamOptions, tools: ToolJsonSchema[] | null;
type SamplerOpts, temperature?: number;
type StreamAdapterContext, top_p?: number | null;
} from './stream-phase-adapter.js'; 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<string, number> | undefined {
const body: Record<string, number> = {};
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<string, string>();
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<string, ReturnType<typeof tool>> {
const out: Record<string, ReturnType<typeof tool>> = {};
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:
// <tool_call>
// <function=NAME>
// <parameter=KEY>VALUE</parameter>
// ...
// </function>
// </tool_call>
//
// v1.13.16: also recognize Anthropic <invoke> markup that qwen3.6-35b-a3b-mxfp4
// drifts to (training-data residue from Claude Code documentation):
// <invoke name="NAME">
// <parameter name="KEY">VALUE</parameter>
// </invoke>
// 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<StreamResult> {
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<NonNullable<typeof aiTools>> = 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 <tool_call> or <invoke> 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<string, unknown>,
});
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 `<tool_c`
// than vanish the text.
if (pendingBuffer.length > 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 async function executeStreamPhase( export async function executeStreamPhase(
ctx: InferenceContext, ctx: InferenceContext,
@@ -58,7 +401,27 @@ export async function executeStreamPhase(
role: 'assistant', role: 'assistant',
}); });
const flusher = createContentFlusher(ctx.sql, assistantMessageId, () => state.accumulated); let pendingFlushTimer: NodeJS.Timeout | null = null;
let flushPromise: Promise<unknown> = 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);
};
// Tool whitelist: if an agent is set, filter the global tool list to only the // 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 // tool names it allows. v1.15.0-mcp-multi: uses matchToolGlob for glob
@@ -71,6 +434,17 @@ export async function executeStreamPhase(
? toolJsonSchemas().filter((t) => matchToolGlob(t.function.name, agent.tools)) ? toolJsonSchemas().filter((t) => matchToolGlob(t.function.name, agent.tools))
: toolJsonSchemas() : toolJsonSchemas()
).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name)); ).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 // 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 // is a Map probe in steady state. We capture nCtx once at the top of the
@@ -110,7 +484,16 @@ export async function executeStreamPhase(
messages, messages,
{ {
tools: effectiveTools, tools: effectiveTools,
...samplerOptsFromAgent(agent), 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,
}, },
(delta) => { (delta) => {
state.accumulated += delta; state.accumulated += delta;
@@ -121,7 +504,7 @@ export async function executeStreamPhase(
content: delta, content: delta,
}); });
ctx.log.debug({ sessionId, delta }, 'inference delta'); ctx.log.debug({ sessionId, delta }, 'inference delta');
flusher.scheduleFlush(); scheduleFlush();
}, },
(prompt, completion) => { (prompt, completion) => {
pendingUsage = { p: prompt, c: completion }; pendingUsage = { p: prompt, c: completion };
@@ -139,10 +522,14 @@ export async function executeStreamPhase(
agent, agent,
); );
} finally { } finally {
if (pendingFlushTimer) {
clearTimeout(pendingFlushTimer);
pendingFlushTimer = null;
}
if (usageTimer) { if (usageTimer) {
clearTimeout(usageTimer); clearTimeout(usageTimer);
usageTimer = null; usageTimer = null;
} }
await flusher.drain(); await flushPromise;
} }
} }

View File

@@ -22,7 +22,7 @@ import type {
InferenceContext, InferenceContext,
StreamResult, StreamResult,
TurnArgs, TurnArgs,
} from './types.js'; } from './turn.js';
// v1.13.13: synthesis pipeline — replaces the immediate recursive turn when // 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 // any of this batch's tool calls is in SYNTHESIS_TOOLS. Falls through to
// recursion on synthesis failure (timeout / model error). See module header // recursion on synthesis failure (timeout / model error). See module header

View File

@@ -0,0 +1,81 @@
/**
* 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<string | null> {
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;
}
}

View File

@@ -1,33 +0,0 @@
// 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 };
}

View File

@@ -1,21 +1,33 @@
import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../../db.js';
import type { Config } from '../../config.js';
import type { import type {
Agent, Agent,
ErrorReason,
Message, Message,
MessageMetadata,
Project, Project,
Session, Session,
ToolCall,
UserStreamFrame, UserStreamFrame,
} from '../../types/api.js'; } from '../../types/api.js';
import { ALL_TOOLS } from '../tools.js';
import { resolveProjectRoot } from '../path_guard.js'; import { resolveProjectRoot } from '../path_guard.js';
import { maybeAutoNameChat } from '../auto_name.js'; import { maybeAutoNameChat } from '../auto_name.js';
import { rewriteSearchQuery } from '../task-search-rewrite.js'; import { rewriteSearchQuery } from '../task-search-rewrite.js';
import { getAgentById } from '../agents.js'; import { getAgentById } from '../agents.js';
import * as compaction from '../compaction.js'; import * as compaction from '../compaction.js';
import { resolveTurnConfig } from './turn-config.js'; import type { Broker } from '../broker.js';
import { decideStep, decidePostToolAction } from './step-decision.js'; import { resolveToolBudget } from './budget.js';
import { import {
detectDoomLoop,
} from './sentinels.js';
import {
detectMistakePattern,
freshMistakeState, freshMistakeState,
recordStep, recordStep,
MISTAKE_RECOVERY_NOTE, MISTAKE_RECOVERY_NOTE,
type MistakeState,
} from './mistake-tracker.js'; } from './mistake-tracker.js';
import { import {
buildMessagesPayload, buildMessagesPayload,
@@ -23,19 +35,13 @@ import {
} from './payload.js'; } from './payload.js';
import { import {
finalizeCompletion, finalizeCompletion,
finalizeEmpty,
handleAbortOrError, handleAbortOrError,
} from './error-handler.js'; } from './error-handler.js';
import { import {
executeStreamPhase, executeStreamPhase,
} from './stream-phase.js'; } from './stream-phase.js';
import { executeToolPhase, type ToolPhaseResult } from './tool-phase.js'; import { executeToolPhase, type ToolPhaseResult } from './tool-phase.js';
import type { import type { StreamPhaseState } from './types.js';
InferenceContext,
StreamPhaseState,
StreamResult,
TurnArgs,
} from './types.js';
import { import {
runCapHitSummary, runCapHitSummary,
runDoomLoopSummary, runDoomLoopSummary,
@@ -43,24 +49,121 @@ import {
insertMistakeRecoverySentinel, insertMistakeRecoverySentinel,
} from './sentinel-summaries.js'; } from './sentinel-summaries.js';
// P5: MAX_STEPS moved to ./turn-config.ts (with resolveTurnConfig). Re-exported // v1.14.0: hard ceiling on the number of stream-and-tool iterations per
// here so the public surface (index.ts → './turn.js') is unchanged. // user-message turn. Per-agent cap via agent.steps is the primary knob;
export { MAX_STEPS } from './turn-config.js'; // 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;
// v1.12.4: re-exported so external callers (tests, future consumers) keep // v1.12.4: re-exported so external callers (tests, future consumers) keep
// importing from services/inference.js as the public surface. // importing from services/inference.js as the public surface.
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js'; export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
export { buildMessagesPayload } from './payload.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 // v1.12.4: payload assembly extracted to ./inference/payload.ts (tests
// import buildMessagesPayload from this module, so a re-export below // import buildMessagesPayload from this module, so a re-export below
// preserves the public surface). Stream + tool phases extracted to // preserves the public surface). Stream + tool phases extracted to
// ./inference/stream-phase.ts and ./inference/tool-phase.ts. // ./inference/stream-phase.ts and ./inference/tool-phase.ts.
//
// P5: the shared pipeline types (InferenceFrame / FramePublisher / export interface StreamResult {
// InferenceContext / StreamResult / TurnArgs) moved to ./types.js to break the finishReason: string | null;
// turn.ts type-hub-and-leaf near-cycle. They are re-exported from there via content: string;
// inference/index.ts for the public surface. 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;
}
export async function runAssistantTurn( export async function runAssistantTurn(
@@ -81,13 +184,17 @@ export async function runAssistantTurn(
const agent = session.agent_id const agent = session.agent_id
? await getAgentById(project.path, session.agent_id) ? await getAgentById(project.path, session.agent_id)
: null; : null;
// P5: pure per-turn config (budget + cap math + text-only flag). const budget = resolveToolBudget(agent);
const { effectiveCap, budget, isTextOnly } = resolveTurnConfig(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);
// steps: 0 special case — model responds text-only. The while loop would // steps: 0 special case — model responds text-only. The while loop would
// never enter (effectiveCap === 0), so we handle it explicitly before the // never enter (effectiveCap === 0), so we handle it explicitly before the
// loop. The model always gets at least one chance to respond with text. // loop. The model always gets at least one chance to respond with text.
if (isTextOnly) { if (effectiveCap === 0) {
const loaded = await loadContext(ctx.sql, sessionId, chatId); const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) { if (loaded) {
await runTextOnlyTurn(ctx, args, loaded.session, loaded.project, loaded.history, agent); await runTextOnlyTurn(ctx, args, loaded.session, loaded.project, loaded.history, agent);
@@ -107,18 +214,20 @@ export async function runAssistantTurn(
let pendingRecoveryNote: string | undefined = args.pendingRecoveryNote; let pendingRecoveryNote: string | undefined = args.pendingRecoveryNote;
while (stepNumber < effectiveCap) { while (stepNumber < effectiveCap) {
// ---- top-of-loop gate: doom-loop, then budget (pure decision) ---- // ---- doom-loop check (moved from top-of-function) ----
const decision = decideStep({ recentToolCalls, toolsUsed, budget }); const loop = detectDoomLoop(recentToolCalls);
if (decision.kind === 'doom') { if (loop) {
// Need fresh history for the summary. // Need fresh history for the summary.
const loaded = await loadContext(ctx.sql, sessionId, chatId); const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) { if (loaded) {
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal }; const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
await runDoomLoopSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, decision.loop); await runDoomLoopSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, loop);
} }
break; break;
} }
if (decision.kind === 'budget') {
// ---- budget check (moved from top-of-function) ----
if (toolsUsed >= budget) {
const loaded = await loadContext(ctx.sql, sessionId, chatId); const loaded = await loadContext(ctx.sql, sessionId, chatId);
if (loaded) { if (loaded) {
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal }; const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal };
@@ -126,7 +235,6 @@ export async function runAssistantTurn(
} }
break; break;
} }
// decision.kind === 'stream' → proceed with compaction + stream + tools.
// ---- compaction check ---- // ---- compaction check ----
// v1.11: if the prior turn flagged this chat for compaction, run it // v1.11: if the prior turn flagged this chat for compaction, run it
@@ -237,17 +345,19 @@ export async function runAssistantTurn(
recordStep(mistakeTracker, o); recordStep(mistakeTracker, o);
} }
// v#12 MistakeTracker: post-tool decision (pure). 'stop' = the tool phase if (toolPhaseResult.action !== 'continue') {
// returned a non-'continue' action ('paused' for user input, or // 'paused' (user input) or 'synthesis_done' — stop the loop. The turn is
// 'synthesis_done') — neither a nudge nor an escalate would change the // already ending, so neither a nudge nor an escalate would change the
// control flow, so the mistake check is skipped. On 'continue' the // control flow; we skip the mistake decision here.
// 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; break;
} }
if (post === 'nudge') {
// 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') {
// Soft intervention: inject model-facing recovery guidance into the NEXT // Soft intervention: inject model-facing recovery guidance into the NEXT
// step's payload, drop a UI sentinel, bump nudges, reset the streak, and // 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 // continue. The note is consumed (and cleared) at the top of the next
@@ -269,16 +379,23 @@ export async function runAssistantTurn(
assistantMessageId = toolPhaseResult.nextAssistantId!; assistantMessageId = toolPhaseResult.nextAssistantId!;
continue; continue;
} }
if (post === 'escalate') { if (mistake === 'escalate') {
// The nudge didn't break the failure run — stop the turn (cap-hit-style) // 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 // to avoid burning the whole step budget on heterogeneous failures. The
// next assistant row is still 'streaming'; finalize it as an empty // next assistant row is still 'streaming'; finalize it as a short note so
// complete row so the slot doesn't dangle, then drop the escalate // the slot doesn't dangle, then drop the escalate sentinel.
// sentinel.
const failureKinds = [...mistakeTracker.run]; const failureKinds = [...mistakeTracker.run];
assistantMessageId = toolPhaseResult.nextAssistantId!; assistantMessageId = toolPhaseResult.nextAssistantId!;
const escalateArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, mistakeTracker, signal }; await ctx.sql`
await finalizeEmpty(ctx, escalateArgs); 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,
});
await insertMistakeRecoverySentinel(ctx, sessionId, chatId, { await insertMistakeRecoverySentinel(ctx, sessionId, chatId, {
failureKinds, failureKinds,
count: failureKinds.length, count: failureKinds.length,
@@ -445,3 +562,4 @@ export function createInferenceRunner(
}; };
} }
export const _toolNames = ALL_TOOLS.map((t) => t.name);

View File

@@ -1,25 +1,6 @@
// v1.12.4: shared inter-phase types/constants for the extracted phase files. // 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 // Lives here so stream-phase, tool-phase, and the summary functions still in
// inference.ts can all reference the same definitions without circular imports. // 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 { export interface StreamPhaseState {
accumulated: string; accumulated: string;
@@ -30,100 +11,3 @@ export interface StreamPhaseState {
// executeStreamPhase, runCapHitSummary, and runDoomLoopSummary — every site // executeStreamPhase, runCapHitSummary, and runDoomLoopSummary — every site
// that does a debounced content flush during streaming. // that does a debounced content flush during streaming.
export const DB_FLUSH_INTERVAL_MS = 500; 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;
}

View File

@@ -1,15 +0,0 @@
// 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';

View File

@@ -18,6 +18,8 @@
export interface ModelContext { export interface ModelContext {
n_ctx: number; n_ctx: number;
total_slots: number;
fetched_at: number;
} }
const NEGATIVE_TTL_MS = 60_000; const NEGATIVE_TTL_MS = 60_000;
@@ -75,13 +77,19 @@ export async function getModelContext(model: string): Promise<ModelContext | nul
} }
const body = (await res.json()) as { const body = (await res.json()) as {
default_generation_settings?: { n_ctx?: number }; default_generation_settings?: { n_ctx?: number };
total_slots?: number;
}; };
const n_ctx = body?.default_generation_settings?.n_ctx; const n_ctx = body?.default_generation_settings?.n_ctx;
if (typeof n_ctx !== 'number' || n_ctx <= 0) { if (typeof n_ctx !== 'number' || n_ctx <= 0) {
negativeCache.set(model, Date.now()); negativeCache.set(model, Date.now());
return null; return null;
} }
const entry: ModelContext = { n_ctx }; // total_slots is informational; default to 1 if missing rather than
// reject the whole response. Most local llama-swap setups run a
// single slot anyway.
const total_slots =
typeof body?.total_slots === 'number' && body.total_slots > 0 ? body.total_slots : 1;
const entry: ModelContext = { n_ctx, total_slots, fetched_at: Date.now() };
positiveCache.set(model, entry); positiveCache.set(model, entry);
// Clear any stale negative entry so a future query sees the positive // Clear any stale negative entry so a future query sees the positive
// hit cleanly (otherwise the negative TTL never expires from the map). // hit cleanly (otherwise the negative TTL never expires from the map).

View File

@@ -3,7 +3,7 @@
// stored in the session's workspace_panes envelope (WorkspaceState.tabNumbers), // 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 // 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. // can import the executor directly without dragging in the whole tool registry.
// Registered in tools.ts ALL_TOOLS. // Registered in tools.ts ALL_TOOLS + READ_ONLY_TOOL_NAMES.
import { z } from 'zod'; import { z } from 'zod';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';

View File

@@ -1,7 +1,6 @@
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import { join, isAbsolute, basename } from 'node:path'; import { join, isAbsolute, basename } from 'node:path';
import { pathGuard, PathScopeError } from './path_guard.js'; 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/<group>/<skill>/ // Batch 9.6: read-only skill library. Folders under /data/skills/<group>/<skill>/
// contain a SKILL.md with YAML frontmatter (name + description) and a markdown // contain a SKILL.md with YAML frontmatter (name + description) and a markdown
@@ -45,6 +44,13 @@ interface Frontmatter {
description?: string; 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 { function parseFrontmatter(yaml: string): Frontmatter {
const fm: Frontmatter = {}; const fm: Frontmatter = {};
for (const raw of yaml.split('\n')) { for (const raw of yaml.split('\n')) {

View File

@@ -24,12 +24,12 @@ import { TOOLS_BY_NAME } from './tools.js';
import { streamCompletion } from './inference/stream-phase.js'; import { streamCompletion } from './inference/stream-phase.js';
import { SYNTHESIS_SYSTEM_PROMPT } from './synthesisPrompt.js'; import { SYNTHESIS_SYSTEM_PROMPT } from './synthesisPrompt.js';
import { insertParts } from './inference/parts.js'; import { insertParts } from './inference/parts.js';
import { finalizeStreamedRow } from './inference/error-handler.js'; import * as modelContext from './model-context.js';
import { readTruncation } from './truncate.js'; import { readTruncation } from './truncate.js';
import type { Session } from '../types/api.js'; import type { Session } from '../types/api.js';
import type { OpenAiMessage } from './inference/payload.js'; import type { OpenAiMessage } from './inference/payload.js';
import type { InferenceContext, TurnArgs } from './inference/types.js'; import type { InferenceContext, TurnArgs } from './inference/turn.js';
export const SYNTHESIS_TOOLS: ReadonlySet<string> = new Set([ export const SYNTHESIS_TOOLS: ReadonlySet<string> = new Set([
'get_codebase_overview', 'get_codebase_overview',
@@ -192,28 +192,44 @@ export async function runSynthesisPass(p: SynthesisParams): Promise<boolean> {
combinedSignal, combinedSignal,
); );
// P5: the n_ctx lookup + complete UPDATE + message_complete frame are the const mctx = await modelContext.getModelContext(p.session.model);
// shared success-finalize atom (finalizeStreamedRow). beforeComplete writes const nCtx = mctx?.n_ctx ?? null;
// the kind='synthesis' part in the original order (UPDATE → insertParts → const [updated] = await p.ctx.sql<
// message_complete), preserving timing exactly. {
await finalizeStreamedRow(p.ctx, { tokens_used: number | null;
sessionId: p.args.sessionId, ctx_used: number | null;
chatId: p.args.chatId, ctx_max: number | null;
messageId: synthMessageId, 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,
model: p.session.model, 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({ p.ctx.publishUser({
type: 'chat_status', type: 'chat_status',

View File

@@ -0,0 +1,24 @@
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<string> {
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,
});
}

View File

@@ -0,0 +1,22 @@
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<string[]> {
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);
}

Some files were not shown because too many files have changed in this diff Show More