Compare commits
7 Commits
v1.13.15-c
...
v1.14.0-ou
| Author | SHA1 | Date | |
|---|---|---|---|
| f4a97808ad | |||
| 211e903620 | |||
| ad45b28250 | |||
| 1a889dcde3 | |||
| b52c5df705 | |||
| 2e1a81de72 | |||
| 61308cf17c |
@@ -28,6 +28,13 @@
|
||||
- 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.
|
||||
|
||||
## Output format
|
||||
|
||||
- Stay in Markdown by default for every reply, short or long.
|
||||
- Switch to a self-contained `<!DOCTYPE html>...</html>` artifact only when the user explicitly asks (e.g. "render this as HTML", "make me a dashboard", "build an interactive diagram"). Detection is opportunistic — the BooChat backend tags the assistant message as an HTML artifact, opens it in a sandboxed pane, and offers Download. Do not emit HTML unprompted; long Markdown is the right answer for most explanatory output.
|
||||
- When asked to produce HTML, avoid generic AI aesthetics: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font. Prefer interactive controls (sliders / knobs / SVG / side-by-side diffs) over passive prose-in-HTML. Pattern reference: claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html (Thariq Shihipar, May 2026).
|
||||
- The HTML artifact is rendered in a sandboxed iframe with `connect-src 'none'` — `fetch()`, WebSockets, and tracking pixels do not work. All logic must be client-side.
|
||||
|
||||
## Convention: rules vs recipes
|
||||
|
||||
Always-true rules (process discipline, refusals, behavior contracts) live here in `BOOCHAT.md` — and in `BOOCODER.md` / `CLAUDE.md` per their scopes — where they are 100% present in every turn. On-demand recipes (specific procedures, scaffolds, checklists) live in `/data/skills/` and invoke roughly 6% of the time in clean multi-turn flow (Codeminer42 measurement, 2026). Don't file workflow rules as skills — they silently misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for the canonical conventions.
|
||||
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,6 +2,30 @@
|
||||
|
||||
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.
|
||||
|
||||
## v1.14.0-outer-loop — 2026-05-23
|
||||
|
||||
Converts the inference engine's ad-hoc `executeToolPhase → runAssistantTurn` recursion into an explicit `while` loop with a configurable step cap. A step is one stream-and-tool-execute iteration; the loop terminates on non-tool finish, step-cap hit, doom-loop, budget exhaustion, abort, or synthesis success. `MAX_STEPS = 200` is the hard ceiling (4x the old effective limit from budget); per-agent `steps:` field in AGENTS.md frontmatter sets tighter caps (Refactorer: 5, Architect: 20, others: unset = bounded only by MAX_STEPS). `executeToolPhase` no longer recurses — returns a `ToolPhaseResult` struct (`action: 'continue' | 'paused' | 'synthesis_done'`) so the caller (the while loop) decides whether to continue or break. `steps: 0` is handled as "no tool calls allowed" — one text-only stream phase, tool calls ignored with a warn log. Step-cap hits produce a sentinel summary (reuses `cap_hit` kind so `CapHitSentinel.tsx` renders it without frontend changes; text distinguishes "Step limit reached" from "Tool budget exhausted"). Doom-loop check migrated from pre-recursion position to top of loop body — same predicate (`detectDoomLoop`), same threshold (3 identical calls), `break` instead of `return`. `step_start` parts are in the schema CHECK but not emitted as message_parts in v1.14 — writing to the assistant message before the stream phase creates a sequence-0 collision with `partsFromAssistantMessage`; a structured log line is emitted instead. Adversarial review caught the collision pre-deploy. 332/332 server tests passing; no frontend changes. Pairs with `v1.13.20-drop-legacy-cols` (parts is now the sole source of truth, and this batch's loop operates entirely through parts).
|
||||
|
||||
## v1.13.20-drop-legacy-cols — 2026-05-23
|
||||
|
||||
Final phase of the v1.13.0 strangler-fig migration. Removes the dual-write into `messages.tool_calls` / `messages.tool_results` JSON columns and drops the columns themselves; `message_parts` is now the only source of truth for tool-call and tool-result data. 10 dual-write sites stripped (5 in `tool-phase.ts`, 2 in `routes/skills.ts`, 2 in `routes/messages.ts`, 1 in `routes/chats.ts` fork-clone) — recon's grep-driven inventory caught 2 sites beyond the original v1.13.2 roadmap count. `messages_with_parts` view simplified to parts-only subselects (COALESCE fallbacks gone) and rewritten via `CREATE OR REPLACE VIEW` BEFORE the column DROP since Postgres rejects column-drop on view-referenced cols. Adversarial review caught a runtime bug the green test suite missed: `chats.ts:/api/chats/:id/discard_stale` had a `RETURNING ... tool_calls, tool_results, ...` clause referencing the dropped columns; would have crashed on every 60s-no-token-activity recovery in production. Fixed by switching to two-step UPDATE-then-SELECT-from-view so the response keeps the parts-synthesized fields. `Message` API type retains `tool_calls?` / `tool_results?` fields (override on the original v1.13.2 plan) — the view continues to populate them from parts, so the wire shape is unchanged and the frontend needs no updates. v1.12.1 cleanup block (`DROP CONSTRAINT messages_status_check`/`messages_role_check`) removed — those one-shots have done their work. `tool_cost_stats.test.ts` had a direct `INSERT INTO messages` touching the legacy columns that wasn't in the roadmap's inventory; rewritten to parts-table inserts and confirmed semantically faithful. 339/339 server tests passing including the 7 DB-integration tests (live-DB applied the schema migration and ran the parts-only view end-to-end). Pairs with `v1.13.0-ai-sdk-v6` (which introduced the dual-write) and `v1.13.1-B` (which moved the read path to `messages_with_parts`); umbrella `v1.13` tag ships on the same commit.
|
||||
|
||||
## v1.13.19-html-artifact-panes — 2026-05-23
|
||||
|
||||
Pane-based artifact viewer with on-request HTML support. Every assistant message gets an "Open in pane" icon button (`PanelRightOpen`, mobile 44px tap-target) in `MessageBubble`'s ActionRow; click opens the message in the workspace splitter as either a Markdown pane (Copy raw source + Download `.md`) or an HTML pane (Download `.html` only, no Copy). The HTML path triggers when the model emits a self-contained `<!DOCTYPE html>` or fenced ` ```html` artifact (opt-in only — `BOOCHAT.md` rule says Markdown is default at every length; HTML only on explicit user request like "render this as HTML"). Backend detection in `finalizeCompletion` (`error-handler.ts`) writes a new `message_parts.kind='html_artifact'` row with payload `{html_content, char_count, title}` (`<title>` → first `<h1>` → first 80 chars of inner text). Schema CHECK extended via the v1.13.13 drop-and-re-add pattern. 1MB cap is graceful — over-cap artifacts skip the part write and plain content lands; decision factored into a pure `decideHtmlArtifactWrite` helper so the warn-and-skip branch is unit-testable without mocking the full InferenceContext. Pane state is reference-only (`{chat_id, message_id, title}`) — content is fetched on mount, keeping `sessions.workspace_panes` jsonb small and avoiding 1MB blobs riding the `session_workspace_updated` WS frame. New `services/artifacts.ts` ships slug derivation (Markdown: first `#` heading → first 6 words; HTML: `<title>` → `<h1>` → inner text) and write helpers that realpath the artifacts directory after `mkdir` to close a symlink-escape gap (`assertArtifactsDirSafe`). `routes/artifacts.ts` exposes POST `/api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html` (writes to `<projectRoot>/.boocode/artifacts/<slug>-<ts>.<ext>`) plus GET `/api/projects/:project_id/artifacts/:filename` with `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: sandbox` defense-in-depth on LLM-served HTML. iframe sandbox locks to `allow-scripts allow-clipboard-write allow-downloads` with no `allow-same-origin` and uses `srcDoc` (not `src`) for opaque-origin isolation. Frontend extracts `MarkdownRenderer.tsx` from `MessageBubble`'s inline `MarkdownBody` for reuse; `MarkdownArtifactPane.tsx` / `HtmlArtifactPane.tsx` render with loading + error states. 404-vs-real-error discrimination in `openInPane`: a real network/500 failure toasts and bails instead of silently masquerading as a Markdown pane. 31 new server unit tests (slug derivation, detection positive/negative, write helpers, symlink-escape, 1MB cap, real-symlink filesystem test); 332/332 server tests passing; `tsc -p apps/web/tsconfig.app.json --noEmit` clean; `pnpm -C apps/web build` green. Smoke deferred to first deploy.
|
||||
|
||||
## v1.13.18-codecontext-file-path — 2026-05-22
|
||||
|
||||
Fix: four codecontext wrappers (`get_file_analysis`, `get_symbol_info`, `get_dependencies`, `get_semantic_neighborhoods`) forwarded `file_path` to the sidecar unchanged, but the sidecar's index is keyed on absolute paths — every relative path from the model returned "File not found in graph" (three back-to-back failures in one chat at 17:56 UTC, ~48 s of wasted tool budget). New `resolveProjectPath` helper in `codecontext_client.ts:64-89` realpath-resolves the candidate, applies the same escape check as the existing `target_dir` resolver (matching the error template byte-for-byte except the field name), and falls through with the normalised absolute on ENOENT so the sidecar issues its own self-correctable "File not found" error. Wired into `callCodecontext` once at the args-spread site — all four wrappers benefit without per-wrapper edits. `.trim()` added to all four `file_path` Zod schemas to absorb trailing newlines from model output. Adversarial review caught a P2 escape-bypass: an absolute path with `..` (e.g. `<projectRoot>/../etc/passwd`) that ENOENTs at realpath would slip through the literal prefix-check, fixed by `resolve()`-normalising the absolute branch too. 9 new test cases in `codecontext_client.test.ts` (7 spec scenarios + symlink-out-of-root + absolute-with-`..` ENOENT) plus a 1-line update in `codecontext_tools.test.ts` asserting the new resolved-absolute contract. Pairs with `v1.13.17-cross-repo-reads` — both harden path traversal, but v1.13.18 stays inside the project root while v1.13.17 widens access outside it.
|
||||
|
||||
## v1.13.17-cross-repo-reads — 2026-05-22
|
||||
|
||||
On-demand read access to paths outside the session's primary project root. Closes the dead-end where `pathGuard` rejected every cross-repo read with no recovery path. New `request_read_access(path, reason)` tool emits an `ask_user_input`-style pause; user picks Allow/Deny via inline chips in `RequestReadAccessCard.tsx`; on Allow, the new `POST /api/chats/:id/grant_read_access` endpoint re-resolves the grant root and appends to `sessions.allowed_read_paths` (new `TEXT[]` column, default empty). Grant unit per design D1 = nearest registered `projects.path` ancestor → else nearest repo-shaped ancestor (`.git/` / `package.json` / `go.mod` / `Cargo.toml`) under `PROJECT_ROOT_WHITELIST` → else refuse without prompting. `pathGuard` extended with an optional `extraRoots` argument threaded from `session.allowed_read_paths` through `executeToolCall` to the four filesystem tools (view_file, list_dir, grep, find_files); `view_file` re-anchors the secret-guard check on `basename(real)` whenever the path resolved via a grant root so `.env` / `id_rsa*` deny still fires across grants. `grant_resolver.ts`'s ancestor walk checks the whitelist invariant on every iteration (not just final parent) so a symlinked input can't escape mid-walk. PATCH `/api/sessions/:id` exposes `allowed_read_paths` only for revocation: zod refines paths to absolute + no traversal markers, and a runtime subset guard (`findUnauthorizedAdditions`) rejects any entry not already present in the row, so a malicious `curl -X PATCH -d '{"allowed_read_paths":["/etc"]}'` 400s instead of bypassing the grant flow. Settings pane gains a per-session revoke list; archiving the session clears grants implicitly. 11 grant_resolver tests pin the symlink-escape-mid-walk guard (Sam's checkpoint-1 ask) and the nearest-project disambiguation; 8 path_guard tests cover extraRoots traversal; 8 sessions PATCH tests cover the subset guard including the `/etc` bypass attempt. Pairs with `v1.13.16-xml-parser` (model now both self-recovers from a wrong tool name AND from a refused path).
|
||||
|
||||
## v1.13.16-xml-parser — 2026-05-22
|
||||
|
||||
Two-part fix for the model-emitted XML drift the v1.13.15 investigation surfaced. **Parser extension:** `xml-parser.ts` now recognizes the Anthropic `<invoke name="…"><parameter name="…">…</parameter></invoke>` shape alongside the existing Qwen/Hermes `<tool_call><function=…>…</function></tool_call>` shape. qwen3.6-35b-a3b-mxfp4 drifts to the Anthropic format when prompted as an Architect-style agent (Claude Code documentation in its pre-training corpus). Both formats route through the same synthetic-id `xml_call_${idx}` ToolCall path. The existing Qwen parser was tightened to tolerate whitespace around `=` (`<function = name>` shape) so a stray space doesn't get absorbed into the function name. **Unknown-tool recovery hint:** new `tool-suggestions.ts` exports `levenshtein()` + `suggestToolName()` + `formatUnknownToolError()`. When the dispatcher (`tool-phase.ts:executeToolCall`) receives an unknown tool name, the error returned to the model includes a "Did you mean: X?" hint based on Levenshtein distance ≤3 or substring match against `Object.keys(TOOLS_BY_NAME)`. Targets the qwen3.6 drift to `read_file` → suggest `view_file`. Test coverage in `xml-parser.test.ts` (46 tests, all green) covers both parsers, the partial-opener detector for both flavors, the unified extraction helper, and the new error formatter.
|
||||
|
||||
## v1.13.15-codecontext-synth — 2026-05-22
|
||||
|
||||
Forced second-inference synthesis pass for codecontext overview-class tools (`get_codebase_overview`, `get_framework_analysis`, `get_semantic_neighborhoods`). After the tool result lands, the pipeline expands the truncated head via in-process `readTruncation`, extracts referenced file paths from the full content, auto-fetches top-N files + project docs (BOOCHAT.md, AGENTS.md, *roadmap*.md, CONTEXT.md) under a 32k-token budget with explicit drop-priority order, then streams a synthesis turn that replaces the recursive `runAssistantTurn`. The 32k truncated head still ships to the synth model (token-budget contract preserved); the expansion is reference-extraction-only. Falls through to recursion on timeout (90s), model error, or non-2xx; user-abort marks the synth message `status='failed'` and re-throws (the outer abort handler operates on the parent turn's message, not the new synth row — without explicit marking, the row would sit `streaming` until the 5-min sweeper, tripping the 60s stale-stream banner). Adds `'synthesis'` to `message_parts.kind` CHECK constraint via `DROP CONSTRAINT IF EXISTS` + `DO $$ pg_constraint` idempotency-guarded re-add. Smokes #1, #2, #6 all clean; smokes #3–#5 are content-quality checks for UI review.
|
||||
|
||||
@@ -46,7 +46,7 @@ Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `app
|
||||
- **Zod** for request validation and config parsing.
|
||||
|
||||
Key services:
|
||||
- **`services/inference/`** — Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js` explicitly (NodeNext doesn't honor directory-index resolution). Layout: `turn.ts` (runAssistantTurn / runInference / createInferenceRunner; exports `InferenceFrame`, `InferenceContext`, `TurnArgs`, `StreamResult`), `stream-phase.ts` (streamCompletion as a v1.13.1-A AI SDK adapter + executeStreamPhase), `provider.ts` (`upstreamModel(baseURL, modelId)` wrapping `createOpenAICompatible` against llama-swap), `tool-phase.ts` (executeToolPhase; value back-edges into turn.ts for the runAssistantTurn recursion — cycle safe because deref at call time, not module top-level), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + their sentinel inserters), `error-handler.ts` (handleAbortOrError, finalizeCompletion), `payload.ts` (buildMessagesPayload, loadContext, maybeFlagForCompaction, `OpenAiMessage`), `sentinels.ts` (`detectDoomLoop`, `DOOM_LOOP_THRESHOLD`, sentinel predicates), `budget.ts` (resolveToolBudget), `xml-parser.ts` (qwen3.6 XML tool-call fallback — KEEP, AI SDK doesn't handle inline-XML tool calls), `parts.ts` (v1.13.0 dual-write helpers: `partsFromAssistantMessage`, `partsFromToolMessage`, `insertParts`), `prune.ts` (v1.13.4 two-tier compaction; `selectPruneTargets` is the pure decision helper), `types.ts` (`StreamPhaseState`, `DB_FLUSH_INTERVAL_MS`). **`TurnArgs`** is the per-turn state envelope threaded through the `executeToolPhase → runAssistantTurn` recursion; reset in `runInference` at user-message boundary. Add new per-turn state to `TurnArgs`, not module-level closures.
|
||||
- **`services/inference/`** — Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js` explicitly (NodeNext doesn't honor directory-index resolution). Layout: `turn.ts` (runAssistantTurn / runInference / createInferenceRunner; exports `InferenceFrame`, `InferenceContext`, `TurnArgs`, `StreamResult`, `MAX_STEPS`), `stream-phase.ts` (streamCompletion as a v1.13.1-A AI SDK adapter + executeStreamPhase), `provider.ts` (`upstreamModel(baseURL, modelId)` wrapping `createOpenAICompatible` against llama-swap), `tool-phase.ts` (executeToolPhase → returns `ToolPhaseResult`; no longer recurses into runAssistantTurn — v1.14.0 converted the recursion to an explicit while loop in turn.ts), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + runStepCapSummary + their sentinel inserters), `error-handler.ts` (handleAbortOrError, finalizeCompletion), `payload.ts` (buildMessagesPayload, loadContext, maybeFlagForCompaction, `OpenAiMessage`), `sentinels.ts` (`detectDoomLoop`, `DOOM_LOOP_THRESHOLD`, sentinel predicates), `budget.ts` (resolveToolBudget), `xml-parser.ts` (qwen3.6 XML tool-call fallback — KEEP, AI SDK doesn't handle inline-XML tool calls), `parts.ts` (parts-table write helpers: `partsFromAssistantMessage`, `partsFromToolMessage`, `insertParts` — v1.13.20 made parts the sole source of truth), `prune.ts` (v1.13.4 two-tier compaction; `selectPruneTargets` is the pure decision helper), `types.ts` (`StreamPhaseState`, `DB_FLUSH_INTERVAL_MS`). **`TurnArgs`** is the per-turn state envelope populated from loop locals each iteration; reset in `runInference` at user-message boundary. The outer loop in `runAssistantTurn` (v1.14.0) runs `while (stepNumber < effectiveCap)` where `effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS=200)`. Per-agent `steps:` field in AGENTS.md frontmatter. `steps: 0` means text-only (no tool execution). Step-cap hit writes a `cap_hit` sentinel so `CapHitSentinel.tsx` renders it.
|
||||
- **AI SDK v6 streamCompletion adapter** (v1.13.1-A; `services/inference/stream-phase.ts`). `streamText` is the underlying call; the BooCode layer above (executeStreamPhase, finalize, dual-write) is shape-preserved via an adapter. Five gotchas the LSP/test suite won't catch:
|
||||
- **Abort signals are swallowed.** `streamText`'s `fullStream` iterator exits cleanly when `abortSignal` fires — no throw. Post-iteration `if (signal?.aborted) throw <AbortError>` is required; without it the row finalizes as `complete` instead of `cancelled`. Comment in stream-phase.ts pins this; don't refactor it away.
|
||||
- **Usage lands only at stream end** via `await result.usage` (`inputTokens` / `outputTokens` v6 names → mapped to `promptTokens` / `completionTokens` for the existing onUsage callback). Mid-stream live tok/s is gone vs v1.12.2; ChatThroughput shows a single value at stream end.
|
||||
@@ -63,7 +63,7 @@ Key services:
|
||||
- **`services/compaction.ts`** + **`services/model-context.ts`** — v1.11.0 anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself on each compaction). Triggered when `chats.needs_compaction` is set after an inference turn exceeds `usable(ctx_max) = floor(0.85 × ctx_max)` (v1.13.9 opencode-pattern early trigger; was `ctx_max - 20k` pre-v1.13.9, which gave only 7.6% headroom at 262k and 0 budget for ≤20k contexts). **`ctx_max` comes from `model-context.getModelContext()` which fetches `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx` (the stream completion's `timings` doesn't carry n_ctx; that read was dead code until v1.11.3 ripped it out). First inferences after a boocode boot may have `ctx_max=NULL` if llama-swap hasn't loaded the model yet; negative cache TTL is 60s, recovers on next turn. v1.13.6: `buildHeadPayload` embeds `reasoning_parts` as a `<reasoning>...</reasoning>` prose prefix on the assistant `content` (OpenAI wire shape has no structured reasoning field; the summarizer reads text). Standalone tag when content is empty (tool-call-only turn). `buildHeadPayload` + `OpenAiMessage` exported for test access — keep them exported.
|
||||
- **`services/system-prompt.ts`** — `buildSystemPrompt` is the string-returning shim; `buildSystemPromptWithFingerprint` is the canonical impl returning `{prompt, fingerprint, drift}`. v1.13.8 instrumentation: SHA-256 of the assembled prefix is logged per `buildMessagesPayload` call (msg `prefix-fingerprint`, level=info); a `Map<sessionId, lastHash>` observer fires `prefix-drift` (level=warn) on hash change with a field-level `changed_inputs` diff. Smoke proved the prefix is byte-stable across turns in steady-state — the originally-planned `system_prompt_cache` DB table was dropped as redundant against the v1.12.0 input-layer mtime caches (BOOCHAT.md here + AGENTS.md global+per-project in `agents.ts:safeStat`).
|
||||
- **`services/inference/budget.ts`** — tool-call budgets: `BUDGET_READ_ONLY = 30`, `BUDGET_NON_READ_ONLY = 10` (forward-looking; no write tools yet), `BUDGET_NO_AGENT = 30` (v1.13.7; was 15 — every tool in `ALL_TOOLS` is read-only today, so no-agent mode shares the read-only-agent cap). Per-agent `max_tool_calls` from AGENTS.md frontmatter overrides.
|
||||
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. `COALESCE`s parts-table rows over the legacy JSON columns, so pre-v1.13.0 history still resolves. Writes still target `messages`; the v1.13.0 dual-write into `message_parts` keeps both halves in sync. New payload-assembly code must use the view — calling `messages.tool_calls` directly will miss anything written post-v1.13.1-B if the JSON column ever drifts (and dual-write makes that easy to miss). Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`.
|
||||
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. v1.13.20 dropped the legacy `messages.tool_calls` / `messages.tool_results` JSON columns; the view now reads parts-only subselects. Writes target `message_parts` exclusively via `insertParts` (or via the helpers `partsFromAssistantMessage` / `partsFromToolMessage`). The `Message` wire type still carries `tool_calls?` / `tool_results?` because the view synthesizes them from parts — frontend reads are unchanged. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returning `id` followed by SELECT from the view — RETURNING off the bare `messages` table no longer carries the tool fields.
|
||||
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
||||
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
||||
|
||||
@@ -118,6 +118,8 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
||||
|
||||
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
|
||||
- Per-batch docs live under `openspec/changes/<slug>/{proposal,tasks,design}.md`. Already-shipped batches are snapshots in `openspec/changes/archived/`. New batches follow the proposal+tasks shape; see `openspec/README.md` for the convention.
|
||||
- Tag naming: `vMAJOR.MINOR.PATCH-slug` (e.g. `v1.13.13-ws-publish`). Monotonic per minor — the slug describes the batch's content so the tag name alone is enough to recall what shipped. No letter suffixes (`-a`/`-b`), no pseudo-ranges (`v1.11.x`), no slug-only sub-versions sharing a number (`v1.13.15-tools` + `-openspec` + `-agentlint` — split into sequential patches instead).
|
||||
- `CHANGELOG.md` is the per-tag release log, most-recent on top. When a new tag is created, add a `## <tag> — <YYYY-MM-DD>` section with a 3–6 sentence paragraph summarizing what shipped, drawn from the commit body. Cross-reference other tags by name when the batch builds on, fixes, or pairs with prior work (e.g. "pairs with `v1.13.12-ws-schemas`", "fixed in `v1.13.5-stability-bundle`"). No nested bullets — one paragraph.
|
||||
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
||||
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||
|
||||
@@ -10,6 +10,7 @@ import { registerProjectRoutes } from './routes/projects.js';
|
||||
import { registerSessionRoutes } from './routes/sessions.js';
|
||||
import { registerSettingsRoutes } from './routes/settings.js';
|
||||
import { registerMessageRoutes } from './routes/messages.js';
|
||||
import { registerArtifactRoutes } from './routes/artifacts.js';
|
||||
import { registerChatRoutes } from './routes/chats.js';
|
||||
import { registerSidebarRoutes } from './routes/sidebar.js';
|
||||
import { registerWebSocket } from './routes/ws.js';
|
||||
@@ -115,7 +116,7 @@ async function main() {
|
||||
broker.publishUserFrame(user, frame as unknown as import('./types/ws-frames.js').WsFrame);
|
||||
}
|
||||
);
|
||||
registerMessageRoutes(app, sql, {
|
||||
registerMessageRoutes(app, sql, config, broker, {
|
||||
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||
},
|
||||
@@ -160,6 +161,7 @@ async function main() {
|
||||
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
|
||||
},
|
||||
});
|
||||
registerArtifactRoutes(app, sql);
|
||||
registerSkillsRoutes(app, sql, {
|
||||
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||
|
||||
70
apps/server/src/routes/__tests__/sessions.test.ts
Normal file
70
apps/server/src/routes/__tests__/sessions.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// v1.13.17-cross-repo-reads: PATCH /api/sessions/:id allowed_read_paths
|
||||
// subset enforcement. Sam flagged in the compliance review that without a
|
||||
// runtime subset check, a malicious client could POST
|
||||
// {"allowed_read_paths":["/etc"]}
|
||||
// and bypass the user-consent grant flow entirely. The findUnauthorizedAdditions
|
||||
// helper is the guard; tests pin its behavior so a regression in the helper
|
||||
// or its callsite (PATCH handler in sessions.ts) trips CI before prod.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { findUnauthorizedAdditions } from '../sessions.js';
|
||||
|
||||
describe('findUnauthorizedAdditions — PATCH allowed_read_paths subset guard', () => {
|
||||
it('returns no extras when requested is empty (full revoke)', () => {
|
||||
expect(findUnauthorizedAdditions(['/opt/forks/foo'], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no extras when requested is a strict subset (single revoke)', () => {
|
||||
expect(
|
||||
findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], ['/opt/forks/foo']),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no extras when requested equals prior (no-op PATCH)', () => {
|
||||
expect(
|
||||
findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], [
|
||||
'/opt/forks/foo',
|
||||
'/opt/forks/bar',
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags an unauthorized addition when prior is empty', () => {
|
||||
// The /etc bypass attempt — Sam's specific concern from the compliance
|
||||
// review. Without this guard, the PATCH would have written /etc directly.
|
||||
expect(findUnauthorizedAdditions([], ['/etc'])).toEqual(['/etc']);
|
||||
});
|
||||
|
||||
it('flags a single unauthorized addition mixed in with valid revokes', () => {
|
||||
// The attacker still tries to be sneaky: keep one legit entry, drop
|
||||
// another, slip in a new one. The guard catches the addition regardless
|
||||
// of how the rest of the array shrinks.
|
||||
expect(
|
||||
findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], [
|
||||
'/opt/forks/foo',
|
||||
'/var/secrets',
|
||||
]),
|
||||
).toEqual(['/var/secrets']);
|
||||
});
|
||||
|
||||
it('flags every unauthorized addition when there are multiple', () => {
|
||||
expect(
|
||||
findUnauthorizedAdditions(['/opt/forks/foo'], ['/opt/forks/foo', '/etc', '/root']),
|
||||
).toEqual(['/etc', '/root']);
|
||||
});
|
||||
|
||||
it('treats requested duplicates correctly (each occurrence checked)', () => {
|
||||
// If the requested array has duplicates of an unauthorized entry, the
|
||||
// guard surfaces each one. (A frontend would never send duplicates, but
|
||||
// the guard's contract shouldn't assume that.)
|
||||
expect(findUnauthorizedAdditions([], ['/etc', '/etc'])).toEqual(['/etc', '/etc']);
|
||||
});
|
||||
|
||||
it('does not flag entries present in prior even if requested has duplicates', () => {
|
||||
// Duplicate of an authorized entry passes — the membership check is by
|
||||
// value, not by index. Settled by Set.has semantics.
|
||||
expect(
|
||||
findUnauthorizedAdditions(['/opt/forks/foo'], ['/opt/forks/foo', '/opt/forks/foo']),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
231
apps/server/src/routes/artifacts.ts
Normal file
231
apps/server/src/routes/artifacts.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// v1.14.x-html-artifact-panes: artifact download routes.
|
||||
//
|
||||
// Two endpoints:
|
||||
// POST /api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html
|
||||
// Materialises a file under <projectRoot>/.boocode/artifacts/ and
|
||||
// returns {path, url}. fmt=html requires an existing html_artifact part
|
||||
// on the message (404 otherwise). fmt=md works on any assistant
|
||||
// message with non-empty content.
|
||||
//
|
||||
// GET /api/projects/:project_id/artifacts/:filename
|
||||
// Streams a previously-written artifact back with
|
||||
// Content-Disposition: attachment. Path-guarded to the project's
|
||||
// artifacts dir; rejects traversal attempts.
|
||||
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { realpath, stat } from 'node:fs/promises';
|
||||
import { resolve, sep, basename } from 'node:path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import {
|
||||
writeHtmlArtifact,
|
||||
writeMarkdownArtifact,
|
||||
type HtmlArtifactPayload,
|
||||
} from '../services/artifacts.js';
|
||||
|
||||
const DownloadQuery = z.object({
|
||||
fmt: z.enum(['md', 'html']),
|
||||
});
|
||||
|
||||
// Filename safety: alnum, dash, dot, underscore only. Blocks `..`, slashes,
|
||||
// nul bytes, etc. before we even touch the filesystem.
|
||||
const FilenameRe = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
interface ChatRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
project_id: string;
|
||||
project_path: string;
|
||||
}
|
||||
|
||||
interface MessageRow {
|
||||
id: string;
|
||||
chat_id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function registerArtifactRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
app.post<{
|
||||
Params: { id: string; msg_id: string };
|
||||
Querystring: { fmt?: string };
|
||||
}>(
|
||||
'/api/chats/:id/messages/:msg_id/artifacts/download',
|
||||
async (req, reply) => {
|
||||
const parsed = DownloadQuery.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid query', details: parsed.error.flatten() };
|
||||
}
|
||||
const { fmt } = parsed.data;
|
||||
const { id: chatId, msg_id: messageId } = req.params;
|
||||
|
||||
const chatRows = await sql<ChatRow[]>`
|
||||
SELECT c.id, c.session_id, s.project_id, p.path AS project_path
|
||||
FROM chats c
|
||||
JOIN sessions s ON s.id = c.session_id
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE c.id = ${chatId}
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'chat not found' };
|
||||
}
|
||||
const chat = chatRows[0]!;
|
||||
|
||||
const msgRows = await sql<MessageRow[]>`
|
||||
SELECT id, chat_id, role, content
|
||||
FROM messages
|
||||
WHERE id = ${messageId} AND chat_id = ${chatId}
|
||||
`;
|
||||
if (msgRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'message not found' };
|
||||
}
|
||||
const msg = msgRows[0]!;
|
||||
if (msg.role !== 'assistant') {
|
||||
reply.code(400);
|
||||
return { error: 'only assistant messages produce artifacts' };
|
||||
}
|
||||
|
||||
const ctx = { projectId: chat.project_id, projectRoot: chat.project_path };
|
||||
|
||||
try {
|
||||
if (fmt === 'md') {
|
||||
if (!msg.content || msg.content.trim().length === 0) {
|
||||
reply.code(400);
|
||||
return { error: 'message has no content to export' };
|
||||
}
|
||||
const result = await writeMarkdownArtifact(
|
||||
{ content: msg.content },
|
||||
ctx,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
// fmt === 'html': require an html_artifact part on the message.
|
||||
const partRows = await sql<{ payload: HtmlArtifactPayload }[]>`
|
||||
SELECT payload
|
||||
FROM message_parts
|
||||
WHERE message_id = ${messageId} AND kind = 'html_artifact'
|
||||
ORDER BY sequence ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
if (partRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'no html_artifact part on this message' };
|
||||
}
|
||||
const result = await writeHtmlArtifact(partRows[0]!.payload, ctx);
|
||||
return result;
|
||||
} catch (err) {
|
||||
req.log.error({ err, messageId, fmt }, 'artifact write failed');
|
||||
reply.code(500);
|
||||
return {
|
||||
error: err instanceof Error ? err.message : 'artifact write failed',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// v1.14.x-html-artifact-panes: HtmlArtifactPane needs the payload on click
|
||||
// to render its iframe. Returns 404 when the message has no html_artifact
|
||||
// sibling part — frontend uses that signal to open the markdown_artifact
|
||||
// pane variant instead. Payload shape matches HtmlArtifactPayload in
|
||||
// services/artifacts.ts.
|
||||
app.get<{ Params: { id: string; msg_id: string } }>(
|
||||
'/api/chats/:id/messages/:msg_id/html_artifact',
|
||||
async (req, reply) => {
|
||||
const { id: chatId, msg_id: messageId } = req.params;
|
||||
const partRows = await sql<{ payload: HtmlArtifactPayload }[]>`
|
||||
SELECT payload
|
||||
FROM message_parts mp
|
||||
JOIN messages m ON m.id = mp.message_id
|
||||
WHERE mp.message_id = ${messageId}
|
||||
AND m.chat_id = ${chatId}
|
||||
AND mp.kind = 'html_artifact'
|
||||
ORDER BY mp.sequence ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
if (partRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'no html_artifact part on this message' };
|
||||
}
|
||||
return partRows[0]!.payload;
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { project_id: string; filename: string } }>(
|
||||
'/api/projects/:project_id/artifacts/:filename',
|
||||
async (req, reply) => {
|
||||
const { project_id: projectId, filename } = req.params;
|
||||
// Strip directory components defensively; only the basename is allowed.
|
||||
const base = basename(filename);
|
||||
if (base !== filename || !FilenameRe.test(base)) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid filename' };
|
||||
}
|
||||
|
||||
const projectRows = await sql<{ id: string; path: string }[]>`
|
||||
SELECT id, path FROM projects WHERE id = ${projectId}
|
||||
`;
|
||||
if (projectRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'project not found' };
|
||||
}
|
||||
const project = projectRows[0]!;
|
||||
|
||||
let resolvedRoot: string;
|
||||
try {
|
||||
resolvedRoot = await realpath(project.path);
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return { error: 'project path missing' };
|
||||
}
|
||||
const artifactsDir = resolve(resolvedRoot, '.boocode/artifacts');
|
||||
const absPath = resolve(artifactsDir, base);
|
||||
if (!absPath.startsWith(artifactsDir + sep)) {
|
||||
reply.code(400);
|
||||
return { error: 'path traversal rejected' };
|
||||
}
|
||||
// Close the symlink-escape gap: if `.boocode/artifacts` (or an
|
||||
// ancestor) is a symlink pointing outside resolvedRoot, the lexical
|
||||
// prefix check above passes but the actual read lands outside the
|
||||
// sandbox. Realpath the artifacts dir and re-verify.
|
||||
try {
|
||||
const realArtifactsDir = await realpath(artifactsDir);
|
||||
if (
|
||||
realArtifactsDir !== resolvedRoot &&
|
||||
!realArtifactsDir.startsWith(resolvedRoot + sep)
|
||||
) {
|
||||
reply.code(400);
|
||||
return { error: 'path traversal rejected' };
|
||||
}
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return { error: 'artifact not found' };
|
||||
}
|
||||
try {
|
||||
await stat(absPath);
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return { error: 'artifact not found' };
|
||||
}
|
||||
const ext = base.toLowerCase().endsWith('.html')
|
||||
? 'text/html; charset=utf-8'
|
||||
: base.toLowerCase().endsWith('.md')
|
||||
? 'text/markdown; charset=utf-8'
|
||||
: 'application/octet-stream';
|
||||
reply.header('Content-Type', ext);
|
||||
// Defense-in-depth on LLM-generated HTML served through this route.
|
||||
// Authelia gates the proxy; these headers limit blast radius if a
|
||||
// payload tries to escape that boundary in-browser.
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.header('Content-Security-Policy', 'sandbox');
|
||||
reply.header(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${base.replace(/"/g, '')}"`,
|
||||
);
|
||||
return reply.send(createReadStream(absPath));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -296,13 +296,13 @@ export function registerChatRoutes(
|
||||
`;
|
||||
await tx`
|
||||
INSERT INTO messages (
|
||||
session_id, chat_id, role, content, kind, tool_calls, tool_results,
|
||||
session_id, chat_id, role, content, kind,
|
||||
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||
created_at, metadata
|
||||
)
|
||||
SELECT
|
||||
${source.session_id}, ${chat!.id}, role, content, kind,
|
||||
tool_calls, tool_results, status,
|
||||
status,
|
||||
tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||
clock_timestamp() + (
|
||||
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
|
||||
@@ -385,21 +385,25 @@ export function registerChatRoutes(
|
||||
reply.code(409);
|
||||
return { error: 'message is not stale yet', age_seconds: msg.age_seconds };
|
||||
}
|
||||
const updated = await sql<Message[]>`
|
||||
const updated = await sql<{ id: string }[]>`
|
||||
UPDATE messages
|
||||
SET status = 'failed',
|
||||
content = COALESCE(content, ''),
|
||||
finished_at = clock_timestamp()
|
||||
WHERE id = ${msg.id} AND status = 'streaming'
|
||||
RETURNING 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
|
||||
RETURNING id
|
||||
`;
|
||||
if (updated.length === 0) {
|
||||
// Race: the row flipped out of 'streaming' between our SELECT and UPDATE.
|
||||
reply.code(409);
|
||||
return { error: 'message status changed mid-request' };
|
||||
}
|
||||
// v1.13.20: re-fetch via messages_with_parts so the returned shape
|
||||
// carries parts-synthesized tool_calls / tool_results. The dropped
|
||||
// legacy columns can no longer be selected directly.
|
||||
const refreshed = await sql<Message[]>`
|
||||
SELECT * FROM messages_with_parts WHERE id = ${msg.id}
|
||||
`;
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_status',
|
||||
chat_id: msg.chat_id,
|
||||
@@ -411,7 +415,7 @@ export function registerChatRoutes(
|
||||
message_id: msg.id,
|
||||
chat_id: msg.chat_id,
|
||||
});
|
||||
return updated[0];
|
||||
return refreshed[0];
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import type { Broker } from '../services/broker.js';
|
||||
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
|
||||
// v1.13.17-cross-repo-reads: grant_read_access resolves the grant root at
|
||||
// decision time (not at request time) so concurrent project changes don't
|
||||
// stale-bind the resolution.
|
||||
import { resolveGrantRoot } from '../services/grant_resolver.js';
|
||||
|
||||
const SendBody = z.object({
|
||||
content: z.string().min(1).max(64_000),
|
||||
@@ -47,6 +53,21 @@ const AskUserInputArgs = z.object({
|
||||
.max(3),
|
||||
});
|
||||
|
||||
// v1.13.17-cross-repo-reads: grant decision body. tool_call_id is the
|
||||
// model-emitted id (e.g. "call_abc123"), not a UUID. decision is binary.
|
||||
const GrantReadAccessBody = z.object({
|
||||
tool_call_id: z.string().min(1),
|
||||
decision: z.enum(['allow', 'deny']),
|
||||
});
|
||||
|
||||
// Same shape as services/request_read_access.ts RequestReadAccessInput.
|
||||
// Re-derived to avoid the services/tools.ts import (matches the
|
||||
// AskUserInputArgs pattern above).
|
||||
const RequestReadAccessArgs = z.object({
|
||||
path: z.string().min(1),
|
||||
reason: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
interface MessageHandlers {
|
||||
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
|
||||
// v1.11: returns a promise that resolves after compaction.process finishes
|
||||
@@ -76,6 +97,8 @@ interface MessageHandlers {
|
||||
export function registerMessageRoutes(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
config: Config,
|
||||
broker: Broker,
|
||||
handlers: MessageHandlers
|
||||
): void {
|
||||
app.get<{ Params: { id: string } }>(
|
||||
@@ -582,15 +605,11 @@ export function registerMessageRoutes(
|
||||
|
||||
const toolMessageId = toolRow.message_id;
|
||||
const result = await sql.begin(async (tx) => {
|
||||
await tx`
|
||||
UPDATE messages
|
||||
SET tool_results = ${tx.json(newToolResults as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
// v1.13.0: replace the pending tool_result part inserted at message
|
||||
// creation (tool-phase.ts) with the answered one. Delete-then-insert
|
||||
// is simpler than UPDATE because parts are append-style elsewhere;
|
||||
// the UNIQUE (message_id, sequence) constraint blocks plain insert.
|
||||
// v1.13.20: parts-only. Replace the pending tool_result part inserted
|
||||
// at message creation (tool-phase.ts) with the answered one. Delete-
|
||||
// then-insert is simpler than UPDATE because parts are append-style
|
||||
// elsewhere; the UNIQUE (message_id, sequence) constraint blocks
|
||||
// plain insert.
|
||||
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
@@ -626,4 +645,230 @@ export function registerMessageRoutes(
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
// v1.13.17-cross-repo-reads: resume an awaiting-grant pause. Mirror shape
|
||||
// of /answer_user_input (validate, look up via message_parts, UPDATE,
|
||||
// publish, enqueue). Differences vs /answer_user_input:
|
||||
// - On 'allow', re-resolves the grant root via grant_resolver (state
|
||||
// may have changed since the prompt fired — concurrent project add,
|
||||
// etc.). Resolution failure auto-falls to a denial with reason text
|
||||
// rather than 500ing.
|
||||
// - On 'allow' with a valid root, appends to sessions.allowed_read_paths
|
||||
// (deduplicated) inside the same transaction.
|
||||
// - On success, also publishes session_updated so an open SettingsPane
|
||||
// refetches the new grant list.
|
||||
// Error codes match /answer:
|
||||
// 400 invalid_body / mismatched_answer_shape (bad args on the tool_call)
|
||||
// 404 chat_not_found / unknown_tool_call_id
|
||||
// 409 tool_call_already_answered
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/chats/:id/grant_read_access',
|
||||
async (req, reply) => {
|
||||
const parsed = GrantReadAccessBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid_body', details: parsed.error.flatten() };
|
||||
}
|
||||
const { tool_call_id, decision } = parsed.data;
|
||||
|
||||
const chatRows = await sql<Chat[]>`
|
||||
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'chat_not_found' };
|
||||
}
|
||||
const chat = chatRows[0]!;
|
||||
const sessionId = chat.session_id;
|
||||
|
||||
// Mirror the /answer lookup: assistant tool_call by id via message_parts.
|
||||
const callerRows = await sql<{
|
||||
message_id: string;
|
||||
payload: { id: string; name: string; args: Record<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 !== 'request_read_access') {
|
||||
reply.code(400);
|
||||
return { error: 'tool_call_not_request_read_access' };
|
||||
}
|
||||
const argsParsed = RequestReadAccessArgs.safeParse(foundCall.args);
|
||||
if (!argsParsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
|
||||
}
|
||||
const requestedPath = argsParsed.data.path;
|
||||
|
||||
// Find the pending tool row.
|
||||
const toolRows = await sql<{
|
||||
message_id: string;
|
||||
payload: { tool_call_id: string; output: unknown };
|
||||
}[]>`
|
||||
SELECT p.message_id, p.payload
|
||||
FROM message_parts p
|
||||
JOIN messages m ON m.id = p.message_id
|
||||
WHERE m.chat_id = ${chat.id}
|
||||
AND m.role = 'tool'
|
||||
AND p.kind = 'tool_result'
|
||||
AND p.payload->>'tool_call_id' = ${tool_call_id}
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const toolRow = toolRows[0];
|
||||
if (!toolRow) {
|
||||
reply.code(404);
|
||||
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
|
||||
}
|
||||
if (toolRow.payload && toolRow.payload.output !== null) {
|
||||
reply.code(409);
|
||||
return { error: 'tool_call_already_answered' };
|
||||
}
|
||||
|
||||
// Look up session + project so we can re-resolve the grant root and
|
||||
// append to allowed_read_paths atomically. We don't need agent or
|
||||
// history here — just the project path for the resolver.
|
||||
const sessionRows = await sql<{
|
||||
id: string;
|
||||
project_id: string;
|
||||
allowed_read_paths: string[];
|
||||
project_path: string;
|
||||
}[]>`
|
||||
SELECT s.id, s.project_id, s.allowed_read_paths, p.path AS project_path
|
||||
FROM sessions s
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE s.id = ${sessionId}
|
||||
`;
|
||||
const sessionRow = sessionRows[0];
|
||||
if (!sessionRow) {
|
||||
reply.code(404);
|
||||
return { error: 'session_not_found' };
|
||||
}
|
||||
|
||||
// Decision branch. 'deny' is the easy path: nothing to resolve or
|
||||
// persist. 'allow' resolves the grant root; if resolution fails (e.g.
|
||||
// path was deleted, project removed since prompt) the tool gets a
|
||||
// denial with the resolver's reason text instead of a 500.
|
||||
let resultOutput: string;
|
||||
let grantRoot: string | null = null;
|
||||
if (decision === 'allow') {
|
||||
const resolution = await resolveGrantRoot(
|
||||
sql,
|
||||
requestedPath,
|
||||
sessionRow.project_path,
|
||||
config.PROJECT_ROOT_WHITELIST,
|
||||
);
|
||||
if (!resolution.ok) {
|
||||
resultOutput = `denied: ${resolution.reason}`;
|
||||
} else {
|
||||
grantRoot = resolution.root;
|
||||
resultOutput = `granted: ${grantRoot}`;
|
||||
}
|
||||
} else {
|
||||
resultOutput = 'denied';
|
||||
}
|
||||
|
||||
const newToolResults = {
|
||||
tool_call_id,
|
||||
output: resultOutput,
|
||||
truncated: false,
|
||||
};
|
||||
const toolMessageId = toolRow.message_id;
|
||||
const dbResult = await sql.begin(async (tx) => {
|
||||
// v1.13.20: parts-only. Same delete+insert dance as /answer —
|
||||
// UNIQUE (message_id, sequence) blocks plain UPDATE on append-style
|
||||
// parts.
|
||||
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)})
|
||||
`;
|
||||
// Persist the grant if we have one. ARRAY-level dedup — append only
|
||||
// when the root isn't already present. The session row gets
|
||||
// touched (updated_at) so the post-update publish below has a
|
||||
// fresh timestamp.
|
||||
let allowedRootsAfter = sessionRow.allowed_read_paths;
|
||||
if (grantRoot !== null) {
|
||||
if (!sessionRow.allowed_read_paths.includes(grantRoot)) {
|
||||
const updated = await tx<{ allowed_read_paths: string[] }[]>`
|
||||
UPDATE sessions
|
||||
SET allowed_read_paths = array_append(allowed_read_paths, ${grantRoot}),
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${sessionId}
|
||||
RETURNING allowed_read_paths
|
||||
`;
|
||||
allowedRootsAfter = updated[0]?.allowed_read_paths ?? sessionRow.allowed_read_paths;
|
||||
} else {
|
||||
// Already present — touch updated_at so any open settings
|
||||
// panel still picks up the no-op via session_updated.
|
||||
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||
}
|
||||
}
|
||||
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
||||
return {
|
||||
tool_message_id: toolMessageId,
|
||||
assistant_message_id: assistantMsg!.id,
|
||||
allowed_roots_after: allowedRootsAfter,
|
||||
};
|
||||
});
|
||||
|
||||
// Publish the deferred tool_result frame so the pending card flips to
|
||||
// its answered view without a refetch.
|
||||
handlers.publishSessionFrame(sessionId, {
|
||||
type: 'tool_result',
|
||||
tool_message_id: dbResult.tool_message_id,
|
||||
tool_call_id,
|
||||
chat_id: chat.id,
|
||||
output: resultOutput,
|
||||
truncated: false,
|
||||
});
|
||||
// session_updated nudge so any open SettingsPane refetches and sees
|
||||
// the new allowed_read_paths. We publish on the user channel to match
|
||||
// the existing PATCH /api/sessions/:id behavior — frontend refetches
|
||||
// via api.sessions.get on receipt.
|
||||
const nowIso = new Date().toISOString();
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_updated',
|
||||
session_id: sessionId,
|
||||
project_id: sessionRow.project_id,
|
||||
// session name doesn't change on grant; we look it up fresh to
|
||||
// avoid carrying stale state if a rename raced us.
|
||||
name:
|
||||
(
|
||||
await sql<{ name: string }[]>`SELECT name FROM sessions WHERE id = ${sessionId}`
|
||||
)[0]?.name ?? '',
|
||||
updated_at: nowIso,
|
||||
});
|
||||
handlers.enqueueInference(sessionId, chat.id, dbResult.assistant_message_id, 'default');
|
||||
|
||||
reply.code(202);
|
||||
return {
|
||||
tool_message_id: dbResult.tool_message_id,
|
||||
assistant_message_id: dbResult.assistant_message_id,
|
||||
allowed_read_paths: dbResult.allowed_roots_after,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,37 @@ const CreateBody = z.object({
|
||||
agent_id: z.string().min(1).max(200).nullable().optional(),
|
||||
});
|
||||
|
||||
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added
|
||||
// as pane kinds. Pane state is a reference only (chat_id + message_id +
|
||||
// title) — the actual artifact body is fetched from the message row or
|
||||
// message_parts.payload by the pane component on mount.
|
||||
const MarkdownArtifactStateZ = z.object({
|
||||
chat_id: z.string().min(1).max(200),
|
||||
message_id: z.string().min(1).max(200),
|
||||
title: z.string().max(500),
|
||||
});
|
||||
const HtmlArtifactStateZ = z.object({
|
||||
chat_id: z.string().min(1).max(200),
|
||||
message_id: z.string().min(1).max(200),
|
||||
title: z.string().max(500),
|
||||
});
|
||||
|
||||
const WorkspacePaneZ = z.object({
|
||||
id: z.string().min(1).max(200),
|
||||
kind: z.enum(['chat', 'terminal', 'agent', 'empty', 'settings']),
|
||||
kind: z.enum([
|
||||
'chat',
|
||||
'terminal',
|
||||
'agent',
|
||||
'empty',
|
||||
'settings',
|
||||
'markdown_artifact',
|
||||
'html_artifact',
|
||||
]),
|
||||
chatId: z.string().min(1).max(200).optional(),
|
||||
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||
activeChatIdx: z.number().int(),
|
||||
markdown_artifact_state: MarkdownArtifactStateZ.optional(),
|
||||
html_artifact_state: HtmlArtifactStateZ.optional(),
|
||||
});
|
||||
|
||||
const WorkspacePanesBody = z.object({
|
||||
@@ -32,6 +57,29 @@ const PatchBody = z.object({
|
||||
agent_id: z.string().min(1).max(200).nullable().optional(),
|
||||
// v1.9: null = inherit from project default; true/false = explicit override.
|
||||
web_search_enabled: z.boolean().nullable().optional(),
|
||||
// v1.13.17-cross-repo-reads: revocation pathway. PATCH with a shortened
|
||||
// list deletes entries; the grant flow itself APPENDS via the separate
|
||||
// grant_read_access endpoint, never via this PATCH. Frontend treats this
|
||||
// as "send the new whole array". Per-entry shape validation: must be
|
||||
// absolute, no NUL, no `/..` traversal segment. Server doesn't re-validate
|
||||
// whitelist membership on PATCH — entries already in the array were
|
||||
// placed there by the grant endpoint after a full whitelist+repo-shape
|
||||
// check. THE SUBSET CHECK (every entry must already be in the current
|
||||
// array) is enforced at runtime in the PATCH handler below, NOT in this
|
||||
// zod refinement, because the refinement has no access to the existing
|
||||
// session row.
|
||||
allowed_read_paths: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('/..'), {
|
||||
message: 'must be an absolute path without traversal markers',
|
||||
}),
|
||||
)
|
||||
.max(64)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
|
||||
@@ -40,6 +88,19 @@ async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
|
||||
return config.DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
// v1.13.17-cross-repo-reads: subset enforcement for PATCH allowed_read_paths.
|
||||
// The PATCH route can only SHRINK the array; growth happens exclusively via
|
||||
// POST /api/chats/:id/grant_read_access (which requires user consent).
|
||||
// Returns the list of disallowed-additions; an empty list means the request
|
||||
// is a valid shrink-or-no-op. Exported for the unit test.
|
||||
export function findUnauthorizedAdditions(
|
||||
prior: readonly string[],
|
||||
requested: readonly string[],
|
||||
): string[] {
|
||||
const priorSet = new Set(prior);
|
||||
return requested.filter((p) => !priorSet.has(p));
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
@@ -56,7 +117,7 @@ export function registerSessionRoutes(
|
||||
}
|
||||
const status = req.query.status === 'archived' ? 'archived' : 'open';
|
||||
const rows = await sql<Session[]>`
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
||||
FROM sessions
|
||||
WHERE project_id = ${req.params.id} AND status = ${status}
|
||||
ORDER BY updated_at DESC
|
||||
@@ -124,7 +185,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
|
||||
const rows = await sql<Session[]>`
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
||||
FROM sessions WHERE id = ${req.params.id}
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
@@ -150,15 +211,53 @@ export function registerSessionRoutes(
|
||||
const newAgentId = parsed.data.agent_id ?? null;
|
||||
const wseProvided = parsed.data.web_search_enabled !== undefined;
|
||||
const newWse = parsed.data.web_search_enabled ?? null;
|
||||
// Read the prior name so the post-update publish can skip no-op renames
|
||||
// (PATCH { name: "Foo" } where the session is already "Foo"). The window
|
||||
// between SELECT and UPDATE is sub-millisecond in the same request handler;
|
||||
// a concurrent rename in that gap would just mean one stale publish, which
|
||||
// existing clients dedup by id.
|
||||
const before = await sql<{ name: string }[]>`
|
||||
SELECT name FROM sessions WHERE id = ${req.params.id}
|
||||
// v1.13.17-cross-repo-reads: tri-state on the wire (undefined = no
|
||||
// change, [] = clear). Frontend currently uses this PATCH only for
|
||||
// revocation (delete a single entry from the existing array, send
|
||||
// shortened result). Append-style grants go through the dedicated
|
||||
// grant_read_access endpoint inside the inference loop.
|
||||
const arpProvided = parsed.data.allowed_read_paths !== undefined;
|
||||
const newArp = parsed.data.allowed_read_paths ?? [];
|
||||
// Read the prior name + grants so the post-update publish can skip no-op
|
||||
// renames (PATCH { name: "Foo" } where the session is already "Foo") AND
|
||||
// so the subset check below has the current grant list to compare against.
|
||||
// The window between SELECT and UPDATE is sub-millisecond in the same
|
||||
// request handler; a concurrent rename in that gap would just mean one
|
||||
// stale publish, which existing clients dedup by id.
|
||||
const before = await sql<{ name: string; allowed_read_paths: string[] }[]>`
|
||||
SELECT name, allowed_read_paths FROM sessions WHERE id = ${req.params.id}
|
||||
`;
|
||||
const priorName = before[0]?.name;
|
||||
const priorArp = before[0]?.allowed_read_paths ?? [];
|
||||
|
||||
// v1.13.17-cross-repo-reads: subset enforcement. The grant flow is the
|
||||
// ONLY path that can add entries to allowed_read_paths — PATCH can only
|
||||
// shrink the array, never grow it. Without this guard, a malicious
|
||||
// client could POST {"allowed_read_paths":["/etc"]} and bypass the
|
||||
// user-consent prompt entirely. Sam flagged this in the v1.13.17
|
||||
// compliance review (2026-05-22).
|
||||
// Race note: a concurrent grant landing between this SELECT and the
|
||||
// UPDATE below would briefly make a "shouldn't-have-been-valid" PATCH
|
||||
// succeed (the newly-granted root sneaks in). Inverse race — a
|
||||
// legitimate revoke happening alongside a concurrent grant — could
|
||||
// briefly reject the revoke; the user retries. Both are acceptable
|
||||
// given the single-user threat model + sub-millisecond window.
|
||||
if (arpProvided) {
|
||||
const extras = findUnauthorizedAdditions(priorArp, newArp);
|
||||
if (extras.length > 0) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: 'invalid body',
|
||||
details: {
|
||||
fieldErrors: {
|
||||
allowed_read_paths: [
|
||||
`entries must already be granted; cannot add via PATCH: ${extras.join(', ')}`,
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const rows = await sql<Session[]>`
|
||||
UPDATE sessions
|
||||
SET
|
||||
@@ -167,10 +266,11 @@ export function registerSessionRoutes(
|
||||
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
|
||||
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
|
||||
web_search_enabled = CASE WHEN ${wseProvided} THEN ${newWse} ELSE web_search_enabled END,
|
||||
allowed_read_paths = CASE WHEN ${arpProvided} THEN ${sql.array(newArp, 25)} ELSE allowed_read_paths END,
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${req.params.id}
|
||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||
agent_id, web_search_enabled, workspace_panes
|
||||
agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
reply.code(404);
|
||||
@@ -213,7 +313,7 @@ export function registerSessionRoutes(
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${req.params.id}
|
||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||
agent_id, web_search_enabled, workspace_panes
|
||||
agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
reply.code(404);
|
||||
|
||||
@@ -86,12 +86,12 @@ export function registerSkillsRoutes(
|
||||
|
||||
const result = await sql.begin(async (tx) => {
|
||||
const [synthAssistant] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, tool_calls, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', ${sql.json(toolCalls as never)}, 'complete', clock_timestamp())
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
// v1.13.0: dual-write the synthetic assistant message's tool_call.
|
||||
// Single skill_use tool_call, no text content, so one part at seq 0.
|
||||
// v1.13.20: parts-only write. Single skill_use tool_call, no text
|
||||
// content, so one part at seq 0.
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${synthAssistant!.id}, 0, 'tool_call', ${tx.json({
|
||||
@@ -101,11 +101,11 @@ export function registerSkillsRoutes(
|
||||
} as never)})
|
||||
`;
|
||||
const [toolMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, tool_results, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'tool', '', ${sql.json(toolResults as never)}, 'complete', clock_timestamp())
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'tool', '', 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
// v1.13.0: dual-write the synthetic tool result (the skill body).
|
||||
// v1.13.20: parts-only write of the synthetic tool result (skill body).
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)})
|
||||
|
||||
@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS message_parts (
|
||||
kind text NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT message_parts_kind_chk CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis')),
|
||||
CONSTRAINT message_parts_kind_chk CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis', 'html_artifact')),
|
||||
CONSTRAINT message_parts_seq_uniq UNIQUE (message_id, sequence)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS message_parts_msg_seq_idx ON message_parts (message_id, sequence);
|
||||
@@ -79,6 +79,10 @@ CREATE INDEX IF NOT EXISTS message_parts_hidden_idx
|
||||
-- 'synthesis'; drop + re-add the constraint with the extended enum. Fresh
|
||||
-- installs hit the inline constraint above (already updated) and skip this
|
||||
-- block via the pg_constraint guard.
|
||||
-- v1.14.x-html-artifact-panes: extend the same constraint with 'html_artifact'.
|
||||
-- DROP IF EXISTS + DO $$ pg_constraint $$ guard remains idempotent across
|
||||
-- both v1.13.13 and v1.14.x boots; the IN list below is the union of every
|
||||
-- kind ever shipped.
|
||||
ALTER TABLE message_parts DROP CONSTRAINT IF EXISTS message_parts_kind_chk;
|
||||
DO $$
|
||||
BEGIN
|
||||
@@ -87,55 +91,48 @@ BEGIN
|
||||
) THEN
|
||||
ALTER TABLE message_parts
|
||||
ADD CONSTRAINT message_parts_kind_chk
|
||||
CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis'));
|
||||
CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis', 'html_artifact'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- v1.13.1-B: read-path view. Read sites SELECT FROM messages_with_parts
|
||||
-- instead of messages so tool_calls / tool_results / reasoning_parts come
|
||||
-- from the granular message_parts table. The COALESCE means pre-v1.13.0
|
||||
-- history (no parts rows) still resolves via the legacy JSON columns; the
|
||||
-- dual-write from v1.13.0 keeps both in sync for all rows written since.
|
||||
-- Writes continue to target `messages` directly — the view is read-only.
|
||||
-- Shapes match the in-memory ToolCall / ToolResult types: tool_calls is a
|
||||
-- jsonb array of {id, name, args}, tool_results is a single jsonb object
|
||||
-- {tool_call_id, output, truncated, error?}. reasoning_parts is new — only
|
||||
-- consumed by the inference history fetch (payload.ts) so v1.13.1-C can
|
||||
-- wire reasoning into the model payload. Not surfaced in external APIs yet.
|
||||
-- from the granular message_parts table.
|
||||
-- v1.13.20: post column-drop. The legacy COALESCE fallback over
|
||||
-- messages.tool_calls / messages.tool_results was removed because those
|
||||
-- columns no longer exist on the table (see the ALTER TABLE DROP COLUMN
|
||||
-- statements below). Writes continue to target `messages` directly — the
|
||||
-- view is read-only. Shapes match the in-memory ToolCall / ToolResult
|
||||
-- types: tool_calls is a jsonb array of {id, name, args}, tool_results is
|
||||
-- a single jsonb object {tool_call_id, output, truncated, error?}.
|
||||
-- reasoning_parts is consumed by the inference history fetch (payload.ts)
|
||||
-- for v1.13.1-C reasoning round-tripping. Not surfaced in external APIs.
|
||||
CREATE OR REPLACE VIEW messages_with_parts AS
|
||||
SELECT
|
||||
m.id, m.session_id, m.chat_id, m.role, m.content, m.kind, m.status,
|
||||
m.last_seq, m.tokens_used, m.ctx_used, m.ctx_max,
|
||||
m.started_at, m.finished_at, m.created_at, m.metadata,
|
||||
m.summary, m.tail_start_id, m.compacted_at,
|
||||
-- v1.13.4: prune semantics need to distinguish "no parts row exists"
|
||||
-- (pre-v1.13.0 fallback to legacy column) from "all parts hidden"
|
||||
-- (prune intended — return null/empty so the row drops from the model
|
||||
-- payload). A naive COALESCE would fall back to the legacy column when
|
||||
-- every part is hidden, undoing the prune. CASE on EXISTS(any kind)
|
||||
-- splits the two cases.
|
||||
CASE
|
||||
WHEN EXISTS (SELECT 1 FROM message_parts pp
|
||||
WHERE pp.message_id = m.id AND pp.kind = 'tool_call')
|
||||
THEN (SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL)
|
||||
ELSE m.tool_calls
|
||||
END AS tool_calls,
|
||||
CASE
|
||||
WHEN EXISTS (SELECT 1 FROM message_parts pp
|
||||
WHERE pp.message_id = m.id AND pp.kind = 'tool_result')
|
||||
THEN (SELECT p.payload
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL) AS tool_calls,
|
||||
(SELECT p.payload
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_result' AND p.hidden_at IS NULL
|
||||
ORDER BY p.sequence LIMIT 1)
|
||||
ELSE m.tool_results
|
||||
END AS tool_results,
|
||||
ORDER BY p.sequence LIMIT 1) AS tool_results,
|
||||
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'reasoning' AND p.hidden_at IS NULL) AS reasoning_parts
|
||||
FROM messages m;
|
||||
|
||||
-- v1.13.20: drop legacy tool_calls/tool_results columns. Reads have routed
|
||||
-- through messages_with_parts since v1.13.1-B; dual-writes removed in this
|
||||
-- batch. The view above was simplified to remove COALESCE fallbacks before
|
||||
-- this drop (Postgres rejects column-drop on view-referenced columns).
|
||||
-- Idempotent via IF EXISTS.
|
||||
ALTER TABLE messages DROP COLUMN IF EXISTS tool_calls;
|
||||
ALTER TABLE messages DROP COLUMN IF EXISTS tool_results;
|
||||
|
||||
-- v1.13.10: per-tool token cost rolling window. Derives from
|
||||
-- messages_with_parts (the v1.13.1-B view that COALESCEs message_parts over
|
||||
-- the legacy JSON column) so this works whether the chat predates v1.13.0
|
||||
@@ -286,19 +283,6 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- v1.12.1: drop stale inline CHECK constraints that were superseded by the
|
||||
-- named *_chk variants above. messages_status_check missed 'cancelled' and
|
||||
-- messages_role_check missed 'system' — both narrower than what's in use.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_status_check') THEN
|
||||
ALTER TABLE messages DROP CONSTRAINT messages_status_check;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_role_check') THEN
|
||||
ALTER TABLE messages DROP CONSTRAINT messages_role_check;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- v1.2-project-ux: projects.status + projects.gitea_remote
|
||||
-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
||||
@@ -330,6 +314,16 @@ END $$;
|
||||
-- agent_id is the slugified agent name. NULL means "use BooCode defaults".
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;
|
||||
|
||||
-- v1.13.17-cross-repo-reads: session-scoped read grants for paths outside the
|
||||
-- session's primary project root. Populated only by the request_read_access
|
||||
-- tool's approve branch; revoked via PATCH /api/sessions/:id. Values are
|
||||
-- absolute paths to project roots OR repo-shaped dirs under
|
||||
-- PROJECT_ROOT_WHITELIST (default /opt). No CHECK constraint — validation
|
||||
-- happens at write time in services/grant_resolver.ts. Cleared automatically
|
||||
-- when the session row is deleted (no cascade needed; the column goes with it).
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN IF NOT EXISTS allowed_read_paths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- v1.8.2: per-message metadata for sentinels (cap-hit) and structured error
|
||||
-- reasons. JSONB so future kinds can extend without further schema churn.
|
||||
-- Shape for cap_hit: { kind: 'cap_hit', used: number, limit: number,
|
||||
|
||||
261
apps/server/src/services/__tests__/artifacts.test.ts
Normal file
261
apps/server/src/services/__tests__/artifacts.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, symlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decideHtmlArtifactWrite,
|
||||
deriveHtmlSlug,
|
||||
deriveHtmlTitle,
|
||||
deriveMarkdownSlug,
|
||||
detectHtmlArtifact,
|
||||
HTML_ARTIFACT_MAX_BYTES,
|
||||
writeHtmlArtifact,
|
||||
writeMarkdownArtifact,
|
||||
} from '../artifacts.js';
|
||||
import { PathScopeError } from '../path_guard.js';
|
||||
|
||||
describe('deriveMarkdownSlug', () => {
|
||||
it('uses the first # heading when present', () => {
|
||||
expect(deriveMarkdownSlug('# Hello World\n\nbody')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('falls back to first 6 words', () => {
|
||||
const s = deriveMarkdownSlug('the quick brown fox jumps over the lazy dog');
|
||||
expect(s).toBe('the-quick-brown-fox-jumps-over');
|
||||
});
|
||||
|
||||
it('returns "artifact" for empty input', () => {
|
||||
expect(deriveMarkdownSlug('')).toBe('artifact');
|
||||
});
|
||||
|
||||
it('caps at 60 chars and lowercases', () => {
|
||||
const long = '# ' + 'A'.repeat(200);
|
||||
const s = deriveMarkdownSlug(long);
|
||||
expect(s.length).toBeLessThanOrEqual(60);
|
||||
expect(s).toMatch(/^[a-z0-9-]+$/);
|
||||
});
|
||||
|
||||
it('strips trailing punctuation', () => {
|
||||
expect(deriveMarkdownSlug('# Hello, World!!!')).toBe('hello-world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveHtmlSlug', () => {
|
||||
it('prefers payload.title when set', () => {
|
||||
expect(
|
||||
deriveHtmlSlug({ html_content: '<html></html>', title: 'My Title' }),
|
||||
).toBe('my-title');
|
||||
});
|
||||
|
||||
it('falls back to <title> tag', () => {
|
||||
expect(
|
||||
deriveHtmlSlug({
|
||||
html_content: '<html><head><title>Page Title</title></head></html>',
|
||||
title: null,
|
||||
}),
|
||||
).toBe('page-title');
|
||||
});
|
||||
|
||||
it('falls back to first <h1> when no <title>', () => {
|
||||
expect(
|
||||
deriveHtmlSlug({
|
||||
html_content: '<html><body><h1>Heading One</h1></body></html>',
|
||||
title: null,
|
||||
}),
|
||||
).toBe('heading-one');
|
||||
});
|
||||
|
||||
it('falls back to inner text words', () => {
|
||||
expect(
|
||||
deriveHtmlSlug({
|
||||
html_content: '<div>one two three four five six seven</div>',
|
||||
title: null,
|
||||
}),
|
||||
).toBe('one-two-three-four-five-six');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveHtmlTitle', () => {
|
||||
it('returns <title> content', () => {
|
||||
expect(deriveHtmlTitle('<html><head><title>T</title></head></html>')).toBe('T');
|
||||
});
|
||||
|
||||
it('falls back to <h1>', () => {
|
||||
expect(deriveHtmlTitle('<body><h1>H</h1></body>')).toBe('H');
|
||||
});
|
||||
|
||||
it('falls back to first 80 chars of inner text', () => {
|
||||
const html = '<div>' + 'x '.repeat(100) + '</div>';
|
||||
const t = deriveHtmlTitle(html);
|
||||
expect(t).not.toBeNull();
|
||||
expect(t!.length).toBeLessThanOrEqual(80);
|
||||
});
|
||||
|
||||
it('returns null for empty html', () => {
|
||||
expect(deriveHtmlTitle('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtmlArtifact', () => {
|
||||
it('detects <!DOCTYPE html> prefix case-insensitively', () => {
|
||||
const html = '<!doctype HTML><html><body>x</body></html>';
|
||||
expect(detectHtmlArtifact(html)).toBe(html);
|
||||
});
|
||||
|
||||
it('strips leading/trailing whitespace before matching', () => {
|
||||
const html = '\n\n<!DOCTYPE html>\n<html></html>\n';
|
||||
expect(detectHtmlArtifact(html)).toBe(html.trim());
|
||||
});
|
||||
|
||||
it('detects fenced ```html block wrapping entire message', () => {
|
||||
const wrapped = '```html\n<!DOCTYPE html>\n<html></html>\n```';
|
||||
expect(detectHtmlArtifact(wrapped)).toContain('<!DOCTYPE html>');
|
||||
});
|
||||
|
||||
it('rejects plain markdown', () => {
|
||||
expect(detectHtmlArtifact('# heading\n\nsome text')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects message with prose before the doctype', () => {
|
||||
expect(
|
||||
detectHtmlArtifact('Here you go: <!DOCTYPE html><html></html>'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects empty input', () => {
|
||||
expect(detectHtmlArtifact('')).toBeNull();
|
||||
expect(detectHtmlArtifact(' \n ')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects fenced block without doctype/<html>', () => {
|
||||
expect(detectHtmlArtifact('```html\n<div>x</div>\n```')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts fenced block containing <html> tag (no doctype)', () => {
|
||||
const r = detectHtmlArtifact('```html\n<html><body>x</body></html>\n```');
|
||||
expect(r).toContain('<html>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeMarkdownArtifact / writeHtmlArtifact', () => {
|
||||
let projectRoot: string;
|
||||
beforeEach(async () => {
|
||||
projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-test-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(projectRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes a markdown artifact under .boocode/artifacts/', async () => {
|
||||
const result = await writeMarkdownArtifact(
|
||||
{ content: '# Hello\n\nbody' },
|
||||
{ projectId: 'pid', projectRoot },
|
||||
);
|
||||
expect(result.path).toMatch(/\.boocode\/artifacts\/hello-\d+\.md$/);
|
||||
expect(result.url).toMatch(/^\/api\/projects\/pid\/artifacts\/hello-\d+\.md$/);
|
||||
const written = await readFile(result.path, 'utf8');
|
||||
expect(written).toBe('# Hello\n\nbody');
|
||||
});
|
||||
|
||||
it('writes an html artifact', async () => {
|
||||
const result = await writeHtmlArtifact(
|
||||
{
|
||||
html_content: '<!DOCTYPE html><html><head><title>X</title></head></html>',
|
||||
char_count: 56,
|
||||
title: 'X',
|
||||
},
|
||||
{ projectId: 'pid', projectRoot },
|
||||
);
|
||||
expect(result.path).toMatch(/\.boocode\/artifacts\/x-\d+\.html$/);
|
||||
const written = await readFile(result.path, 'utf8');
|
||||
expect(written).toContain('<!DOCTYPE html>');
|
||||
});
|
||||
|
||||
it('creates the artifacts directory if absent', async () => {
|
||||
// Confirm the writer mkdir-recursive's the artifacts dir on first call.
|
||||
const result = await writeMarkdownArtifact(
|
||||
{ content: '# T' },
|
||||
{ projectId: 'pid', projectRoot },
|
||||
);
|
||||
expect(result.path).toContain('.boocode/artifacts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('1MB cap behavior', () => {
|
||||
it('reports the correct byte threshold', () => {
|
||||
expect(HTML_ARTIFACT_MAX_BYTES).toBe(1_048_576);
|
||||
});
|
||||
|
||||
it('exceeds threshold for oversize payload', () => {
|
||||
const oversize = '<!DOCTYPE html>' + 'A'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
||||
expect(Buffer.byteLength(oversize, 'utf8')).toBeGreaterThan(
|
||||
HTML_ARTIFACT_MAX_BYTES,
|
||||
);
|
||||
});
|
||||
|
||||
it('detectHtmlArtifact still returns content above the cap (cap is checked by caller)', () => {
|
||||
// Detection is content-shape; the cap check lives in finalizeCompletion
|
||||
// (error-handler.ts). This test pins that contract: the helper does not
|
||||
// silently drop oversize payloads on the floor.
|
||||
const big = '<!DOCTYPE html>' + 'x'.repeat(2_000_000);
|
||||
expect(detectHtmlArtifact(big)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideHtmlArtifactWrite', () => {
|
||||
// Pure helper extracted from finalizeCompletion's cap-skip branch. Pins
|
||||
// the warn-and-skip decision without mocking the full InferenceContext.
|
||||
it('returns write=true for payloads under the cap', () => {
|
||||
const html = '<!DOCTYPE html><html></html>';
|
||||
const decision = decideHtmlArtifactWrite(html);
|
||||
expect(decision.write).toBe(true);
|
||||
expect(decision.byteLen).toBe(Buffer.byteLength(html, 'utf8'));
|
||||
});
|
||||
|
||||
it('returns write=false with cap_exceeded reason for oversize payloads', () => {
|
||||
const big = '<!DOCTYPE html>' + 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
||||
const decision = decideHtmlArtifactWrite(big);
|
||||
expect(decision.write).toBe(false);
|
||||
if (!decision.write) {
|
||||
expect(decision.reason).toBe('cap_exceeded');
|
||||
expect(decision.byteLen).toBeGreaterThan(HTML_ARTIFACT_MAX_BYTES);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts payload exactly at the cap (boundary)', () => {
|
||||
// byteLen === cap should write; only strictly greater skips.
|
||||
const exact = 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
||||
const decision = decideHtmlArtifactWrite(exact);
|
||||
expect(decision.write).toBe(true);
|
||||
expect(decision.byteLen).toBe(HTML_ARTIFACT_MAX_BYTES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('symlink escape protection', () => {
|
||||
// Closes the gap where `.boocode/artifacts` is a symlink pointing
|
||||
// outside the project root. The lexical prefix check on the resolved
|
||||
// candidate path passes (it's under projectRoot textually), but the
|
||||
// post-mkdir realpath verification must catch the escape.
|
||||
let projectRoot: string;
|
||||
let outside: string;
|
||||
beforeEach(async () => {
|
||||
projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-symlink-root-'));
|
||||
outside = await mkdtemp(join(tmpdir(), 'artifacts-symlink-outside-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(projectRoot, { recursive: true, force: true });
|
||||
await rm(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('throws PathScopeError when .boocode/artifacts is a symlink to outside the project', async () => {
|
||||
// Create .boocode dir, then make `artifacts` a symlink pointing outside.
|
||||
await mkdir(join(projectRoot, '.boocode'), { recursive: true });
|
||||
await symlink(outside, join(projectRoot, '.boocode', 'artifacts'));
|
||||
await expect(
|
||||
writeMarkdownArtifact(
|
||||
{ content: '# Hello' },
|
||||
{ projectId: 'pid', projectRoot },
|
||||
),
|
||||
).rejects.toBeInstanceOf(PathScopeError);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { callCodecontext } from '../codecontext_client.js';
|
||||
@@ -203,3 +203,197 @@ describe('callCodecontext — error paths', () => {
|
||||
).rejects.toThrow(/timed out after 30000ms/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- v1.13.18: file_path resolution tests -----------------------------------
|
||||
|
||||
describe('callCodecontext — file_path resolution', () => {
|
||||
// Case 1: relative path resolves to absolute under project root
|
||||
it('resolves a relative file_path to an absolute path inside project root', async () => {
|
||||
// Create a real file so realpath can canonicalise it
|
||||
const fileName = 'src_module.ts';
|
||||
await writeFile(join(projectDir, fileName), '// hello');
|
||||
const fetcher = vi.fn().mockResolvedValue(
|
||||
mockJSONResponse({ result: 'file analysis', error: null }),
|
||||
);
|
||||
await callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: fileName },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||
// Should be the resolved absolute path
|
||||
expect(body.file_path).toBe(join(projectDir, fileName));
|
||||
});
|
||||
|
||||
// Case 2: absolute path inside project root → realpathed → forwarded
|
||||
it('passes through an absolute file_path inside project root', async () => {
|
||||
const fileName = 'absolute_target.ts';
|
||||
const absPath = join(projectDir, fileName);
|
||||
await writeFile(absPath, '// absolute');
|
||||
const fetcher = vi.fn().mockResolvedValue(
|
||||
mockJSONResponse({ result: 'analysis', error: null }),
|
||||
);
|
||||
await callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: absPath },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
);
|
||||
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||
expect(body.file_path).toBe(absPath);
|
||||
});
|
||||
|
||||
// Case 3: relative escape path → rejected with same error shape as target_dir escape
|
||||
it('rejects a relative file_path that escapes the project root', async () => {
|
||||
const fetcher = vi.fn();
|
||||
await expect(
|
||||
callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: '../../etc/passwd' },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
),
|
||||
).rejects.toThrow(/escapes project root/);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Case 4: absolute path outside project root → rejected
|
||||
it('rejects an absolute file_path outside the project root', async () => {
|
||||
const fetcher = vi.fn();
|
||||
await expect(
|
||||
callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
// /etc/passwd is outside any tmpdir project root
|
||||
args: { file_path: '/etc/passwd' },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
),
|
||||
).rejects.toThrow(/escapes project root/);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Case 5: nonexistent file (ENOENT) → forwarded as un-realpath'd absolute
|
||||
it('forwards a nonexistent file_path as absolute without throwing', async () => {
|
||||
const missingPath = join(projectDir, 'does_not_exist.ts');
|
||||
const fetcher = vi.fn().mockResolvedValue(
|
||||
mockJSONResponse({ result: null, error: 'File not found in graph: ' + missingPath }),
|
||||
);
|
||||
// The resolver should NOT throw; the error comes back from the sidecar
|
||||
await expect(
|
||||
callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: 'does_not_exist.ts' },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
),
|
||||
).rejects.toThrow(/File not found in graph/);
|
||||
// Wire was still called — resolver forwarded the path
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||
// Should receive the absolute (non-realpathed) path
|
||||
expect(body.file_path).toBe(missingPath);
|
||||
});
|
||||
|
||||
// Case 6: empty string → skipped by guard, reaches wire unmodified
|
||||
// Note: Zod .trim().min(1) in get_file_analysis rejects empty before the
|
||||
// shim is reached in production. At the shim layer, the guard
|
||||
// `file_path.trim() !== ''` skips the resolver for empty strings so that
|
||||
// optional-file_path wrappers treat '' as "not provided". This is a
|
||||
// deliberate design; callers that require file_path validate at the Zod layer.
|
||||
it('skips resolver for empty string file_path (treated as not provided)', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue(
|
||||
mockJSONResponse({ result: 'analysis', error: null }),
|
||||
);
|
||||
// Should succeed — empty string is treated as "no file_path"
|
||||
await callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: '' },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||
// Empty string passes through unchanged (resolver not invoked)
|
||||
expect(body.file_path).toBe('');
|
||||
});
|
||||
|
||||
// Case 7: wrapper without file_path (e.g. get_codebase_overview) → resolver not invoked
|
||||
it('does not invoke file_path resolver when file_path is absent from args', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue(
|
||||
mockJSONResponse({ result: 'overview', error: null }),
|
||||
);
|
||||
await callCodecontext(
|
||||
{
|
||||
toolName: 'get_codebase_overview',
|
||||
args: { include_stats: true },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||
// No file_path in the wire body
|
||||
expect('file_path' in body).toBe(false);
|
||||
});
|
||||
|
||||
// Case 8: absolute path with `..` that resolves outside project root, even
|
||||
// when the literal path is ENOENT. Without resolve() in the absolute branch
|
||||
// the prefix check false-positives because the raw `<projectDir>/../etc/x`
|
||||
// literal starts with `<projectDir>/`.
|
||||
it('rejects absolute file_path with `..` resolving outside project root (ENOENT branch)', async () => {
|
||||
const fetcher = vi.fn();
|
||||
const escapingAbsolute = `${projectDir}/../etc/non_existent_passwd`;
|
||||
await expect(
|
||||
callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: escapingAbsolute },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
),
|
||||
).rejects.toThrow(/escapes project root/);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Case 9: in-project symlink targeting outside the project root. This is the
|
||||
// canonical realpath defense — realpath must canonicalise the symlink and
|
||||
// the escape check must reject. Without this test, a symlink-out hole could
|
||||
// regress silently.
|
||||
it('rejects file_path that resolves through a symlink leaving project root', async () => {
|
||||
const outsideDir = await mkdtemp(join(tmpdir(), 'codecontext-outside-'));
|
||||
try {
|
||||
const evilTarget = join(outsideDir, 'secrets.txt');
|
||||
await writeFile(evilTarget, 'top secret');
|
||||
await symlink(evilTarget, join(projectDir, 'evil-link'));
|
||||
const fetcher = vi.fn();
|
||||
await expect(
|
||||
callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: 'evil-link' },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
),
|
||||
).rejects.toThrow(/escapes project root/);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('codecontext wrappers — toolName + args forwarding', () => {
|
||||
const { url, body } = parsePOST(fetcher);
|
||||
expect(url).toMatch(/\/v1\/get_file_analysis$/);
|
||||
expect(body).toMatchObject({
|
||||
file_path: 'apps/server/src/index.ts',
|
||||
file_path: join(projectDir, 'apps/server/src/index.ts'),
|
||||
target_dir: projectDir,
|
||||
});
|
||||
});
|
||||
|
||||
199
apps/server/src/services/__tests__/grant_resolver.test.ts
Normal file
199
apps/server/src/services/__tests__/grant_resolver.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// v1.13.17-cross-repo-reads: resolveGrantRoot decision tree.
|
||||
//
|
||||
// Sam's dispatch note (2026-05-22): "in the project-root resolver ancestor
|
||||
// walk, stop the moment parent exits PROJECT_ROOT_WHITELIST or hits
|
||||
// filesystem root — check on every iteration, not just final parent.
|
||||
// Symlinked input must not be able to escape the whitelist during the
|
||||
// walk." The symlink-escape-mid-walk test below pins that invariant —
|
||||
// without the per-iteration whitelist check, this case would walk OUTSIDE
|
||||
// the whitelist root and return a phantom grant.
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { resolveGrantRoot } from '../grant_resolver.js';
|
||||
import type { Sql } from '../../db.js';
|
||||
|
||||
let tmp: string;
|
||||
let whitelist: string;
|
||||
let project: string;
|
||||
let fork: string;
|
||||
let outside: string;
|
||||
|
||||
// Fake sql tag — returns the projects rows we want without touching a real
|
||||
// database. The resolver only ever does a single SELECT, so a single-shot
|
||||
// mock that returns the prepared rows on every invocation is enough.
|
||||
function makeSql(rows: Array<{ path: string }>): Sql {
|
||||
const tag = ((..._args: unknown[]) => Promise.resolve(rows)) as unknown as Sql;
|
||||
return tag;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-gr-')));
|
||||
whitelist = join(tmp, 'whitelist');
|
||||
project = join(whitelist, 'boocode');
|
||||
fork = join(whitelist, 'forks', 'codecontext');
|
||||
outside = join(tmp, 'outside');
|
||||
await mkdir(project, { recursive: true });
|
||||
await mkdir(fork, { recursive: true });
|
||||
await mkdir(outside, { recursive: true });
|
||||
// Mark project as a repo (.git directory).
|
||||
await mkdir(join(project, '.git'));
|
||||
await writeFile(join(project, 'README.md'), 'project readme');
|
||||
// Mark fork as a repo via go.mod (matches the proposal's example).
|
||||
await writeFile(join(fork, 'go.mod'), 'module example.com/foo');
|
||||
await writeFile(join(fork, 'main.go'), 'package main');
|
||||
await writeFile(join(outside, 'secret.txt'), 'forbidden');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('resolveGrantRoot — happy paths', () => {
|
||||
it('refuses when the requested path is already under projectRoot', async () => {
|
||||
const result = await resolveGrantRoot(makeSql([]), join(project, 'README.md'), project, whitelist);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/already accessible/);
|
||||
});
|
||||
|
||||
it('returns the project root when the path falls under a registered project', async () => {
|
||||
// Register `fork` as a known project. Resolver should return the project
|
||||
// ancestor (LONGEST match wins) rather than the repo-shape fallback.
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([{ path: fork }]),
|
||||
join(fork, 'main.go'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.root).toBe(fork);
|
||||
expect(result.source).toBe('project');
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the nearest repo-shaped ancestor when no project matches', async () => {
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(fork, 'main.go'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.root).toBe(fork);
|
||||
expect(result.source).toBe('whitelist');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGrantRoot — refusals', () => {
|
||||
it('refuses paths outside PROJECT_ROOT_WHITELIST', async () => {
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(outside, 'secret.txt'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/);
|
||||
});
|
||||
|
||||
it('refuses non-absolute paths', async () => {
|
||||
const result = await resolveGrantRoot(makeSql([]), 'relative/path', project, whitelist);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/absolute/);
|
||||
});
|
||||
|
||||
it('refuses missing paths without prompting', async () => {
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(whitelist, 'nope'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/does not exist/);
|
||||
});
|
||||
|
||||
it('refuses when no repo-shape marker is found before hitting the whitelist root', async () => {
|
||||
// Build a directory tree under the whitelist that has NO repo markers
|
||||
// all the way up to the whitelist root.
|
||||
const plain = join(whitelist, 'plain-dir', 'nested');
|
||||
await mkdir(plain, { recursive: true });
|
||||
await writeFile(join(plain, 'just-a-file.txt'), 'x');
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(plain, 'just-a-file.txt'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/);
|
||||
});
|
||||
|
||||
it('does not grant the whitelist root itself as a fallback', async () => {
|
||||
// Even if .git existed at the whitelist root (it doesn't), we'd refuse.
|
||||
// Easier to assert: a path directly under whitelist with no repo marker.
|
||||
const direct = join(whitelist, 'lone-file.txt');
|
||||
await writeFile(direct, 'x');
|
||||
const result = await resolveGrantRoot(makeSql([]), direct, project, whitelist);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGrantRoot — symlink-escape-mid-walk guard (Sam 2026-05-22)', () => {
|
||||
it('refuses a symlinked input whose realpath sits outside the whitelist', async () => {
|
||||
// The symlink lives nominally inside the whitelist, but its target
|
||||
// (realpath) is outside. The guard's first realpath() call normalizes
|
||||
// and the up-front whitelist check refuses immediately.
|
||||
const link = join(whitelist, 'escape-link');
|
||||
try {
|
||||
await symlink(outside, link);
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(link, 'secret.txt'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/);
|
||||
} finally {
|
||||
await rm(link, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('walk loop terminates at the whitelist root, not at filesystem /', async () => {
|
||||
// Construct a deep tree with NO repo markers anywhere. Without a bound,
|
||||
// the walk would chase parents up to "/". The bound flips the loop into
|
||||
// a refusal once the cursor equals the realpath'd whitelist root.
|
||||
const deep = join(whitelist, 'a', 'b', 'c', 'd');
|
||||
await mkdir(deep, { recursive: true });
|
||||
await writeFile(join(deep, 'leaf.txt'), 'x');
|
||||
const result = await resolveGrantRoot(makeSql([]), join(deep, 'leaf.txt'), project, whitelist);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGrantRoot — nearest-project disambiguation', () => {
|
||||
it('prefers the longest matching project path over a shorter ancestor', async () => {
|
||||
const outer = whitelist;
|
||||
const inner = fork; // /whitelist/forks/codecontext, deeper than outer
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([{ path: outer }, { path: inner }]),
|
||||
join(fork, 'main.go'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.root).toBe(inner);
|
||||
});
|
||||
});
|
||||
|
||||
// Belt-and-suspenders: silence a known dynamic-import warning that vitest
|
||||
// occasionally emits on transient fs operations in CI but never in dev.
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
93
apps/server/src/services/__tests__/path_guard.test.ts
Normal file
93
apps/server/src/services/__tests__/path_guard.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// v1.13.17-cross-repo-reads: pathGuard now accepts an optional extraRoots
|
||||
// list. Validates the primary-root path stays the source of truth and that
|
||||
// extra roots are consulted when (and only when) the primary rejects.
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { pathGuard, PathScopeError } from '../path_guard.js';
|
||||
|
||||
let tmp: string;
|
||||
let projectRoot: string;
|
||||
let altRoot: string;
|
||||
let outsideDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-pg-')));
|
||||
projectRoot = join(tmp, 'project');
|
||||
altRoot = join(tmp, 'alt');
|
||||
outsideDir = join(tmp, 'outside');
|
||||
await mkdir(projectRoot, { recursive: true });
|
||||
await mkdir(altRoot, { recursive: true });
|
||||
await mkdir(outsideDir, { recursive: true });
|
||||
await writeFile(join(projectRoot, 'inside.txt'), 'p');
|
||||
await writeFile(join(altRoot, 'cross.txt'), 'a');
|
||||
await writeFile(join(outsideDir, 'forbidden.txt'), 'x');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('pathGuard (v1.13.17 extraRoots)', () => {
|
||||
it('accepts paths inside the primary projectRoot', async () => {
|
||||
const real = await pathGuard(projectRoot, 'inside.txt');
|
||||
expect(real).toBe(join(projectRoot, 'inside.txt'));
|
||||
});
|
||||
|
||||
it('rejects paths outside the primary root when no extra roots given', async () => {
|
||||
await expect(pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'))).rejects.toBeInstanceOf(
|
||||
PathScopeError,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts cross-root paths when the matching extra root is provided', async () => {
|
||||
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), [altRoot]);
|
||||
expect(real).toBe(join(altRoot, 'cross.txt'));
|
||||
});
|
||||
|
||||
it('rejects cross-root paths even with extra roots when no root matches', async () => {
|
||||
await expect(
|
||||
pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'), [altRoot]),
|
||||
).rejects.toBeInstanceOf(PathScopeError);
|
||||
});
|
||||
|
||||
it('ignores empty-string extra roots silently', async () => {
|
||||
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), ['', altRoot]);
|
||||
expect(real).toBe(join(altRoot, 'cross.txt'));
|
||||
});
|
||||
|
||||
it('error message contains the request_read_access hint when scope rejects', async () => {
|
||||
try {
|
||||
await pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'));
|
||||
throw new Error('should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PathScopeError);
|
||||
expect((err as Error).message).toContain('request_read_access');
|
||||
}
|
||||
});
|
||||
|
||||
it('still resolves symlinks before the scope check', async () => {
|
||||
const linkPath = join(projectRoot, 'link-to-outside');
|
||||
await symlink(join(outsideDir, 'forbidden.txt'), linkPath);
|
||||
// Symlink target escapes both primary and the single extra root, so
|
||||
// even though the surface path "looks" inside projectRoot, the real
|
||||
// path resolves outside and the guard rejects.
|
||||
await expect(pathGuard(projectRoot, linkPath, [altRoot])).rejects.toBeInstanceOf(
|
||||
PathScopeError,
|
||||
);
|
||||
// But adding outsideDir as an extra root accepts (realpath inside it).
|
||||
const real = await pathGuard(projectRoot, linkPath, [altRoot, outsideDir]);
|
||||
expect(real).toBe(join(outsideDir, 'forbidden.txt'));
|
||||
});
|
||||
|
||||
it('tries extra roots in order until one accepts', async () => {
|
||||
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), [
|
||||
outsideDir, // rejects
|
||||
altRoot, // accepts
|
||||
]);
|
||||
expect(real).toBe(join(altRoot, 'cross.txt'));
|
||||
});
|
||||
});
|
||||
@@ -78,16 +78,18 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
|
||||
args: {},
|
||||
}));
|
||||
const created = opts.createdAt ?? new Date();
|
||||
// v1.13.20: parts-only. messages.tool_calls column was dropped; the
|
||||
// tool_cost_stats view reads through messages_with_parts which derives
|
||||
// tool_calls from message_parts rows.
|
||||
const rows = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (
|
||||
session_id, chat_id, role, content, kind, status,
|
||||
tool_calls, tokens_used, ctx_used,
|
||||
tokens_used, ctx_used,
|
||||
metadata, created_at
|
||||
)
|
||||
VALUES (
|
||||
${sessionId}, ${chatId}, 'assistant', '', 'message',
|
||||
${opts.status ?? 'complete'},
|
||||
${sql.json(toolCalls as never)},
|
||||
${opts.tokensUsed},
|
||||
${opts.ctxUsed},
|
||||
${opts.metadata ? sql.json(opts.metadata as never) : null},
|
||||
@@ -95,7 +97,14 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
return rows[0]!.id;
|
||||
const messageId = rows[0]!.id;
|
||||
for (let i = 0; i < toolCalls.length; i++) {
|
||||
await sql`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${messageId}, ${i}, 'tool_call', ${sql.json(toolCalls[i] as never)})
|
||||
`;
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
it('returns empty when no tool calls exist for a tool name', async () => {
|
||||
@@ -197,18 +206,17 @@ describeFn('tool_cost_stats view (v1.13.10)', () => {
|
||||
|
||||
it('reads tool_calls via messages_with_parts (parts-authoritative)', async () => {
|
||||
const t = tname('parts');
|
||||
// Insert an assistant row with messages.tool_calls=NULL but a
|
||||
// message_parts row carrying the tool_call. The view reads via
|
||||
// messages_with_parts, which COALESCEs the parts table over the legacy
|
||||
// column — so this row should still aggregate.
|
||||
// v1.13.20: post-column-drop the only source for tool_calls is
|
||||
// message_parts. This test asserts the same path the view always took
|
||||
// (parts-derived), now that the legacy column COALESCE fallback is gone.
|
||||
const rows = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (
|
||||
session_id, chat_id, role, content, kind, status,
|
||||
tool_calls, tokens_used, ctx_used
|
||||
tokens_used, ctx_used
|
||||
)
|
||||
VALUES (
|
||||
${sessionId}, ${chatId}, 'assistant', '', 'message', 'complete',
|
||||
NULL, 200, 5000
|
||||
200, 5000
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
357
apps/server/src/services/__tests__/xml-parser.test.ts
Normal file
357
apps/server/src/services/__tests__/xml-parser.test.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
// v1.13.16: covers the Qwen/Hermes <tool_call> parser, the new Anthropic
|
||||
// <invoke> parser, the partial-opener detector for both flavors, the unified
|
||||
// extraction helper, and the unknown-tool error formatter that downstream
|
||||
// dispatch uses to give the model a recovery hint when it drifts to a
|
||||
// Claude Code tool name like read_file instead of BooCode's view_file.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
parseXmlToolCall,
|
||||
parseInvokeToolCall,
|
||||
partialXmlOpenerStart,
|
||||
extractToolCallBlocks,
|
||||
XML_TOOL_OPEN,
|
||||
XML_TOOL_CLOSE,
|
||||
INVOKE_TOOL_OPEN,
|
||||
INVOKE_TOOL_CLOSE,
|
||||
} from '../inference/xml-parser.js';
|
||||
import {
|
||||
levenshtein,
|
||||
suggestToolName,
|
||||
formatUnknownToolError,
|
||||
} from '../inference/tool-suggestions.js';
|
||||
|
||||
describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
|
||||
it('parses a well-formed single-parameter call', () => {
|
||||
const block = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('parses multi-parameter call', () => {
|
||||
const block = '<tool_call><function=grep><parameter=pattern>foo</parameter><parameter=path>src/</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({
|
||||
name: 'grep',
|
||||
args: { pattern: 'foo', path: 'src/' },
|
||||
});
|
||||
});
|
||||
|
||||
it('JSON-parses numeric parameter values', () => {
|
||||
const block = '<tool_call><function=foo><parameter=count>42</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({ name: 'foo', args: { count: 42 } });
|
||||
});
|
||||
|
||||
it('tolerates whitespace around = in function (v1.13.16 tightening)', () => {
|
||||
const block = '<tool_call><function = view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates whitespace around = in parameter (v1.13.16 tightening)', () => {
|
||||
const block = '<tool_call><function=view_file><parameter = path>/tmp/foo</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when function name is missing', () => {
|
||||
const block = '<tool_call><parameter=path>/tmp/foo</parameter></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
|
||||
// Spec case 1
|
||||
it('parses a well-formed single-parameter call (spec case 1)', () => {
|
||||
const block = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
// Spec case 2
|
||||
it('parses a multi-parameter call (spec case 2)', () => {
|
||||
const block = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'grep',
|
||||
args: { pattern: 'foo', path: 'src/' },
|
||||
});
|
||||
});
|
||||
|
||||
// Spec case 3
|
||||
it('tolerates newlines and spaces in attributes (spec case 3)', () => {
|
||||
const block = `<invoke
|
||||
name="view_file"
|
||||
>
|
||||
<parameter
|
||||
name="path"
|
||||
>/tmp/foo</parameter>
|
||||
</invoke>`;
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
// Spec case 4 (parser portion — the not-found enrichment is tested below)
|
||||
it('parses a call whose name is not a registered BooCode tool (spec case 4)', () => {
|
||||
const block = '<invoke name="read_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('supports single-quoted attribute values', () => {
|
||||
const block = "<invoke name='view_file'><parameter name='path'>/tmp/foo</parameter></invoke>";
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('JSON-parses numeric parameter values', () => {
|
||||
const block = '<invoke name="foo"><parameter name="count">42</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({ name: 'foo', args: { count: 42 } });
|
||||
});
|
||||
|
||||
it('tolerates spaces around = inside name attribute', () => {
|
||||
const block = '<invoke name = "view_file"><parameter name = "path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when name attribute is missing', () => {
|
||||
const block = '<invoke><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when name attribute is empty', () => {
|
||||
const block = '<invoke name=""><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toBeNull();
|
||||
});
|
||||
|
||||
it('exports the expected delimiters', () => {
|
||||
expect(INVOKE_TOOL_OPEN).toBe('<invoke');
|
||||
expect(INVOKE_TOOL_CLOSE).toBe('</invoke>');
|
||||
expect(XML_TOOL_OPEN).toBe('<tool_call>');
|
||||
expect(XML_TOOL_CLOSE).toBe('</tool_call>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('partialXmlOpenerStart (v1.13.16 — both flavors)', () => {
|
||||
it('returns -1 when the buffer is empty', () => {
|
||||
expect(partialXmlOpenerStart('')).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns -1 when the buffer has no openers', () => {
|
||||
expect(partialXmlOpenerStart('plain prose, no markup')).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns the index of a complete <tool_call> opener (existing)', () => {
|
||||
expect(partialXmlOpenerStart('prose <tool_call>more')).toBe(6);
|
||||
});
|
||||
|
||||
it('returns the index of a complete <invoke opener (v1.13.16)', () => {
|
||||
expect(partialXmlOpenerStart('prose <invoke name=')).toBe(6);
|
||||
});
|
||||
|
||||
it('holds a partial <tool_ prefix at end of buffer', () => {
|
||||
expect(partialXmlOpenerStart('text <tool_')).toBe(5);
|
||||
});
|
||||
|
||||
it('holds a partial <invo prefix at end of buffer (v1.13.16)', () => {
|
||||
expect(partialXmlOpenerStart('text <invo')).toBe(5);
|
||||
});
|
||||
|
||||
it('holds a bare < at end of buffer', () => {
|
||||
expect(partialXmlOpenerStart('text <')).toBe(5);
|
||||
});
|
||||
|
||||
it('returns -1 when < is followed by non-opener text', () => {
|
||||
expect(partialXmlOpenerStart('text <unknown>')).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns the earliest opener when both flavors are present', () => {
|
||||
expect(partialXmlOpenerStart('xxx <tool_call>YYY <invoke>')).toBe(4);
|
||||
expect(partialXmlOpenerStart('xxx <invoke>YYY <tool_call>')).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
||||
// Spec case 1 (extraction-level)
|
||||
it('extracts a single <invoke> block (spec case 1)', () => {
|
||||
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
const result = extractToolCallBlocks(input);
|
||||
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
||||
expect(result.flushed).toBe('');
|
||||
expect(result.remaining).toBe('');
|
||||
});
|
||||
|
||||
// Spec case 5: opener arrives in one chunk, closer in the next.
|
||||
it('holds the partial <invoke> chunk when the closer has not arrived (spec case 5, first chunk)', () => {
|
||||
const firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>';
|
||||
const result = extractToolCallBlocks(firstChunk);
|
||||
expect(result.calls).toEqual([]);
|
||||
expect(result.flushed).toBe('');
|
||||
expect(result.remaining).toBe(firstChunk);
|
||||
});
|
||||
|
||||
it('extracts the block once the closer arrives in a later chunk (spec case 5, completion)', () => {
|
||||
const firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>';
|
||||
const r1 = extractToolCallBlocks(firstChunk);
|
||||
const combined = r1.remaining + '</invoke>';
|
||||
const r2 = extractToolCallBlocks(combined);
|
||||
expect(r2.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
||||
expect(r2.flushed).toBe('');
|
||||
expect(r2.remaining).toBe('');
|
||||
});
|
||||
|
||||
// Spec case 6: prose interleaving
|
||||
it('flushes prose around a recognized block but not the markup itself (spec case 6)', () => {
|
||||
const input = 'I will read the file.\n<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>\nThanks.';
|
||||
const result = extractToolCallBlocks(input);
|
||||
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
||||
expect(result.flushed).toBe('I will read the file.\n\nThanks.');
|
||||
expect(result.remaining).toBe('');
|
||||
});
|
||||
|
||||
// Spec case 7 regression
|
||||
it('extracts a <tool_call> Qwen block alongside the new code path (spec case 7 regression)', () => {
|
||||
const input = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
||||
const result = extractToolCallBlocks(input);
|
||||
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
||||
expect(result.flushed).toBe('');
|
||||
expect(result.remaining).toBe('');
|
||||
});
|
||||
|
||||
it('extracts mixed-format blocks in source order (hand-back: shared counter)', () => {
|
||||
const input =
|
||||
'<invoke name="view_file"><parameter name="path">/a</parameter></invoke>' +
|
||||
' middle ' +
|
||||
'<tool_call><function=grep><parameter=pattern>foo</parameter></function></tool_call>';
|
||||
const result = extractToolCallBlocks(input);
|
||||
expect(result.calls).toEqual([
|
||||
{ name: 'view_file', args: { path: '/a' } },
|
||||
{ name: 'grep', args: { pattern: 'foo' } },
|
||||
]);
|
||||
expect(result.flushed).toBe(' middle ');
|
||||
expect(result.remaining).toBe('');
|
||||
});
|
||||
|
||||
it('drops a malformed <invoke> block silently (matches existing <tool_call> behavior)', () => {
|
||||
const input = 'prose <invoke><parameter name="path">/a</parameter></invoke> trailing';
|
||||
const result = extractToolCallBlocks(input);
|
||||
expect(result.calls).toEqual([]);
|
||||
expect(result.flushed).toBe('prose trailing');
|
||||
expect(result.remaining).toBe('');
|
||||
});
|
||||
|
||||
it('holds a tail with a fresh partial opener after extracting earlier complete blocks', () => {
|
||||
const input = '<invoke name="view_file"><parameter name="path">/a</parameter></invoke> next: <tool_';
|
||||
const result = extractToolCallBlocks(input);
|
||||
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/a' } }]);
|
||||
expect(result.flushed).toBe(' next: ');
|
||||
expect(result.remaining).toBe('<tool_');
|
||||
});
|
||||
|
||||
it('passes plain prose straight through when no markup is present', () => {
|
||||
const input = 'just some text with a < character but no opener';
|
||||
const result = extractToolCallBlocks(input);
|
||||
expect(result.calls).toEqual([]);
|
||||
expect(result.flushed).toBe(input);
|
||||
expect(result.remaining).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('levenshtein', () => {
|
||||
it('returns 0 for identical strings', () => {
|
||||
expect(levenshtein('view_file', 'view_file')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the length when one string is empty', () => {
|
||||
expect(levenshtein('', 'view_file')).toBe(9);
|
||||
expect(levenshtein('view_file', '')).toBe(9);
|
||||
});
|
||||
|
||||
it('computes a small distance for a single-character substitution', () => {
|
||||
expect(levenshtein('cat', 'bat')).toBe(1);
|
||||
});
|
||||
|
||||
it('computes a known case: read_file → view_file is 4', () => {
|
||||
// r→v, e→i, a→e, d→w → 4 substitutions, same length
|
||||
expect(levenshtein('read_file', 'view_file')).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestToolName (v1.13.16)', () => {
|
||||
const tools = [
|
||||
'view_file',
|
||||
'list_dir',
|
||||
'grep',
|
||||
'find_files',
|
||||
'view_truncated_output',
|
||||
'ask_user_input',
|
||||
'web_search',
|
||||
];
|
||||
|
||||
it('suggests the closest match when distance is small', () => {
|
||||
expect(suggestToolName('view_files', tools)).toBe('view_file');
|
||||
});
|
||||
|
||||
it('suggests via substring match when distance alone would miss', () => {
|
||||
// 'file' is a substring of multiple tools; closest by distance wins.
|
||||
expect(suggestToolName('file', tools)).toBe('view_file');
|
||||
});
|
||||
|
||||
it('returns null when nothing is close', () => {
|
||||
expect(suggestToolName('xxxx_yyyy_zzzz', tools)).toBeNull();
|
||||
});
|
||||
|
||||
it('is case-insensitive in the distance check', () => {
|
||||
expect(suggestToolName('VIEW_FILE', tools)).toBe('view_file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUnknownToolError (v1.13.16)', () => {
|
||||
const tools = ['view_file', 'list_dir', 'grep', 'find_files'];
|
||||
|
||||
it('includes the wrong name and the available tools list', () => {
|
||||
const msg = formatUnknownToolError('read_file', tools);
|
||||
expect(msg).toContain("Tool 'read_file' not found");
|
||||
expect(msg).toContain('Available tools:');
|
||||
expect(msg).toContain('view_file');
|
||||
expect(msg).toContain('find_files');
|
||||
});
|
||||
|
||||
it('includes a suggestion when the drifted name is within threshold', () => {
|
||||
// distance(view_files, view_file) = 1 (one extra char)
|
||||
const msg = formatUnknownToolError('view_files', tools);
|
||||
expect(msg).toContain('Did you mean: view_file?');
|
||||
});
|
||||
|
||||
it('omits the suggestion clause when no tool is close enough', () => {
|
||||
const msg = formatUnknownToolError('zzzzzzz', tools);
|
||||
expect(msg).toContain("Tool 'zzzzzzz' not found");
|
||||
expect(msg).toContain('Available tools:');
|
||||
expect(msg).not.toContain('Did you mean');
|
||||
});
|
||||
|
||||
// The drift incident in the recon (chat 30d8…1be7167, msg 7ff558f4) had the
|
||||
// model emit <invoke name="read_file">. lev(read_file, view_file) = 4, so
|
||||
// the spec's threshold (<=3) doesn't suggest view_file — the model still
|
||||
// gets the available-tools list to pick from. This pins that behavior so a
|
||||
// future loosening of the threshold is a deliberate choice.
|
||||
it('does not suggest view_file for the read_file drift case (distance is 4, over threshold)', () => {
|
||||
const msg = formatUnknownToolError('read_file', tools);
|
||||
expect(msg).not.toContain('Did you mean');
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,10 @@ interface ParsedFrontmatter {
|
||||
// v1.8.2: optional per-agent tool-loop budget. Absent → inference resolves
|
||||
// from the agent's toolset at runtime.
|
||||
max_tool_calls?: number;
|
||||
// v1.14.0: optional per-agent step cap. Absent → bounded only by MAX_STEPS
|
||||
// (200) in the outer loop. Integer ≥ 0; steps: 0 means "no tool calls
|
||||
// allowed" — the model responds text-only.
|
||||
steps?: number;
|
||||
}
|
||||
|
||||
function stripQuotes(s: string): string {
|
||||
@@ -112,6 +116,21 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
|
||||
} else {
|
||||
errors.push(`max_tool_calls must be an integer 1-100 (got "${valueRaw}")`);
|
||||
}
|
||||
} else if (key === 'steps') {
|
||||
// v1.14.0: per-agent step cap for the outer inference loop. Integer ≥ 0.
|
||||
// steps: 0 means "no tool calls allowed" — model responds text-only.
|
||||
// Non-integer or negative values are warned and ignored (falls back to
|
||||
// MAX_STEPS ceiling), matching the max_tool_calls pattern above.
|
||||
const n = Number(valueRaw);
|
||||
if (Number.isInteger(n) && n >= 0) {
|
||||
data.steps = n;
|
||||
} else if (Number.isInteger(n)) {
|
||||
console.warn(
|
||||
`agents: steps ${n} is negative, ignoring (falling back to default)`,
|
||||
);
|
||||
} else {
|
||||
errors.push(`steps must be a non-negative integer (got "${valueRaw}")`);
|
||||
}
|
||||
}
|
||||
// Unknown keys silently ignored — forward-compat.
|
||||
}
|
||||
@@ -204,6 +223,7 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
||||
tools: filteredTools,
|
||||
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
|
||||
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
|
||||
steps: typeof fm.steps === 'number' ? fm.steps : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
255
apps/server/src/services/artifacts.ts
Normal file
255
apps/server/src/services/artifacts.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
// v1.14.x-html-artifact-panes: artifact writer + slug derivation.
|
||||
//
|
||||
// Writes Markdown and HTML artifacts to `<projectRoot>/.boocode/artifacts/`
|
||||
// as plain files. Returns `{path, url}` where:
|
||||
// - path is the absolute on-disk path
|
||||
// - url is a project-scoped REST URL pointing at the GET download route
|
||||
// registered in routes/artifacts.ts. The route streams the file with
|
||||
// Content-Disposition: attachment.
|
||||
//
|
||||
// Path safety: we do NOT use path_guard.ts (it realpaths and throws ENOENT
|
||||
// for files that don't exist yet, which artifact creation requires).
|
||||
// Instead we mirror the v1.13.18 codecontext_client.ts pattern: resolve
|
||||
// the candidate path against the realpath'd projectRoot, then verify the
|
||||
// result starts with projectRoot + sep (or equals projectRoot).
|
||||
|
||||
import { mkdir, realpath, writeFile } from 'node:fs/promises';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { PathScopeError } from './path_guard.js';
|
||||
import type { Message } from '../types/api.js';
|
||||
|
||||
export interface HtmlArtifactPayload {
|
||||
html_content: string;
|
||||
char_count: number;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface ArtifactWriteResult {
|
||||
path: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const ARTIFACT_SUBDIR = '.boocode/artifacts';
|
||||
|
||||
// ---- slug helpers ----
|
||||
|
||||
// Lowercase, replace non-alnum runs with '-', trim leading/trailing '-',
|
||||
// collapse repeated '-', cap at 60 chars. Empty → 'artifact'.
|
||||
function slugify(input: string): string {
|
||||
const cleaned = input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.slice(0, 60)
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return cleaned || 'artifact';
|
||||
}
|
||||
|
||||
function firstHeading(md: string): string | null {
|
||||
// Match the first `# ` ATX heading at the start of a line.
|
||||
const m = md.match(/^[ \t]*#[ \t]+(.+?)\s*$/m);
|
||||
if (!m) return null;
|
||||
const text = m[1]?.trim() ?? '';
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
function firstNWords(s: string, n: number): string {
|
||||
const words = s.trim().split(/\s+/).filter(Boolean).slice(0, n);
|
||||
return words.join(' ');
|
||||
}
|
||||
|
||||
export function deriveMarkdownSlug(messageContent: string): string {
|
||||
const heading = firstHeading(messageContent);
|
||||
if (heading) return slugify(heading);
|
||||
const sixWords = firstNWords(messageContent, 6);
|
||||
return slugify(sixWords);
|
||||
}
|
||||
|
||||
// Strip HTML tags for inner-text extraction. Crude but sufficient for slug
|
||||
// derivation — we're not rendering, just finding readable words.
|
||||
function stripTags(html: string): string {
|
||||
return html
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
|
||||
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractTitleTag(html: string): string | null {
|
||||
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
if (!m) return null;
|
||||
const text = stripTags(m[1] ?? '').trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
function extractH1(html: string): string | null {
|
||||
const m = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
||||
if (!m) return null;
|
||||
const text = stripTags(m[1] ?? '').trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
export function deriveHtmlSlug(payload: {
|
||||
html_content: string;
|
||||
title: string | null;
|
||||
}): string {
|
||||
if (payload.title && payload.title.trim().length > 0) {
|
||||
return slugify(payload.title);
|
||||
}
|
||||
const title = extractTitleTag(payload.html_content);
|
||||
if (title) return slugify(title);
|
||||
const h1 = extractH1(payload.html_content);
|
||||
if (h1) return slugify(h1);
|
||||
const inner = stripTags(payload.html_content);
|
||||
return slugify(firstNWords(inner, 6));
|
||||
}
|
||||
|
||||
// Derive title for the html_artifact part payload: <title> → first <h1> →
|
||||
// first 80 chars of inner text. Returns null if nothing useful is found.
|
||||
export function deriveHtmlTitle(html: string): string | null {
|
||||
const t = extractTitleTag(html);
|
||||
if (t) return t;
|
||||
const h1 = extractH1(html);
|
||||
if (h1) return h1;
|
||||
const inner = stripTags(html);
|
||||
if (inner.length === 0) return null;
|
||||
return inner.slice(0, 80);
|
||||
}
|
||||
|
||||
// ---- HTML detection (B4) ----
|
||||
|
||||
// Returns the inner HTML content if `text` is a recognised HTML artifact:
|
||||
// - starts with <!DOCTYPE html> (case-insensitive, whitespace-trimmed), OR
|
||||
// - wrapped entirely in a fenced ```html ... ``` block.
|
||||
// Returns null if neither matches.
|
||||
export function detectHtmlArtifact(text: string): string | null {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
if (/^<!doctype\s+html/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
// Fenced ```html block consuming the entire (trimmed) message. Allow an
|
||||
// optional trailing newline before the closing fence.
|
||||
const fence = trimmed.match(/^```html\s*\n([\s\S]*?)\n?```\s*$/i);
|
||||
if (fence) {
|
||||
const inner = fence[1] ?? '';
|
||||
if (/^\s*<!doctype\s+html/i.test(inner) || /<html[\s>]/i.test(inner)) {
|
||||
return inner.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- path resolution ----
|
||||
|
||||
// Resolve `<projectRoot>/.boocode/artifacts/<filename>` and verify the
|
||||
// result stays under projectRoot. Mirrors the v1.13.18 codecontext_client.ts
|
||||
// approach: realpath projectRoot first, then prefix-check the candidate.
|
||||
// Throws on escape.
|
||||
async function resolveArtifactPath(
|
||||
projectRoot: string,
|
||||
filename: string,
|
||||
): Promise<{ resolvedRoot: string; artifactsDir: string; absPath: string }> {
|
||||
const resolvedRoot = await realpath(projectRoot);
|
||||
const artifactsDir = resolve(resolvedRoot, ARTIFACT_SUBDIR);
|
||||
const absPath = resolve(artifactsDir, filename);
|
||||
// Lexical prefix check on the resolved candidates. (The `!== resolvedRoot`
|
||||
// branch was dead — ARTIFACT_SUBDIR is non-empty so artifactsDir always
|
||||
// differs from resolvedRoot.)
|
||||
if (!artifactsDir.startsWith(resolvedRoot + sep)) {
|
||||
throw new PathScopeError(
|
||||
`artifacts dir escapes project root: ${artifactsDir}`,
|
||||
);
|
||||
}
|
||||
if (!absPath.startsWith(artifactsDir + sep)) {
|
||||
throw new PathScopeError(
|
||||
`artifact filename escapes artifacts dir: ${filename}`,
|
||||
);
|
||||
}
|
||||
return { resolvedRoot, artifactsDir, absPath };
|
||||
}
|
||||
|
||||
// After mkdir, realpath the artifacts dir and re-verify it stays under
|
||||
// resolvedRoot. Closes the symlink-escape gap: if `.boocode/artifacts` (or
|
||||
// any ancestor below resolvedRoot) is a symlink pointing outside the
|
||||
// project, the lexical check in resolveArtifactPath passes but the actual
|
||||
// write lands outside the sandbox. Throws PathScopeError on escape.
|
||||
async function assertArtifactsDirSafe(
|
||||
artifactsDir: string,
|
||||
resolvedRoot: string,
|
||||
): Promise<void> {
|
||||
const realDir = await realpath(artifactsDir);
|
||||
if (realDir !== resolvedRoot && !realDir.startsWith(resolvedRoot + sep)) {
|
||||
throw new PathScopeError(
|
||||
`artifacts dir resolves outside project root: ${realDir}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pure decision helper for whether finalizeCompletion should write the
|
||||
// `html_artifact` part. Exported for unit testing the cap-skip branch.
|
||||
// Returns `{write: true, byteLen}` when the payload is under the cap, or
|
||||
// `{write: false, byteLen, reason: 'cap_exceeded'}` when oversize.
|
||||
export type HtmlArtifactDecision =
|
||||
| { write: true; byteLen: number }
|
||||
| { write: false; byteLen: number; reason: 'cap_exceeded' };
|
||||
|
||||
export function decideHtmlArtifactWrite(
|
||||
htmlContent: string,
|
||||
): HtmlArtifactDecision {
|
||||
const byteLen = Buffer.byteLength(htmlContent, 'utf8');
|
||||
if (byteLen > HTML_ARTIFACT_MAX_BYTES) {
|
||||
return { write: false, byteLen, reason: 'cap_exceeded' };
|
||||
}
|
||||
return { write: true, byteLen };
|
||||
}
|
||||
|
||||
function buildUrl(projectId: string, filename: string): string {
|
||||
return `/api/projects/${projectId}/artifacts/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
export interface WriteContext {
|
||||
projectId: string;
|
||||
projectRoot: string;
|
||||
}
|
||||
|
||||
export async function writeMarkdownArtifact(
|
||||
message: Pick<Message, 'content'>,
|
||||
ctx: WriteContext,
|
||||
): Promise<ArtifactWriteResult> {
|
||||
const slug = deriveMarkdownSlug(message.content);
|
||||
const filename = `${slug}-${Date.now()}.md`;
|
||||
const { resolvedRoot, artifactsDir, absPath } = await resolveArtifactPath(
|
||||
ctx.projectRoot,
|
||||
filename,
|
||||
);
|
||||
await mkdir(artifactsDir, { recursive: true });
|
||||
await assertArtifactsDirSafe(artifactsDir, resolvedRoot);
|
||||
await writeFile(absPath, message.content, 'utf8');
|
||||
return { path: absPath, url: buildUrl(ctx.projectId, filename) };
|
||||
}
|
||||
|
||||
export async function writeHtmlArtifact(
|
||||
payload: HtmlArtifactPayload,
|
||||
ctx: WriteContext,
|
||||
): Promise<ArtifactWriteResult> {
|
||||
const slug = deriveHtmlSlug(payload);
|
||||
const filename = `${slug}-${Date.now()}.html`;
|
||||
const { resolvedRoot, artifactsDir, absPath } = await resolveArtifactPath(
|
||||
ctx.projectRoot,
|
||||
filename,
|
||||
);
|
||||
await mkdir(artifactsDir, { recursive: true });
|
||||
await assertArtifactsDirSafe(artifactsDir, resolvedRoot);
|
||||
await writeFile(absPath, payload.html_content, 'utf8');
|
||||
return { path: absPath, url: buildUrl(ctx.projectId, filename) };
|
||||
}
|
||||
|
||||
// 1MB cap on HTML artifacts (proposal S6). Larger payloads are not written
|
||||
// to the `html_artifact` part — the assistant text lands as plain content
|
||||
// and a warning is logged. Streaming abort was considered but the graceful
|
||||
// "no artifact, plain text falls back" path is simpler and lossless from
|
||||
// the user's perspective.
|
||||
export const HTML_ARTIFACT_MAX_BYTES = 1_048_576;
|
||||
@@ -17,7 +17,7 @@
|
||||
// which we re-surface with a hint to add the file to .codecontextignore.
|
||||
|
||||
import { access, copyFile, realpath } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { isAbsolute, join, resolve, sep } from 'node:path';
|
||||
import { truncateIfNeeded } from './truncate.js';
|
||||
|
||||
// v1.13.12 fix: codecontext crashes on empty source files (upstream issue #37)
|
||||
@@ -51,6 +51,45 @@ async function ensureIgnoreFile(projectRoot: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.13.18: resolve a `file_path` arg to an absolute path anchored within
|
||||
// the (already realpath'd) projectRoot. Contract:
|
||||
// - empty/whitespace-only → INVALID_FILE_PATH error
|
||||
// - relative path → resolve(projectRoot, rawPath) (normalises dot-segments)
|
||||
// - absolute path → resolve(rawPath) (also normalises — e.g. /root/../etc
|
||||
// becomes /etc so the prefix-check below rejects it even in the ENOENT
|
||||
// fallthrough where realpath couldn't canonicalise)
|
||||
// - try realpath; on ENOENT fall through with the (normalised) absolute
|
||||
// (the sidecar issues its own "File not found in graph" that the model
|
||||
// can self-correct on; re-implementing the check here would diverge)
|
||||
// - if the final path doesn't sit inside projectRoot → escape error
|
||||
// (same shape as target_dir escape, only the field name differs)
|
||||
async function resolveProjectPath(
|
||||
projectRoot: string,
|
||||
rawPath: string,
|
||||
): Promise<string> {
|
||||
if (rawPath.trim() === '') {
|
||||
throw new Error('INVALID_FILE_PATH: file_path must not be empty');
|
||||
}
|
||||
const candidate = isAbsolute(rawPath) ? resolve(rawPath) : resolve(projectRoot, rawPath);
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = await realpath(candidate);
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
// File doesn't exist yet (or was deleted). Forward the absolute path;
|
||||
// codecontext will return "File not found in graph" which the model
|
||||
// can self-correct on.
|
||||
resolved = candidate;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (resolved !== projectRoot && !resolved.startsWith(projectRoot + sep)) {
|
||||
throw new Error(`file_path ${rawPath} escapes project root ${projectRoot}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export interface CodecontextRequest {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
@@ -96,7 +135,14 @@ export async function callCodecontext(
|
||||
|
||||
// Step 2: re-build args with the resolved target_dir so codecontext sees
|
||||
// the real absolute path, not a symlink or relative form.
|
||||
const argsToSend = { ...req.args, target_dir: resolvedTarget };
|
||||
// v1.13.18: also resolve file_path when present — the sidecar index is keyed
|
||||
// on absolute paths, so a relative path from the model yields "File not found
|
||||
// in graph". Same escape check as target_dir; ENOENT falls through so the
|
||||
// sidecar produces the canonical "File not found in graph" the model can fix.
|
||||
const argsToSend: Record<string, unknown> = { ...req.args, target_dir: resolvedTarget };
|
||||
if (typeof req.args['file_path'] === 'string' && req.args['file_path'].trim() !== '') {
|
||||
argsToSend['file_path'] = await resolveProjectPath(resolvedProject, req.args['file_path']);
|
||||
}
|
||||
|
||||
// Step 3: POST with a hard timeout. AbortController + setTimeout pattern
|
||||
// matches web_fetch.ts; nothing fancier needed.
|
||||
|
||||
@@ -47,8 +47,12 @@ export interface FindFilesResult {
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export async function listDir(projectRoot: string, relPath: string): Promise<ListDirResult> {
|
||||
const real = await pathGuard(projectRoot, relPath);
|
||||
export async function listDir(
|
||||
projectRoot: string,
|
||||
relPath: string,
|
||||
opts?: { extra_roots?: readonly string[] },
|
||||
): Promise<ListDirResult> {
|
||||
const real = await pathGuard(projectRoot, relPath, opts?.extra_roots);
|
||||
const s = await stat(real);
|
||||
if (!s.isDirectory()) {
|
||||
throw new PathScopeError(`not a directory: ${relPath}`);
|
||||
@@ -82,8 +86,12 @@ export async function listDir(projectRoot: string, relPath: string): Promise<Lis
|
||||
};
|
||||
}
|
||||
|
||||
export async function viewFile(projectRoot: string, relPath: string): Promise<ViewFileResult> {
|
||||
const real = await pathGuard(projectRoot, relPath);
|
||||
export async function viewFile(
|
||||
projectRoot: string,
|
||||
relPath: string,
|
||||
opts?: { extra_roots?: readonly string[] },
|
||||
): Promise<ViewFileResult> {
|
||||
const real = await pathGuard(projectRoot, relPath, opts?.extra_roots);
|
||||
const s = await stat(real);
|
||||
if (!s.isFile()) {
|
||||
throw new PathScopeError(`not a file: ${relPath}`);
|
||||
@@ -119,10 +127,10 @@ interface RipgrepMatch {
|
||||
export async function grep(
|
||||
projectRoot: string,
|
||||
pattern: string,
|
||||
opts?: { path?: string; max_matches?: number; case_sensitive?: boolean; hidden?: boolean }
|
||||
opts?: { path?: string; max_matches?: number; case_sensitive?: boolean; hidden?: boolean; extra_roots?: readonly string[] }
|
||||
): Promise<GrepResult> {
|
||||
const targetPath = opts?.path ?? projectRoot;
|
||||
const target = await pathGuard(projectRoot, targetPath);
|
||||
const target = await pathGuard(projectRoot, targetPath, opts?.extra_roots);
|
||||
const limit = Math.min(
|
||||
Math.max(opts?.max_matches ?? DEFAULT_GREP_RESULTS, 1),
|
||||
MAX_GREP_RESULTS
|
||||
@@ -192,14 +200,14 @@ export async function grep(
|
||||
export async function findFiles(
|
||||
projectRoot: string,
|
||||
pattern?: string,
|
||||
opts?: { type?: 'file' | 'dir'; max_results?: number; path?: string }
|
||||
opts?: { type?: 'file' | 'dir'; max_results?: number; path?: string; extra_roots?: readonly string[] }
|
||||
): Promise<FindFilesResult> {
|
||||
const limit = Math.min(
|
||||
Math.max(opts?.max_results ?? DEFAULT_FIND_RESULTS, 1),
|
||||
MAX_FIND_RESULTS
|
||||
);
|
||||
const target = opts?.path != null
|
||||
? await pathGuard(projectRoot, opts.path)
|
||||
? await pathGuard(projectRoot, opts.path, opts?.extra_roots)
|
||||
: projectRoot;
|
||||
const args = ['--files'];
|
||||
if (pattern) args.push('--glob', pattern);
|
||||
|
||||
161
apps/server/src/services/grant_resolver.ts
Normal file
161
apps/server/src/services/grant_resolver.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// v1.13.17-cross-repo-reads: derives the grant root for a path the user is
|
||||
// being asked to approve cross-repo read access to.
|
||||
//
|
||||
// Per design decision D1: grant unit = nearest registered project root,
|
||||
// then nearest path-whitelist ancestor that looks like a repo root, then
|
||||
// refuse. Granting the literal file path is too narrow (next file in the
|
||||
// same repo re-prompts). Granting an arbitrary parent dir over-scopes.
|
||||
//
|
||||
// The resolver runs in two contexts:
|
||||
// 1. request_read_access.execute — pre-prompt validation (cheap; bails
|
||||
// early if the path can't plausibly be granted so the user is never
|
||||
// asked about /etc/passwd)
|
||||
// 2. POST /api/chats/:id/grant_read_access — at decision time, re-derives
|
||||
// the root and persists it on sessions.allowed_read_paths
|
||||
//
|
||||
// Sam (2026-05-22 dispatch confirmation): "in the project-root resolver
|
||||
// ancestor walk, stop the moment parent exits PROJECT_ROOT_WHITELIST or hits
|
||||
// filesystem root — check on every iteration, not just final parent.
|
||||
// Symlinked input must not be able to escape the whitelist during the
|
||||
// walk." Hence the loop here checks both the walk bound AND the still-
|
||||
// inside-whitelist invariant every step.
|
||||
|
||||
import { access, realpath } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import { dirname, isAbsolute, sep } from 'node:path';
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
// Files whose presence in a directory marks it as a repo root for grant
|
||||
// purposes. Kept narrow on purpose; broader heuristics (e.g. ".project",
|
||||
// "pyproject.toml") can be added with measured intent. Each entry is a
|
||||
// literal basename — no globs.
|
||||
const REPO_MARKERS: ReadonlyArray<string> = [
|
||||
'.git',
|
||||
'package.json',
|
||||
'go.mod',
|
||||
'Cargo.toml',
|
||||
];
|
||||
|
||||
export type GrantResolution =
|
||||
| { ok: true; root: string; source: 'project' | 'whitelist' }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
function isUnder(child: string, parent: string): boolean {
|
||||
return child === parent || child.startsWith(parent + sep);
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isRepoShaped(dir: string): Promise<boolean> {
|
||||
for (const marker of REPO_MARKERS) {
|
||||
if (await exists(`${dir}${sep}${marker}`)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolves an absolute path to its grant root or refuses with a reason
|
||||
// string suitable for surfacing to the model. Pure helper — no DB writes,
|
||||
// no broker publishes. Caller persists the root on session.allowed_read_paths
|
||||
// if it wants the grant to stick.
|
||||
//
|
||||
// Arguments:
|
||||
// sql — used only to read projects.path (no writes)
|
||||
// requestedPath — absolute path the model wants to read
|
||||
// projectRoot — the session's primary project root (already
|
||||
// realpath'd by caller). Used to short-circuit
|
||||
// "already in scope".
|
||||
// whitelistRoot — PROJECT_ROOT_WHITELIST from config (default /opt).
|
||||
// Walk bound for the repo-shape fallback.
|
||||
//
|
||||
// Returns { ok: true, root, source } on success; { ok: false, reason } else.
|
||||
export async function resolveGrantRoot(
|
||||
sql: Sql,
|
||||
requestedPath: string,
|
||||
projectRoot: string,
|
||||
whitelistRoot: string,
|
||||
): Promise<GrantResolution> {
|
||||
if (typeof requestedPath !== 'string' || requestedPath.length === 0) {
|
||||
return { ok: false, reason: 'path is required' };
|
||||
}
|
||||
if (!isAbsolute(requestedPath)) {
|
||||
return { ok: false, reason: 'path must be absolute' };
|
||||
}
|
||||
|
||||
// Resolve symlinks so subsequent ancestor checks compare apples-to-apples
|
||||
// with realpath'd projectRoot. If the path doesn't exist at all, bail
|
||||
// before bothering the user — the model is asking about a phantom.
|
||||
let real: string;
|
||||
try {
|
||||
real = await realpath(requestedPath);
|
||||
} catch {
|
||||
return { ok: false, reason: `path does not exist: ${requestedPath}` };
|
||||
}
|
||||
|
||||
// Whitelist guard. Symlinked inputs can resolve outside the whitelist
|
||||
// even when the surface-form path looks inside it; that's why we test
|
||||
// the *real* path here, not the requested one.
|
||||
let realWhitelist: string;
|
||||
try {
|
||||
realWhitelist = await realpath(whitelistRoot);
|
||||
} catch {
|
||||
return { ok: false, reason: `whitelist root does not exist: ${whitelistRoot}` };
|
||||
}
|
||||
if (!isUnder(real, realWhitelist)) {
|
||||
return { ok: false, reason: 'path outside permitted scope' };
|
||||
}
|
||||
|
||||
// Already in scope? No prompt needed; the tool's caller should retry.
|
||||
if (isUnder(real, projectRoot)) {
|
||||
return { ok: false, reason: 'path already accessible without a grant' };
|
||||
}
|
||||
|
||||
// Look for a registered project whose root is an ancestor of the
|
||||
// requested path. Pick the LONGEST match (nearest ancestor wins) so
|
||||
// sub-projects don't get over-broadened.
|
||||
const projectRows = await sql<{ path: string }[]>`
|
||||
SELECT path FROM projects WHERE status = 'open'
|
||||
`;
|
||||
let bestProject: string | null = null;
|
||||
for (const row of projectRows) {
|
||||
if (!row.path) continue;
|
||||
if (!isUnder(real, row.path)) continue;
|
||||
if (bestProject === null || row.path.length > bestProject.length) {
|
||||
bestProject = row.path;
|
||||
}
|
||||
}
|
||||
if (bestProject !== null) {
|
||||
return { ok: true, root: bestProject, source: 'project' };
|
||||
}
|
||||
|
||||
// Repo-shape fallback. Walk from the requested path upward toward the
|
||||
// whitelist root. At every iteration: confirm we're still inside the
|
||||
// whitelist (so a symlinked component can't slip the bound mid-walk)
|
||||
// and confirm we haven't hit the filesystem root. The first dir with a
|
||||
// REPO_MARKER child is the grant root.
|
||||
let cursor = real;
|
||||
while (true) {
|
||||
// Don't grant the whitelist root itself — that would be far too broad.
|
||||
if (cursor === realWhitelist) {
|
||||
return { ok: false, reason: 'no repo-shaped ancestor found under whitelist' };
|
||||
}
|
||||
if (!isUnder(cursor, realWhitelist)) {
|
||||
return { ok: false, reason: 'path outside permitted scope' };
|
||||
}
|
||||
const parent = dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
// Hit filesystem root without finding a repo marker.
|
||||
return { ok: false, reason: 'no repo-shaped ancestor found under whitelist' };
|
||||
}
|
||||
if (await isRepoShaped(cursor)) {
|
||||
return { ok: true, root: cursor, source: 'whitelist' };
|
||||
}
|
||||
cursor = parent;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { MessageMetadata, Session } from '../../types/api.js';
|
||||
import {
|
||||
decideHtmlArtifactWrite,
|
||||
detectHtmlArtifact,
|
||||
deriveHtmlTitle,
|
||||
HTML_ARTIFACT_MAX_BYTES,
|
||||
} from '../artifacts.js';
|
||||
import * as modelContext from '../model-context.js';
|
||||
import { maybeFlagForCompaction } from './payload.js';
|
||||
import { insertParts, partsFromAssistantMessage } from './parts.js';
|
||||
import type { PartInsert } from './parts.js';
|
||||
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
|
||||
|
||||
export async function handleAbortOrError(
|
||||
@@ -120,17 +127,42 @@ export async function finalizeCompletion(
|
||||
// a kind='reasoning' part alongside the text.
|
||||
// TODO(v1.13.1): wrap the UPDATE above and this insertParts in a single
|
||||
// sql.begin before flipping read authority to message_parts.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromAssistantMessage({
|
||||
const baseParts: PartInsert[] = partsFromAssistantMessage({
|
||||
content,
|
||||
tool_calls: null,
|
||||
reasoning: result.reasoning,
|
||||
}).map((p) => ({
|
||||
...p,
|
||||
message_id: assistantMessageId,
|
||||
})),
|
||||
}));
|
||||
// v1.14.x-html-artifact-panes: opportunistic HTML detection. Adds a
|
||||
// SIBLING html_artifact part — never replaces the text part. 1MB cap is
|
||||
// graceful: oversized payloads are skipped and the assistant message
|
||||
// lands as plain content (warn logged).
|
||||
const htmlContent = detectHtmlArtifact(content);
|
||||
if (htmlContent !== null) {
|
||||
const decision = decideHtmlArtifactWrite(htmlContent);
|
||||
if (!decision.write) {
|
||||
ctx.log.warn(
|
||||
{ assistantMessageId, byteLen: decision.byteLen, cap: HTML_ARTIFACT_MAX_BYTES },
|
||||
'html_artifact exceeded 1MB cap; skipping artifact part',
|
||||
);
|
||||
} else {
|
||||
const title = deriveHtmlTitle(htmlContent);
|
||||
const nextSeq = baseParts.reduce((m, p) => Math.max(m, p.sequence), -1) + 1;
|
||||
baseParts.push({
|
||||
message_id: assistantMessageId,
|
||||
sequence: nextSeq,
|
||||
kind: 'html_artifact',
|
||||
payload: {
|
||||
html_content: htmlContent,
|
||||
char_count: htmlContent.length,
|
||||
title,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
await insertParts(ctx.sql, baseParts);
|
||||
// v1.11: flag for compaction on the terminal turn too. Catches the common
|
||||
// case of a turn that hit the limit without invoking tools.
|
||||
await maybeFlagForCompaction(ctx, chatId, updated);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
export {
|
||||
createInferenceRunner,
|
||||
MAX_STEPS,
|
||||
runAssistantTurn,
|
||||
runInference,
|
||||
} from './turn.js';
|
||||
@@ -16,5 +17,6 @@ export type {
|
||||
StreamResult,
|
||||
TurnArgs,
|
||||
} from './turn.js';
|
||||
export type { ToolPhaseResult } from './tool-phase.js';
|
||||
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
||||
export { buildMessagesPayload } from './payload.js';
|
||||
|
||||
@@ -11,13 +11,16 @@ import type { ToolCall, ToolResult } from '../../types/api.js';
|
||||
// (schema.sql adds 'synthesis' to message_parts_kind_chk on startup). The
|
||||
// dispatch's claim that no schema migration was needed assumed kind was a
|
||||
// bare text column — it isn't; the constraint enumerates allowed values.
|
||||
// v1.14.x-html-artifact-panes: 'html_artifact' added. Schema CHECK constraint
|
||||
// in schema.sql updated in lockstep.
|
||||
export type PartKind =
|
||||
| 'text'
|
||||
| 'tool_call'
|
||||
| 'tool_result'
|
||||
| 'reasoning'
|
||||
| 'step_start'
|
||||
| 'synthesis';
|
||||
| 'synthesis'
|
||||
| 'html_artifact';
|
||||
|
||||
export interface PartInsert {
|
||||
message_id: string;
|
||||
|
||||
@@ -476,6 +476,202 @@ export async function runDoomLoopSummary(
|
||||
);
|
||||
}
|
||||
|
||||
// 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 },
|
||||
(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(
|
||||
ctx: InferenceContext,
|
||||
sessionId: string,
|
||||
|
||||
@@ -6,12 +6,9 @@ import type {
|
||||
import * as modelContext from '../model-context.js';
|
||||
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
|
||||
import type { OpenAiMessage } from './payload.js';
|
||||
import {
|
||||
XML_TOOL_CLOSE,
|
||||
XML_TOOL_OPEN,
|
||||
parseXmlToolCall,
|
||||
partialXmlOpenerStart,
|
||||
} from './xml-parser.js';
|
||||
// v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and
|
||||
// recognizes both Qwen <tool_call> and Anthropic <invoke> markup in one pass.
|
||||
import { extractToolCallBlocks } from './xml-parser.js';
|
||||
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
|
||||
import type {
|
||||
InferenceContext,
|
||||
@@ -132,16 +129,24 @@ function buildAiTools(schemas: ToolJsonSchema[]): Record<string, ReturnType<type
|
||||
// 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, mirroring the pre-AI-SDK behavior.
|
||||
// before flushing it to the client.
|
||||
//
|
||||
// XML shape:
|
||||
// Qwen shape:
|
||||
// <tool_call>
|
||||
// <function=NAME>
|
||||
// <parameter=KEY>VALUE</parameter>
|
||||
// ...
|
||||
// </function>
|
||||
// </tool_call>
|
||||
// Multiple <tool_call> blocks may appear back-to-back; they never nest.
|
||||
//
|
||||
// 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,
|
||||
@@ -209,47 +214,24 @@ export async function streamCompletion(
|
||||
switch (part.type) {
|
||||
case 'text-delta': {
|
||||
pendingBuffer += part.text;
|
||||
// Extract any complete <tool_call>...</tool_call> blocks before
|
||||
// flushing visible text.
|
||||
while (true) {
|
||||
const startIdx = pendingBuffer.indexOf(XML_TOOL_OPEN);
|
||||
if (startIdx === -1) break;
|
||||
const closeIdx = pendingBuffer.indexOf(XML_TOOL_CLOSE, startIdx);
|
||||
if (closeIdx === -1) break;
|
||||
const blockEnd = closeIdx + XML_TOOL_CLOSE.length;
|
||||
const block = pendingBuffer.slice(startIdx, blockEnd);
|
||||
if (startIdx > 0) {
|
||||
const before = pendingBuffer.slice(0, startIdx);
|
||||
content += before;
|
||||
onDelta(before);
|
||||
// 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);
|
||||
}
|
||||
const parsedCall = parseXmlToolCall(block);
|
||||
if (parsedCall) {
|
||||
for (const call of extracted.calls) {
|
||||
const synthIdx = toolCalls.length;
|
||||
toolCalls.push({
|
||||
id: `xml_call_${synthIdx}`,
|
||||
name: parsedCall.name,
|
||||
args: parsedCall.args,
|
||||
name: call.name,
|
||||
args: call.args,
|
||||
});
|
||||
}
|
||||
// Parse failures still drop the block — leaking <tool_call> XML to
|
||||
// the chat would look worse than silently swallowing the bad block.
|
||||
pendingBuffer = pendingBuffer.slice(blockEnd);
|
||||
}
|
||||
// Hold back any (partial or full) unclosed opener; flush the rest.
|
||||
const partialIdx = partialXmlOpenerStart(pendingBuffer);
|
||||
if (partialIdx >= 0) {
|
||||
if (partialIdx > 0) {
|
||||
const flush = pendingBuffer.slice(0, partialIdx);
|
||||
content += flush;
|
||||
onDelta(flush);
|
||||
}
|
||||
pendingBuffer = pendingBuffer.slice(partialIdx);
|
||||
} else if (pendingBuffer.length > 0) {
|
||||
content += pendingBuffer;
|
||||
onDelta(pendingBuffer);
|
||||
pendingBuffer = '';
|
||||
}
|
||||
pendingBuffer = extracted.remaining;
|
||||
break;
|
||||
}
|
||||
case 'tool-call': {
|
||||
|
||||
@@ -4,16 +4,21 @@ import { PathScopeError } from '../path_guard.js';
|
||||
import { TOOLS_BY_NAME } from '../tools.js';
|
||||
import { maybeFlagForCompaction } from './payload.js';
|
||||
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
||||
// v1.13.16: richer unknown-tool error so the model can self-correct when it
|
||||
// drifts to a Claude Code tool name (e.g. read_file → suggest view_file).
|
||||
// Applies to all unknown tool names, not just <invoke>-derived ones — at the
|
||||
// dispatch layer we no longer know which format produced the call, and the
|
||||
// extra signal is harmless for Qwen-derived calls.
|
||||
import { formatUnknownToolError } from './tool-suggestions.js';
|
||||
// v1.13.17-cross-repo-reads: pre-prompt validation for request_read_access.
|
||||
// Resolves the grant root before pausing the loop so the user is never
|
||||
// prompted about paths we couldn't grant anyway (e.g. /etc/passwd).
|
||||
import { resolveGrantRoot } from '../grant_resolver.js';
|
||||
import type {
|
||||
InferenceContext,
|
||||
StreamResult,
|
||||
TurnArgs,
|
||||
} from './turn.js';
|
||||
// v1.12.4: ESM value-import cycle. executeToolPhase recurses into
|
||||
// runAssistantTurn which lives in inference.ts. The cycle is safe because
|
||||
// the reference is read at call time (inside an async function body), not
|
||||
// at module top-level. Node + tsc resolve this cleanly.
|
||||
import { runAssistantTurn } from './turn.js';
|
||||
// v1.13.13: synthesis pipeline — replaces the immediate recursive turn when
|
||||
// any of this batch's tool calls is in SYNTHESIS_TOOLS. Falls through to
|
||||
// recursion on synthesis failure (timeout / model error). See module header
|
||||
@@ -22,11 +27,16 @@ import { SYNTHESIS_TOOLS, runSynthesisPass } from '../synthesisPipeline.js';
|
||||
|
||||
async function executeToolCall(
|
||||
projectRoot: string,
|
||||
toolCall: ToolCall
|
||||
toolCall: ToolCall,
|
||||
extraRoots: readonly string[],
|
||||
): Promise<{ output: unknown; truncated: boolean; error?: string }> {
|
||||
const tool = TOOLS_BY_NAME[toolCall.name];
|
||||
if (!tool) {
|
||||
return { output: null, truncated: false, error: `unknown tool: ${toolCall.name}` };
|
||||
return {
|
||||
output: null,
|
||||
truncated: false,
|
||||
error: formatUnknownToolError(toolCall.name, Object.keys(TOOLS_BY_NAME)),
|
||||
};
|
||||
}
|
||||
const parsed = tool.inputSchema.safeParse(toolCall.args);
|
||||
if (!parsed.success) {
|
||||
@@ -53,7 +63,7 @@ async function executeToolCall(
|
||||
};
|
||||
}
|
||||
try {
|
||||
const output = await tool.execute(parsed.data, projectRoot);
|
||||
const output = await tool.execute(parsed.data, projectRoot, extraRoots);
|
||||
const truncated =
|
||||
typeof output === 'object' && output !== null && 'truncated' in output
|
||||
? Boolean((output as { truncated: unknown }).truncated)
|
||||
@@ -71,6 +81,16 @@ async function executeToolCall(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.14.0: return struct from executeToolPhase so the caller (the outer
|
||||
// while loop in turn.ts) can decide whether to continue, break, or handle
|
||||
// synthesis. Replaces the recursive call into runAssistantTurn.
|
||||
export interface ToolPhaseResult {
|
||||
action: 'continue' | 'paused' | 'synthesis_done';
|
||||
toolCallCount: number;
|
||||
toolCalls: ToolCall[];
|
||||
nextAssistantId: string | null;
|
||||
}
|
||||
|
||||
export async function executeToolPhase(
|
||||
ctx: InferenceContext,
|
||||
args: TurnArgs,
|
||||
@@ -78,8 +98,8 @@ export async function executeToolPhase(
|
||||
startedAt: string | null,
|
||||
session: Session,
|
||||
projectRoot: string
|
||||
): Promise<void> {
|
||||
const { sessionId, chatId, assistantMessageId, toolsUsed, signal } = args;
|
||||
): Promise<ToolPhaseResult> {
|
||||
const { sessionId, chatId, assistantMessageId } = args;
|
||||
const { content, toolCalls, promptTokens, completionTokens } = result;
|
||||
|
||||
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
|
||||
@@ -95,7 +115,6 @@ export async function executeToolPhase(
|
||||
UPDATE messages
|
||||
SET content = ${content},
|
||||
status = 'complete',
|
||||
tool_calls = ${ctx.sql.json(toolCalls as never)},
|
||||
tokens_used = ${completionTokens},
|
||||
ctx_used = ${promptTokens},
|
||||
ctx_max = ${nCtx},
|
||||
@@ -103,15 +122,11 @@ export async function executeToolPhase(
|
||||
WHERE id = ${assistantMessageId}
|
||||
RETURNING tokens_used, ctx_used, ctx_max, finished_at
|
||||
`;
|
||||
// v1.13.0: dual-write to message_parts. v1.13.1-B made parts authoritative
|
||||
// for reads via the messages_with_parts view; the JSON column write above
|
||||
// remains for v1.13.1 fallback compatibility (dropped in v1.13.2).
|
||||
// v1.13.20: message_parts is the sole source of truth for tool_calls.
|
||||
// Legacy messages.tool_calls column was dropped; reads route through the
|
||||
// messages_with_parts view.
|
||||
// v1.13.1-C: include result.reasoning so models with separate reasoning
|
||||
// channels (qwen3.6) get a kind='reasoning' part at sequence 0.
|
||||
// TODO(v1.13.1): wrap the UPDATE above and this insertParts in a single
|
||||
// sql.begin before flipping read authority to message_parts. Without the
|
||||
// transaction, a crash between the two leaves an orphan message that
|
||||
// becomes invisible in the parts-authoritative read path.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromAssistantMessage({
|
||||
@@ -177,16 +192,9 @@ export async function executeToolPhase(
|
||||
if (tc.name === 'ask_user_input') {
|
||||
pausingForUserInput = true;
|
||||
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(sentinel as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
// v1.13.0: mirror the pending sentinel into message_parts. The
|
||||
// answer-endpoint UPDATE later (messages.ts:576) will delete and
|
||||
// re-insert this part when the user submits their answer.
|
||||
// TODO(v1.13.1): wrap the INSERT + UPDATE + insertParts triple in
|
||||
// a per-iteration sql.begin before flipping read authority.
|
||||
// v1.13.20: parts-only. The answer-endpoint UPDATE later
|
||||
// (messages.ts) will delete and re-insert this part when the user
|
||||
// submits their answer.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
|
||||
@@ -196,7 +204,63 @@ export async function executeToolPhase(
|
||||
);
|
||||
return;
|
||||
}
|
||||
const tres = await executeToolCall(projectRoot, tc);
|
||||
// v1.13.17-cross-repo-reads: request_read_access pauses identically to
|
||||
// ask_user_input EXCEPT for an up-front validation pass — if the path
|
||||
// can't be granted under the whitelist / repo-shape rules, surface an
|
||||
// immediate denial without prompting the user. Per design D1, we never
|
||||
// ask the user about /etc/passwd or paths outside PROJECT_ROOT_WHITELIST.
|
||||
if (tc.name === 'request_read_access') {
|
||||
const tcArgs = tc.args as { path?: unknown; reason?: unknown };
|
||||
const requested =
|
||||
typeof tcArgs.path === 'string' ? tcArgs.path : '';
|
||||
const resolution = await resolveGrantRoot(
|
||||
ctx.sql,
|
||||
requested,
|
||||
projectRoot,
|
||||
ctx.config.PROJECT_ROOT_WHITELIST,
|
||||
);
|
||||
if (!resolution.ok) {
|
||||
// Auto-deny without pausing. The model sees the reason on its
|
||||
// next turn and decides what to do.
|
||||
const stored = {
|
||||
tool_call_id: tc.id,
|
||||
output: `denied: ${resolution.reason}`,
|
||||
truncated: false,
|
||||
};
|
||||
// v1.13.20: parts-only write.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromToolMessage({ tool_results: stored }).map((p) => ({
|
||||
...p,
|
||||
message_id: toolMessageId,
|
||||
})),
|
||||
);
|
||||
ctx.publish(sessionId, {
|
||||
type: 'tool_result',
|
||||
tool_message_id: toolMessageId,
|
||||
chat_id: chatId,
|
||||
tool_call_id: tc.id,
|
||||
output: stored.output,
|
||||
truncated: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Path is plausibly grantable — install the pending sentinel and
|
||||
// pause. The grant endpoint re-derives the root at decision time
|
||||
// (state may have changed in the meantime) so we don't stash it here.
|
||||
pausingForUserInput = true;
|
||||
const sentinel = { tool_call_id: tc.id, output: null, truncated: false };
|
||||
// v1.13.20: parts-only write.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromToolMessage({ tool_results: sentinel }).map((p) => ({
|
||||
...p,
|
||||
message_id: toolMessageId,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
|
||||
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
||||
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
||||
}
|
||||
@@ -206,14 +270,7 @@ export async function executeToolPhase(
|
||||
truncated: tres.truncated,
|
||||
...(tres.error ? { error: tres.error } : {}),
|
||||
};
|
||||
await ctx.sql`
|
||||
UPDATE messages
|
||||
SET tool_results = ${ctx.sql.json(stored as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
// v1.13.0: dual-write the tool_result part.
|
||||
// TODO(v1.13.1): wrap the INSERT + UPDATE + insertParts triple in a
|
||||
// per-iteration sql.begin before flipping read authority.
|
||||
// v1.13.20: parts-only write. Reads route through messages_with_parts.
|
||||
await insertParts(
|
||||
ctx.sql,
|
||||
partsFromToolMessage({ tool_results: stored }).map((p) => ({
|
||||
@@ -244,7 +301,12 @@ export async function executeToolPhase(
|
||||
{ sessionId, chatId, assistantMessageId },
|
||||
'inference paused awaiting user input',
|
||||
);
|
||||
return;
|
||||
return {
|
||||
action: 'paused' as const,
|
||||
toolCallCount: toolCalls.length,
|
||||
toolCalls,
|
||||
nextAssistantId: null,
|
||||
};
|
||||
}
|
||||
|
||||
// v1.13.13: synthesis-pipeline branch. When any of this batch's tool calls
|
||||
@@ -276,30 +338,30 @@ export async function executeToolPhase(
|
||||
...(typeof out?.truncated === 'boolean' ? { truncated: out.truncated } : {}),
|
||||
...(typeof out?.outputPath === 'string' ? { outputPath: out.outputPath } : {}),
|
||||
});
|
||||
if (ran) return;
|
||||
if (ran) {
|
||||
return {
|
||||
action: 'synthesis_done' as const,
|
||||
toolCallCount: toolCalls.length,
|
||||
toolCalls,
|
||||
nextAssistantId: null,
|
||||
};
|
||||
}
|
||||
// ran === false → synthesis failed (timeout / model error) → fall through
|
||||
// to the standard recursive turn below. The synth message (if created)
|
||||
// to the standard continue path below. The synth message (if created)
|
||||
// was already marked status='failed' inside runSynthesisPass.
|
||||
}
|
||||
|
||||
// v1.14.0: create the next assistant row and return a continue result.
|
||||
// The caller (outer while loop in turn.ts) handles the iteration.
|
||||
const [nextAssistant] = await ctx.sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await runAssistantTurn(ctx, {
|
||||
sessionId,
|
||||
chatId,
|
||||
assistantMessageId: nextAssistant!.id,
|
||||
// v1.8.2: charge this turn's actual tool invocations against the budget.
|
||||
// One assistant message can emit multiple tool_calls, so we add the run
|
||||
// count, not 1. The next turn's budget check sees the cumulative total.
|
||||
toolsUsed: toolsUsed + result.toolCalls.length,
|
||||
// v1.11.6: append the just-executed tool calls to the per-turn history
|
||||
// so the next runAssistantTurn's doom-loop check can see them. We don't
|
||||
// cap the array length here — per-turn budgets keep it bounded
|
||||
// (typically <30 entries), and slicing happens inside detectDoomLoop.
|
||||
recentToolCalls: [...args.recentToolCalls, ...result.toolCalls],
|
||||
signal,
|
||||
});
|
||||
return {
|
||||
action: 'continue' as const,
|
||||
toolCallCount: toolCalls.length,
|
||||
toolCalls,
|
||||
nextAssistantId: nextAssistant!.id,
|
||||
};
|
||||
}
|
||||
|
||||
63
apps/server/src/services/inference/tool-suggestions.ts
Normal file
63
apps/server/src/services/inference/tool-suggestions.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// v1.13.16: Levenshtein + suggestion + formatter for the unknown-tool error
|
||||
// returned to the model when an XML-extracted tool call references a name
|
||||
// that isn't in TOOLS_BY_NAME. The drift incident this targets: qwen3.6
|
||||
// emitting <invoke name="read_file"> from its Claude Code training residue
|
||||
// when BooCode's actual file-read tool is view_file. Hand-rolled distance
|
||||
// function — no new dep.
|
||||
|
||||
export function levenshtein(a: string, b: string): number {
|
||||
if (a.length === 0) return b.length;
|
||||
if (b.length === 0) return a.length;
|
||||
const dp: number[][] = Array.from(
|
||||
{ length: a.length + 1 },
|
||||
() => new Array<number>(b.length + 1).fill(0),
|
||||
);
|
||||
for (let i = 0; i <= a.length; i++) dp[i]![0] = i;
|
||||
for (let j = 0; j <= b.length; j++) dp[0]![j] = j;
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||
dp[i]![j] = Math.min(
|
||||
dp[i - 1]![j]! + 1,
|
||||
dp[i]![j - 1]! + 1,
|
||||
dp[i - 1]![j - 1]! + cost,
|
||||
);
|
||||
}
|
||||
}
|
||||
return dp[a.length]![b.length]!;
|
||||
}
|
||||
|
||||
// Threshold per the v1.13.16 dispatch: distance <= 3 OR substring match
|
||||
// (either direction). Ties broken by smallest distance, then alphabetical.
|
||||
export function suggestToolName(
|
||||
name: string,
|
||||
available: readonly string[],
|
||||
): string | null {
|
||||
const lower = name.toLowerCase();
|
||||
let best: { name: string; dist: number } | null = null;
|
||||
for (const tool of available) {
|
||||
const tlower = tool.toLowerCase();
|
||||
const dist = levenshtein(lower, tlower);
|
||||
const isSubstr = tlower.includes(lower) || lower.includes(tlower);
|
||||
if (dist > 3 && !isSubstr) continue;
|
||||
if (
|
||||
best === null ||
|
||||
dist < best.dist ||
|
||||
(dist === best.dist && tool.localeCompare(best.name) < 0)
|
||||
) {
|
||||
best = { name: tool, dist };
|
||||
}
|
||||
}
|
||||
return best?.name ?? null;
|
||||
}
|
||||
|
||||
export function formatUnknownToolError(
|
||||
name: string,
|
||||
available: readonly string[],
|
||||
): string {
|
||||
const sorted = [...available].sort();
|
||||
const suggestion = suggestToolName(name, sorted);
|
||||
const list = sorted.join(', ');
|
||||
const tail = suggestion ? ` Did you mean: ${suggestion}?` : '';
|
||||
return `Tool '${name}' not found. Available tools: [${list}].${tail}`;
|
||||
}
|
||||
@@ -16,11 +16,9 @@ import { resolveProjectRoot } from '../path_guard.js';
|
||||
import { maybeAutoNameChat } from '../auto_name.js';
|
||||
import { getAgentById } from '../agents.js';
|
||||
import * as compaction from '../compaction.js';
|
||||
import * as modelContext from '../model-context.js';
|
||||
import type { Broker } from '../broker.js';
|
||||
import { resolveToolBudget } from './budget.js';
|
||||
import {
|
||||
DOOM_LOOP_THRESHOLD,
|
||||
detectDoomLoop,
|
||||
} from './sentinels.js';
|
||||
import {
|
||||
@@ -33,15 +31,23 @@ import {
|
||||
} from './error-handler.js';
|
||||
import {
|
||||
executeStreamPhase,
|
||||
streamCompletion,
|
||||
} from './stream-phase.js';
|
||||
import { executeToolPhase } from './tool-phase.js';
|
||||
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
|
||||
import { executeToolPhase, type ToolPhaseResult } from './tool-phase.js';
|
||||
import type { StreamPhaseState } from './types.js';
|
||||
import {
|
||||
runCapHitSummary,
|
||||
runDoomLoopSummary,
|
||||
runStepCapSummary,
|
||||
} from './sentinel-summaries.js';
|
||||
|
||||
// v1.14.0: hard ceiling on the number of stream-and-tool iterations per
|
||||
// user-message turn. Per-agent cap via agent.steps is the primary knob;
|
||||
// MAX_STEPS is the safety ceiling. 200 is 4x the effective budget ceiling
|
||||
// (50 tool calls) — in practice budget fires first unless the model makes
|
||||
// many 0-tool-call iterations (which exit the loop via the non-tool finish
|
||||
// path anyway).
|
||||
export const MAX_STEPS = 200;
|
||||
|
||||
// v1.12.4: re-exported so external callers (tests, future consumers) keep
|
||||
// importing from services/inference.js as the public surface.
|
||||
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
||||
@@ -145,13 +151,70 @@ export async function runAssistantTurn(
|
||||
ctx: InferenceContext,
|
||||
args: TurnArgs,
|
||||
): Promise<void> {
|
||||
const { sessionId, chatId } = args;
|
||||
const { sessionId, chatId, signal } = args;
|
||||
|
||||
// v1.11: if the prior turn flagged this chat for compaction, run it first
|
||||
// so loadContext below reads the post-compaction history. We swallow
|
||||
// compaction failures (clearing the flag so we don't loop) and proceed
|
||||
// with the un-compacted history — a slow turn that hits the model's
|
||||
// hard limit is recoverable; a dead session is not.
|
||||
// v1.14.0: resolve agent once at the top. The agent stays fixed for the
|
||||
// duration of this user-message turn — PATCH agent_id mid-conversation
|
||||
// takes effect on the next runInference, not mid-loop.
|
||||
const initialLoaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (!initialLoaded) {
|
||||
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
||||
return;
|
||||
}
|
||||
const { session, project } = initialLoaded;
|
||||
const agent = session.agent_id
|
||||
? await getAgentById(project.path, session.agent_id)
|
||||
: null;
|
||||
const budget = resolveToolBudget(agent);
|
||||
|
||||
// v1.14.0: effectiveCap = min(agent.steps ?? Infinity, MAX_STEPS).
|
||||
// steps: 0 means "no tool calls allowed" — the first stream phase runs
|
||||
// but if it emits tool calls they are not executed (finalize as text-only).
|
||||
const effectiveCap = Math.min(agent?.steps ?? Infinity, MAX_STEPS);
|
||||
|
||||
// steps: 0 special case — model responds text-only. The while loop would
|
||||
// never enter (effectiveCap === 0), so we handle it explicitly before the
|
||||
// loop. The model always gets at least one chance to respond with text.
|
||||
if (effectiveCap === 0) {
|
||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (loaded) {
|
||||
await runTextOnlyTurn(ctx, args, loaded.session, loaded.project, loaded.history, agent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let stepNumber = 0;
|
||||
let toolsUsed = args.toolsUsed;
|
||||
let recentToolCalls = args.recentToolCalls;
|
||||
let assistantMessageId = args.assistantMessageId;
|
||||
|
||||
while (stepNumber < effectiveCap) {
|
||||
// ---- doom-loop check (moved from top-of-function) ----
|
||||
const loop = detectDoomLoop(recentToolCalls);
|
||||
if (loop) {
|
||||
// Need fresh history for the summary.
|
||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (loaded) {
|
||||
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||
await runDoomLoopSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, loop);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ---- budget check (moved from top-of-function) ----
|
||||
if (toolsUsed >= budget) {
|
||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (loaded) {
|
||||
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||
await runCapHitSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, budget);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ---- compaction check ----
|
||||
// v1.11: if the prior turn flagged this chat for compaction, run it
|
||||
// before loadContext so we read post-compaction history. Swallow
|
||||
// failures and proceed with un-compacted history.
|
||||
const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>`
|
||||
SELECT needs_compaction FROM chats WHERE id = ${chatId}
|
||||
`;
|
||||
@@ -170,50 +233,103 @@ export async function runAssistantTurn(
|
||||
}
|
||||
}
|
||||
|
||||
// ---- load context (must re-load each iteration — new messages since last step) ----
|
||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (!loaded) {
|
||||
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
||||
return;
|
||||
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
|
||||
break;
|
||||
}
|
||||
const { session, project, history } = loaded;
|
||||
const projectRoot = await resolveProjectRoot(project.path);
|
||||
// Agent resolution is per-turn so PATCH agent_id mid-conversation takes
|
||||
// effect on the next message. Unknown agent_id returns null silently —
|
||||
// session falls back to base prompt + all tools + default temperature.
|
||||
const agent = session.agent_id
|
||||
? await getAgentById(project.path, session.agent_id)
|
||||
: null;
|
||||
const { session: iterSession, project: iterProject, history } = loaded;
|
||||
const projectRoot = await resolveProjectRoot(iterProject.path);
|
||||
|
||||
// v1.8.2: cap-hit replaces the older "tool loop depth exceeded" failure.
|
||||
// When we've already burned the budget *before* this turn even runs, we
|
||||
// skip straight to the summary flow — the in-flight assistant message slot
|
||||
// gets reused for the wrap-up reply instead of being marked failed.
|
||||
const budget = resolveToolBudget(agent);
|
||||
if (args.toolsUsed >= budget) {
|
||||
await runCapHitSummary(ctx, args, session, project, history, agent, budget);
|
||||
return;
|
||||
// v1.14.0: log step boundary for instrumentation. step_start parts are in
|
||||
// the schema CHECK but not emitted here — writing to the assistant message
|
||||
// before the stream phase creates a sequence-0 collision with
|
||||
// partsFromAssistantMessage. A WS frame or structured log is sufficient
|
||||
// since the frontend doesn't render step boundaries in v1.14.
|
||||
ctx.log.info({ sessionId, chatId, step: stepNumber, assistantMessageId }, 'step_start');
|
||||
|
||||
// ---- build messages + stream phase ----
|
||||
const messages = await buildMessagesPayload(iterSession, iterProject, history, agent, ctx.log);
|
||||
const webToolsEnabled =
|
||||
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
|
||||
|
||||
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
||||
let result: StreamResult;
|
||||
try {
|
||||
result = await executeStreamPhase(ctx, iterArgs, iterSession, messages, state, agent, webToolsEnabled);
|
||||
} catch (err) {
|
||||
await handleAbortOrError(ctx, iterArgs, state.accumulated, err);
|
||||
break;
|
||||
}
|
||||
|
||||
// v1.11.6: doom-loop guard. Detected BEFORE the budget cap (the model can
|
||||
// burn through 3 identical calls long before the 15-call budget fires).
|
||||
// Same in-flight-slot-reuse pattern as runCapHitSummary — wrap-up reply
|
||||
// lands in args.assistantMessageId, then a doom_loop sentinel is inserted
|
||||
// to make the abort visible in the chat history.
|
||||
const loop = detectDoomLoop(args.recentToolCalls);
|
||||
if (loop) {
|
||||
await runDoomLoopSummary(ctx, args, session, project, history, agent, loop);
|
||||
return;
|
||||
// ---- non-tool finish → finalize and exit ----
|
||||
if (result.toolCalls.length === 0) {
|
||||
await finalizeCompletion(ctx, iterArgs, result, state.startedAt, iterSession);
|
||||
break;
|
||||
}
|
||||
|
||||
// ---- steps: 0 edge case ----
|
||||
// effectiveCap check above guarantees we're inside the loop, but this
|
||||
// guard handles the theoretical case where the model emits tool calls
|
||||
// on step 0 when effectiveCap would have been 0 (impossible since the
|
||||
// while condition prevents entry, but kept for safety). If effectiveCap
|
||||
// is 1 and we're on step 0, tool calls ARE executed — steps counts
|
||||
// iterations, not post-first-stream.
|
||||
|
||||
// ---- tool phase ----
|
||||
let toolPhaseResult: ToolPhaseResult;
|
||||
try {
|
||||
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot);
|
||||
} catch (err) {
|
||||
// Tool phase errors are unexpected (individual tool failures are
|
||||
// caught inside executeToolPhase). Log and break.
|
||||
ctx.log.error({ err, sessionId, chatId, step: stepNumber }, 'tool phase threw unexpectedly');
|
||||
break;
|
||||
}
|
||||
|
||||
// ---- update loop locals ----
|
||||
toolsUsed += toolPhaseResult.toolCallCount;
|
||||
recentToolCalls = [...recentToolCalls, ...toolPhaseResult.toolCalls];
|
||||
stepNumber++;
|
||||
|
||||
if (toolPhaseResult.action !== 'continue') {
|
||||
// 'paused' (user input) or 'synthesis_done' — stop the loop.
|
||||
break;
|
||||
}
|
||||
// 'continue' — advance to next assistant message.
|
||||
assistantMessageId = toolPhaseResult.nextAssistantId!;
|
||||
}
|
||||
|
||||
// ---- post-loop: step-cap sentinel ----
|
||||
// When the loop exits because stepNumber reached effectiveCap, the last
|
||||
// iteration's tool phase returned 'continue' with a nextAssistantId that
|
||||
// is still in 'streaming' status (unfilled). Use it for the wrap-up.
|
||||
if (stepNumber >= effectiveCap && effectiveCap < Infinity) {
|
||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (loaded) {
|
||||
const capArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||
await runStepCapSummary(ctx, capArgs, loaded.session, loaded.project, loaded.history, agent, stepNumber, effectiveCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1.14.0: special handling for steps: 0 — the model responds text-only.
|
||||
// The while loop never enters (effectiveCap === 0). We stream once with
|
||||
// no tools, finalize, and return. If the model emits tool calls despite
|
||||
// not being offered tools, they're ignored (finalize as text-only).
|
||||
async function runTextOnlyTurn(
|
||||
ctx: InferenceContext,
|
||||
args: TurnArgs,
|
||||
session: Session,
|
||||
project: Project,
|
||||
history: Message[],
|
||||
agent: Agent | null,
|
||||
): Promise<void> {
|
||||
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
|
||||
|
||||
// v1.11.8: resolve per-chat web-tools opt-in. Tri-state on the wire:
|
||||
// - session.web_search_enabled = null → inherit project default
|
||||
// - session.web_search_enabled = true/false → explicit
|
||||
// Both web_search and web_fetch are gated by this single flag (the UI
|
||||
// label is "Enable web search and fetch" — same store, both tools).
|
||||
// Default is false unless explicitly opted in, matching the v1.9
|
||||
// plumbing intent ("inert until Batch 8 ships the actual tools").
|
||||
// Web tools are irrelevant when steps: 0 (no tool execution), but we
|
||||
// still need to resolve the flag for executeStreamPhase's signature.
|
||||
const webToolsEnabled =
|
||||
session.web_search_enabled ?? project.default_web_search_enabled ?? false;
|
||||
|
||||
@@ -227,8 +343,12 @@ export async function runAssistantTurn(
|
||||
}
|
||||
|
||||
if (result.toolCalls.length > 0) {
|
||||
await executeToolPhase(ctx, args, result, state.startedAt, session, projectRoot);
|
||||
return;
|
||||
ctx.log.warn(
|
||||
{ chatId: args.chatId, toolCallCount: result.toolCalls.length },
|
||||
'steps: 0 agent emitted tool calls; ignoring and finalizing as text-only',
|
||||
);
|
||||
// Override: strip tool calls so finalizeCompletion treats it as text-only.
|
||||
result = { ...result, toolCalls: [] };
|
||||
}
|
||||
|
||||
await finalizeCompletion(ctx, args, result, state.startedAt, session);
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
// v1.10.5: XML-tag tool-call fallback. Some models emit
|
||||
// <tool_call><function=foo><parameter=key>value</parameter></function></tool_call>
|
||||
// in plain content instead of using the OpenAI tool_calls JSON channel.
|
||||
// The streaming loop in inference.ts extracts these blocks via these helpers.
|
||||
// The streaming loop in stream-phase.ts extracts these blocks via these helpers.
|
||||
//
|
||||
// v1.13.16: also recognize Anthropic <invoke name="..."><parameter name="...">
|
||||
// markup. qwen3.6-35b-a3b-mxfp4 drifts to this format when prompted as an
|
||||
// "Architect"-style agent because Claude Code documentation in its
|
||||
// pre-training data uses this shape. Both formats route through the same
|
||||
// synthetic ToolCall path with shared xml_call_${idx} IDs; downstream
|
||||
// dispatch handles unknown tool names with a richer error (see
|
||||
// tool-suggestions.ts + tool-phase.ts).
|
||||
|
||||
export const XML_TOOL_OPEN = '<tool_call>';
|
||||
export const XML_TOOL_CLOSE = '</tool_call>';
|
||||
|
||||
export function parseXmlToolCall(
|
||||
block: string,
|
||||
): { name: string; args: Record<string, unknown> } | null {
|
||||
const nameMatch = block.match(/<function=([^>]+)>/);
|
||||
// v1.13.16: Anthropic <invoke> opener is matched by prefix (not the full
|
||||
// `<invoke ...>` tag) because attributes follow. Closer is the literal tag.
|
||||
export const INVOKE_TOOL_OPEN = '<invoke';
|
||||
export const INVOKE_TOOL_CLOSE = '</invoke>';
|
||||
|
||||
export interface ParsedCall {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// v1.10.5: Qwen-flavor parser. Tightened in v1.13.16 to tolerate whitespace
|
||||
// around `=` (e.g. `<function = view_file>`). Name capture is non-whitespace,
|
||||
// non-`>` so a stray space doesn't get absorbed into the function name.
|
||||
const QWEN_FUNCTION_RE = /<function\s*=\s*([^>\s]+)\s*>/;
|
||||
const QWEN_PARAM_RE = /<parameter\s*=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g;
|
||||
|
||||
export function parseXmlToolCall(block: string): ParsedCall | null {
|
||||
const nameMatch = block.match(QWEN_FUNCTION_RE);
|
||||
if (!nameMatch || !nameMatch[1]) return null;
|
||||
const name = nameMatch[1].trim();
|
||||
if (!name) return null;
|
||||
const args: Record<string, unknown> = {};
|
||||
// Non-greedy body so each <parameter=…>…</parameter> pair is matched
|
||||
// independently even when multiple appear in the same block.
|
||||
const paramRe = /<parameter=([^>]+)>([\s\S]*?)<\/parameter>/g;
|
||||
for (const m of block.matchAll(paramRe)) {
|
||||
for (const m of block.matchAll(QWEN_PARAM_RE)) {
|
||||
const key = (m[1] ?? '').trim();
|
||||
if (!key) continue;
|
||||
const raw = (m[2] ?? '').trim();
|
||||
@@ -30,24 +49,121 @@ export function parseXmlToolCall(
|
||||
return { name, args };
|
||||
}
|
||||
|
||||
// v1.13.16: Anthropic-flavor parser. Same JSON-parse-with-string-fallback
|
||||
// shape as parseXmlToolCall so the dispatch layer doesn't need to care which
|
||||
// flavor produced the call.
|
||||
const INVOKE_NAME_RE =
|
||||
/<invoke\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>/;
|
||||
const INVOKE_PARAM_RE =
|
||||
/<parameter\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>([\s\S]*?)<\/parameter>/g;
|
||||
|
||||
export function parseInvokeToolCall(block: string): ParsedCall | null {
|
||||
const nameMatch = block.match(INVOKE_NAME_RE);
|
||||
if (!nameMatch) return null;
|
||||
const name = (nameMatch[2] ?? nameMatch[3] ?? '').trim();
|
||||
if (!name) return null;
|
||||
const args: Record<string, unknown> = {};
|
||||
for (const m of block.matchAll(INVOKE_PARAM_RE)) {
|
||||
const key = ((m[2] ?? m[3] ?? '') as string).trim();
|
||||
if (!key) continue;
|
||||
const raw = (m[4] ?? '').trim();
|
||||
try {
|
||||
args[key] = JSON.parse(raw);
|
||||
} catch {
|
||||
args[key] = raw;
|
||||
}
|
||||
}
|
||||
return { name, args };
|
||||
}
|
||||
|
||||
// Locate the first character that begins (or completely contains) an
|
||||
// unfinished <tool_call> opener in `s`. Returns -1 when `s` can be flushed
|
||||
// to the client in full without risking a partial tag leak.
|
||||
// Case 1: a full `<tool_call>` opener with no matching closer — caller
|
||||
// must keep everything from that index forward until the next
|
||||
// chunk arrives with the closer.
|
||||
// Case 2: `s` ends with a strict prefix of `<tool_call>` (e.g. `<tool_c`).
|
||||
// Caller must keep just that suffix in the buffer.
|
||||
// unfinished opener (either flavor) in `s`. Returns -1 when `s` can be
|
||||
// flushed to the client in full without risking a partial tag leak.
|
||||
// Case 1: a full opener (`<tool_call>` or `<invoke`) with no matching
|
||||
// closer — caller must keep everything from that index forward
|
||||
// until the next chunk arrives with the closer.
|
||||
// Case 2: `s` ends with a strict prefix of either opener (e.g. `<tool_c`
|
||||
// or `<invo`). Caller must keep just that suffix in the buffer.
|
||||
// Note: case 1 assumes the calling loop already extracted every complete
|
||||
// <tool_call>…</tool_call> pair before reaching this check.
|
||||
// block before reaching this check.
|
||||
const ALL_OPENERS = [XML_TOOL_OPEN, INVOKE_TOOL_OPEN] as const;
|
||||
|
||||
export function partialXmlOpenerStart(s: string): number {
|
||||
const fullOpener = s.indexOf(XML_TOOL_OPEN);
|
||||
if (fullOpener !== -1) return fullOpener;
|
||||
let earliest = -1;
|
||||
for (const op of ALL_OPENERS) {
|
||||
const idx = s.indexOf(op);
|
||||
if (idx === -1) continue;
|
||||
if (earliest === -1 || idx < earliest) earliest = idx;
|
||||
}
|
||||
if (earliest !== -1) return earliest;
|
||||
const lastLt = s.lastIndexOf('<');
|
||||
if (lastLt === -1) return -1;
|
||||
const suffix = s.slice(lastLt);
|
||||
if (XML_TOOL_OPEN.startsWith(suffix) && suffix.length < XML_TOOL_OPEN.length) {
|
||||
return lastLt;
|
||||
for (const op of ALL_OPENERS) {
|
||||
if (op.startsWith(suffix) && suffix.length < op.length) return lastLt;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// v1.13.16: unified extraction. Replaces the inline loop that used to live
|
||||
// in stream-phase.ts. Pure function — returns the visible text to flush,
|
||||
// the parsed tool-call payloads in source order, and the buffer remainder
|
||||
// to retain for the next streaming chunk. Parse failures are silently
|
||||
// dropped (matches the pre-v1.13.16 behavior — leaking partial XML to the
|
||||
// chat looks worse than swallowing a bad block).
|
||||
export interface ToolCallExtraction {
|
||||
flushed: string;
|
||||
calls: ParsedCall[];
|
||||
remaining: string;
|
||||
}
|
||||
|
||||
interface OpenerSpec {
|
||||
open: string;
|
||||
close: string;
|
||||
parse: (block: string) => ParsedCall | null;
|
||||
}
|
||||
|
||||
const OPENER_SPECS: ReadonlyArray<OpenerSpec> = [
|
||||
{ open: XML_TOOL_OPEN, close: XML_TOOL_CLOSE, parse: parseXmlToolCall },
|
||||
{ open: INVOKE_TOOL_OPEN, close: INVOKE_TOOL_CLOSE, parse: parseInvokeToolCall },
|
||||
];
|
||||
|
||||
export function extractToolCallBlocks(buffer: string): ToolCallExtraction {
|
||||
let flushed = '';
|
||||
const calls: ParsedCall[] = [];
|
||||
let pos = 0;
|
||||
|
||||
while (pos < buffer.length) {
|
||||
let next: { spec: OpenerSpec; openIdx: number; closeIdx: number } | null = null;
|
||||
for (const spec of OPENER_SPECS) {
|
||||
const openIdx = buffer.indexOf(spec.open, pos);
|
||||
if (openIdx === -1) continue;
|
||||
const closeIdx = buffer.indexOf(spec.close, openIdx);
|
||||
if (closeIdx === -1) continue;
|
||||
if (next === null || openIdx < next.openIdx) {
|
||||
next = { spec, openIdx, closeIdx };
|
||||
}
|
||||
}
|
||||
if (next === null) break;
|
||||
|
||||
if (next.openIdx > pos) {
|
||||
flushed += buffer.slice(pos, next.openIdx);
|
||||
}
|
||||
const blockEnd = next.closeIdx + next.spec.close.length;
|
||||
const block = buffer.slice(next.openIdx, blockEnd);
|
||||
const parsed = next.spec.parse(block);
|
||||
if (parsed) calls.push(parsed);
|
||||
pos = blockEnd;
|
||||
}
|
||||
|
||||
const tail = buffer.slice(pos);
|
||||
const partialIdx = partialXmlOpenerStart(tail);
|
||||
if (partialIdx === -1) {
|
||||
flushed += tail;
|
||||
return { flushed, calls, remaining: '' };
|
||||
}
|
||||
if (partialIdx > 0) {
|
||||
flushed += tail.slice(0, partialIdx);
|
||||
}
|
||||
return { flushed, calls, remaining: tail.slice(partialIdx) };
|
||||
}
|
||||
|
||||
@@ -16,9 +16,22 @@ export async function resolveProjectRoot(projectPath: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
function isUnder(real: string, root: string): boolean {
|
||||
return real === root || real.startsWith(root + sep);
|
||||
}
|
||||
|
||||
// v1.13.17-cross-repo-reads: pathGuard now accepts an optional extraRoots
|
||||
// list (typically session.allowed_read_paths). The primary projectRoot is
|
||||
// tried first; if the resolved path doesn't sit under it, each extraRoot is
|
||||
// tried in turn. Throws PathScopeError if no root accepts. The error message
|
||||
// includes a hint pointing the model at the request_read_access tool so it
|
||||
// can self-correct on the next turn — extraRoots IS the persistence
|
||||
// mechanism for those grants, so we only suggest it when there's a missing
|
||||
// grant to ask for (i.e. the path isn't already under any allowed root).
|
||||
export async function pathGuard(
|
||||
projectRoot: string,
|
||||
requested: string
|
||||
requested: string,
|
||||
extraRoots: readonly string[] = [],
|
||||
): Promise<string> {
|
||||
if (typeof requested !== 'string' || requested.length === 0) {
|
||||
throw new PathScopeError('path is required');
|
||||
@@ -30,10 +43,13 @@ export async function pathGuard(
|
||||
} catch {
|
||||
throw new PathScopeError(`path does not exist: ${requested}`);
|
||||
}
|
||||
if (real !== projectRoot && !real.startsWith(projectRoot + sep)) {
|
||||
if (isUnder(real, projectRoot)) return real;
|
||||
for (const extra of extraRoots) {
|
||||
if (extra.length === 0) continue;
|
||||
if (isUnder(real, extra)) return real;
|
||||
}
|
||||
throw new PathScopeError(
|
||||
`path escapes project root: ${requested} -> ${real}`
|
||||
`path escapes project root: ${requested} -> ${real}. ` +
|
||||
`Use request_read_access(path, reason) to ask the user for permission.`,
|
||||
);
|
||||
}
|
||||
return real;
|
||||
}
|
||||
|
||||
82
apps/server/src/services/request_read_access.ts
Normal file
82
apps/server/src/services/request_read_access.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// v1.13.17-cross-repo-reads: tool the model uses to request read access to
|
||||
// a path outside its session's primary project root. When the model emits
|
||||
// view_file("/opt/forks/foo/go.mod") under a session scoped to /opt/boocode,
|
||||
// pathGuard's error message hints at this tool. The model then emits
|
||||
// request_read_access(path="/opt/forks/foo/go.mod",
|
||||
// reason="investigating foo to write the design doc")
|
||||
// The tool's execute does cheap up-front validation: if the requested path
|
||||
// can't possibly be granted under the current whitelist + repo-shape rules,
|
||||
// it returns a denial immediately without prompting the user. Otherwise, the
|
||||
// tool-phase pause branch (parallel of ask_user_input) stores a pending
|
||||
// sentinel and waits for the user's allow/deny via the grant_read_access
|
||||
// endpoint.
|
||||
//
|
||||
// The execute body never directly mutates state; the grant endpoint owns
|
||||
// the persistence path. This keeps the tool-side logic side-effect-free
|
||||
// (it's just a request) and matches ask_user_input's "server-side no-op
|
||||
// fallback, pause happens in tool-phase" shape.
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from './tools.js';
|
||||
|
||||
const RequestReadAccessInput = z.object({
|
||||
path: z.string().min(1),
|
||||
reason: z.string().min(1).max(500),
|
||||
});
|
||||
type RequestReadAccessInputT = z.infer<typeof RequestReadAccessInput>;
|
||||
|
||||
export const requestReadAccess: ToolDef<RequestReadAccessInputT> = {
|
||||
name: 'request_read_access',
|
||||
description:
|
||||
"Ask the user for read-only access to a path outside the current " +
|
||||
"session's project scope. Use when a previous read tool (view_file, " +
|
||||
'list_dir, grep, find_files) was refused with a path-escapes-project ' +
|
||||
'error and the path is plausibly under another known repository (e.g. ' +
|
||||
'/opt/forks/foo). Provide a short reason describing why you need the ' +
|
||||
"access. Pauses the conversation until the user picks Allow or Deny; " +
|
||||
'the next assistant turn sees the result. On Allow, the tool result ' +
|
||||
'is "granted: <root>" — subsequent reads under that root succeed for ' +
|
||||
'the rest of the session. On Deny, the tool result is "denied". Do ' +
|
||||
'not call this for paths that are already inside the project root.',
|
||||
inputSchema: RequestReadAccessInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'request_read_access',
|
||||
description:
|
||||
"Ask the user for read-only access to a path outside the session's " +
|
||||
'project scope. Pauses the conversation until the user picks Allow ' +
|
||||
'or Deny. Subsequent reads under the granted root succeed for the ' +
|
||||
'rest of the session.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Absolute path the model wants to read. Must be under the ' +
|
||||
"server's PROJECT_ROOT_WHITELIST (default /opt) and outside " +
|
||||
"the session's primary project root.",
|
||||
},
|
||||
reason: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Short rationale (<=500 chars) shown to the user explaining ' +
|
||||
'why the access is needed. The user uses this to decide.',
|
||||
},
|
||||
},
|
||||
required: ['path', 'reason'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Server-side no-op. The "execution" of request_read_access is the
|
||||
// pause-and-resume cycle managed by tool-phase.ts + the grant endpoint.
|
||||
// The inference loop catches this tool name BEFORE executeToolCall fires
|
||||
// and inserts a pending sentinel instead — this fallback only runs if
|
||||
// something bypasses that branch, in which case we surface the pending
|
||||
// shape so downstream code can still detect it. Mirrors ask_user_input.
|
||||
async execute(input) {
|
||||
return { _pending: true, path: input.path, reason: input.reason };
|
||||
},
|
||||
};
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
getSemanticNeighborhoods,
|
||||
getFrameworkAnalysis,
|
||||
} from './tools/codecontext/index.js';
|
||||
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
|
||||
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||
// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
|
||||
import { requestReadAccess } from './request_read_access.js';
|
||||
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const DEFAULT_VIEW_LINES = 200;
|
||||
@@ -45,7 +49,13 @@ export interface ToolDef<TInput> {
|
||||
description: string;
|
||||
inputSchema: z.ZodType<TInput>;
|
||||
jsonSchema: ToolJsonSchema;
|
||||
execute(input: TInput, projectRoot: string): Promise<unknown>;
|
||||
// v1.13.17-cross-repo-reads: extraRoots is the session's
|
||||
// allowed_read_paths, threaded through executeToolCall in tool-phase.ts.
|
||||
// Only the filesystem tools (view_file, list_dir, grep, find_files,
|
||||
// view_truncated_output) forward it to pathGuard; other tools accept the
|
||||
// arg and ignore it. The execute signature stays compatible with
|
||||
// pre-v1.13.17 callsites because the parameter is optional.
|
||||
execute(input: TInput, projectRoot: string, extraRoots?: readonly string[]): Promise<unknown>;
|
||||
}
|
||||
|
||||
const ViewFileInput = z.object({
|
||||
@@ -78,14 +88,19 @@ export const viewFile: ToolDef<ViewFileInputT> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
const real = await pathGuard(projectRoot, input.path);
|
||||
async execute(input, projectRoot, extraRoots) {
|
||||
const real = await pathGuard(projectRoot, input.path, extraRoots);
|
||||
// v1.11.7: secret-file deny check. Test the project-relative path
|
||||
// (matches the form continue.dev's patterns expect: basenames + dir
|
||||
// segments). Throw a typed error so executeToolCall in inference.ts
|
||||
// surfaces a clear "blocked" message to the LLM instead of silently
|
||||
// returning content the user wanted hidden.
|
||||
const relPath = relative(projectRoot, real) || basename(real);
|
||||
// v1.13.17: when the resolved path is outside the primary projectRoot
|
||||
// (i.e. via an allowed_read_paths grant), `relative()` returns "../…"
|
||||
// which won't match secret-file basename patterns. Re-anchor on the
|
||||
// file's basename so the secret deny still fires across all grant roots.
|
||||
const rel = relative(projectRoot, real);
|
||||
const relPath = rel && !rel.startsWith('..') ? rel : basename(real);
|
||||
if (isSecretPath(relPath)) {
|
||||
throw new SecretBlockedError(relPath);
|
||||
}
|
||||
@@ -157,8 +172,8 @@ export const listDir: ToolDef<ListDirInputT> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
const real = await pathGuard(projectRoot, input.path);
|
||||
async execute(input, projectRoot, extraRoots) {
|
||||
const real = await pathGuard(projectRoot, input.path, extraRoots);
|
||||
const s = await stat(real);
|
||||
if (!s.isDirectory()) {
|
||||
throw new PathScopeError(`not a directory: ${input.path}`);
|
||||
@@ -264,7 +279,7 @@ export const grep: ToolDef<GrepInputT> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
async execute(input, projectRoot, extraRoots) {
|
||||
const limit = Math.min(
|
||||
Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1),
|
||||
MAX_GREP_RESULTS
|
||||
@@ -276,6 +291,7 @@ export const grep: ToolDef<GrepInputT> = {
|
||||
max_matches: limit,
|
||||
case_sensitive: input.case_sensitive,
|
||||
hidden: input.hidden,
|
||||
extra_roots: extraRoots,
|
||||
});
|
||||
const reshaped = result.matches.map((m) => ({
|
||||
path: m.path,
|
||||
@@ -325,7 +341,7 @@ export const findFiles: ToolDef<FindFilesInputT> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
async execute(input, projectRoot, extraRoots) {
|
||||
const limit = Math.min(
|
||||
Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1),
|
||||
MAX_FIND_RESULTS
|
||||
@@ -335,6 +351,7 @@ export const findFiles: ToolDef<FindFilesInputT> = {
|
||||
const result = await fileOpsFindFiles(projectRoot, input.pattern, {
|
||||
path: input.path,
|
||||
max_results: limit,
|
||||
extra_roots: extraRoots,
|
||||
});
|
||||
// v1.11.7: drop paths matching secret patterns. The original `total`
|
||||
// from file_ops includes pre-truncation count; we report the visible
|
||||
@@ -383,7 +400,10 @@ export const viewTruncatedOutput: ToolDef<ViewTruncatedOutputInputT> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, _projectRoot) {
|
||||
// view_truncated_output doesn't touch the filesystem — it pulls from tmpfs
|
||||
// by opaque id. extraRoots is irrelevant here; declared for signature parity
|
||||
// with the v1.13.17 ToolDef contract.
|
||||
async execute(input, _projectRoot, _extraRoots) {
|
||||
const content = await readTruncation(input.id);
|
||||
if (content === null) {
|
||||
return {
|
||||
@@ -658,6 +678,11 @@ export const ALL_TOOLS: ReadonlyArray<ToolDef<unknown>> = [
|
||||
watchChanges as ToolDef<unknown>,
|
||||
getSemanticNeighborhoods as ToolDef<unknown>,
|
||||
getFrameworkAnalysis as ToolDef<unknown>,
|
||||
// v1.13.17-cross-repo-reads: paired with the pause-on-pending-grant
|
||||
// branch in tool-phase.ts. Read-only — only ever READS files; the only
|
||||
// state change is appending to sessions.allowed_read_paths via the
|
||||
// grant endpoint, gated by user consent.
|
||||
requestReadAccess as ToolDef<unknown>,
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
||||
@@ -694,6 +719,10 @@ export const READ_ONLY_TOOL_NAMES = [
|
||||
'watch_changes',
|
||||
'get_semantic_neighborhoods',
|
||||
'get_framework_analysis',
|
||||
// v1.13.17-cross-repo-reads: pauses execution but doesn't mutate project
|
||||
// state directly (the grant endpoint appends to sessions.allowed_read_paths
|
||||
// only with user consent). Belongs in the read-only budget tier.
|
||||
'request_read_access',
|
||||
] as const;
|
||||
|
||||
export const TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
|
||||
export const GetDependenciesInput = z.object({
|
||||
file_path: z.string().optional(),
|
||||
file_path: z.string().trim().optional(),
|
||||
direction: z.enum(['incoming', 'outgoing', 'both']).optional(),
|
||||
});
|
||||
export type GetDependenciesInputT = z.infer<typeof GetDependenciesInput>;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
|
||||
export const GetFileAnalysisInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
file_path: z.string().trim().min(1),
|
||||
});
|
||||
export type GetFileAnalysisInputT = z.infer<typeof GetFileAnalysisInput>;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
|
||||
export const GetSemanticNeighborhoodsInput = z.object({
|
||||
file_path: z.string().optional(),
|
||||
file_path: z.string().trim().optional(),
|
||||
include_basic: z.boolean().optional(),
|
||||
include_quality: z.boolean().optional(),
|
||||
max_results: z.number().int().positive().optional(),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { callCodecontext, type CodecontextResponse } from '../../codecontext_cli
|
||||
|
||||
export const GetSymbolInfoInput = z.object({
|
||||
symbol_name: z.string().min(1),
|
||||
file_path: z.string().optional(),
|
||||
file_path: z.string().trim().optional(),
|
||||
framework_type: z.string().optional(),
|
||||
});
|
||||
export type GetSymbolInfoInputT = z.infer<typeof GetSymbolInfoInput>;
|
||||
|
||||
@@ -42,9 +42,40 @@ export interface Session {
|
||||
// v1.12.1: server-side workspace pane layout. Replaces per-device
|
||||
// localStorage so all devices viewing the session see the same panes.
|
||||
workspace_panes: WorkspacePane[];
|
||||
// v1.13.17: absolute paths the agent has been granted read access to via
|
||||
// the request_read_access tool. Empty by default; populated only by the
|
||||
// grant_read_access endpoint's allow branch. Revoked via PATCH session.
|
||||
// path_guard's extraRoots check consults this list before refusing reads
|
||||
// outside the primary project root.
|
||||
allowed_read_paths: string[];
|
||||
}
|
||||
|
||||
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
|
||||
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added.
|
||||
// Optional payload state lives on the pane row itself so the jsonb survives
|
||||
// a hard reload without needing a re-fetch.
|
||||
export type WorkspacePaneKind =
|
||||
| 'chat'
|
||||
| 'terminal'
|
||||
| 'agent'
|
||||
| 'empty'
|
||||
| 'settings'
|
||||
| 'markdown_artifact'
|
||||
| 'html_artifact';
|
||||
|
||||
// v1.14.x: reference-only — the actual artifact body lives in the message
|
||||
// row (markdown) or message_parts.payload (html_artifact). Pane components
|
||||
// fetch on mount.
|
||||
export interface MarkdownArtifactState {
|
||||
chat_id: string;
|
||||
message_id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface HtmlArtifactState {
|
||||
chat_id: string;
|
||||
message_id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface WorkspacePane {
|
||||
id: string;
|
||||
@@ -52,6 +83,9 @@ export interface WorkspacePane {
|
||||
chatId?: string;
|
||||
chatIds: string[];
|
||||
activeChatIdx: number;
|
||||
// v1.14.x: populated only when kind === 'markdown_artifact' / 'html_artifact'.
|
||||
markdown_artifact_state?: MarkdownArtifactState;
|
||||
html_artifact_state?: HtmlArtifactState;
|
||||
}
|
||||
|
||||
// v1.8.1: agents come from two sources. 'global' = /data/AGENTS.md (always
|
||||
@@ -72,6 +106,9 @@ export interface Agent {
|
||||
// agent's toolset (30 if all tools are read-only, 10 otherwise) or 15 for
|
||||
// raw chat with no agent.
|
||||
max_tool_calls: number | null;
|
||||
// v1.14.0: per-agent step cap for the outer inference loop. null means
|
||||
// bounded only by MAX_STEPS (200). 0 means "no tool calls allowed."
|
||||
steps: number | null;
|
||||
}
|
||||
|
||||
// One entry per malformed `## Name` block. Per-block errors don't fail the
|
||||
|
||||
@@ -123,7 +123,20 @@ export const api = {
|
||||
get: (id: string) => request<Session>(`/api/sessions/${id}`),
|
||||
update: (
|
||||
id: string,
|
||||
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt' | 'agent_id' | 'web_search_enabled'>>
|
||||
body: Partial<
|
||||
Pick<
|
||||
Session,
|
||||
| 'name'
|
||||
| 'model'
|
||||
| 'system_prompt'
|
||||
| 'agent_id'
|
||||
| 'web_search_enabled'
|
||||
// v1.13.17: revocation path — frontend sends the shortened list
|
||||
// when the user removes a grant. Grants are appended only via the
|
||||
// separate grantReadAccess endpoint below.
|
||||
| 'allowed_read_paths'
|
||||
>
|
||||
>
|
||||
) =>
|
||||
request<Session>(`/api/sessions/${id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -228,6 +241,19 @@ export const api = {
|
||||
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
|
||||
},
|
||||
),
|
||||
// v1.13.17-cross-repo-reads: resume a paused request_read_access. On
|
||||
// 'allow' the server re-resolves the grant root and appends it to
|
||||
// sessions.allowed_read_paths; the returned list reflects the post-
|
||||
// grant state. On 'deny' the array is unchanged.
|
||||
grantReadAccess: (chatId: string, toolCallId: string, decision: 'allow' | 'deny') =>
|
||||
request<{
|
||||
tool_message_id: string;
|
||||
assistant_message_id: string;
|
||||
allowed_read_paths: string[];
|
||||
}>(`/api/chats/${chatId}/grant_read_access`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tool_call_id: toolCallId, decision }),
|
||||
}),
|
||||
},
|
||||
|
||||
messages: {
|
||||
@@ -250,6 +276,24 @@ export const api = {
|
||||
request<void>(`/api/chats/${chatId}/messages/${messageId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
// v1.14.x-html-artifact-panes: write the artifact to
|
||||
// <projectRoot>/.boocode/artifacts/<slug>-<ts>.<ext> and return the
|
||||
// path + a /api/projects/.../artifacts/<filename> URL the browser can
|
||||
// GET to download. fmt=html requires the assistant message to carry an
|
||||
// html_artifact part (404 otherwise).
|
||||
downloadArtifact: (chatId: string, messageId: string, fmt: 'md' | 'html') =>
|
||||
request<{ path: string; url: string }>(
|
||||
`/api/chats/${chatId}/messages/${messageId}/artifacts/download?fmt=${fmt}`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
// v1.14.x-html-artifact-panes: fetch the html_artifact part payload so
|
||||
// HtmlArtifactPane can render the iframe srcdoc. 404 = no html_artifact
|
||||
// part on this message; MessageBubble uses that as a signal to fall back
|
||||
// to the markdown pane variant.
|
||||
getHtmlArtifact: (chatId: string, messageId: string) =>
|
||||
request<{ html_content: string; char_count: number; title: string }>(
|
||||
`/api/chats/${chatId}/messages/${messageId}/html_artifact`,
|
||||
),
|
||||
},
|
||||
|
||||
models: () => request<ModelInfo[]>('/api/models'),
|
||||
|
||||
@@ -48,6 +48,11 @@ export interface Session {
|
||||
web_search_enabled: boolean | null;
|
||||
// v1.12.1: server-authoritative pane layout, replaces localStorage.
|
||||
workspace_panes: WorkspacePane[];
|
||||
// v1.13.17: paths the agent has been granted read access to via the
|
||||
// request_read_access tool. Empty by default. Settings UI surfaces the
|
||||
// list with per-row revoke; the grant flow itself appends through the
|
||||
// dedicated POST /api/chats/:id/grant_read_access endpoint (not PATCH).
|
||||
allowed_read_paths: string[];
|
||||
}
|
||||
|
||||
// v1.8.1: 'global' = /data/AGENTS.md (always-on), 'project' = per-project
|
||||
@@ -68,6 +73,9 @@ export interface Agent {
|
||||
// the agent's toolset (30 for all read-only, 10 otherwise) or 15 for raw
|
||||
// chat with no agent.
|
||||
max_tool_calls: number | null;
|
||||
// v1.14.0: per-agent step cap for the outer inference loop. null means
|
||||
// bounded only by MAX_STEPS (200). 0 means "no tool calls allowed."
|
||||
steps: number | null;
|
||||
}
|
||||
|
||||
export interface AgentParseError {
|
||||
@@ -311,7 +319,37 @@ export interface AskUserAnswerSet {
|
||||
// v1.9: 'settings' is an ephemeral pane kind — never persisted, always
|
||||
// singleton per workspace. The pane hook filters it out before writing to
|
||||
// localStorage and dedupes on insertion via toggleSettingsPane().
|
||||
export type WorkspacePaneKind = 'chat' | 'terminal' | 'agent' | 'empty' | 'settings';
|
||||
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added.
|
||||
// Both carry payload state on the WorkspacePane row itself so
|
||||
// useWorkspacePanes's JSON-string dedup + persisted jsonb stay self-contained
|
||||
// — no extra fetch on rehydrate.
|
||||
export type WorkspacePaneKind =
|
||||
| 'chat'
|
||||
| 'terminal'
|
||||
| 'agent'
|
||||
| 'empty'
|
||||
| 'settings'
|
||||
| 'markdown_artifact'
|
||||
| 'html_artifact';
|
||||
|
||||
// v1.14.x: per-pane artifact payloads. Optional + namespaced so older saved
|
||||
// pane rows (without these fields) deserialize unchanged.
|
||||
// v1.14.x: pane state is a reference only — the pane component fetches the
|
||||
// actual content on mount. This keeps sessions.workspace_panes jsonb small and
|
||||
// makes the message body / html_artifact part the single source of truth.
|
||||
export interface MarkdownArtifactState {
|
||||
// chat_id is needed for the download endpoint
|
||||
// (POST /api/chats/:chat_id/messages/:msg_id/artifacts/download).
|
||||
chat_id: string;
|
||||
message_id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface HtmlArtifactState {
|
||||
chat_id: string;
|
||||
message_id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface WorkspacePane {
|
||||
id: string;
|
||||
@@ -319,6 +357,9 @@ export interface WorkspacePane {
|
||||
chatId?: string;
|
||||
chatIds: string[];
|
||||
activeChatIdx: number;
|
||||
// v1.14.x: populated only when kind === 'markdown_artifact' / 'html_artifact'.
|
||||
markdown_artifact_state?: MarkdownArtifactState;
|
||||
html_artifact_state?: HtmlArtifactState;
|
||||
}
|
||||
|
||||
export type WsFrame =
|
||||
|
||||
116
apps/web/src/components/HtmlArtifactPane.tsx
Normal file
116
apps/web/src/components/HtmlArtifactPane.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
// v1.14.x-html-artifact-panes: full-height HTML artifact viewer. Renders the
|
||||
// model's HTML inside a sandboxed iframe — no allow-same-origin, srcdoc only
|
||||
// (no separate URL), CSP injected by the backend writer. JS runs inside the
|
||||
// iframe (interactive controls work) but fetch / WS / tracking pixels are
|
||||
// blocked by connect-src 'none' on the CSP. NO Copy button per the spec.
|
||||
//
|
||||
// Pane state is a reference only (chat_id + message_id + title); the iframe
|
||||
// payload is fetched on mount from
|
||||
// GET /api/chats/:chat_id/messages/:msg_id/html_artifact so that
|
||||
// sessions.workspace_panes jsonb stays small and message_parts.payload is the
|
||||
// single source of truth.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { HtmlArtifactState } from '@/api/types';
|
||||
|
||||
interface Props {
|
||||
chatId: string;
|
||||
state: HtmlArtifactState;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function HtmlArtifactPane({ chatId, state, onClose }: Props) {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [htmlContent, setHtmlContent] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setHtmlContent(null);
|
||||
setLoadError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const payload = await api.messages.getHtmlArtifact(chatId, state.message_id);
|
||||
if (cancelled) return;
|
||||
setHtmlContent(payload.html_content);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setLoadError(err instanceof Error ? err.message : 'failed to load HTML artifact');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chatId, state.message_id]);
|
||||
|
||||
async function download() {
|
||||
if (downloading) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { url, path } = await api.messages.downloadArtifact(
|
||||
chatId,
|
||||
state.message_id,
|
||||
'html',
|
||||
);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.rel = 'noopener';
|
||||
a.click();
|
||||
toast.success(`Saved to ${path}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'download failed');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||
<span className="text-xs text-muted-foreground truncate flex-1" title={state.title}>
|
||||
{state.title || 'HTML artifact'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void download()}
|
||||
disabled={downloading || htmlContent === null}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Download HTML"
|
||||
title="Download"
|
||||
>
|
||||
<Download size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Close artifact pane"
|
||||
title="Close"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden bg-background">
|
||||
{loadError ? (
|
||||
<div className="p-4 text-sm text-destructive">Failed to load: {loadError}</div>
|
||||
) : htmlContent === null ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">Loading HTML artifact…</div>
|
||||
) : (
|
||||
<iframe
|
||||
// Sandbox attributes are non-negotiable per the v1.14.x spec S5:
|
||||
// no allow-same-origin → opaque origin → can't reach parent cookies
|
||||
// or DOM. srcdoc (not src) means no URL exists to leak. JS runs
|
||||
// (allow-scripts) but connect-src 'none' on the CSP inside the
|
||||
// payload blocks fetch / WS / pixels.
|
||||
srcDoc={htmlContent}
|
||||
sandbox="allow-scripts allow-clipboard-write allow-downloads"
|
||||
className="w-full h-full border-0"
|
||||
title={state.title || 'HTML artifact'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
apps/web/src/components/MarkdownArtifactPane.tsx
Normal file
137
apps/web/src/components/MarkdownArtifactPane.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
// v1.14.x-html-artifact-panes: dedicated full-height Markdown viewer used
|
||||
// when a user clicks "Open in pane" on an assistant message that has NO
|
||||
// html_artifact part. Header carries Copy (raw source) + Download (server-
|
||||
// materialised .md under <projectRoot>/.boocode/artifacts/) + close.
|
||||
//
|
||||
// Pane state is a reference only (chat_id + message_id + title); the markdown
|
||||
// body is fetched on mount from GET /api/chats/:chat_id/messages by locating
|
||||
// the matching message_id. This keeps sessions.workspace_panes jsonb small
|
||||
// and the assistant message row remains the single source of truth.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, Copy, Download, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { MarkdownArtifactState } from '@/api/types';
|
||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||
|
||||
interface Props {
|
||||
chatId: string;
|
||||
state: MarkdownArtifactState;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MarkdownArtifactPane({ chatId, state, onClose }: Props) {
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setContent(null);
|
||||
setLoadError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
// No single-message GET endpoint exists; the chat-messages list is
|
||||
// already cached server-side and the lookup is O(n) over a small
|
||||
// window. Cheaper than adding a new route for one call site.
|
||||
const messages = await api.chats.messages(chatId);
|
||||
if (cancelled) return;
|
||||
const msg = messages.find((m) => m.id === state.message_id);
|
||||
if (!msg) {
|
||||
setLoadError('Message not found');
|
||||
return;
|
||||
}
|
||||
setContent(msg.content ?? '');
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setLoadError(err instanceof Error ? err.message : 'failed to load message');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chatId, state.message_id]);
|
||||
|
||||
async function copy() {
|
||||
if (content === null) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setJustCopied(true);
|
||||
setTimeout(() => setJustCopied(false), 1200);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'copy failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
if (downloading) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { url, path } = await api.messages.downloadArtifact(
|
||||
chatId,
|
||||
state.message_id,
|
||||
'md',
|
||||
);
|
||||
// Trigger browser download from the returned URL. The endpoint stamps
|
||||
// Content-Disposition: attachment so the click lands as a save.
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.rel = 'noopener';
|
||||
a.click();
|
||||
toast.success(`Saved to ${path}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'download failed');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||
<span className="text-xs text-muted-foreground truncate flex-1" title={state.title}>
|
||||
{state.title || 'Markdown artifact'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy()}
|
||||
disabled={content === null}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Copy markdown source"
|
||||
title="Copy"
|
||||
>
|
||||
{justCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void download()}
|
||||
disabled={downloading || content === null}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Download markdown"
|
||||
title="Download"
|
||||
>
|
||||
<Download size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Close artifact pane"
|
||||
title="Close"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto px-4 py-3 text-sm">
|
||||
{loadError ? (
|
||||
<div className="text-destructive">Failed to load: {loadError}</div>
|
||||
) : content === null ? (
|
||||
<div className="text-muted-foreground">Loading…</div>
|
||||
) : (
|
||||
<MarkdownRenderer content={content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
apps/web/src/components/MarkdownRenderer.tsx
Normal file
148
apps/web/src/components/MarkdownRenderer.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
// v1.14.x-html-artifact-panes: extracted from MessageBubble.tsx so both the
|
||||
// in-chat bubble renderer and the MarkdownArtifactPane share the same Shiki +
|
||||
// remark-gfm + path-linkifier pipeline. Behavior preserved byte-for-byte from
|
||||
// the original MessageBubble.MarkdownBody helper (and its linkify helpers).
|
||||
import { Children, cloneElement, isValidElement } from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
|
||||
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
|
||||
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
|
||||
// match, but `src/foo.ts` will). False positives at the edges are accepted
|
||||
// per Sam's design decision (2026-05-14).
|
||||
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
|
||||
|
||||
function isPathLike(s: string): boolean {
|
||||
return s.includes('/');
|
||||
}
|
||||
|
||||
function emitOpenFile(path: string): void {
|
||||
sessionEvents.emit({ type: 'open_file_in_browser', path });
|
||||
}
|
||||
|
||||
function linkifyPaths(text: string, keyPrefix: string): ReactNode {
|
||||
const out: ReactNode[] = [];
|
||||
let lastIdx = 0;
|
||||
let idx = 0;
|
||||
for (const match of text.matchAll(PATH_REGEX)) {
|
||||
const matchedText = match[0];
|
||||
const start = match.index ?? 0;
|
||||
if (!isPathLike(matchedText)) continue;
|
||||
if (start > lastIdx) out.push(text.slice(lastIdx, start));
|
||||
out.push(
|
||||
<button
|
||||
key={`${keyPrefix}-${idx}`}
|
||||
type="button"
|
||||
onClick={() => emitOpenFile(matchedText)}
|
||||
className="text-primary underline cursor-pointer hover:text-primary/80"
|
||||
>
|
||||
{matchedText}
|
||||
</button>
|
||||
);
|
||||
lastIdx = start + matchedText.length;
|
||||
idx += 1;
|
||||
}
|
||||
if (out.length === 0) return text;
|
||||
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
||||
return out;
|
||||
}
|
||||
|
||||
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
|
||||
const arr = Children.toArray(children);
|
||||
return arr.map((child, i) => {
|
||||
if (typeof child === 'string') {
|
||||
return (
|
||||
<span key={`${keyPrefix}-${i}`}>
|
||||
{linkifyPaths(child, `${keyPrefix}-${i}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isValidElement(child)) {
|
||||
const el = child as ReactElement<{ children?: ReactNode }>;
|
||||
if (el.type === 'code' || el.type === CodeBlock) return child;
|
||||
const grandchildren = el.props.children;
|
||||
if (grandchildren === undefined) return child;
|
||||
return cloneElement(el, {
|
||||
key: el.key ?? `linkified-${i}`,
|
||||
children: linkifyChildren(grandchildren, `${keyPrefix}-${i}`),
|
||||
});
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
const codeRenderer = (props: { children?: unknown; className?: string }) => {
|
||||
const { children, className, ...rest } = props;
|
||||
const text = String(children ?? '').replace(/\n$/, '');
|
||||
const langMatch = /language-([\w-]+)/.exec(className ?? '');
|
||||
const isBlock = !!langMatch || text.includes('\n');
|
||||
if (isBlock) {
|
||||
return <CodeBlock code={text} lang={langMatch?.[1]} />;
|
||||
}
|
||||
return (
|
||||
<code
|
||||
{...rest}
|
||||
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
|
||||
>
|
||||
{children as React.ReactNode}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
|
||||
export function MarkdownRenderer({ content }: { content: string }) {
|
||||
return (
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
code: codeRenderer,
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc pl-5 space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
|
||||
p: ({ children }) => (
|
||||
<p className="leading-relaxed">{linkifyChildren(children)}</p>
|
||||
),
|
||||
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="border-collapse text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-border px-2 py-1">
|
||||
{linkifyChildren(children)}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
import { Children, cloneElement, isValidElement, useEffect, useState } from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||
import { api } from '@/api/client';
|
||||
import { api, ApiError } from '@/api/client';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||
import { CapHitSentinel } from './CapHitSentinel';
|
||||
import { DoomLoopSentinel } from './DoomLoopSentinel';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -90,76 +88,20 @@ const ERROR_REASON_LABELS: Record<ErrorReason, string> = {
|
||||
summary_after_cap_failed: 'Summary after tool budget hit failed',
|
||||
};
|
||||
|
||||
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
|
||||
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
|
||||
// match, but `src/foo.ts` will). False positives at the edges are accepted
|
||||
// per Sam's design decision (2026-05-14).
|
||||
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
|
||||
// v1.14.x-html-artifact-panes: MarkdownBody and its path-linkifier helpers
|
||||
// moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact
|
||||
// panes can render assistant content with the same Shiki + remark-gfm setup.
|
||||
|
||||
function isPathLike(s: string): boolean {
|
||||
return s.includes('/');
|
||||
}
|
||||
|
||||
function emitOpenFile(path: string): void {
|
||||
sessionEvents.emit({ type: 'open_file_in_browser', path });
|
||||
}
|
||||
|
||||
// Split a plain string into a flat array of strings and clickable button
|
||||
// nodes for path-shaped substrings. If no matches, returns the original
|
||||
// string verbatim (no array wrapping).
|
||||
function linkifyPaths(text: string, keyPrefix: string): ReactNode {
|
||||
const out: ReactNode[] = [];
|
||||
let lastIdx = 0;
|
||||
let idx = 0;
|
||||
for (const match of text.matchAll(PATH_REGEX)) {
|
||||
const matchedText = match[0];
|
||||
const start = match.index ?? 0;
|
||||
if (!isPathLike(matchedText)) continue;
|
||||
if (start > lastIdx) out.push(text.slice(lastIdx, start));
|
||||
out.push(
|
||||
<button
|
||||
key={`${keyPrefix}-${idx}`}
|
||||
type="button"
|
||||
onClick={() => emitOpenFile(matchedText)}
|
||||
className="text-primary underline cursor-pointer hover:text-primary/80"
|
||||
>
|
||||
{matchedText}
|
||||
</button>
|
||||
);
|
||||
lastIdx = start + matchedText.length;
|
||||
idx += 1;
|
||||
}
|
||||
if (out.length === 0) return text;
|
||||
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
||||
return out;
|
||||
}
|
||||
|
||||
// Walk react-markdown children, linkifying string text nodes. Children of
|
||||
// <code> nodes (CodeBlock and inline code) are left untouched — the regex
|
||||
// shouldn't run inside code spans.
|
||||
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
|
||||
const arr = Children.toArray(children);
|
||||
return arr.map((child, i) => {
|
||||
if (typeof child === 'string') {
|
||||
return (
|
||||
<span key={`${keyPrefix}-${i}`}>
|
||||
{linkifyPaths(child, `${keyPrefix}-${i}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isValidElement(child)) {
|
||||
const el = child as ReactElement<{ children?: ReactNode }>;
|
||||
// Skip inline/block code — paths in code spans aren't link targets.
|
||||
if (el.type === 'code' || el.type === CodeBlock) return child;
|
||||
const grandchildren = el.props.children;
|
||||
if (grandchildren === undefined) return child;
|
||||
return cloneElement(el, {
|
||||
key: el.key ?? `linkified-${i}`,
|
||||
children: linkifyChildren(grandchildren, `${keyPrefix}-${i}`),
|
||||
});
|
||||
}
|
||||
return child;
|
||||
});
|
||||
// Pane-header title derivation for a markdown artifact. Order matches the
|
||||
// server slug logic in services/artifacts.ts: first `# ` heading → first 6
|
||||
// words of the body → 'Markdown artifact'. Truncated to keep the pane header
|
||||
// readable.
|
||||
function deriveMarkdownTitle(content: string): string {
|
||||
const headingMatch = content.match(/^\s*#\s+(.+?)\s*$/m);
|
||||
if (headingMatch && headingMatch[1]) return headingMatch[1].slice(0, 80);
|
||||
const words = content.trim().split(/\s+/).slice(0, 6).join(' ');
|
||||
if (words) return words.slice(0, 80);
|
||||
return 'Markdown artifact';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -170,80 +112,6 @@ interface Props {
|
||||
capHitInfo?: { position: number; isLatest: boolean };
|
||||
}
|
||||
|
||||
function MarkdownBody({ content }: { content: string }) {
|
||||
return (
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
code: (props) => {
|
||||
const { children, className, ...rest } = props as {
|
||||
children?: unknown;
|
||||
className?: string;
|
||||
};
|
||||
const text = String(children ?? '').replace(/\n$/, '');
|
||||
const langMatch = /language-([\w-]+)/.exec(className ?? '');
|
||||
const isBlock = !!langMatch || text.includes('\n');
|
||||
if (isBlock) {
|
||||
return <CodeBlock code={text} lang={langMatch?.[1]} />;
|
||||
}
|
||||
return (
|
||||
<code
|
||||
{...rest}
|
||||
className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
|
||||
>
|
||||
{children as React.ReactNode}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc pl-5 space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
|
||||
p: ({ children }) => (
|
||||
<p className="leading-relaxed">{linkifyChildren(children)}</p>
|
||||
),
|
||||
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="border-collapse text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-border px-2 py-1">
|
||||
{linkifyChildren(children)}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsLine({ message }: { message: Message }) {
|
||||
const tokens = message.tokens_used;
|
||||
if (typeof tokens !== 'number' || tokens <= 0) return null;
|
||||
@@ -337,6 +205,54 @@ function ActionRow({
|
||||
const canRegen = isAssistant && message.status !== 'streaming';
|
||||
const canFork = message.status === 'complete';
|
||||
const canDelete = message.status !== 'streaming';
|
||||
const [openingPane, setOpeningPane] = useState(false);
|
||||
|
||||
// v1.14.x-html-artifact-panes: probe for an html_artifact part. If present,
|
||||
// open the HTML pane variant; otherwise fall back to the markdown variant.
|
||||
// Title derivation for markdown: first `# ` heading → first 6 words of the
|
||||
// body → 'Markdown artifact' (mirrors the slug logic in
|
||||
// services/artifacts.ts).
|
||||
async function openInPane() {
|
||||
if (openingPane || message.status === 'streaming') return;
|
||||
setOpeningPane(true);
|
||||
try {
|
||||
try {
|
||||
const payload = await api.messages.getHtmlArtifact(
|
||||
message.chat_id,
|
||||
message.id,
|
||||
);
|
||||
sessionEvents.emit({
|
||||
type: 'open_html_artifact_pane',
|
||||
state: {
|
||||
chat_id: message.chat_id,
|
||||
message_id: message.id,
|
||||
title: payload.title,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
// 404 (no html_artifact part) is the expected fall-through path —
|
||||
// markdown variant opens below. Any other error (network, 500) is
|
||||
// a real failure; toast and bail rather than masquerading as markdown.
|
||||
const status = err instanceof ApiError ? err.status : null;
|
||||
if (status !== 404) {
|
||||
toast.error(err instanceof Error ? err.message : 'open in pane failed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const title = deriveMarkdownTitle(message.content);
|
||||
sessionEvents.emit({
|
||||
type: 'open_markdown_artifact_pane',
|
||||
state: {
|
||||
chat_id: message.chat_id,
|
||||
message_id: message.id,
|
||||
title,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setOpeningPane(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -350,6 +266,18 @@ function ActionRow({
|
||||
>
|
||||
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
</button>
|
||||
{isAssistant && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openInPane()}
|
||||
disabled={openingPane || message.status === 'streaming'}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Open in pane"
|
||||
title="Open in pane"
|
||||
>
|
||||
<PanelRightOpen className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
{isAssistant && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -588,7 +516,7 @@ function SummaryCard({ message }: { message: Message }) {
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="px-3 pb-3 text-xs leading-relaxed border-t pt-2">
|
||||
<MarkdownBody content={message.content} />
|
||||
<MarkdownRenderer content={message.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -667,7 +595,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
||||
{(hasContent || isStreaming) && (
|
||||
<SendToTerminalMenu>
|
||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
||||
{hasContent ? <MarkdownBody content={message.content} /> : null}
|
||||
{hasContent ? <MarkdownRenderer content={message.content} /> : null}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MessageBubble } from './MessageBubble';
|
||||
import { ToolCallGroup } from './ToolCallGroup';
|
||||
import { ToolCallLine, type ToolRun } from './ToolCallLine';
|
||||
import { AskUserInputCard } from './AskUserInputCard';
|
||||
import { RequestReadAccessCard } from './RequestReadAccessCard';
|
||||
|
||||
interface Props {
|
||||
messages: Message[];
|
||||
@@ -85,7 +86,9 @@ function group(items: RenderItem[]): RenderItem[] {
|
||||
continue;
|
||||
}
|
||||
const name = item.run.call.name;
|
||||
if (name === 'ask_user_input') {
|
||||
if (name === 'ask_user_input' || name === 'request_read_access') {
|
||||
// v1.13.17: same rationale as ask_user_input — grouping would collapse
|
||||
// the interactive pause card into a non-actionable ToolCallLine.
|
||||
out.push(item);
|
||||
i += 1;
|
||||
continue;
|
||||
@@ -181,6 +184,16 @@ export function MessageList({ messages, sessionChats }: Props) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.run.call.name === 'request_read_access') {
|
||||
return (
|
||||
<RequestReadAccessCard
|
||||
key={item.key}
|
||||
toolCall={item.run.call}
|
||||
toolResult={item.run.result}
|
||||
chatId={item.chatId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ToolCallLine key={item.key} run={item.run} />;
|
||||
}
|
||||
return <ToolCallGroup key={item.key} runs={item.runs} />;
|
||||
|
||||
193
apps/web/src/components/RequestReadAccessCard.tsx
Normal file
193
apps/web/src/components/RequestReadAccessCard.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, FolderOpen, ShieldOff } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { ToolCall, ToolResult } from '@/api/types';
|
||||
|
||||
// v1.13.17-cross-repo-reads. Renders an inline allow/deny picker for a
|
||||
// paused request_read_access tool call. Mirrors AskUserInputCard's pending
|
||||
// vs answered render dance:
|
||||
// - Pending: server pre-stamps a sentinel tool_result with output=null.
|
||||
// The card shows path + reason and lets the user pick Allow or Deny.
|
||||
// - Answered: the eventual WS tool_result frame carries the actual
|
||||
// decision string ("granted: <root>" or "denied" or "denied: <reason>").
|
||||
// The card flips to a read-only summary line.
|
||||
//
|
||||
// Tool name discrimination lives in MessageList.flatten/group — anything
|
||||
// with tc.name === 'request_read_access' bypasses grouping and renders this
|
||||
// card directly.
|
||||
|
||||
interface Props {
|
||||
toolCall: ToolCall;
|
||||
toolResult: ToolResult | null;
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
interface ParsedArgs {
|
||||
path: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function parseArgs(raw: unknown): ParsedArgs | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const obj = raw as { path?: unknown; reason?: unknown };
|
||||
if (typeof obj.path !== 'string' || obj.path.length === 0) return null;
|
||||
if (typeof obj.reason !== 'string' || obj.reason.length === 0) return null;
|
||||
return { path: obj.path, reason: obj.reason };
|
||||
}
|
||||
|
||||
function decisionVariant(output: unknown): 'granted' | 'denied' | 'unknown' {
|
||||
if (typeof output !== 'string') return 'unknown';
|
||||
if (output.startsWith('granted:')) return 'granted';
|
||||
if (output === 'denied' || output.startsWith('denied:')) return 'denied';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function RequestReadAccessCard({ toolCall, toolResult, chatId }: Props) {
|
||||
const args = parseArgs(toolCall.args);
|
||||
|
||||
if (!args) {
|
||||
return (
|
||||
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
|
||||
request_read_access: malformed tool args
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Non-null output means the WS tool_result frame arrived (or the row was
|
||||
// re-fetched from history).
|
||||
const answered = toolResult && toolResult.output !== null;
|
||||
if (answered) {
|
||||
return <AnsweredView args={args} output={toolResult!.output} />;
|
||||
}
|
||||
|
||||
return <PendingView args={args} toolCallId={toolCall.id} chatId={chatId} />;
|
||||
}
|
||||
|
||||
function PendingView({
|
||||
args,
|
||||
toolCallId,
|
||||
chatId,
|
||||
}: {
|
||||
args: ParsedArgs;
|
||||
toolCallId: string;
|
||||
chatId: string;
|
||||
}) {
|
||||
const [submitting, setSubmitting] = useState<'allow' | 'deny' | null>(null);
|
||||
|
||||
async function decide(decision: 'allow' | 'deny') {
|
||||
if (submitting) return;
|
||||
setSubmitting(decision);
|
||||
try {
|
||||
await api.chats.grantReadAccess(chatId, toolCallId, decision);
|
||||
// Card stays mounted; the incoming WS tool_result frame swaps it to
|
||||
// AnsweredView via the parent prop change.
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'request failed');
|
||||
setSubmitting(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 text-sm">
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-amber-700 dark:text-amber-300">
|
||||
<ShieldOff className="size-3.5" />
|
||||
<span>Read-access request</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">Path</div>
|
||||
<div className="font-mono text-xs break-all rounded bg-background/60 border px-2 py-1">
|
||||
{args.path}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">Reason</div>
|
||||
<div className="text-sm leading-snug whitespace-pre-wrap">{args.reason}</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground pt-1">
|
||||
Allow grants the agent read access to the matching repository root for
|
||||
the rest of this session. Revoke any time from the session settings.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-amber-500/20 px-4 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={submitting !== null}
|
||||
onClick={() => void decide('deny')}
|
||||
>
|
||||
{submitting === 'deny' ? 'Denying…' : 'Deny'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={submitting !== null}
|
||||
onClick={() => void decide('allow')}
|
||||
>
|
||||
{submitting === 'allow' ? 'Allowing…' : 'Allow'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnsweredView({ args, output }: { args: ParsedArgs; output: unknown }) {
|
||||
const variant = decisionVariant(output);
|
||||
const text = typeof output === 'string' ? output : 'unknown';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
variant === 'granted'
|
||||
? 'rounded-lg border border-emerald-500/40 bg-emerald-500/5 text-sm'
|
||||
: variant === 'denied'
|
||||
? 'rounded-lg border bg-muted/20 text-sm'
|
||||
: 'rounded-lg border border-destructive/40 bg-destructive/5 text-sm'
|
||||
}
|
||||
>
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wide">
|
||||
{variant === 'granted' ? (
|
||||
<>
|
||||
<Check className="size-3.5 text-emerald-600" />
|
||||
<span className="text-emerald-700 dark:text-emerald-300">Read access granted</span>
|
||||
</>
|
||||
) : variant === 'denied' ? (
|
||||
<>
|
||||
<ShieldOff className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Read access denied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldOff className="size-3.5 text-destructive" />
|
||||
<span className="text-destructive">Read access request — unknown result</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">Path</div>
|
||||
<div className="font-mono text-xs break-all rounded bg-background/60 border px-2 py-1">
|
||||
{args.path}
|
||||
</div>
|
||||
</div>
|
||||
{variant === 'granted' && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">Granted root</div>
|
||||
<div className="font-mono text-xs break-all rounded bg-background/60 border px-2 py-1 flex items-center gap-1.5">
|
||||
<FolderOpen className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span>{text.replace(/^granted:\s*/, '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{variant === 'denied' && text !== 'denied' && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{text.replace(/^denied:\s*/, '')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { terminalsRegistry } from '@/lib/events';
|
||||
import { ChatPane } from '@/components/panes/ChatPane';
|
||||
import { SettingsPane } from '@/components/panes/SettingsPane';
|
||||
import { TerminalPane } from '@/components/panes/TerminalPane';
|
||||
import { MarkdownArtifactPane } from '@/components/MarkdownArtifactPane';
|
||||
import { HtmlArtifactPane } from '@/components/HtmlArtifactPane';
|
||||
import { ChatTabBar } from '@/components/ChatTabBar';
|
||||
import { SessionLandingPage } from '@/components/SessionLandingPage';
|
||||
import {
|
||||
@@ -182,6 +184,7 @@ export function Workspace({
|
||||
{panes.map((pane, idx) => {
|
||||
const isSettings = pane.kind === 'settings';
|
||||
const isTerminal = pane.kind === 'terminal';
|
||||
const isArtifact = pane.kind === 'markdown_artifact' || pane.kind === 'html_artifact';
|
||||
// v1.9: when maximized, hide every pane except the settings one.
|
||||
// display:none keeps the React tree mounted so streams / drafts
|
||||
// survive the toggle without re-mount cost.
|
||||
@@ -195,7 +198,7 @@ export function Workspace({
|
||||
}
|
||||
// Terminal panes own their tab strip (no chats, no ChatTabBar) and
|
||||
// are not drag-reorderable for now — keeps the layout grid simple.
|
||||
const isChromeless = isSettings || isTerminal;
|
||||
const isChromeless = isSettings || isTerminal || isArtifact;
|
||||
return (
|
||||
<div
|
||||
key={pane.id}
|
||||
@@ -318,6 +321,18 @@ export function Workspace({
|
||||
label={terminalLabels.get(pane.id) ?? 'Terminal'}
|
||||
active={idx === activePaneIdx}
|
||||
/>
|
||||
) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? (
|
||||
<MarkdownArtifactPane
|
||||
chatId={pane.markdown_artifact_state.chat_id}
|
||||
state={pane.markdown_artifact_state}
|
||||
onClose={() => removePane(idx)}
|
||||
/>
|
||||
) : pane.kind === 'html_artifact' && pane.html_artifact_state ? (
|
||||
<HtmlArtifactPane
|
||||
chatId={pane.html_artifact_state.chat_id}
|
||||
state={pane.html_artifact_state}
|
||||
onClose={() => removePane(idx)}
|
||||
/>
|
||||
) : pane.kind === 'chat' && pane.chatId ? (
|
||||
<ChatPane
|
||||
sessionId={sessionId}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Archive, Maximize2, Minimize2, X } from 'lucide-react';
|
||||
import { Archive, FolderOpen, Maximize2, Minimize2, Trash2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { Project, Session } from '@/api/types';
|
||||
@@ -269,6 +269,8 @@ function SessionSection({ session, project }: { session: Session; project: Proje
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AllowedReadPathsSection session={session} />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
@@ -337,6 +339,76 @@ function SessionSection({ session, project }: { session: Session; project: Proje
|
||||
);
|
||||
}
|
||||
|
||||
// v1.13.17-cross-repo-reads: revoke UI for session.allowed_read_paths.
|
||||
// Append happens through the inline request_read_access pause flow; this
|
||||
// section only shrinks the list. PATCH /api/sessions/:id replaces the
|
||||
// whole array, so we send the original list minus the deleted entry.
|
||||
function AllowedReadPathsSection({ session }: { session: Session }) {
|
||||
const [paths, setPaths] = useState<string[]>(session.allowed_read_paths);
|
||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
|
||||
|
||||
// Re-sync on session prop change (e.g. WS session_updated after a new
|
||||
// grant lands). Without this, a grant approved in this same chat wouldn't
|
||||
// appear in the list until the user closes and reopens settings.
|
||||
useEffect(() => {
|
||||
setPaths(session.allowed_read_paths);
|
||||
}, [session.id, session.allowed_read_paths]);
|
||||
|
||||
async function remove(path: string) {
|
||||
if (pendingDelete) return;
|
||||
setPendingDelete(path);
|
||||
const next = paths.filter((p) => p !== path);
|
||||
try {
|
||||
const updated = await api.sessions.update(session.id, { allowed_read_paths: next });
|
||||
setPaths(updated.allowed_read_paths);
|
||||
toast.success('Grant revoked');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to revoke');
|
||||
} finally {
|
||||
setPendingDelete(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Cross-repo read grants
|
||||
</label>
|
||||
{paths.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
The agent has no access outside this project. Grants are created when
|
||||
the agent asks for them inline.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{paths.map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
className="flex items-center gap-2 rounded border bg-background/60 px-2 py-1.5"
|
||||
>
|
||||
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="font-mono text-xs flex-1 min-w-0 break-all">{p}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void remove(p)}
|
||||
disabled={pendingDelete !== null}
|
||||
aria-label={`Revoke ${p}`}
|
||||
title="Revoke"
|
||||
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Grants are session-scoped. Archiving the session clears them.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSection({ project }: { project: Project }) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [defaultPrompt, setDefaultPrompt] = useState(project.default_system_prompt);
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
// across hooks (e.g. AI rename arriving via WS in the session view needs to
|
||||
// also refresh the sidebar's session list).
|
||||
|
||||
import type { Chat, ErrorReason, Project, Session } from '@/api/types';
|
||||
import type {
|
||||
Chat,
|
||||
ErrorReason,
|
||||
HtmlArtifactState,
|
||||
MarkdownArtifactState,
|
||||
Project,
|
||||
Session,
|
||||
} from '@/api/types';
|
||||
import type { Attachment } from '@/lib/attachments';
|
||||
|
||||
export interface SessionRenamedEvent {
|
||||
@@ -68,6 +75,19 @@ export interface OpenChatInActivePaneEvent {
|
||||
chat_id: string;
|
||||
}
|
||||
|
||||
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of
|
||||
// these; useWorkspacePanes subscribes and inserts the corresponding artifact
|
||||
// pane (or focuses an existing one keyed by message_id).
|
||||
export interface OpenMarkdownArtifactPaneEvent {
|
||||
type: 'open_markdown_artifact_pane';
|
||||
state: MarkdownArtifactState;
|
||||
}
|
||||
|
||||
export interface OpenHtmlArtifactPaneEvent {
|
||||
type: 'open_html_artifact_pane';
|
||||
state: HtmlArtifactState;
|
||||
}
|
||||
|
||||
// Client-side event fired by the sidebar Settings button when a session is
|
||||
// currently mounted. Session.tsx subscribes and calls
|
||||
// panesHook.toggleSettingsPane() (open on first click, close on second).
|
||||
@@ -154,6 +174,8 @@ export type SessionEvent =
|
||||
| OpenFileInBrowserEvent
|
||||
| AttachChatFileEvent
|
||||
| OpenChatInActivePaneEvent
|
||||
| OpenMarkdownArtifactPaneEvent
|
||||
| OpenHtmlArtifactPaneEvent
|
||||
| OpenSettingsPaneEvent
|
||||
| SessionArchivedEvent
|
||||
| ChatCreatedEvent
|
||||
|
||||
@@ -154,6 +154,11 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
||||
case 'open_chat_in_active_pane':
|
||||
// Consumed by Workspace; sidebar has no business with pane state.
|
||||
return prev;
|
||||
case 'open_markdown_artifact_pane':
|
||||
case 'open_html_artifact_pane':
|
||||
// v1.14.x-html-artifact-panes: consumed by useWorkspacePanes; sidebar
|
||||
// has no business with pane state.
|
||||
return prev;
|
||||
case 'open_settings_pane':
|
||||
// Consumed by Session.tsx (calls toggleSettingsPane on its panesHook).
|
||||
// Sidebar data is untouched.
|
||||
|
||||
@@ -2,7 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { DragEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { WorkspacePane } from '@/api/types';
|
||||
import type {
|
||||
HtmlArtifactState,
|
||||
MarkdownArtifactState,
|
||||
WorkspacePane,
|
||||
} from '@/api/types';
|
||||
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
|
||||
@@ -43,6 +47,28 @@ function settingsPane(): WorkspacePane {
|
||||
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
|
||||
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
|
||||
// the pane row so the sessions.workspace_panes jsonb survives reload.
|
||||
function markdownArtifactPane(state: MarkdownArtifactState): WorkspacePane {
|
||||
return {
|
||||
id: generateId(),
|
||||
kind: 'markdown_artifact',
|
||||
chatIds: [],
|
||||
activeChatIdx: -1,
|
||||
markdown_artifact_state: state,
|
||||
};
|
||||
}
|
||||
|
||||
function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
|
||||
return {
|
||||
id: generateId(),
|
||||
kind: 'html_artifact',
|
||||
chatIds: [],
|
||||
activeChatIdx: -1,
|
||||
html_artifact_state: state,
|
||||
};
|
||||
}
|
||||
|
||||
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
|
||||
// page reload always returns to a clean workspace; the user re-opens via the
|
||||
// sidebar Settings button when needed.
|
||||
@@ -169,6 +195,50 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" emits one of
|
||||
// these per click. If a pane already exists for the same message_id, focus
|
||||
// it instead of stacking a duplicate. Otherwise append (capped at MAX_PANES;
|
||||
// settings panes don't count, matching addSplitPane's rule).
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((ev) => {
|
||||
if (
|
||||
ev.type !== 'open_markdown_artifact_pane' &&
|
||||
ev.type !== 'open_html_artifact_pane'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setPanes((prev) => {
|
||||
const targetKind: WorkspacePane['kind'] =
|
||||
ev.type === 'open_html_artifact_pane' ? 'html_artifact' : 'markdown_artifact';
|
||||
const messageId = ev.state.message_id;
|
||||
const existingIdx = prev.findIndex((p) =>
|
||||
p.kind === 'markdown_artifact'
|
||||
? p.markdown_artifact_state?.message_id === messageId
|
||||
: p.kind === 'html_artifact'
|
||||
? p.html_artifact_state?.message_id === messageId
|
||||
: false,
|
||||
);
|
||||
if (existingIdx >= 0) {
|
||||
setActivePaneIdx(existingIdx);
|
||||
return prev;
|
||||
}
|
||||
if (nonSettingsCount(prev) >= MAX_PANES) {
|
||||
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||
return prev;
|
||||
}
|
||||
const newPane =
|
||||
ev.type === 'open_html_artifact_pane'
|
||||
? htmlArtifactPane(ev.state)
|
||||
: markdownArtifactPane(ev.state);
|
||||
// Defensive: assert kind matches for the discriminated union.
|
||||
if (newPane.kind !== targetKind) return prev;
|
||||
const next = [...prev, newPane];
|
||||
setActivePaneIdx(next.length - 1);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// v1.12.1: debounced PATCH on every change. Settings panes are stripped
|
||||
// before saving (ephemeral per v1.9).
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BooCode v1.x — Roadmap
|
||||
|
||||
Last updated: 2026-05-22
|
||||
Last updated: 2026-05-23
|
||||
|
||||
> **Companion doc:** `boocode_code_review.md` holds the full external-repo inventory, lift rationale, and license analysis. This document is the canonical source for shipping state, version ordering, and what's planned vs. shipped.
|
||||
|
||||
@@ -27,7 +27,7 @@ External code lifted from / referenced in: see `boocode_code_review.md` for full
|
||||
|
||||
-----
|
||||
|
||||
## Shipped (status as of 2026-05-22)
|
||||
## Shipped (status as of 2026-05-23)
|
||||
|
||||
|Version |Theme |Tag |
|
||||
|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------|
|
||||
@@ -72,9 +72,9 @@ External code lifted from / referenced in: see `boocode_code_review.md` for full
|
||||
|
||||
-----
|
||||
|
||||
### Shipped (v1.13.x — written 2026-05-22, retagged same day)
|
||||
### Shipped (v1.13.x — strangler-fig closed 2026-05-23)
|
||||
|
||||
All v1.13.x batches were retagged to the `vMAJOR.MINOR.PATCH-slug` scheme on 2026-05-22. `CHANGELOG.md` is the canonical per-tag record (slug describes what shipped; tag name alone recalls the batch). Tip is `v1.13.14-skills-audit` (`0fa46cd`); the next batch is `v1.13.15-codecontext-synth` (this batch, tag pending). Tags in chronological order:
|
||||
All v1.13.x batches use the `vMAJOR.MINOR.PATCH-slug` tag scheme adopted 2026-05-22. `CHANGELOG.md` is the canonical per-tag record (slug describes what shipped; tag name alone recalls the batch). The v1.13.x line ran 21 batches over a single intense window; the umbrella `v1.13` tag sits on `211e903` (same commit as `v1.13.20-drop-legacy-cols`), marking the strangler-fig closed. Tags in chronological order:
|
||||
|
||||
- `v1.13.0-ai-sdk-v6` — AI SDK v6 migration; `streamCompletion` adapter; `messages_with_parts` view; reasoning_parts end-to-end
|
||||
- `v1.13.1-cleanup-bundle` — `statement_timeout='30s'`, alpha-sorted tool registry, 60s stuck-row sweeper, `experimental_repairToolCall` pass-through
|
||||
@@ -91,114 +91,15 @@ All v1.13.x batches were retagged to the `vMAJOR.MINOR.PATCH-slug` scheme on 202
|
||||
- `v1.13.12-ws-schemas` — Zod schemas for all 27 wire-format frames; `publishFrame` / `publishUserFrame` wrappers; parity test
|
||||
- `v1.13.13-ws-publish` — all ~80 publish sites converted to the typed wrappers; every WS frame now Zod-validated at boundary
|
||||
- `v1.13.14-skills-audit` — 26 skills vendored + audited via 5 parallel agent teams; 14 kept, 11 dropped, 1 migrated to BOOCHAT.md/BOOCODER.md
|
||||
- `v1.13.15-codecontext-synth` — **this batch, tag pending** — forced second-inference synthesis pass for codecontext overview tools
|
||||
- `v1.13.15-codecontext-synth` — forced second-inference synthesis pass for codecontext overview tools (truncation-aware extraction; auto-fetched top-N files + project docs; 32k payload-budget contract preserved)
|
||||
- `v1.13.16-xml-parser` — Anthropic `<invoke>` parser support + Levenshtein-based unknown-tool recovery hints (qwen3.6 drift to Claude Code-style tool names like `read_file`); xml-parser test coverage
|
||||
- `v1.13.17-cross-repo-reads` — `request_read_access` tool + per-session `allowed_read_paths` grants; `pathGuard` extended with `extraRoots`; pause/resume reuses the `ask_user_input` mechanism
|
||||
- `v1.13.18-codecontext-file-path` — `resolveProjectPath` in `codecontext_client.ts` realpath-resolves `file_path` arg the same way `target_dir` was; closes the silent-fail path the sidecar exhibited on relative paths
|
||||
- `v1.13.19-html-artifact-panes` — pane-based artifact viewer with on-request HTML; `<!DOCTYPE html>` detection adds `message_parts.kind='html_artifact'` row; Markdown + HTML panes both open via "Open in pane" affordance; iframe sandbox `allow-scripts allow-clipboard-write allow-downloads` (no `allow-same-origin`, `srcDoc`); CSP `connect-src 'none'`. Scope-revised mid-design from auto-bias-to-HTML to Markdown-default / HTML-on-request
|
||||
- `v1.13.20-drop-legacy-cols` — final strangler-fig step. Drops `messages.tool_calls` + `tool_results` columns; 10 dual-write sites removed (recon caught 2 beyond the original roadmap inventory); `messages_with_parts` view simplified to parts-only subselects via `CREATE OR REPLACE` before the column DROPs (Postgres ordering constraint). Adversarial-review catch: `discard_stale` had a `RETURNING tool_calls, tool_results` clause; fixed via two-step UPDATE-then-SELECT-from-view. `Message` API type retains the fields — view synthesizes them from parts so the wire shape is unchanged
|
||||
- `v1.13` — **umbrella tag on the same commit as v1.13.20.** Marks the AI SDK v6 + parts-table migration complete
|
||||
|
||||
The remaining strangler-fig final step (drop `messages.tool_calls` + `tool_results` columns) is still pending under its old `v1.13.2` working name; will get a new tag slug when scoped.
|
||||
|
||||
## In flight / next (v1.13.x cleanup line)
|
||||
|
||||
Five more single-dispatch batches before the strangler-fig closes. Each ships independently with its own smoke and rollback surface. **Do not fold.** Order is locked:
|
||||
|
||||
### v1.13.8 — system-prompt prefix stability verify-and-measure (REFRAMED, 2026-05-22)
|
||||
|
||||
**Original plan:** add a `system_prompt_cache` DB table keyed by `(agent_id, project_id, skills_version)`, mtime-invalidated.
|
||||
|
||||
**Why reframed:** recon disproved the premise. `apps/server/src/services/system-prompt.ts:buildSystemPrompt` already runs over mtime-cached inputs at the file layer:
|
||||
|
||||
- BOOCHAT.md / BOOCODER.md cached in `system-prompt.ts:25` (`cachedGuidance`, keyed by mtime)
|
||||
- global + per-project AGENTS.md cached in `agents.ts:245` (`safeStat` pattern, 60s TTL)
|
||||
- `session.system_prompt` / `project.default_system_prompt` are DB scalars (byte-stable until edited)
|
||||
- BASE_SYSTEM_PROMPT is a hardcoded template with `${projectPath}` interpolation
|
||||
|
||||
Output assembly is a microsecond pure-string concat with no I/O. Skills aren't in the prefix (runtime discovery via `skill_find`). Tools live in a separate request body field, alpha-sorted by v1.13.3. **In theory the prefix is already byte-stable across turns; nothing has measured it.**
|
||||
|
||||
**New scope — instrumentation only, no cache:**
|
||||
|
||||
1. SHA-256 fingerprint of `buildSystemPrompt`'s output logged per turn at `level=info`, msg `prefix-fingerprint`, with project_id / agent_id / session_id / prefix_hash / prefix_length / mtime fields.
|
||||
2. Module-level `Map<sessionId, lastHash>` observer. On hash change for a known session → emit `prefix-drift` at `level=warn` with `prev_hash`, `new_hash`, and a field-level `changed_inputs` diff.
|
||||
3. Unit-level byte-stability assertion in `system-prompt.test.ts`: two consecutive `buildSystemPrompt` calls with the same inputs return byte-identical strings.
|
||||
|
||||
**Decision criterion:** smoke 5 turns in a fresh session. 5 identical hashes + zero drift logs → close v1.13.8 as no-op, **drop the DB cache plan permanently**, move to v1.13.9. If drift surfaces → characterize the failure mode in a follow-up batch (the answer may not be a cache at all).
|
||||
|
||||
**Doctrine:** matches the v1.13.6 audit pattern. Don't add infrastructure without a proven cache miss. The v1.12.0 mtime caches at the input layer plus alpha tool ordering at the request body layer already address the load-bearing cache-stability surfaces.
|
||||
|
||||
**Dispatch brief:** `handoff_v1.13.8_prefix_verify.md`.
|
||||
|
||||
**Estimated:** ~95 LoC (system-prompt.ts + small `getAgentsMtimes` accessor in agents.ts + 3 new tests).
|
||||
|
||||
### v1.13.9 — compaction overflow trigger formula
|
||||
|
||||
opencode pattern: `0.85 * ctx_max` early trigger (not at 100% saturation). Reduces tail-loss risk and gives compaction a safer window. Tiny change but tied to v1.13.4's tier logic — sequence matters.
|
||||
|
||||
**Lift source:** `anomalyco/opencode` `session/overflow.ts`.
|
||||
|
||||
**Estimated:** ~30 LoC.
|
||||
|
||||
### v1.13.10 — per-tool token cost accounting
|
||||
|
||||
Rolling average per tool, surfaced in AgentPicker tooltip + agent-pick decisions. Backend tracks `(tool_name, prompt_tokens_in, completion_tokens_out)` per call; surfaces a 100-call rolling mean. Frontend reads it for tool-cost hints. **Depends on v1.13.7's `includeUsage` fix** — without real token numbers in DB rows, the rolling average is empty.
|
||||
|
||||
**Estimated:** ~250 LoC.
|
||||
|
||||
### v1.13.11 — WebSocket frame typing
|
||||
|
||||
Zod schemas validated both ends. Catches the recurring class of bug that drove the 2026-05-21 debugging spike (silent protocol drift). Upfront work that pays back every time the protocol changes. `chat_status`, `usage`, `parts_appended`, `session_workspace_updated`, `tool_running` — every frame gets a Zod schema, every send/receive site validates.
|
||||
|
||||
**Estimated:** ~300 LoC.
|
||||
|
||||
### v1.13.12 — skills audit pass (NEW, 2026-05-22)
|
||||
|
||||
**Goal:** apply the rules→recipes split (per Codeminer42 activation-gap data: plain skills invoke 6% in clean multi-turn, `CLAUDE.md`/`AGENTS.md` is 100% present) to BooCode's 7 vendored v1.12 skills. Sort each into: (a) move to `AGENTS.md` as always-true rule, (b) keep as recipe invoked via `/skill <name>`, (c) move bulky context into `references/` flat subdirectory inside the skill, (d) delete (Claude already does it reliably).
|
||||
|
||||
**Scope:**
|
||||
|
||||
1. **Audit each of the 7 vendored skills against the 4-way split.** Most workflow-rule content ("always do X before Y", "never do Z") moves to `AGENTS.md` since it should be 100% present. Recipe content ("here's how to scaffold a component", "here's the release checklist") stays as skill, gets `context: fork` if heavy.
|
||||
1. **Adopt Anthropic best-practices conventions** for any skills that remain after audit: gerund names (`scaffolding-components`, not `component-helper`), SKILL.md ≤500 lines, references one level deep, third-person imperative voice, MCP tool references in `ServerName:tool_name` format, no Windows-style paths, no time-sensitive info, consistent terminology, no "voodoo constants."
|
||||
1. **Run each remaining skill through the 4-step validation protocol** from `mgechev/skills-best-practices` (Discovery → Logic → Edge Case → Architecture Refinement) using a fresh Claude chat per step. Prompts are paste-ready; ~10 minutes per skill.
|
||||
1. **Install `skillgrade` on Sam's host** (`npm i -g skillgrade`). For each remaining skill, write a minimal `eval.yaml` with 2–3 tasks and run `skillgrade --smoke` (5 trials, ~5 min) to confirm the skill triggers when expected and produces correct output. **Likely outcome: some skills show 0–20% trigger rate — confirms they belong in AGENTS.md, not as skills.**
|
||||
1. **Document the rules→recipes split as a BooCode convention** in `BOOCODER.md` / `BOOCHAT.md`. Future-proofs against re-adding workflow rules as skills.
|
||||
|
||||
**Lift sources:**
|
||||
|
||||
- `blog.codeminer42.com/stop-putting-best-practices-in-skills/` — empirical 6%/33%/66%/100% invocation-rate data with Vercel-style multi-turn methodology. The activation-gap framing.
|
||||
- `mgechev/skills-best-practices` (25 stars, MIT) — 4-step validation protocol with paste-ready prompts. Directory structure conventions.
|
||||
- `mgechev/skillgrade` (132 stars, MIT) — agent-agnostic skill eval framework. `eval.yaml` task+grader schema. Smoke/reliable/regression presets.
|
||||
- `platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices` — canonical Anthropic standard. 500-line ceiling, gerund naming, progressive disclosure patterns, MCP tool reference format, verification checklist.
|
||||
|
||||
**Dependencies:** none (the 7 v1.12 skills already exist; this is an audit pass on shipped material). Can ship at any point in the v1.13.x line.
|
||||
|
||||
**Estimated:** zero code changes, ~one evening of audit work, plus skillgrade install. Per-skill eval.yaml authoring is ~30 min per skill including the 4-step validation. Total roughly 5–6 hours of focused work for all 7 skills.
|
||||
|
||||
### v1.13.2 — drop legacy columns (final phase of strangler-fig)
|
||||
|
||||
**Wait at least one week of production traffic on v1.13.1 before shipping.** The dual-write is rollback insurance. Drop the columns and that rollback is gone.
|
||||
|
||||
**Verification query before shipping:**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE m.tool_calls IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM message_parts p WHERE p.message_id = m.id AND p.kind = 'tool_call'
|
||||
)) AS missing_tool_call_parts,
|
||||
COUNT(*) FILTER (WHERE m.tool_results IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM message_parts p WHERE p.message_id = m.id AND p.kind = 'tool_result'
|
||||
)) AS missing_tool_result_parts
|
||||
FROM messages m
|
||||
WHERE m.created_at > '2026-05-22'::timestamptz;
|
||||
```
|
||||
|
||||
Both columns must read 0.
|
||||
|
||||
**Scope (~150 LoC, mostly deletions):**
|
||||
|
||||
1. Remove dual-write from every v1.13.0 site: `tool-phase.ts` (3 sites), `finalizeCompletion`, `skills.ts` (2 sites), `messages.ts` answer flow, `chats.ts` (fork). Keep only the parts write.
|
||||
1. Simplify `messages_with_parts` view — drop COALESCE fallbacks since legacy columns are about to disappear.
|
||||
1. `ALTER TABLE messages DROP COLUMN tool_calls, DROP COLUMN tool_results`.
|
||||
1. Remove `tool_calls`/`tool_results` fields from `Message` API type. API boundary unchanged (frontend already reads parts-derived values).
|
||||
1. Drop the stale `messages_status_check` cleanup DO block from v1.12.1 schema if still present.
|
||||
1. Update test fixtures in `inference.test.ts` and `compaction.test.ts` to construct parts instead of inline `tool_calls: null, tool_results: null` literals. ~30 fixture rewrites.
|
||||
|
||||
After v1.13.2 ships, tag the umbrella `v1.13` on the same commit (or on -C — Sam's call).
|
||||
The v1.13.x line is closed. Three batches still sit in the **In flight** column conceptually but none of them are v1.13.x scope: **live-smoke of v1.13.19** (manual browser exercise of the artifact panes — five minutes, independent), and the two v1.14 branches below. Independent siblings (`v1.14.x-mcp`, `v1.14.x-html`, `v1.16`) can ship in any order relative to v1.14 itself.
|
||||
|
||||
-----
|
||||
|
||||
@@ -262,45 +163,52 @@ After v1.13.2 ships, tag the umbrella `v1.13` on the same commit (or on -C — S
|
||||
|
||||
-----
|
||||
|
||||
## v1.14.x-html — HTML artifacts in BooChat (NEW, 2026-05-22)
|
||||
## v1.14.x-html — pane-based artifact viewer with Markdown + HTML (REVISED, 2026-05-23)
|
||||
|
||||
**Goal:** integrate Thariq Shihipar's "HTML > Markdown for agent output at length" pattern (`claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html`, May 20 2026) into BooChat. Bias the model toward HTML for outputs >100 lines: information density, visual clarity, interactive controls (sliders/knobs/SVG diagrams/side-by-side comparisons), shareability. BooChat already renders into a webview, so the surface fit is unusually good.
|
||||
**Goal:** every assistant message gets an "Open in pane" affordance that renders it as an artifact — Markdown by default (the model's normal output), HTML only when the user explicitly asks for it (e.g. "render this as HTML", "make me a dashboard", "build an interactive diagram"). Both artifact types open in BooChat's existing workspace splitter. Markdown panes have **Copy** (raw source) + **Download** (`.md`); HTML panes have **Download** (`.html`) only. No inline iframe preview — artifacts are pane-only.
|
||||
|
||||
Inspired by Thariq Shihipar's "HTML > Markdown at length" pattern (`claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html`, May 20 2026), but scoped down from that post's "auto-bias to HTML for >100 lines" recommendation: Markdown stays the default everywhere, HTML is an on-request rendering target for cases where interactive controls / diagrams / side-by-side layouts pay off.
|
||||
|
||||
**Scope:**
|
||||
|
||||
1. **Model-side prompting** (no code change yet, just AGENTS.md guidance):
|
||||
- Add HTML-bias rule to global `AGENTS.md`: "For outputs >100 lines, default to a self-contained `<!DOCTYPE html>...</html>` artifact unless the user explicitly asks for Markdown. For outputs <100 lines or for short conversational replies, stay in Markdown."
|
||||
- Reasoning shown in the rule: HTML carries diagrams, tabs, illustrations, code-with-syntax-highlighting, interactive controls, mobile-responsive layouts. Markdown is restrictive at any length.
|
||||
- Cite Thariq's blog post in the rule comment so future audit passes know where it came from.
|
||||
1. **Detection at the BooChat backend.** In `apps/chat/services/inference/stream-phase.ts` post-processing: detect any assistant text part starting with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed) — or wrapped in a fenced ` ```html` block — and tag it as an HTML artifact. Emit a new part kind `html_artifact` into `message_parts` (CHECK constraint update). Payload: `{html_content, char_count, title}`. Title pulled from `<title>` tag or first `<h1>` if available.
|
||||
1. **Three render targets (Sam's pick: "3 with a download"):**
|
||||
- **Inline preview** in the chat stream: small sandboxed iframe (~400px tall), renders the artifact next to where it was streamed. Default size, click-to-expand.
|
||||
- **Open in pane**: button on the inline preview opens the artifact in a full-height pane in BooChat's existing workspace splitter, alongside the file viewer and BooTerm. Pane is dismissible. Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports this).
|
||||
- **Download**: button writes the artifact to `/opt/<project>/.boocode/artifacts/<slug>-<unix-timestamp>.html` (path-guarded same as native write tools), surfaces an OS download link via the existing file-serving path. Filename slug derived from artifact title.
|
||||
1. **Security stance — locked 2026-05-22:** the iframe is sandboxed with `sandbox="allow-scripts allow-clipboard-write allow-downloads"`. **Crucially, omit `allow-same-origin`** so the artifact has its own opaque origin and cannot read BooChat's cookies, Authelia session, or DOM. Backend serves the iframe content via `srcdoc=...` inline (not `src=`) so no separate URL exists to disclose. CSP header on the iframe response: `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'`. The `connect-src 'none'` is the key clause — artifacts can't `fetch()`, can't open WebSockets, can't ping a tracking pixel, can't exfiltrate. JS runs (so Thariq's interactive knobs/sliders/copy-as-prompt buttons work) but nothing else network-touching does. **None of Thariq's blog examples need the relaxed permissions** — they're all client-side.
|
||||
1. **Frontend rendering** (`apps/web/src/components/HtmlArtifactPart.tsx`):
|
||||
- Inline preview: `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" className="..." />` with the strict-sandbox attributes above.
|
||||
- "Open in pane" button: dispatches workspace-pane action with `{type: 'html_artifact', message_part_id, html_content}`.
|
||||
- "Download" button: POST to new endpoint `/api/chats/:id/artifacts/:part_id/download` which writes to disk (path-guarded) and returns the absolute path or pre-signed URL for the existing static-file serving route.
|
||||
1. **No artifact persistence beyond the chat.** Artifacts live in `message_parts.payload->>'html_content'` with the chat. Downloads go to `/opt/<project>/.boocode/artifacts/` and are user-managed from there. No separate artifacts table.
|
||||
1. **Token-budget guard.** Single artifact can be at most 1MB of HTML in `message_parts.payload`. Larger triggers a streaming abort with a friendly error: "Artifact exceeded 1MB; consider splitting into multiple files or reducing inline assets."
|
||||
1. **No `web-artifacts-builder` skill vendor.** That skill (`anthropics/skills/web-artifacts-builder`) is built for Claude.ai's runtime with Vite + Parcel + tspaths + html-inline toolchain. BooChat has no shell execution surface. The pattern transplants; the toolchain doesn't. Treat the skill's "avoid AI slop" design principles (no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font) as conventions inlined in the HTML-bias AGENTS.md rule. The init/bundle scripts are out of scope.
|
||||
1. **Model-side prompting** (no code change, just AGENTS.md guidance):
|
||||
- Add HTML-on-request rule to global `AGENTS.md`: "Stay in Markdown by default for all outputs, short or long. Switch to a self-contained `<!DOCTYPE html>...</html>` artifact only when the user explicitly asks (e.g. 'render this as HTML', 'make a dashboard', 'build a diagram')."
|
||||
- Inline the `web-artifacts-builder` "avoid AI slop" design principles for when HTML is requested: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font, no generic AI aesthetics.
|
||||
- Cite Thariq's blog post in the rule comment so future audit passes know where the design conventions came from.
|
||||
1. **Detection at the BooChat backend.** In `apps/chat/services/inference/stream-phase.ts` post-processing: detect any assistant text part starting with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed) — or wrapped in a fenced ` ```html` block — and tag it as an HTML artifact. Emit a new part kind `html_artifact` into `message_parts` (CHECK constraint update). Payload: `{html_content, char_count, title}`. Title pulled from `<title>` tag or first `<h1>` if available. Detection is opportunistic — when the model produces HTML (because the user asked), the tag fires; otherwise the message stays plain-Markdown and no `html_artifact` part is written.
|
||||
1. **Pane-only render surface.** Every assistant message in the chat stream gets an "Open in pane" affordance (icon button in the message footer, alongside the existing copy/regenerate controls). Clicking it opens the message as an artifact pane in BooChat's existing workspace splitter, alongside the file viewer and BooTerm. Pane is dismissible. Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports this).
|
||||
- **Markdown pane** — renders via the same Markdown component used inline in `MessageBubble` (so syntax highlighting, fenced code blocks, tables, etc. all work). Header carries **Copy** (writes raw Markdown source to clipboard via `navigator.clipboard.writeText`) and **Download** (`.md`) buttons.
|
||||
- **HTML pane** — renders the artifact in a sandboxed iframe at full pane height. Header carries **Download** (`.html`) only. **No Copy button** — HTML source isn't useful clipboard content; if the user wants the source they can Download and inspect.
|
||||
1. **Download path & filename slug.** Both formats write to `/opt/<project>/.boocode/artifacts/<slug>-<unix-timestamp>.<ext>` (path-guarded same as native write tools), and surface an OS download link via the existing file-serving path.
|
||||
- Markdown slug: derived from the message's first heading (`# ...`) if present, else the first 6 words of the message body, lowercased + hyphenated.
|
||||
- HTML slug: derived from the artifact's `<title>` tag if present, else first `<h1>`, else first 6 words of the inner text. Same lowercase-hyphen treatment.
|
||||
1. **Security stance for HTML pane — locked 2026-05-22:** the iframe is sandboxed with `sandbox="allow-scripts allow-clipboard-write allow-downloads"`. **Crucially, omit `allow-same-origin`** so the artifact has its own opaque origin and cannot read BooChat's cookies, Authelia session, or DOM. Backend serves the iframe content via `srcdoc=...` inline (not `src=`) so no separate URL exists to disclose. CSP header on the iframe response: `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'`. The `connect-src 'none'` is the key clause — artifacts can't `fetch()`, can't open WebSockets, can't ping a tracking pixel, can't exfiltrate. JS runs (so interactive knobs/sliders/copy-as-prompt buttons work) but nothing else network-touching does.
|
||||
1. **Frontend components:**
|
||||
- `apps/web/src/components/MarkdownArtifactPane.tsx` — pane shell + header (Copy + Download) + Markdown render reusing the existing component.
|
||||
- `apps/web/src/components/HtmlArtifactPane.tsx` — pane shell + header (Download only) + `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" />`.
|
||||
- `MessageBubble.tsx` — add "Open in pane" affordance to every assistant message footer. Dispatches workspace-pane action `{type: 'markdown_artifact' | 'html_artifact', message_id, html_content?}`. When the message has an `html_artifact` part, the affordance opens as an HTML pane; otherwise it opens as a Markdown pane.
|
||||
- Download button → POST to new endpoint `/api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html` which writes to disk (path-guarded) and returns the absolute path or pre-signed URL for the existing static-file serving route.
|
||||
1. **No artifact persistence beyond the chat.** Artifacts live in `message_parts.payload->>'html_content'` (for HTML) or are derived on-demand from the assistant message's content (for Markdown). Downloads go to `/opt/<project>/.boocode/artifacts/` and are user-managed from there. No separate artifacts table.
|
||||
1. **Token-budget guard.** Single HTML artifact can be at most 1MB of HTML in `message_parts.payload`. Larger triggers a streaming abort with a friendly error: "Artifact exceeded 1MB; consider splitting into multiple files or reducing inline assets." Markdown artifacts have no separate cap — they're bounded by the existing message-size envelope.
|
||||
1. **No `web-artifacts-builder` skill vendor.** That skill (`anthropics/skills/web-artifacts-builder`) is built for Claude.ai's runtime with Vite + Parcel + tspaths + html-inline toolchain. BooChat has no shell execution surface. The pattern transplants; the toolchain doesn't. Treat the skill's "avoid AI slop" design principles as conventions inlined in the HTML-on-request AGENTS.md rule. The init/bundle scripts are out of scope.
|
||||
|
||||
**Lift sources:**
|
||||
|
||||
- `claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html` (Thariq Shihipar, May 20 2026) — the pattern, the use-case taxonomy (specs/code-review/design/reports/custom editors), the design philosophy.
|
||||
- `claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html` (Thariq Shihipar, May 20 2026) — design conventions and use-case taxonomy (specs/code-review/design/reports/custom editors). The "auto-bias for >100 lines" recommendation is deliberately NOT lifted.
|
||||
- HTML iframe sandbox spec (web platform standard, no license issues).
|
||||
- `anthropics/skills/web-artifacts-builder` — design-principle reference only ("avoid AI slop" rules). **Do not vendor the toolchain.**
|
||||
|
||||
**Dependencies:** v1.13 merged (`message_parts` table is where artifacts live). Independent of v1.14 (outer loop) and v1.14.x-mcp (MCP PoC). Can ship in any order relative to those.
|
||||
**Dependencies:** v1.13 merged (`message_parts` table is where HTML artifacts live). Independent of v1.14 (outer loop) and v1.14.x-mcp (MCP PoC). Can ship in any order relative to those.
|
||||
|
||||
**Estimated:** ~400 LoC. Roughly half backend (detection + part-kind extension + download endpoint + path-guard integration), half frontend (HtmlArtifactPart component + pane integration + download button wiring).
|
||||
**Estimated:** ~400 LoC. Roughly half backend (HTML detection + part-kind extension + download endpoint + path-guard integration + Markdown slug derivation) and half frontend (two artifact-pane components + MessageBubble affordance + pane integration + download wiring).
|
||||
|
||||
**Schema addition:**
|
||||
|
||||
- `message_parts.kind` CHECK constraint adds `'html_artifact'` to the allowed set.
|
||||
|
||||
**Skip-condition:** none — independent batch, ships clean any time after v1.13. Highest user-visible payoff of any v1.13.x/v1.14.x batch (transforms what the model can produce, not just how the backend handles it).
|
||||
**Skip-condition:** none — independent batch, ships clean any time after v1.13. Pane-based artifact view is a structural UX improvement (full-height read surface for long replies, durable download path) on top of the HTML-on-request rendering capability.
|
||||
|
||||
**Shipped as `v1.13.19-html-artifact-panes` on 2026-05-23.** Two scope-revisions during impl: (a) the HTML-on-request rule landed in `BOOCHAT.md` (always-true rules layer), not `data/AGENTS.md` (per-agent registry) — per BOOCHAT.md's own convention block. (b) Pane state stayed reference-only — `{chat_id, message_id, title}` — content fetched on mount via the existing chat-messages endpoint (Markdown) and a new `GET /api/chats/:id/messages/:msg_id/html_artifact` (HTML). Storing content in pane state would have ridden 1MB blobs through the `session_workspace_updated` WS frame and bloated the jsonb column on multi-pane sessions. Defense-in-depth additions beyond the original proposal: `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox` on the GET serve route, and `assertArtifactsDirSafe` realpaths the artifacts dir after `mkdir` to close a symlink-escape gap that would otherwise let a planted symlink under `.boocode/artifacts/` route writes outside the project root. Smoke not run pre-tag; first deploy is the smoke.
|
||||
|
||||
-----
|
||||
|
||||
@@ -500,8 +408,12 @@ term.indifferentketchup.com → booterm :9501 (or routed under code.
|
||||
- **v1.13.12-ws-schemas:** none (Zod schemas + wrappers in TS, no DB)
|
||||
- **v1.13.13-ws-publish:** none (publish-site conversion + protocol-drift fix in `compaction.ts`, no DB)
|
||||
- **v1.13.14-skills-audit:** none (skills + AGENTS.md migration into git via `.gitignore` negation patterns; no DB)
|
||||
- **v1.13.15-codecontext-synth (this batch, tag pending):** `message_parts.kind` CHECK constraint extended with `'synthesis'` value (DROP + DO $$ pg_constraint idempotency-guarded re-add)
|
||||
- **(column drop, pending — old working name v1.13.2):** drop `messages.tool_calls`, `messages.tool_results`; simplify `messages_with_parts` view
|
||||
- **v1.13.15-codecontext-synth:** `message_parts.kind` CHECK constraint extended with `'synthesis'` value (DROP + DO $$ pg_constraint idempotency-guarded re-add)
|
||||
- **v1.13.16-xml-parser:** none (parser change + new `tool-suggestions.ts` helper in TS, no DB)
|
||||
- **v1.13.17-cross-repo-reads:** `sessions.allowed_read_paths text[] NOT NULL DEFAULT ARRAY[]::text[]` (per-session cross-repo read grants)
|
||||
- **v1.13.18-codecontext-file-path:** none (path resolver in `codecontext_client.ts`, no DB)
|
||||
- **v1.13.19-html-artifact-panes:** `message_parts.kind` CHECK constraint extended with `'html_artifact'` value (same v1.13.15 pattern)
|
||||
- **v1.13.20-drop-legacy-cols:** `ALTER TABLE messages DROP COLUMN tool_calls, DROP COLUMN tool_results` (the strangler-fig's final phase). `messages_with_parts` view rewritten to parts-only subselects via `CREATE OR REPLACE VIEW` BEFORE the drops (Postgres ordering constraint). v1.12.1 `messages_status_check`/`messages_role_check` cleanup block removed (one-shot effective long ago)
|
||||
- **v1.14:** `agents.steps` column (or AGENTS.md parser extension; no DB if file-only)
|
||||
- **v1.14.x-mcp (NEW):** none — single-server MCP-client PoC is config-only at first, no schema change
|
||||
- **v1.14.x-html (NEW):** `message_parts.kind` CHECK constraint extended with `'html_artifact'` value
|
||||
@@ -611,7 +523,17 @@ Earlier May 18 chat recommended Option A (thin orchestration shell over OpenCode
|
||||
|
||||
### v1.13.x cleanup line locked (2026-05-22)
|
||||
|
||||
After the 2026-05-22 retag, the v1.13.x cleanup line in `vMAJOR.MINOR.PATCH-slug` form is **v1.13.0-ai-sdk-v6 ✅ → v1.13.1-cleanup-bundle ✅ → v1.13.2-compaction-prune ✅ → v1.13.3-truncate ✅ → v1.13.4-reasoning-fix ✅ → v1.13.5-stability-bundle ✅ → v1.13.6-prefix-stability ✅ → v1.13.7-compaction-trigger ✅ → v1.13.8-tool-cost ✅ → v1.13.9-agentlint ✅ → v1.13.10-openspec ✅ → v1.13.11-tools ✅ → v1.13.12-ws-schemas ✅ → v1.13.13-ws-publish ✅ → v1.13.14-skills-audit ✅ → v1.13.15-codecontext-synth (this batch, tag pending) → column drop (final, pending — old working name v1.13.2)**. **Do not fold.** Smoke isolation matters: each batch has a distinct rollback surface, and bisecting a 750-LoC merge across four unrelated changes is worse than four separate dispatches.
|
||||
The v1.13.x cleanup line shipped 21 batches over a single intense window in `vMAJOR.MINOR.PATCH-slug` form: **v1.13.0-ai-sdk-v6 ✅ → v1.13.1-cleanup-bundle ✅ → v1.13.2-compaction-prune ✅ → v1.13.3-truncate ✅ → v1.13.4-reasoning-fix ✅ → v1.13.5-stability-bundle ✅ → v1.13.6-prefix-stability ✅ → v1.13.7-compaction-trigger ✅ → v1.13.8-tool-cost ✅ → v1.13.9-agentlint ✅ → v1.13.10-openspec ✅ → v1.13.11-tools ✅ → v1.13.12-ws-schemas ✅ → v1.13.13-ws-publish ✅ → v1.13.14-skills-audit ✅ → v1.13.15-codecontext-synth ✅ → v1.13.16-xml-parser ✅ → v1.13.17-cross-repo-reads ✅ → v1.13.18-codecontext-file-path ✅ → v1.13.19-html-artifact-panes ✅ → v1.13.20-drop-legacy-cols ✅** → umbrella `v1.13` ✅. **Do not fold** was the discipline — each batch has a distinct rollback surface, and bisecting a 750-LoC merge across four unrelated changes is worse than four separate dispatches. Held throughout; CHANGELOG.md is the per-tag canonical record.
|
||||
|
||||
### Numbering and scope-revision discipline during v1.13.x (2026-05-23)
|
||||
|
||||
The v1.13.x line ran 21 batches; planned-vs-shipped numbering diverged for half of them, and three batches had material scope revisions mid-design. Pattern that emerged and is worth carrying forward:
|
||||
|
||||
- **Patch numbers are assigned at ship time, not in planning.** The proposal/openspec folder uses a planning slug (e.g. `v1.14.x-html-artifact-panes`); the final tag uses a concrete patch monotonic-per-minor (e.g. `v1.13.19-html-artifact-panes`). Avoids the "we said v1.13.8 but actually shipped seventh" confusion that ate two retrospective passes on the roadmap.
|
||||
- **Scope-revise the proposal before dispatching.** v1.13.19-html-artifact-panes flipped mid-design from "auto-bias to HTML for >100 lines" to "Markdown default, HTML on request" — the proposal got rewritten before recon. Far cheaper than discovering the wrong approach in implementation. The "brainstorm before code" discipline.
|
||||
- **Recon-first dispatch finds 25–30% more sites than the roadmap inventory.** v1.13.20 recon caught 2 extra dual-write sites (chats.ts fork-clone + 2 in tool-phase.ts) and an extra fixture file. v1.13.19 recon corrected which `Pane` type to extend. Skipping recon to save a step doesn't.
|
||||
- **Adversarial reviews catch what test suites miss.** v1.13.19 reviewer caught silent error-promotion in `openInPane`; v1.13.20 reviewer caught a `RETURNING tool_calls, tool_results` clause that crashes in production but slips past green tests. Both are routine code-reviewer dispatches; both saved a same-day hotfix. **Two-stage review (spec then quality) is non-negotiable when shipping fast.**
|
||||
- **Calendar-gated waits are production-safety hedges that don't apply here.** v1.13.20 originally said "wait one week of production traffic on v1.13.1 before dropping columns." Sam called it out: single-user self-hosted, no rollback constraint, code-level audit + DB COUNT query is the actual safety check. Dropped the wait. Don't ritualize production-grade hedges in a single-user codebase.
|
||||
|
||||
### v1.13 retrospective (what shipped)
|
||||
|
||||
@@ -624,7 +546,21 @@ After the 2026-05-22 retag, the v1.13.x cleanup line in `vMAJOR.MINOR.PATCH-slug
|
||||
- **v1.13.5** — opencode truncate.ts port + view_truncated_output tool. Tagged on `f8fc5db`.
|
||||
- **v1.13.6** — compaction head-assembly audit + reasoning fix. Closed the Q3 reasoning gap from v1.13.1-C. Tagged on `81d837c`.
|
||||
- **v1.13.7** — stability bundle: includeUsage fix + trim guards + payload filter + budget bump. Surfaces tokens (closes a v1.13.1-A latent regression where `result.usage` resolved empty), kills the empty-bubble + ActionRow noise between tool calls on single-tool-call turns, and unblocks Continue after cap-hit on chats that have trailing empty/failed assistants.
|
||||
- **v1.13.2 deferred** — at least one week of production traffic on v1.13.1 before dropping legacy columns. Dual-write is rollback insurance.
|
||||
- **v1.13.6 (numbering re-aligned)** — system-prompt prefix verify-and-measure batch (originally numbered v1.13.8 in the planning doc). Reframed mid-design from "add a `system_prompt_cache` table" to "instrument-and-prove" after recon showed input-layer mtime caches already achieve byte-stable prefixes. Smoke confirmed zero drift across 5 turns; dropped the planned DB table.
|
||||
- **v1.13.7-compaction-trigger** — 0.85×ctx_max early trigger (planned as v1.13.8 / v1.13.9).
|
||||
- **v1.13.8-tool-cost** — `tool_cost_stats` SQL view + AgentPicker tooltip surfacing (planned as v1.13.9 / v1.13.10).
|
||||
- **v1.13.9-agentlint** — instruction-file AgentLint pass (planned as part of v1.13.11 skills audit; split into its own batch when it grew larger than fitting).
|
||||
- **v1.13.10-openspec** — `openspec/changes/<slug>/{proposal,tasks,design}.md` batch-doc structure adoption.
|
||||
- **v1.13.11-tools** — tiered tool loading via `BOOCODE_TOOLS=core|standard|all` env (~30 LoC; was a far-future optional item, slotted in).
|
||||
- **v1.13.12-ws-schemas** + **v1.13.13-ws-publish** — Zod schemas for all 27 wire-format frames, `publishFrame`/`publishUserFrame` wrappers, ~80 publish sites converted (planned as v1.13.10 / v1.13.11).
|
||||
- **v1.13.14-skills-audit** — 26 skills vendored + audited via 5 parallel agent teams; 14 kept, 11 dropped, 1 migrated to BOOCHAT.md/BOOCODER.md. Codeminer42 rules-vs-recipes framing applied.
|
||||
- **v1.13.15-codecontext-synth** — forced second-inference synthesis pass for codecontext overview tools (truncation-aware extraction; auto-fetched top-N files + project docs under 32k payload budget).
|
||||
- **v1.13.16-xml-parser** — Anthropic `<invoke>` parser support + Levenshtein unknown-tool recovery hints (qwen3.6 drift to Claude Code-style tool names).
|
||||
- **v1.13.17-cross-repo-reads** — `request_read_access` tool + per-session `allowed_read_paths` grants; `pathGuard` extraRoots; reuses the `ask_user_input` pause/resume mechanism.
|
||||
- **v1.13.18-codecontext-file-path** — `resolveProjectPath` in `codecontext_client.ts` realpath-resolves `file_path` the same way `target_dir` was already resolved.
|
||||
- **v1.13.19-html-artifact-panes** — pane-based artifact viewer (Markdown default + HTML on request). Scope-revised mid-design from auto-bias-HTML to Markdown-default. `<!DOCTYPE html>` detection adds `message_parts.kind='html_artifact'` row; iframe sandbox `allow-scripts allow-clipboard-write allow-downloads` (no `allow-same-origin`); CSP `connect-src 'none'` + `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox` defense-in-depth. Pane state is reference-only — content fetched on mount to keep jsonb small.
|
||||
- **v1.13.20-drop-legacy-cols** — final strangler-fig step. 10 dual-write sites stripped (recon caught 2 beyond the original v1.13.2 inventory). `messages_with_parts` simplified to parts-only via `CREATE OR REPLACE` before column DROPs (Postgres ordering constraint). Adversarial-review catch: `discard_stale` had `RETURNING tool_calls, tool_results` — fixed via two-step UPDATE-then-SELECT-from-view. `Message` type retains the fields, populated by the view. v1.12.1 cleanup DO block removed.
|
||||
- **`v1.13` umbrella** — tagged on the same commit as v1.13.20 (`211e903`). AI SDK v6 + parts-table migration complete.
|
||||
|
||||
### Pre-v1.13 architectural decisions (still load-bearing)
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ Rules:
|
||||
## Refactorer
|
||||
---
|
||||
temperature: 0.3
|
||||
steps: 5
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
||||
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
||||
---
|
||||
@@ -97,6 +98,7 @@ Codecontext usage:
|
||||
## Architect
|
||||
---
|
||||
temperature: 0.5
|
||||
steps: 20
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
||||
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
||||
---
|
||||
|
||||
185
openspec/changes/v1.13.17-cross-repo-reads/proposal.md
Normal file
185
openspec/changes/v1.13.17-cross-repo-reads/proposal.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# v1.13.17-cross-repo-reads — on-demand read access to another repo (draft, 2026-05-22)
|
||||
|
||||
BooChat sessions are scoped to one project root. When the agent needs context from another repo (e.g. `/opt/forks/codecontext` to investigate a dependency), `pathGuard` rejects every read tool and the agent has no recovery path.
|
||||
|
||||
This batch adds a reactive `ask_user_input`-style flow that the agent triggers on `PathScopeError`. User approves once per session per project root; subsequent reads under that root succeed without further prompting.
|
||||
|
||||
## Trigger flow
|
||||
|
||||
1. Model emits `view_file("/opt/forks/codecontext/go.mod")` while session is scoped to `/opt/boocode`.
|
||||
2. `pathGuard` throws `PathScopeError`. Existing tool wrapper catches it and returns the error to the model. **The error message now ends with a hint:** `"Use request_read_access(path, reason) to ask the user for permission."`
|
||||
3. Model self-issues `request_read_access("/opt/forks/codecontext/go.mod", "investigating codecontext fork to write design doc")` on the next turn.
|
||||
4. The new tool emits a pending tool-call frame (same pause mechanism as `ask_user_input`); inference loop pauses.
|
||||
5. Frontend renders approve/deny chips with the path + reason.
|
||||
6. User picks Allow → append the grant root to `session.allowed_read_paths`, resume inference, tool returns `"granted: /opt/forks/codecontext"`. Model retries the original `view_file` on the next turn.
|
||||
7. User picks Deny → tool returns `"denied"` without mutating session state; model decides what to do next.
|
||||
|
||||
## Decisions (draft — override in dispatch if different)
|
||||
|
||||
### D1. Grant unit = nearest registered project root, then nearest path-whitelist ancestor, then refuse
|
||||
|
||||
When user approves access to `/opt/forks/codecontext/go.mod`:
|
||||
- If a row in `projects.path` is an ancestor of the requested path → grant the project's root path.
|
||||
- Else if `PROJECT_ROOT_WHITELIST` env (default `/opt`) is an ancestor and the immediate child dir of the whitelist looks like a repo root (`.git/`, `package.json`, `go.mod`, or `Cargo.toml` present) → grant that immediate child dir (e.g. `/opt/forks/codecontext`).
|
||||
- Else → refuse without prompting. Tool returns `"denied: path outside permitted scope"`. No user prompt fires.
|
||||
|
||||
Why: granting the literal path is too narrow (next file in the same repo re-prompts). Granting an arbitrary parent dir over-scopes. The nearest repo-shaped directory is the natural unit.
|
||||
|
||||
### D2. Persistence = per-session, no expiry
|
||||
|
||||
`sessions.allowed_read_paths` is the source of truth. Grants stick until the session is archived. A new session in the same project re-prompts on the first cross-repo read.
|
||||
|
||||
Why: per-chat is too granular for the typical workflow (Sam investigates the same fork across multiple chats in one investigation session). Per-project is too broad (different sessions in the same project might have different scope needs). Per-session is the natural unit and matches `session.web_search_enabled`'s scope.
|
||||
|
||||
### D3. Secret-file deny list applies across all grant roots
|
||||
|
||||
`is_secret_path` in `secret_guard.ts` filters filenames (`.env`, `*.pem`, `credentials.json`, etc.) regardless of which root they're under. The check is post-`pathGuard`, so it already runs on the resolved path. No change needed.
|
||||
|
||||
### D4. Revocation UI = chat-settings panel + automatic clear on archive
|
||||
|
||||
- Settings panel under the session-info popover: lists current `allowed_read_paths` with a per-row delete button.
|
||||
- Session archive deletes the row (no need to clear allowed_read_paths separately — the row goes).
|
||||
- No expiry timer.
|
||||
|
||||
Optional v1.13.18 follow-up if Sam wants it: a `/clear_grants` slash command for power users. Out of scope for v1.13.17.
|
||||
|
||||
## Schema
|
||||
|
||||
```sql
|
||||
-- v1.13.17: session-scoped cross-repo read grants. Populated via the
|
||||
-- request_read_access tool's approve path; never written by other code.
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN IF NOT EXISTS allowed_read_paths text[] NOT NULL DEFAULT ARRAY[]::text[];
|
||||
```
|
||||
|
||||
No CHECK constraint — values are absolute paths validated at write time against the projects table + whitelist heuristic.
|
||||
|
||||
## New tool: `request_read_access`
|
||||
|
||||
```ts
|
||||
// apps/server/src/services/request_read_access.ts (new)
|
||||
|
||||
export const requestReadAccessInput = z.object({
|
||||
path: z.string().min(1),
|
||||
reason: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
export const requestReadAccess: ToolDef<...> = {
|
||||
name: 'request_read_access',
|
||||
description:
|
||||
'Ask the user for read-only access to a path outside the current ' +
|
||||
'session\'s project scope. Use when pathGuard rejected a read ' +
|
||||
'attempt and the path is plausibly under another known repo. ' +
|
||||
'Returns "granted: <root>" or "denied".',
|
||||
inputSchema: requestReadAccessInput,
|
||||
jsonSchema: { ... },
|
||||
category: 'read_only',
|
||||
async execute(input, projectRoot) {
|
||||
// Validate path: must be absolute, must be under PROJECT_ROOT_WHITELIST
|
||||
// (default /opt), must NOT already be under the session's primary
|
||||
// projectRoot (silly to ask for what's already in scope).
|
||||
// Validation failures return sentinel without prompting the user.
|
||||
|
||||
// Emit pending-grant tool result (parallel of ask_user_input's pause
|
||||
// sentinel). Inference loop pauses on this kind=pending_grant marker.
|
||||
// User picks Allow/Deny via a new POST /api/messages/:id/grant endpoint.
|
||||
// On Allow: derive grant root per D1 + UPDATE sessions SET
|
||||
// allowed_read_paths = array_append(allowed_read_paths, <root>);
|
||||
// resume inference; tool returns "granted: <root>".
|
||||
// On Deny: resume immediately; tool returns "denied".
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Registered in `ALL_TOOLS` + `READ_ONLY_TOOL_NAMES`. Available to all agents by default (no agent's `tools` whitelist needs to be updated to grant access — the tool registry's filter is per-agent).
|
||||
|
||||
## `pathGuard` extension
|
||||
|
||||
```ts
|
||||
// apps/server/src/services/path_guard.ts — current signature:
|
||||
// pathGuard(projectRoot, requestedPath): Promise<string>
|
||||
//
|
||||
// Extended:
|
||||
// pathGuard(projectRoot, requestedPath, extraRoots?: string[]): Promise<string>
|
||||
//
|
||||
// Tries primary projectRoot first; on PathScopeError, walks extraRoots and
|
||||
// returns the first one that resolves the requestedPath inside its tree.
|
||||
// Throws PathScopeError if no root accepts.
|
||||
```
|
||||
|
||||
Every tool that calls `pathGuard` (currently `view_file`, `list_dir`, `grep`, `find_files`, `view_truncated_output`) threads `session.allowed_read_paths` through `executeToolCall`. The `Session` interface already flows through `TurnArgs`; tool-phase just needs to forward `session.allowed_read_paths` as the third arg.
|
||||
|
||||
## Pause/resume infrastructure reuse
|
||||
|
||||
The pending-grant pause uses the **same mechanism as `ask_user_input`**:
|
||||
- Tool insert with `payload.output = null` + `payload.kind = 'pending_grant'`.
|
||||
- `pausingForUserInput` branch in `tool-phase.ts` is widened to also catch pending grants.
|
||||
- `chat_status` flips to `waiting_for_input` per the v1.12.1 5-state model.
|
||||
|
||||
New endpoint `POST /api/messages/:tool_msg_id/grant` (parallel of the existing `/answer`):
|
||||
- Body: `{ decision: 'allow' | 'deny' }`.
|
||||
- Resolves grant root per D1 if Allow. UPDATEs `sessions.allowed_read_paths`. UPDATEs tool message with output. Resumes inference via existing enqueue path.
|
||||
|
||||
## Frontend changes (in scope; small)
|
||||
|
||||
- `MessageBubble.tsx`: render `pending_grant` tool messages with Allow/Deny chips + the path + reason text. Wires to `api.messages.grant(toolMsgId, decision)`.
|
||||
- New API client method `api.messages.grant`.
|
||||
- Settings popover: `allowed_read_paths` list with per-row delete (calls `PATCH /api/sessions/:id` with the modified array).
|
||||
|
||||
## Hard rules
|
||||
|
||||
- No git commit, no git push, no git pull during dispatch. Sam commits manually.
|
||||
- Backup every file before edit per the standard convention.
|
||||
- TS strict, no `any`.
|
||||
- No new deps.
|
||||
- Schema migration is **additive only** (ADD COLUMN IF NOT EXISTS), idempotent on re-run.
|
||||
- Tool is **read-only** — no path under `allowed_read_paths` can ever be written by BooChat (no write tools registered today; this is a structural guarantee).
|
||||
- Secret-file deny list still runs unconditionally on resolved paths.
|
||||
|
||||
## Stop checkpoints
|
||||
|
||||
1. After recon (read existing path_guard + ask_user_input + answer endpoint patterns): stop, hand back the recon report.
|
||||
2. After code edits, before schema migration applies: stop, hand back the diff.
|
||||
3. After schema migration applies in dev: stop, run smoke plan, report.
|
||||
|
||||
## Smoke plan
|
||||
|
||||
1. **Approve flow.** Send a chat in a `/opt/boocode` session asking the agent to investigate `/opt/forks/codecontext/go.mod`. Confirm:
|
||||
- `pathGuard` throws on the first attempt; tool result includes the `request_read_access` hint.
|
||||
- Agent calls `request_read_access`; tool-call frame lands; chat status flips to `waiting_for_input`.
|
||||
- Frontend renders Allow/Deny chips with the path + reason.
|
||||
- Pick Allow → grant root resolves to `/opt/forks/codecontext` (per D1); `sessions.allowed_read_paths` shows the entry; agent retries `view_file` successfully on the next turn.
|
||||
2. **Deny flow.** Same setup; pick Deny. Confirm session state unchanged, tool returns `"denied"`, agent gives up or asks differently.
|
||||
3. **Persistence.** In the same session, a second `view_file` against a different file under `/opt/forks/codecontext/` succeeds without re-prompting.
|
||||
4. **Cross-session isolation.** Open a fresh session in the boocode project, try the same path — re-prompts (allowed_read_paths is empty on the new session).
|
||||
5. **Secret-file deny still fires.** Approve access to a repo that contains a `.env` file. Try `view_file('/opt/forks/some-repo/.env')`. Confirm refused via `is_secret_path`, not via pathGuard scope.
|
||||
6. **Out-of-scope refusal.** Try `request_read_access('/etc/passwd', 'system file')`. Tool validates against the whitelist + repo-shape heuristic, returns `"denied: path outside permitted scope"` without prompting the user.
|
||||
|
||||
## Done when
|
||||
|
||||
- New `request_read_access` tool + `POST /api/messages/:id/grant` endpoint shipped.
|
||||
- `path_guard.ts` extended; all read tools forward `allowed_read_paths`.
|
||||
- `MessageBubble.tsx` renders pending-grant bubbles; settings popover lists + clears grants.
|
||||
- Schema migration applied (sessions.allowed_read_paths).
|
||||
- Smoke plan green.
|
||||
- v1.13.17-cross-repo-reads tag + CHANGELOG entry + roadmap retrospective bullet.
|
||||
|
||||
## Files expected to touch
|
||||
|
||||
- `apps/server/src/schema.sql` — new column
|
||||
- `apps/server/src/services/request_read_access.ts` — NEW
|
||||
- `apps/server/src/services/path_guard.ts` — extra-roots param + helpful PathScopeError message
|
||||
- `apps/server/src/services/tools.ts` — register the new tool, update view_file / list_dir / grep / find_files / view_truncated_output to thread allowed_read_paths
|
||||
- `apps/server/src/services/inference/tool-phase.ts` — pause-on-pending-grant branch (alongside ask_user_input)
|
||||
- `apps/server/src/routes/messages.ts` — new `/grant` endpoint
|
||||
- `apps/server/src/types/api.ts` — `Session.allowed_read_paths`
|
||||
- `apps/web/src/api/client.ts` — `api.messages.grant`
|
||||
- `apps/web/src/api/types.ts` — `Session.allowed_read_paths`
|
||||
- `apps/web/src/components/MessageBubble.tsx` — render pending_grant chips
|
||||
- `apps/web/src/components/` — settings-popover grants list (file TBD during impl)
|
||||
|
||||
Estimate: ~120 LoC across backend + frontend + schema. Single batch.
|
||||
|
||||
## Open questions for dispatch
|
||||
|
||||
The four design decisions above are my recommendations. Override any of them in the dispatch and I'll update the proposal before recon. Most likely-overridable: **D1** (grant unit — you may want exact-path-only for tighter scoping, accepting the re-prompt cost) and **D4** (revocation UI — you may want it deferred entirely).
|
||||
46
openspec/changes/v1.13.18-codecontext-file-path/design.md
Normal file
46
openspec/changes/v1.13.18-codecontext-file-path/design.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# v1.13.18 — design notes
|
||||
|
||||
## Resolver contract
|
||||
|
||||
`resolveProjectPath(projectRoot: string, rawPath: string): Promise<string>`
|
||||
|
||||
1. **Trim check** — `rawPath.trim() === ''` throws `INVALID_FILE_PATH`. This is defensive code; the Zod `.trim().min(1)` in required-`file_path` wrappers catches empty paths before the shim. For optional-`file_path` wrappers, the caller guard `file_path.trim() !== ''` prevents `resolveProjectPath` from being reached at all when the string is empty or whitespace-only.
|
||||
|
||||
2. **Absolute branch** — `isAbsolute(rawPath)` uses the candidate as-is; otherwise `resolve(projectRoot, rawPath)` anchors it.
|
||||
|
||||
3. **realpath with ENOENT fallthrough** — `realpath(candidate)` resolves symlinks and normalises the path. On `ENOENT` (file doesn't exist), the un-realpathed absolute is used as the forwarded value. Any other error (EACCES, EBADF, etc.) re-throws immediately.
|
||||
|
||||
4. **Escape check** — `resolved !== projectRoot && !resolved.startsWith(projectRoot + sep)`. Uses `path.sep` not a string literal `'/'` so the check is platform-safe (Windows posture, forward compatibility).
|
||||
|
||||
5. **Return** — the resolved absolute path, which replaces `req.args['file_path']` in `argsToSend`.
|
||||
|
||||
The guard in `callCodecontext` only invokes `resolveProjectPath` when `typeof req.args['file_path'] === 'string' && req.args['file_path'].trim() !== ''`. Wrappers that don't include `file_path` in their args object are unaffected.
|
||||
|
||||
## Error-shape parity rationale
|
||||
|
||||
The `target_dir` escape error message is: `target_dir <targetDir> escapes project root <resolvedProject>`.
|
||||
|
||||
The `file_path` escape error message is: `file_path <rawPath> escapes project root <projectRoot>`.
|
||||
|
||||
The template is byte-identical except for the field name prefix. This is intentional:
|
||||
|
||||
- The existing escape error regex `/escapes project root/` used in tests and potentially in log alerting applies to both error types without special-casing.
|
||||
- A model receiving either error message can apply the same self-correction: the escape check is the same invariant (`path starts with project root + sep`), so the same remediation applies (use a path inside the project).
|
||||
- Keeping the shapes uniform reduces cognitive overhead when reading logs that mix both error types.
|
||||
|
||||
## ENOENT fallthrough rationale
|
||||
|
||||
When a `file_path` doesn't exist on disk, `resolveProjectPath` forwards the un-realpathed absolute path to the sidecar. The sidecar responds with its own error: `"file not found: <path>"` (or `"File not found in graph: <path>"`).
|
||||
|
||||
The alternative — re-implementing the "file not found" check in the resolver — would:
|
||||
1. Diverge from the sidecar's canonical error language, producing two different "not found" messages depending on whether the file existed at realpath time.
|
||||
2. Conflict with future scenarios where the sidecar's graph is stale (file existed at index time but was deleted, or vice versa). The sidecar's error is always authoritative.
|
||||
3. Add no user-visible value: the model can self-correct on either "file not found" message by checking the path.
|
||||
|
||||
The resolver's job is path safety (scope enforcement) and path normalisation (relative → absolute). Existence checking is the sidecar's job.
|
||||
|
||||
## `codecontext_tools.test.ts` impact
|
||||
|
||||
The existing `get_file_analysis forwards file_path` test in `codecontext_tools.test.ts` passes `'apps/server/src/index.ts'` as a relative `file_path` and asserts it reaches the wire unchanged. After this fix the path is resolved to `join(projectDir, 'apps/server/src/index.ts')`. The test now fails.
|
||||
|
||||
This test file is outside this batch's allowed file list. Sam should update the test assertion to expect the resolved absolute path, or create the file in the test tmpdir and assert the full resolved path. The fix is a one-liner: change `file_path: 'apps/server/src/index.ts'` to `file_path: join(projectDir, 'apps/server/src/index.ts')` in the `expect(body).toMatchObject(...)` call, and create the file before the call (so realpath succeeds).
|
||||
36
openspec/changes/v1.13.18-codecontext-file-path/proposal.md
Normal file
36
openspec/changes/v1.13.18-codecontext-file-path/proposal.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# v1.13.18 — codecontext file_path resolver
|
||||
|
||||
Fixes a silent failure that caused all four `file_path`-taking codecontext wrappers to return "file not found" whenever the model passed a relative path.
|
||||
|
||||
## Why
|
||||
|
||||
BooCode's codecontext sidecar (`codecontext_client.ts`) already realpath-resolves `target_dir` before forwarding it to the HTTP shim. It did not do the same for `file_path`. The sidecar's internal file index is keyed on absolute paths, so any relative path from the model produced a JSON error response:
|
||||
|
||||
```
|
||||
{"error":"file not found: apps/server/src/services/inference/turn.ts","result":null}
|
||||
```
|
||||
|
||||
This was observed repeatedly in the 2026-05-22 docker logs (17:56 UTC window) — the model passed relative paths on every `get_file_analysis` tool call and received no useful output, burning tool budget on dead calls.
|
||||
|
||||
## Scope
|
||||
|
||||
Four wrappers take a `file_path` argument:
|
||||
|
||||
- `tools/codecontext/get_file_analysis.ts` — `file_path` required
|
||||
- `tools/codecontext/get_symbol_info.ts` — `file_path` optional
|
||||
- `tools/codecontext/get_dependencies.ts` — `file_path` optional
|
||||
- `tools/codecontext/get_semantic_neighborhoods.ts` — `file_path` optional
|
||||
|
||||
Fix lands in one place: `callCodecontext` in `codecontext_client.ts`. A new `resolveProjectPath` helper is inserted at the args-spread site and invoked whenever `file_path` is present and non-empty. All four wrappers benefit automatically; no per-wrapper edits required.
|
||||
|
||||
Zod `.trim()` is added to all four `file_path` schema entries so that whitespace-padded paths from the model are cleaned before they reach the resolver.
|
||||
|
||||
## Decision: single resolver over per-wrapper edits
|
||||
|
||||
Four wrappers, one shared code path. Per-wrapper edits would require four edits and make it easy to miss one. The `callCodecontext` shim already owns `target_dir` validation; `file_path` validation belongs there too for symmetry.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No changes to the `target_dir` resolver — it already works correctly.
|
||||
- No extension to wrappers that do not take `file_path` (`get_codebase_overview`, `get_framework_analysis`, `search_symbols`, `watch_changes`).
|
||||
- No fix for the unrelated RPC errors and Go map-race warnings visible in the codecontext sidecar logs — those are upstream bugs.
|
||||
57
openspec/changes/v1.13.18-codecontext-file-path/tasks.md
Normal file
57
openspec/changes/v1.13.18-codecontext-file-path/tasks.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# v1.13.18 tasks
|
||||
|
||||
## B1 — Backups
|
||||
|
||||
- [x] `apps/server/src/services/codecontext_client.ts.bak-v1.13.18-20260522`
|
||||
- [x] `apps/server/src/services/tools/codecontext/get_file_analysis.ts.bak-v1.13.18-20260522`
|
||||
- [x] `apps/server/src/services/tools/codecontext/get_symbol_info.ts.bak-v1.13.18-20260522`
|
||||
- [x] `apps/server/src/services/tools/codecontext/get_dependencies.ts.bak-v1.13.18-20260522`
|
||||
- [x] `apps/server/src/services/tools/codecontext/get_semantic_neighborhoods.ts.bak-v1.13.18-20260522`
|
||||
|
||||
## B2 — Resolver implementation in `codecontext_client.ts`
|
||||
|
||||
- [x] Import `isAbsolute`, `resolve`, `sep` from `node:path` (alongside existing `join`)
|
||||
- [x] Add `resolveProjectPath(projectRoot, rawPath)` helper — trim check, isAbsolute branch, realpath with ENOENT fallthrough, escape check
|
||||
- [x] Wire into `callCodecontext` at args-spread site — guard on `file_path.trim() !== ''`
|
||||
- [x] Error-shape parity verified: `file_path <raw> escapes project root <root>` mirrors `target_dir <dir> escapes project root <root>`
|
||||
|
||||
## B3 — Zod `.trim()` on wrapper schemas
|
||||
|
||||
- [x] `get_file_analysis.ts` — `z.string().trim().min(1)`
|
||||
- [x] `get_symbol_info.ts` — `z.string().trim().optional()`
|
||||
- [x] `get_dependencies.ts` — `z.string().trim().optional()`
|
||||
- [x] `get_semantic_neighborhoods.ts` — `z.string().trim().optional()`
|
||||
|
||||
## B4 — Tests
|
||||
|
||||
- [x] Added `describe('callCodecontext — file_path resolution', ...)` to `codecontext_client.test.ts`
|
||||
- [x] Case 1: relative path resolves to absolute inside project root
|
||||
- [x] Case 2: absolute path inside project root passes through
|
||||
- [x] Case 3: relative escape (`../../etc/passwd`) rejected with `escapes project root`
|
||||
- [x] Case 4: absolute path outside project root rejected
|
||||
- [x] Case 5: nonexistent file (ENOENT) forwarded as un-realpath'd absolute
|
||||
- [x] Case 6: empty string skipped by guard (treated as not provided)
|
||||
- [x] Case 7: wrapper without `file_path` — resolver not invoked, no `file_path` in wire body
|
||||
- [x] All 17 tests in `codecontext_client.test.ts` pass
|
||||
|
||||
## B5 — Typecheck + smoke
|
||||
|
||||
- [x] `npx tsc --noEmit -p apps/server` — 0 errors
|
||||
- [x] Before-fix smoke (relative path): `{"error":"file not found: apps/server/src/services/inference/turn.ts","result":null}`
|
||||
- [x] Before-fix smoke (absolute path): returns `Lines: 330 / Symbols: 48` as expected
|
||||
|
||||
## B6 — Test asserting old buggy behavior updated
|
||||
|
||||
- [x] `apps/server/src/services/__tests__/codecontext_tools.test.ts` — assertion at line 73 updated from `file_path: 'apps/server/src/index.ts'` to `file_path: join(projectDir, 'apps/server/src/index.ts')` to match the new resolved-absolute contract.
|
||||
|
||||
## B7 — OpenSpec docs
|
||||
|
||||
- [x] `openspec/changes/v1.13.18-codecontext-file-path/proposal.md`
|
||||
- [x] `openspec/changes/v1.13.18-codecontext-file-path/tasks.md`
|
||||
- [x] `openspec/changes/v1.13.18-codecontext-file-path/design.md`
|
||||
|
||||
## B8 — Review-pass defence-in-depth (P2 fixes from adversarial review)
|
||||
|
||||
- [x] `codecontext_client.ts:71` — absolute branch now goes through `resolve()` to normalise dot-segments. Closes the ENOENT-fallthrough escape gap where `<projectRoot>/../etc/x` would prefix-match `<projectRoot>/` literally.
|
||||
- [x] `codecontext_client.test.ts` — added Case 8 (absolute path with `..` resolving outside root, ENOENT branch) and Case 9 (in-project symlink whose target sits outside root). 19 tests pass.
|
||||
- [x] Updated `resolveProjectPath` docstring to reflect the new normalisation step.
|
||||
126
openspec/changes/v1.13.20-drop-legacy-cols/proposal.md
Normal file
126
openspec/changes/v1.13.20-drop-legacy-cols/proposal.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# v1.13.20-drop-legacy-cols — drop messages.tool_calls + messages.tool_results
|
||||
|
||||
Final phase of the v1.13.0 strangler-fig migration. Removes the dual-write into `messages.tool_calls` / `messages.tool_results` JSON columns and drops the columns themselves. After this batch, `message_parts` is the only source of truth for tool-call and tool-result data.
|
||||
|
||||
Tag `v1.13` (umbrella) ships on the same commit per the original roadmap entry.
|
||||
|
||||
## Why
|
||||
|
||||
v1.13.0 (AI SDK v6 migration) introduced `message_parts` as the new canonical store for tool calls, tool results, reasoning, text, synthesis, and now html_artifact. To stay safe during the migration, every write site also dual-wrote to the legacy `messages.tool_calls` / `messages.tool_results` JSON columns, and `messages_with_parts` view COALESCEs over both. Reads have been migrated; dual-writes are pure overhead at this point.
|
||||
|
||||
Verification query (per the original v1.13.2 plan) returns `0 / 0` orphan rows. Today's DB is also empty (0 messages on the live instance), so the COUNT query alone is weakly informative — the safety check shifts to a code-level audit: every dual-write site listed in the v1.13.2 roadmap entry must be located and its parts-write half kept, JSON-column half removed.
|
||||
|
||||
## Scope
|
||||
|
||||
### S1. Remove dual-write from every site
|
||||
|
||||
Per the v1.13.2 roadmap entry, dual-writes live at:
|
||||
|
||||
- `services/inference/tool-phase.ts` — 3 sites
|
||||
- `services/inference/error-handler.ts` — `finalizeCompletion`
|
||||
- `routes/skills.ts` — 2 sites
|
||||
- `routes/messages.ts` — answer flow
|
||||
- `routes/chats.ts` — fork flow
|
||||
|
||||
Implementer must grep for every UPDATE / INSERT that touches `tool_calls` or `tool_results` columns and verify it has a paired `insertParts(...)` call. Keep the parts write, remove the column write. If a site only writes to the JSON column with no parts pair — STOP and escalate (would indicate a bug in the v1.13.0 dual-write rollout we haven't caught).
|
||||
|
||||
### S2. Simplify `messages_with_parts` view
|
||||
|
||||
Current view COALESCEs parts-table rows over legacy JSON columns to support pre-v1.13.0 history. After this batch, the JSON columns no longer exist — drop the COALESCE fallbacks. The view should read only from `message_parts` joined to `messages`.
|
||||
|
||||
### S3. Drop the columns
|
||||
|
||||
```sql
|
||||
ALTER TABLE messages DROP COLUMN tool_calls;
|
||||
ALTER TABLE messages DROP COLUMN tool_results;
|
||||
```
|
||||
|
||||
Idempotent via `IF EXISTS`. Apply unconditionally on startup (matches the rest of `schema.sql`'s shape).
|
||||
|
||||
### S4. Remove from API types
|
||||
|
||||
`Message` interface in `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts` — drop `tool_calls?` and `tool_results?` fields. The API boundary is unchanged because every consumer already reads parts-derived values through `messages_with_parts`. Mirror byte-for-byte.
|
||||
|
||||
### S5. Drop the stale `messages_status_check` cleanup DO block from v1.12.1 if still present
|
||||
|
||||
Per the v1.13.2 roadmap entry, there's a v1.12.1 `DO $$ DROP CONSTRAINT messages_status_check` block that was meant to clean up the old anonymous constraint. If still present in `schema.sql`, remove — it's been one-shot effective.
|
||||
|
||||
### S6. Update test fixtures
|
||||
|
||||
`inference.test.ts` and `compaction.test.ts` (and any other test file the grep finds) construct Message-shaped fixtures with `tool_calls: null, tool_results: null` literals. Rewrite ~30 fixtures to construct via `message_parts` rows where the test actually exercises tool calls. For tests that don't exercise tool calls at all, just drop the now-absent fields.
|
||||
|
||||
`partsFromAssistantMessage` and `partsFromToolMessage` helpers in `parts.ts` currently take `tool_calls` and `tool_results` as args (because that's what the legacy Message shape carried). Keep their input shapes — they're useful constructors. The change is at the call sites, not the helpers.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **No changes to `message_parts` schema.** It's correct as-is.
|
||||
- **No changes to the `messages_with_parts` view name or interface.** Just the implementation simplifies.
|
||||
- **No removal of `partsFromAssistantMessage` / `partsFromToolMessage`.** They're useful as constructors; their job becomes producing parts from raw ToolCall/ToolResult objects, not from a legacy Message row.
|
||||
- **No frontend changes beyond the type mirror.** Web reads parts via `messages_with_parts` already.
|
||||
- **No reads from the legacy columns in any code path.** Verify with grep.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- No git commits during dispatch. Sam commits manually (handled by controller after all dispatches done).
|
||||
- Backups: every modified file → `.bak-v1.13.20-20260523`.
|
||||
- TS strict, no `any`.
|
||||
- No new deps.
|
||||
- Schema migration: additive-or-destructive but idempotent (`IF EXISTS` on the column drops).
|
||||
- Run the full server test suite after — must be green.
|
||||
- Frontend: `tsc -p apps/web/tsconfig.app.json --noEmit` + `pnpm -C apps/web build` clean.
|
||||
|
||||
## Stop checkpoints
|
||||
|
||||
1. **After recon** (grep-driven inventory of dual-write call sites + read sites still touching the legacy columns): stop, hand back inventory. The roadmap listed 7+ sites; verify nothing's been missed.
|
||||
2. **After code edits, before schema migration**: stop, hand back diff + test results. Confirm the parts write at every former dual-write site still happens.
|
||||
3. **After schema migration applies in dev**: stop, run tests, run a fresh `applySchema()` cycle (boot twice), confirm idempotent.
|
||||
|
||||
## Smoke plan
|
||||
|
||||
1. **Fresh boot.** Restart the boocode container, confirm `applySchema()` completes without error.
|
||||
2. **Idempotent boot.** Restart again, confirm no error on the second pass (column DROP IF EXISTS is a no-op).
|
||||
3. **Send a chat that triggers a tool call.** Confirm:
|
||||
- Assistant message lands with content + reasoning + tool_call parts (all in `message_parts`).
|
||||
- Tool result lands as a `tool_result` part.
|
||||
- `messages_with_parts` returns the same shape the frontend expects (verify by reading the live chat in the UI).
|
||||
4. **DB inspection.** `\d messages` — confirm `tool_calls` and `tool_results` columns are gone.
|
||||
5. **Compaction roundtrip.** Trigger a compaction-eligible turn (long context); confirm the rolling summary still anchors correctly and uses parts as input.
|
||||
|
||||
## Done when
|
||||
|
||||
- All dual-write sites converted to parts-only writes.
|
||||
- View simplified, columns dropped, types updated.
|
||||
- Test suite green.
|
||||
- Frontend typecheck + build clean.
|
||||
- Smoke green.
|
||||
- Tagged `v1.13.20-drop-legacy-cols` AND the umbrella `v1.13` on the same commit.
|
||||
- CHANGELOG.md entry + roadmap retrospective bullet.
|
||||
|
||||
## Files expected to touch
|
||||
|
||||
**Backend:**
|
||||
- `apps/server/src/schema.sql` — DROP columns + simplify view + remove v1.12.1 cleanup block
|
||||
- `apps/server/src/services/inference/tool-phase.ts` — remove 3 dual-write sites
|
||||
- `apps/server/src/services/inference/error-handler.ts` — remove dual-write in `finalizeCompletion`
|
||||
- `apps/server/src/routes/skills.ts` — remove 2 dual-write sites
|
||||
- `apps/server/src/routes/messages.ts` — remove dual-write in answer flow
|
||||
- `apps/server/src/routes/chats.ts` — remove dual-write in fork
|
||||
- `apps/server/src/types/api.ts` — drop `tool_calls?` / `tool_results?` from Message
|
||||
- `apps/server/src/services/__tests__/inference.test.ts` — fixture rewrites
|
||||
- `apps/server/src/services/__tests__/compaction.test.ts` — fixture rewrites
|
||||
- `apps/server/src/services/__tests__/parts.test.ts` — likely some fixture updates
|
||||
- `apps/server/src/services/__tests__/tool_cost_stats.test.ts` — likely some fixture updates
|
||||
- `apps/server/src/services/__tests__/system-prompt.test.ts` — likely some fixture updates
|
||||
|
||||
**Frontend:**
|
||||
- `apps/web/src/api/types.ts` — mirror Message change
|
||||
|
||||
**Docs:**
|
||||
- `BOOCHAT.md` — no change expected (rules don't mention the legacy columns)
|
||||
- `boocode_roadmap.md` — retrospective bullet
|
||||
- `CHANGELOG.md` — new section
|
||||
- `CLAUDE.md` — drop the v1.13.0 dual-write notes that no longer apply (audit the surrounding paragraphs)
|
||||
|
||||
## Estimate
|
||||
|
||||
~150 LoC net (mostly deletions). Mechanical work — same per-batch shape as v1.13.18.
|
||||
104
openspec/changes/v1.13.20-drop-legacy-cols/tasks.md
Normal file
104
openspec/changes/v1.13.20-drop-legacy-cols/tasks.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# v1.13.20-drop-legacy-cols tasks
|
||||
|
||||
## B1 — Recon (STOP after this step)
|
||||
|
||||
- [ ] Grep `apps/server/src/**/*.ts` for every `tool_calls` and `tool_results` mention. Categorize each hit as:
|
||||
- **dual-write** (an UPDATE / INSERT that writes the JSON column)
|
||||
- **read** (a SELECT that reads the JSON column, or code that destructures it from a row)
|
||||
- **type-only** (interface / type field reference)
|
||||
- **test fixture** (literal in a test file)
|
||||
- **comment / docs**
|
||||
- [ ] Confirm the v1.13.2 roadmap inventory is complete:
|
||||
- tool-phase.ts: 3 sites
|
||||
- error-handler.ts (`finalizeCompletion`): 1 site
|
||||
- routes/skills.ts: 2 sites
|
||||
- routes/messages.ts (answer flow): 1 site
|
||||
- routes/chats.ts (fork): 1 site
|
||||
- Any extras the grep finds: list them
|
||||
- [ ] Confirm no READ sites still touching the legacy columns (everything should go through `messages_with_parts`). If reads remain, flag them — they need to migrate to the view BEFORE dropping the columns.
|
||||
- [ ] Hand back inventory as a per-file table: file, line, kind (dual-write / read / type / fixture), action (delete / migrate-to-view / type-prune).
|
||||
|
||||
## B2 — Backups
|
||||
|
||||
- [ ] `cp <file> <file>.bak-v1.13.20-20260523` for every file in B1's action list before editing.
|
||||
|
||||
## B3 — Remove dual-writes
|
||||
|
||||
- [ ] Remove the JSON-column UPDATE / INSERT at every site identified in B1 as a dual-write. Keep the paired `insertParts(...)` call.
|
||||
- [ ] If a site only writes the JSON column with no parts pair (would indicate a bug from v1.13.0) — STOP, report as BLOCKED.
|
||||
- [ ] Verify by grep: zero remaining writes to `tool_calls` or `tool_results` outside of `schema.sql` and test fixtures.
|
||||
|
||||
## B4 — Simplify `messages_with_parts` view
|
||||
|
||||
- [ ] Open `schema.sql`. Find the view definition.
|
||||
- [ ] Drop the COALESCE fallbacks that read `m.tool_calls` / `m.tool_results` from `messages`.
|
||||
- [ ] View now reads only from `message_parts` joined to `messages`.
|
||||
- [ ] Confirm view's output column shapes are unchanged: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]`.
|
||||
|
||||
## B5 — Drop columns
|
||||
|
||||
- [ ] `ALTER TABLE messages DROP COLUMN IF EXISTS tool_calls;`
|
||||
- [ ] `ALTER TABLE messages DROP COLUMN IF EXISTS tool_results;`
|
||||
- [ ] Idempotent on re-run.
|
||||
- [ ] Apply order in `schema.sql`: AFTER the view is updated (view depends on the columns; can't drop a column referenced by a view).
|
||||
- [ ] Actually verify the order — if the view references the columns, you must drop the view first OR change it before the ALTER.
|
||||
|
||||
## B6 — Remove v1.12.1 cleanup block
|
||||
|
||||
- [ ] Find the `DO $$ DROP CONSTRAINT messages_status_check` block in `schema.sql` (likely near the messages CHECK constraints).
|
||||
- [ ] Confirm it's safe to remove (the constraint should have been dropped long ago).
|
||||
- [ ] Delete the block.
|
||||
|
||||
## B7 — Type pruning
|
||||
|
||||
- [ ] `apps/server/src/types/api.ts` — remove `tool_calls?` and `tool_results?` from the `Message` interface.
|
||||
- [ ] `apps/web/src/api/types.ts` — mirror byte-for-byte.
|
||||
- [ ] Search for any other type references — `ToolCallsField`, `ToolResultsField`, etc.
|
||||
|
||||
## B8 — Test fixture updates
|
||||
|
||||
- [ ] Run `pnpm -C apps/server test` to see what breaks.
|
||||
- [ ] For each failing test that constructs a `Message` literal with `tool_calls: null` / `tool_results: null` — remove those fields.
|
||||
- [ ] For tests that exercised tool-call behavior via the legacy columns, rewrite to construct via `message_parts` rows.
|
||||
- [ ] Confirm: `pnpm -C apps/server test` — all green.
|
||||
|
||||
## B9 — Type / build verification
|
||||
|
||||
- [ ] `npx tsc --noEmit -p apps/server` — 0 errors.
|
||||
- [ ] `npx tsc -p apps/web/tsconfig.app.json --noEmit` — 0 errors.
|
||||
- [ ] `pnpm -C apps/web build` — green.
|
||||
|
||||
## B10 — STOP checkpoint, hand back diff
|
||||
|
||||
- [ ] Hand controller the diff for backend changes + test results.
|
||||
|
||||
## B11 — Schema deploy
|
||||
|
||||
- [ ] `docker compose up --build -d` rebuilds with new schema.
|
||||
- [ ] Boot twice in sequence — confirm idempotent (column DROP IF EXISTS is a no-op on the second boot).
|
||||
- [ ] `docker exec boocode_db psql -U boocode -d boocode -c "\d messages"` — confirm columns absent.
|
||||
- [ ] `docker logs boocode 2>&1 | tail -50` — confirm no schema errors.
|
||||
|
||||
## B12 — Smoke
|
||||
|
||||
- [ ] Live-smoke: send a chat that triggers at least one tool call. Confirm:
|
||||
- [ ] Assistant message renders with content + tool_call ActionRow.
|
||||
- [ ] Tool result renders.
|
||||
- [ ] No console errors in browser or `docker logs boocode`.
|
||||
- [ ] Trigger a compaction-eligible turn (long context). Confirm rolling summary anchors correctly.
|
||||
|
||||
## B13 — Docs
|
||||
|
||||
- [ ] `CHANGELOG.md` entry for v1.13.20-drop-legacy-cols.
|
||||
- [ ] `boocode_roadmap.md` retrospective bullet on the v1.13.2 section (note the slug rename and ship date).
|
||||
- [ ] `CLAUDE.md` — drop the v1.13.0 dual-write notes that no longer apply. Audit the surrounding paragraphs.
|
||||
|
||||
## B14 — Tag + push + rebuild
|
||||
|
||||
- [ ] `git add` only the v1.13.20 batch files (per CLAUDE.md convention).
|
||||
- [ ] `git commit` with HEREDOC commit message.
|
||||
- [ ] `git tag v1.13.20-drop-legacy-cols` AND `git tag v1.13` (umbrella, per original v1.13.2 plan).
|
||||
- [ ] Push: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin main`
|
||||
- [ ] Push both tags.
|
||||
- [ ] `docker compose up --build -d`.
|
||||
- [ ] Curl health check.
|
||||
72
openspec/changes/v1.14-outer-loop/design.md
Normal file
72
openspec/changes/v1.14-outer-loop/design.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# v1.14.0-outer-loop — design decisions
|
||||
|
||||
Answers to the dispatch's blocking questions, resolved 2026-05-23.
|
||||
|
||||
## D1. Step cap — what replaces MAX_TOOL_LOOP_DEPTH?
|
||||
|
||||
`MAX_TOOL_LOOP_DEPTH` never existed — no hard recursion depth guard was ever in the codebase. Safety came from budget (50 tool calls) + doom-loop (3 identical calls).
|
||||
|
||||
**Decision:** introduce `MAX_STEPS = 200` as a hard ceiling. Per-agent cap via `agent.steps` is the primary knob. Resolution: `effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS)`.
|
||||
|
||||
**Rationale:** Sam reports BooChat gets stuck at 50 tool calls (the budget) too often. The step cap should be generous — 200 is 4x the current de-facto ceiling. Budget (50 tool calls total across all steps) remains a separate concern and is not changed in this batch.
|
||||
|
||||
Note: "step" ≠ "tool call." One step = one stream iteration that may produce multiple parallel tool calls. Budget counts individual tool calls; step cap counts iterations. At 200 steps with average 1-2 tool calls per step, the budget (50) will fire well before the step cap in most scenarios. The step cap is a safety ceiling for cases where the model makes many 1-tool-call iterations.
|
||||
|
||||
## D2. step_finish — emit or not?
|
||||
|
||||
**Decision:** No `step_finish` part. The next `step_start` (or assistant message completion) implicitly ends the previous step.
|
||||
|
||||
**Rationale:** opencode only emits `step_start`. Less noise in parts, simpler code. If UI ever needs step durations, compute from the timestamps of consecutive `step_start` parts.
|
||||
|
||||
## D3. Step-cap hit — sentinel or quiet?
|
||||
|
||||
**Decision:** Write a sentinel summary on step-cap hit. Visible to the user in chat, same as budget-exhaustion's `runCapHitSummary`.
|
||||
|
||||
**Implementation:** Extend `runCapHitSummary` to accept a `reason: 'budget' | 'step_cap'` parameter (or add a parallel `runStepCapSummary`). The sentinel metadata kind stays `cap_hit` — frontend `CapHitSentinel` component already renders it. The sentinel's text distinguishes the two cases ("Tool budget exhausted" vs "Step limit reached").
|
||||
|
||||
## D4. agent.steps = 0
|
||||
|
||||
**Decision:** `steps: 0` means "no tool calls allowed." The loop body never executes. The assistant can only respond with text.
|
||||
|
||||
**Implementation:** When `effectiveCap === 0`, skip the loop entirely. Stream the first assistant turn (text-only), finalize, return. The model receives no tools in the request payload when `steps: 0` (or equivalently, tools are passed but the loop never enters the tool-execution branch).
|
||||
|
||||
Actually, cleaner: `steps: 0` means the loop cap is 0. The while condition `stepNumber < effectiveCap` is false on the first check. The stream phase still runs (the model produces a text response), but if it emits tool calls they're ignored and the turn finalizes as text-only. This may produce a confusing response if the model's text references tool results it never got — but `steps: 0` is an explicit constraint the agent author chose. Document in AGENTS.md parser validation.
|
||||
|
||||
## D5. Synthesis success terminates the loop?
|
||||
|
||||
**Decision:** Yes. `break` out of the loop after synthesis success. Preserves current behavior (synthesis replaces the recursive call; no further iterations).
|
||||
|
||||
**Rationale:** The synthesis pass produces a self-contained summary turn. Continuing the loop after synthesis would let the model issue more tool calls on top of a synthesis summary, which is semantically wrong — the synthesis IS the final answer for that tool call batch.
|
||||
|
||||
## D6. executeToolPhase return struct
|
||||
|
||||
The recursive call at `tool-phase.ts:342` is currently the last thing `executeToolPhase` does (after creating the next assistant row). After the conversion, `executeToolPhase` returns a struct the loop body reads:
|
||||
|
||||
```typescript
|
||||
interface ToolPhaseResult {
|
||||
action: 'continue' | 'paused' | 'synthesis_done';
|
||||
toolCallCount: number;
|
||||
toolCalls: ToolCall[];
|
||||
nextAssistantId: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
- `continue` → loop continues; `nextAssistantId` is the new assistant message's UUID.
|
||||
- `paused` → user-input or grant pause; loop breaks. `nextAssistantId` is null.
|
||||
- `synthesis_done` → synthesis succeeded; loop breaks. `nextAssistantId` is null (synthesis wrote its own parts).
|
||||
|
||||
The loop body then:
|
||||
1. Updates `toolsUsed += result.toolCallCount`
|
||||
2. Appends `result.toolCalls` to `recentToolCalls`
|
||||
3. Sets `assistantMessageId = result.nextAssistantId` for the next iteration
|
||||
4. Increments `stepNumber`
|
||||
5. Checks `result.action` — if not `continue`, breaks.
|
||||
|
||||
## D7. Budget vs steps interaction
|
||||
|
||||
Budget counts **individual tool calls** across the entire turn. Steps counts **loop iterations**. They are orthogonal:
|
||||
|
||||
- Budget fires when `toolsUsed >= resolveToolBudget(agent)` (currently 50 for read-only). Checked at the top of each iteration.
|
||||
- Step cap fires when `stepNumber >= effectiveCap`. Checked by the loop condition.
|
||||
|
||||
Both produce a sentinel summary. A turn can be terminated by whichever fires first. In practice, budget (50 tool calls) fires before step cap (200 steps) unless the model produces many 0-tool-call iterations (which shouldn't happen — 0 tool calls means non-tool finish, which exits the loop via the `break` path).
|
||||
112
openspec/changes/v1.14-outer-loop/proposal.md
Normal file
112
openspec/changes/v1.14-outer-loop/proposal.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# v1.14.0-outer-loop — explicit outer agent loop
|
||||
|
||||
Replace the ad-hoc `executeToolPhase → runAssistantTurn` recursion with an explicit `while` loop. A **step** is one stream-and-tool-execute iteration; a step can contain multiple parallel tool calls. The loop terminates on non-tool finish OR step-cap hit OR doom-loop OR budget exhaustion OR abort OR synthesis success.
|
||||
|
||||
## Why
|
||||
|
||||
The current recursion works but has two problems: (a) stack depth grows linearly with tool iterations — 50 nested async frames is fragile, (b) there's no explicit step counter, so there's no per-agent step cap and no step-boundary instrumentation. BooChat also gets stuck at 50 tool calls (the budget ceiling) more often than it should — the new `MAX_STEPS = 200` hard ceiling lets the loop run much longer before the step cap fires, while the existing budget (50 tool calls) remains a separate concern.
|
||||
|
||||
## Recon findings (verified 2026-05-23)
|
||||
|
||||
- `runAssistantTurn` at `turn.ts:144-147` is the recursive entry. Returns `Promise<void>`.
|
||||
- `executeToolPhase` at `tool-phase.ts:89-96` calls back into `runAssistantTurn` at `tool-phase.ts:342`.
|
||||
- Recursion terminates on: non-tool finish, budget exhaustion (`args.toolsUsed >= budget`), doom-loop (3 identical calls via `detectDoomLoop`), user-input pause (ask_user_input / request_read_access), synthesis success, stream error, abort.
|
||||
- **No existing hard recursion depth limit** — `MAX_TOOL_LOOP_DEPTH` does not exist. Safety comes from budget (50) + doom-loop (3 identical).
|
||||
- `TurnArgs` defined in `turn.ts:127-141`, not `types.ts`. Fields: `sessionId`, `chatId`, `assistantMessageId`, `toolsUsed`, `recentToolCalls`, `signal`. All mutable fields are threaded through the recursive call.
|
||||
- Synthesis pipeline (`synthesisPipeline.ts`) is a branch in `executeToolPhase` — if synthesis succeeds, recursion is skipped.
|
||||
- `step_start` already in the `message_parts.kind` CHECK constraint. No schema change needed.
|
||||
- `agents.ts` does NOT currently parse a `steps` field. Needs adding to `ParsedFrontmatter`.
|
||||
|
||||
## Scope
|
||||
|
||||
### S1. Outer loop in `turn.ts`
|
||||
|
||||
Convert the recursive chain to a `while (stepNumber < effectiveCap)` loop:
|
||||
|
||||
```
|
||||
let stepNumber = 0
|
||||
while (stepNumber < effectiveCap) {
|
||||
// doom-loop check
|
||||
// budget check
|
||||
// emit step_start part
|
||||
// stream phase (executeStreamPhase)
|
||||
// if no tool calls → finalize, break
|
||||
// tool phase (executeToolPhase — now returns, doesn't recurse)
|
||||
// if paused (user input / grant) → break
|
||||
// if synthesis succeeded → break
|
||||
// create next assistant message row
|
||||
// increment stepNumber, update toolsUsed, append recentToolCalls
|
||||
}
|
||||
// if stepNumber >= effectiveCap → sentinel summary
|
||||
```
|
||||
|
||||
`effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS)` where `MAX_STEPS = 200`.
|
||||
|
||||
### S2. `executeToolPhase` becomes non-recursive
|
||||
|
||||
Remove the `runAssistantTurn` call at `tool-phase.ts:342`. Instead, return a result indicating what happened: `{action: 'continue' | 'paused' | 'synthesis_done', toolsUsed, recentToolCalls, nextAssistantId}`. The caller (the while loop) uses the action to decide whether to continue or break.
|
||||
|
||||
### S3. `agent.steps` field
|
||||
|
||||
`agents.ts:ParsedFrontmatter` gains `steps?: number`. Parser extracts it from YAML frontmatter (integer ≥ 0). `steps: 0` means "no tool calls allowed" — loop body never executes; assistant responds text-only.
|
||||
|
||||
### S4. Step-boundary events
|
||||
|
||||
At the top of each loop iteration, emit a `step_start` part with payload `{step_number, started_at}`. Uses `insertParts` into the current assistant message. No `step_finish` — the next `step_start` (or message completion) implicitly ends the previous step.
|
||||
|
||||
### S5. Doom-loop migration
|
||||
|
||||
`detectDoomLoop` check moves from `runAssistantTurn` (top of function, pre-stream) to the top of the while-loop body (same logical position). Same predicate, same threshold (3). Same `runDoomLoopSummary` call. Control flow changes from `return` (unwinding recursion) to `break` (exiting loop).
|
||||
|
||||
### S6. Step-cap sentinel
|
||||
|
||||
When `stepNumber >= effectiveCap`, write a sentinel summary like the existing `runCapHitSummary`. Reuse `runCapHitSummary` with a reason parameter distinguishing "budget exhaustion" from "step cap hit", or create a parallel `runStepCapSummary`. The sentinel makes the cap visible in chat.
|
||||
|
||||
### S7. AGENTS.md updates
|
||||
|
||||
Add `steps:` to each agent in `data/AGENTS.md`:
|
||||
- Refactorer: `steps: 5`
|
||||
- Architect: `steps: 20`
|
||||
- All others: unset (infinity — bounded only by `MAX_STEPS = 200`)
|
||||
|
||||
### S8. Tests
|
||||
|
||||
New test file `apps/server/src/services/__tests__/outer-loop.test.ts` covering:
|
||||
- Clean finish (stream returns non-tool, loop exits after 1 iteration)
|
||||
- Step-cap hit (loop exits at cap, sentinel written)
|
||||
- Doom-loop break (3 identical calls, sentinel written)
|
||||
- Budget exhaustion (toolsUsed >= budget, cap-hit sentinel written)
|
||||
- Abort mid-step (signal fires, loop exits)
|
||||
- `steps: 0` edge case (no loop iterations, text-only response)
|
||||
- Synthesis success (loop exits after synthesis)
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No frontend changes. `step_start` parts surface via `messages_with_parts` automatically; UI doesn't render them in v1.14.
|
||||
- No `output_schema` / `exit_expression` / `execution_strategy` AGENTS.md fields.
|
||||
- No per-step snapshot for revert (v2.0 BooCoder concern).
|
||||
- No changes to budget constants (50 / 10 / 50). That's a separate concern.
|
||||
- No `repairToolCall` changes.
|
||||
- No compaction changes.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- No git commit, push. Sam commits.
|
||||
- Backup before editing.
|
||||
- TS strict, no `any`.
|
||||
- Doom-loop threshold stays at 3.
|
||||
- 332+ existing tests still pass + new outer-loop tests.
|
||||
|
||||
## Files expected to touch
|
||||
|
||||
- `apps/server/src/services/inference/turn.ts` — recursion → loop
|
||||
- `apps/server/src/services/inference/tool-phase.ts` — remove recursive call, return result struct
|
||||
- `apps/server/src/services/inference/sentinel-summaries.ts` — step-cap sentinel (or extend cap-hit)
|
||||
- `apps/server/src/services/agents.ts` — parse `steps` field
|
||||
- `data/AGENTS.md` — add `steps:` to Refactorer + Architect
|
||||
- `apps/server/src/services/__tests__/outer-loop.test.ts` — NEW
|
||||
- `apps/server/src/services/inference/index.ts` — re-export if new types needed
|
||||
|
||||
## Estimate
|
||||
|
||||
~300 LoC net (turn.ts refactor + tool-phase return struct + agents parser + tests). The conversion is structural, not behavioral — every exit path is preserved, just expressed as loop control flow instead of recursion unwinding.
|
||||
82
openspec/changes/v1.14-outer-loop/tasks.md
Normal file
82
openspec/changes/v1.14-outer-loop/tasks.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# v1.14.0-outer-loop tasks
|
||||
|
||||
## B1 — Backups
|
||||
|
||||
- [ ] `turn.ts`, `tool-phase.ts`, `sentinel-summaries.ts`, `agents.ts`, `data/AGENTS.md`
|
||||
|
||||
## B2 — agents.ts: parse `steps` field
|
||||
|
||||
- [ ] Add `steps?: number` to `ParsedFrontmatter` interface
|
||||
- [ ] Parse from YAML frontmatter: integer ≥ 0, warn on out-of-range (negative or non-integer), clamp to 0
|
||||
- [ ] Expose on the `Agent` type returned by `getAgentsForProject`
|
||||
- [ ] `npx tsc --noEmit -p apps/server` clean
|
||||
|
||||
## B3 — AGENTS.md: add `steps:` to Refactorer + Architect
|
||||
|
||||
- [ ] `data/AGENTS.md` — Refactorer: `steps: 5`
|
||||
- [ ] `data/AGENTS.md` — Architect: `steps: 20`
|
||||
- [ ] All others: leave unset (infinite, bounded by MAX_STEPS=200)
|
||||
|
||||
## B4 — tool-phase.ts: remove recursive call, return result struct
|
||||
|
||||
- [ ] Define `ToolPhaseResult` interface: `{action: 'continue' | 'paused' | 'synthesis_done', toolCallCount: number, toolCalls: ToolCall[], nextAssistantId: string | null}`
|
||||
- [ ] Remove `runAssistantTurn` import and call at line ~342
|
||||
- [ ] `executeToolPhase` returns `ToolPhaseResult` instead of `Promise<void>`
|
||||
- [ ] On normal path (after creating next assistant row): return `{action: 'continue', toolCallCount, toolCalls: result.toolCalls, nextAssistantId}`
|
||||
- [ ] On user-input pause: return `{action: 'paused', toolCallCount: <calls executed so far>, toolCalls: result.toolCalls, nextAssistantId: null}`
|
||||
- [ ] On synthesis success: return `{action: 'synthesis_done', toolCallCount, toolCalls: result.toolCalls, nextAssistantId: null}`
|
||||
- [ ] `npx tsc --noEmit -p apps/server` will FAIL here (turn.ts still expects void) — expected, fixed in B5
|
||||
|
||||
## B5 — turn.ts: recursion → while loop
|
||||
|
||||
- [ ] Add `MAX_STEPS = 200` constant
|
||||
- [ ] Resolve `effectiveCap = Math.min(agent?.steps ?? Infinity, MAX_STEPS)` at the top of `runAssistantTurn`
|
||||
- [ ] Convert `runAssistantTurn` body into a `while (stepNumber < effectiveCap)` loop:
|
||||
- Top of loop: doom-loop check (move from current position; `break` instead of `return`)
|
||||
- Top of loop: budget check (move from current position; `break` instead of `return`, but still call `runCapHitSummary` before break)
|
||||
- Emit `step_start` part via `insertParts` with payload `{step_number: stepNumber, started_at: new Date().toISOString()}`
|
||||
- Call `executeStreamPhase`
|
||||
- If no tool calls → `finalizeCompletion`, `break`
|
||||
- Call `executeToolPhase` (now returns `ToolPhaseResult`)
|
||||
- If `result.action !== 'continue'` → `break`
|
||||
- Update `toolsUsed += result.toolCallCount`
|
||||
- Update `recentToolCalls = [...recentToolCalls, ...result.toolCalls]`
|
||||
- Update `assistantMessageId = result.nextAssistantId!`
|
||||
- Increment `stepNumber`
|
||||
- [ ] After loop: if `stepNumber >= effectiveCap` → call step-cap sentinel (B6)
|
||||
- [ ] `effectiveCap === 0` edge case: the while condition is immediately false; stream the first turn text-only (the stream phase at the top of the function runs once before the loop — OR handle this by structuring the loop as do-while, OR handle by pre-checking and skipping tools from the request). Pick the cleanest approach.
|
||||
- [ ] Remove `TurnArgs` from the module export if it's no longer threaded through recursion — OR keep it and populate from loop locals. (Design note: `TurnArgs` is still used by `executeStreamPhase`, `executeToolPhase`, `sentinel-summaries.ts`, `error-handler.ts`. Keep the interface; populate from loop locals each iteration.)
|
||||
- [ ] `npx tsc --noEmit -p apps/server` clean
|
||||
- [ ] `pnpm -C apps/server test` — all existing tests pass
|
||||
|
||||
## B6 — sentinel-summaries.ts: step-cap sentinel
|
||||
|
||||
- [ ] Add `runStepCapSummary` (or extend `runCapHitSummary` with a `reason` param)
|
||||
- [ ] Write a sentinel with `metadata.kind = 'cap_hit'` (same as budget) so `CapHitSentinel` UI renders it
|
||||
- [ ] Sentinel text distinguishes "Step limit reached (N steps)" from "Tool budget exhausted (N calls)"
|
||||
- [ ] Called from the post-loop check in turn.ts (B5)
|
||||
|
||||
## B7 — Tests
|
||||
|
||||
- [ ] NEW `apps/server/src/services/__tests__/outer-loop.test.ts`
|
||||
- [ ] Test: clean finish — stream returns no tool calls, loop exits after 1 step
|
||||
- [ ] Test: step-cap hit — mock agent with `steps: 2`, model always returns tool calls, loop exits at 2, sentinel written
|
||||
- [ ] Test: doom-loop — 3 identical tool calls, sentinel written, loop breaks
|
||||
- [ ] Test: budget exhaustion — toolsUsed >= budget, cap-hit sentinel written
|
||||
- [ ] Test: `steps: 0` — no loop iterations, text-only response
|
||||
- [ ] Test: synthesis success — loop breaks after synthesis
|
||||
- [ ] `pnpm -C apps/server test` — all 332+ existing + new tests pass
|
||||
|
||||
## B8 — Verification
|
||||
|
||||
- [ ] `npx tsc --noEmit -p apps/server` — 0 errors
|
||||
- [ ] `npx tsc -p apps/web/tsconfig.app.json --noEmit` — 0 errors (no web changes; should pass)
|
||||
- [ ] `pnpm -C apps/web build` — green
|
||||
- [ ] `pnpm -C apps/server test` — all green
|
||||
|
||||
## B9 — Docs + tag + deploy
|
||||
|
||||
- [ ] `CHANGELOG.md` entry for v1.14.0-outer-loop
|
||||
- [ ] `boocode_roadmap.md` retrospective bullet on the v1.14 section
|
||||
- [ ] `CLAUDE.md` updates: mention the outer loop, MAX_STEPS, agent.steps in the inference/ section
|
||||
- [ ] Commit, tag `v1.14.0-outer-loop`, push, rebuild
|
||||
194
openspec/changes/v1.14.x-html-artifact-panes/proposal.md
Normal file
194
openspec/changes/v1.14.x-html-artifact-panes/proposal.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# v1.14.x-html-artifact-panes — pane-based artifact viewer (Markdown + HTML)
|
||||
|
||||
Every assistant message gets an "Open in pane" affordance that renders it as a full-height artifact in BooChat's existing workspace splitter. Markdown is the default render (the model's normal output, just promoted to a pane); HTML is opt-in when the user explicitly asks (e.g. "render this as HTML", "make me a dashboard", "build an interactive diagram"). Pane headers expose Copy + Download for Markdown, Download-only for HTML. **No inline iframe preview** — artifacts are pane-only.
|
||||
|
||||
Final tag slug to be assigned at ship time depending on ordering against v1.14 (outer loop) and v1.14.x-mcp (MCP PoC). This batch is independent of both.
|
||||
|
||||
## Why
|
||||
|
||||
Three pressures land in the same place:
|
||||
|
||||
1. **Long assistant replies are uncomfortable to read in the chat stream.** Scrolling a 400-line Markdown reply between bubbles is worse than reading it in a dedicated pane next to the chat. The workspace splitter already exists; the splitter just has no artifact pane type yet.
|
||||
2. **HTML output is a real format the model wants to produce sometimes** (Thariq Shihipar's "HTML > Markdown at length" pattern, May 20 2026 Claude blog) — diagrams, sliders, syntax-highlighted code, side-by-side comparisons, mobile-responsive layouts. But auto-biasing the model to HTML for >100-line outputs (the blog's recommendation) is too aggressive for BooChat's typical workflow; most replies are conversational and Markdown is the right surface. **HTML stays opt-in.**
|
||||
3. **Durable artifact downloads** — Sam can already copy Markdown out of a chat bubble, but there's no path to "save this reply as a `.md` next to the project, keep it around." Adding a Download button parallel to Copy gives every long reply a portable form.
|
||||
|
||||
## Scope
|
||||
|
||||
### S1. AGENTS.md guidance (no code change)
|
||||
|
||||
Add HTML-on-request rule to global `data/AGENTS.md`:
|
||||
|
||||
> Stay in Markdown by default for all outputs, short or long. Switch to a self-contained `<!DOCTYPE html>...</html>` artifact only when the user explicitly asks (e.g. "render this as HTML", "make a dashboard", "build a diagram"). When producing HTML, follow these design conventions: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font, no generic AI aesthetics. See `claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html` (Thariq Shihipar, May 2026) for the design taxonomy.
|
||||
|
||||
The "auto-bias to HTML for >100 lines" recommendation from the blog post is deliberately NOT adopted. Markdown stays the default at every length.
|
||||
|
||||
### S2. Backend: HTML detection + part-kind extension
|
||||
|
||||
In `apps/server/src/services/inference/stream-phase.ts` post-processing, detect when an assistant text part:
|
||||
|
||||
- Starts with `<!DOCTYPE html>` (case-insensitive, whitespace-trimmed), OR
|
||||
- Is wrapped entirely in a fenced ` ```html ... ``` ` block
|
||||
|
||||
When detected, emit a new `message_parts` row with `kind='html_artifact'` and payload `{html_content, char_count, title}`. Title resolution order: `<title>` tag → first `<h1>` text → first 80 chars of inner text.
|
||||
|
||||
Detection is **opportunistic** — fires only when the model produced HTML (because the user asked). Otherwise the message stays plain-Markdown and no `html_artifact` part is written.
|
||||
|
||||
**Schema:**
|
||||
|
||||
```sql
|
||||
-- v1.14.x: extend message_parts.kind CHECK constraint with html_artifact
|
||||
ALTER TABLE message_parts DROP CONSTRAINT IF EXISTS message_parts_kind_chk;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'message_parts_kind_chk') THEN
|
||||
ALTER TABLE message_parts ADD CONSTRAINT message_parts_kind_chk
|
||||
CHECK (kind IN ('text', 'reasoning', 'tool_call', 'tool_result', 'synthesis', 'html_artifact'));
|
||||
END IF;
|
||||
END $$;
|
||||
```
|
||||
|
||||
Idempotent on re-run (drops + re-adds on every startup; trivial cost).
|
||||
|
||||
### S3. Frontend: pane affordance + two pane types
|
||||
|
||||
**MessageBubble.tsx** — add an "Open in pane" icon button to every assistant message footer, alongside the existing copy/regenerate controls. Click dispatches a workspace-pane action:
|
||||
|
||||
- If the message has an `html_artifact` part → opens `{type: 'html_artifact', message_id, html_content}`.
|
||||
- Otherwise → opens `{type: 'markdown_artifact', message_id}`.
|
||||
|
||||
**New pane types** registered in the workspace splitter (currently chat / empty / placeholder terminal+agent — adds `markdown_artifact` and `html_artifact`):
|
||||
|
||||
- `MarkdownArtifactPane.tsx` — pane shell. Header: title (derived from first heading or first 6 words), Copy button (raw Markdown source via `navigator.clipboard.writeText`), Download button (POST to `/api/chats/:id/messages/:msg_id/artifacts/download?fmt=md`). Body: reuses the same Markdown component used inline in `MessageBubble` (Shiki syntax highlighting, fenced code, tables, all preserved).
|
||||
- `HtmlArtifactPane.tsx` — pane shell. Header: title (from `html_artifact.payload.title`), Download button only (`?fmt=html`). Body: `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" />` at full pane height. **No Copy button** for HTML.
|
||||
|
||||
Pane state persisted via `sessions.workspace_panes jsonb` (the v1.12.1 schema already supports arbitrary pane payloads — extend the `Pane` discriminated union with two new variants).
|
||||
|
||||
### S4. Download endpoint
|
||||
|
||||
New endpoint `POST /api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html`:
|
||||
|
||||
- Resolves the message and (for HTML) its `html_artifact` part.
|
||||
- Computes slug:
|
||||
- Markdown: first `# ` heading text, else first 6 words of message body, lowercased + hyphenated.
|
||||
- HTML: `<title>` tag content, else first `<h1>` text, else first 6 words of inner text. Same lowercase-hyphen treatment.
|
||||
- Writes to `/opt/<project>/.boocode/artifacts/<slug>-<unix-timestamp>.<ext>`. Path-guarded same as native write tools — must stay under the project root.
|
||||
- Returns `{path, url}` where `url` is the pre-signed link via the existing static-file serving route.
|
||||
|
||||
### S5. HTML iframe security stance
|
||||
|
||||
Locked from the original 2026-05-22 design:
|
||||
|
||||
```
|
||||
sandbox="allow-scripts allow-clipboard-write allow-downloads"
|
||||
```
|
||||
|
||||
**No `allow-same-origin`** — artifact has its own opaque origin, cannot read BooChat's cookies, Authelia session, or DOM. Backend serves the iframe content via `srcdoc=` inline (not `src=`) so no separate URL exists to disclose.
|
||||
|
||||
CSP applied to the iframe content (via `<meta http-equiv="Content-Security-Policy">` injected into the artifact's `<head>` if not already present):
|
||||
|
||||
```
|
||||
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; connect-src 'none'
|
||||
```
|
||||
|
||||
`connect-src 'none'` is the key clause — artifacts can't `fetch()`, can't open WebSockets, can't ping tracking pixels, can't exfiltrate. JS runs (interactive controls work) but nothing network-touching does.
|
||||
|
||||
### S6. Token-budget guard
|
||||
|
||||
Single HTML artifact: max 1MB of HTML in `message_parts.payload`. Larger triggers a streaming abort with a friendly error:
|
||||
|
||||
> Artifact exceeded 1MB; consider splitting into multiple files or reducing inline assets.
|
||||
|
||||
Markdown artifacts have no separate cap — they're bounded by the existing message-size envelope.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- No git commit, no git push, no git pull during dispatch. Sam commits manually.
|
||||
- Backup every file before edit per the standard convention (`.bak-v1.14.x-html-<YYYYMMDD>`).
|
||||
- TS strict, no `any`.
|
||||
- No new deps. The Markdown renderer, Shiki, the workspace splitter, and `navigator.clipboard.writeText` are all already in the bundle.
|
||||
- Schema migration is additive only (extend CHECK constraint), idempotent on re-run.
|
||||
- Path-guard layer (`apps/server/src/services/path_guard.ts`) enforces that downloads stay under the project root.
|
||||
- Secret-file deny list still runs on the resolved download path.
|
||||
- HTML iframe sandbox attributes are non-negotiable — exact attribute string as written in S5.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **No auto-bias to HTML for long outputs.** The AGENTS.md rule explicitly says Markdown is default at every length.
|
||||
- **No inline iframe preview in the chat stream.** Pane-only.
|
||||
- **No Copy button on HTML panes.** Download-only for HTML.
|
||||
- **No separate artifacts table.** Artifacts live in `message_parts` (HTML) or derive from the assistant message (Markdown). Downloads are user-managed on disk under `/opt/<project>/.boocode/artifacts/`.
|
||||
- **No vendor of `anthropics/skills/web-artifacts-builder`.** That skill is built for Claude.ai's Vite/Parcel runtime; BooChat has no shell execution surface. Just lift the design principles into AGENTS.md.
|
||||
- **No changes to `apps/booterm` or `apps/coder`.** This is a BooChat-only batch.
|
||||
|
||||
## Stop checkpoints
|
||||
|
||||
1. After recon (read existing `Pane` discriminated union + workspace splitter + MessageBubble + `message_parts` shape + path_guard): stop, hand back the recon report.
|
||||
2. After backend edits (detection + schema + download endpoint), before frontend work: stop, hand back diff + curl test of the download endpoint.
|
||||
3. After frontend edits, before schema migration applies in dev: stop, hand back diff.
|
||||
4. After schema migration applies in dev: stop, run smoke plan, report.
|
||||
|
||||
## Smoke plan
|
||||
|
||||
1. **Markdown pane — happy path.** Send a chat that produces a long Markdown reply (e.g. "explain the inference loop in detail"). Click "Open in pane" on the assistant message. Confirm:
|
||||
- Pane opens in the workspace splitter at full height.
|
||||
- Markdown renders with syntax highlighting on fenced code blocks (Shiki working).
|
||||
- Header shows a sensible title (first heading or first 6 words).
|
||||
- Copy button writes raw Markdown source to clipboard — paste into a text editor and verify it's the same source the assistant emitted.
|
||||
- Download button writes `/opt/boocode/.boocode/artifacts/<slug>-<ts>.md` and the file contains the raw source.
|
||||
|
||||
2. **HTML pane — happy path.** Send "render a simple HTML dashboard with three interactive sliders that update a div in real time." Confirm:
|
||||
- Model produces `<!DOCTYPE html>...` content.
|
||||
- `message_parts` row with `kind='html_artifact'` is written.
|
||||
- Click "Open in pane" — HTML pane renders the artifact in a sandboxed iframe.
|
||||
- Sliders work (JS runs inside the iframe).
|
||||
- Download button writes `.html` to the artifacts dir.
|
||||
- No Copy button on the HTML pane.
|
||||
|
||||
3. **HTML security — exfil attempt.** Send "render an HTML page that tries to fetch('https://example.com/exfil') and display the result." Confirm:
|
||||
- Iframe loads but the `fetch()` is blocked by `connect-src 'none'`.
|
||||
- Browser devtools shows the CSP violation.
|
||||
- No network request leaves the iframe.
|
||||
|
||||
4. **HTML security — DOM access attempt.** Send "render an HTML page with `<script>document.cookie</script>`." Confirm the script sees the iframe's own (empty) cookie jar, NOT BooChat's parent cookies — sandbox without `allow-same-origin` enforces opaque origin.
|
||||
|
||||
5. **Markdown opt-in HTML.** Send a normal "summarize the codebase" reply (Markdown), then a follow-up "now render that as HTML." Confirm the second reply produces an HTML artifact while the first stays plain-Markdown — detection is opportunistic, doesn't auto-promote.
|
||||
|
||||
6. **1MB cap.** Construct a synthetic test that asks for a >1MB HTML artifact. Confirm the streaming aborts with the friendly error message; no `message_parts` row with oversized payload is written.
|
||||
|
||||
7. **Path-guard enforcement on download.** Try to download with a hand-crafted slug containing `../`. Confirm the path-guard rejects it.
|
||||
|
||||
8. **Persistence across reload.** Open both a Markdown and an HTML pane. Hard-reload the browser. Confirm both panes restore via `sessions.workspace_panes`.
|
||||
|
||||
## Done when
|
||||
|
||||
- Backend: `stream-phase.ts` detects HTML, writes `html_artifact` part. Schema migration shipped. Download endpoint live + path-guarded.
|
||||
- Frontend: `MarkdownArtifactPane` + `HtmlArtifactPane` components shipped. MessageBubble has the "Open in pane" affordance. Workspace `Pane` discriminated union extended.
|
||||
- AGENTS.md updated with the HTML-on-request rule.
|
||||
- Smoke plan green (all 8 steps).
|
||||
- Tag + CHANGELOG entry + roadmap retrospective bullet at the bottom of the v1.14.x-html roadmap section.
|
||||
|
||||
## Files expected to touch
|
||||
|
||||
**Backend:**
|
||||
- `apps/server/src/schema.sql` — extend `message_parts.kind` CHECK constraint
|
||||
- `apps/server/src/services/inference/stream-phase.ts` — HTML detection in post-processing
|
||||
- `apps/server/src/services/inference/parts.ts` — `PartKind` union adds `'html_artifact'`
|
||||
- `apps/server/src/routes/messages.ts` — new `POST /api/chats/:id/messages/:msg_id/artifacts/download` endpoint (or new `artifacts.ts` route file)
|
||||
- `apps/server/src/services/artifacts.ts` — NEW. `writeMarkdownArtifact(msg, projectRoot)` + `writeHtmlArtifact(part, projectRoot)` + slug derivation helpers
|
||||
- `apps/server/src/services/path_guard.ts` — no change expected; existing guard handles the artifacts dir as a project-scoped write target
|
||||
|
||||
**Frontend:**
|
||||
- `apps/web/src/components/MessageBubble.tsx` — add "Open in pane" affordance to assistant message footer
|
||||
- `apps/web/src/components/MarkdownArtifactPane.tsx` — NEW
|
||||
- `apps/web/src/components/HtmlArtifactPane.tsx` — NEW
|
||||
- `apps/web/src/types/panes.ts` (or wherever `Pane` lives) — extend discriminated union with `markdown_artifact` + `html_artifact` variants
|
||||
- `apps/web/src/api/client.ts` — `api.messages.downloadArtifact(msgId, fmt)`
|
||||
- `apps/web/src/api/types.ts` — mirror the new pane variants and `html_artifact` part kind
|
||||
|
||||
**Docs:**
|
||||
- `data/AGENTS.md` — HTML-on-request rule
|
||||
- `boocode_roadmap.md` — retrospective bullet at the bottom of the v1.14.x-html section
|
||||
- `CHANGELOG.md` — new `##` entry with the tag
|
||||
|
||||
## Estimate
|
||||
|
||||
~400 LoC total. Backend ~200 LoC (detection + part-kind extension + download endpoint + slug derivation). Frontend ~200 LoC (two pane components + MessageBubble affordance + pane integration + API client wiring).
|
||||
124
openspec/changes/v1.14.x-html-artifact-panes/tasks.md
Normal file
124
openspec/changes/v1.14.x-html-artifact-panes/tasks.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# v1.14.x-html-artifact-panes tasks
|
||||
|
||||
## B1 — Backups
|
||||
|
||||
- [ ] `apps/server/src/schema.sql.bak-v1.14.x-html-<YYYYMMDD>`
|
||||
- [ ] `apps/server/src/services/inference/stream-phase.ts.bak-v1.14.x-html-<YYYYMMDD>`
|
||||
- [ ] `apps/server/src/services/inference/parts.ts.bak-v1.14.x-html-<YYYYMMDD>`
|
||||
- [ ] `apps/server/src/routes/messages.ts.bak-v1.14.x-html-<YYYYMMDD>`
|
||||
- [ ] `apps/web/src/components/MessageBubble.tsx.bak-v1.14.x-html-<YYYYMMDD>`
|
||||
- [ ] `apps/web/src/api/client.ts.bak-v1.14.x-html-<YYYYMMDD>`
|
||||
- [ ] `apps/web/src/api/types.ts.bak-v1.14.x-html-<YYYYMMDD>`
|
||||
- [ ] `data/AGENTS.md.bak-v1.14.x-html-<YYYYMMDD>`
|
||||
|
||||
## B2 — Recon (STOP after this step)
|
||||
|
||||
- [ ] Read existing `Pane` discriminated union and locate the workspace splitter component
|
||||
- [ ] Read `MessageBubble.tsx` to find the assistant-message footer (copy/regenerate controls location)
|
||||
- [ ] Read `message_parts` shape + `PartKind` union in `parts.ts`
|
||||
- [ ] Read `stream-phase.ts` post-processing path (where text parts are finalized into rows)
|
||||
- [ ] Read `path_guard.ts` to confirm write semantics for `/opt/<project>/.boocode/artifacts/`
|
||||
- [ ] Read the existing static-file serving route to understand the URL shape for downloads
|
||||
- [ ] Hand back a recon report: exact line numbers + signatures of insertion points
|
||||
|
||||
## B3 — Schema migration
|
||||
|
||||
- [ ] Extend `message_parts.kind` CHECK constraint with `'html_artifact'`
|
||||
- [ ] Use the `DROP CONSTRAINT IF EXISTS` + `DO $$ pg_constraint $$` pattern (matches the rest of `schema.sql`)
|
||||
- [ ] Confirm idempotent on re-run: apply twice in dev, no error
|
||||
|
||||
## B4 — Backend: HTML detection
|
||||
|
||||
- [ ] Extend `PartKind` union in `apps/server/src/services/inference/parts.ts` with `'html_artifact'`
|
||||
- [ ] In `stream-phase.ts` post-processing: detect text parts starting with `<!DOCTYPE html>` (case-insensitive, trimmed) OR wrapped in fenced ` ```html ` block
|
||||
- [ ] Title resolution helper: `<title>` tag → first `<h1>` text → first 80 chars of inner text
|
||||
- [ ] Write the `html_artifact` part with payload `{html_content, char_count, title}` via the existing `insertParts` helper
|
||||
- [ ] 1MB cap check before write: abort stream with friendly error if exceeded
|
||||
- [ ] Detection is opportunistic — does NOT replace the text part, just adds a sibling `html_artifact` part
|
||||
|
||||
## B5 — Backend: artifacts service
|
||||
|
||||
- [ ] NEW `apps/server/src/services/artifacts.ts`
|
||||
- [ ] `deriveMarkdownSlug(messageContent: string): string` — first `# ` heading → first 6 words → lowercase + hyphenate
|
||||
- [ ] `deriveHtmlSlug(payload: HtmlArtifactPayload): string` — `<title>` → first `<h1>` → first 6 words of inner text → lowercase + hyphenate
|
||||
- [ ] `writeMarkdownArtifact(message, projectRoot): Promise<{path, url}>` — slug + timestamp + write to `<projectRoot>/.boocode/artifacts/`
|
||||
- [ ] `writeHtmlArtifact(part, projectRoot): Promise<{path, url}>` — same shape
|
||||
- [ ] Path-guard both writes via existing helpers
|
||||
- [ ] Ensure `<projectRoot>/.boocode/artifacts/` exists (mkdir recursive)
|
||||
|
||||
## B6 — Backend: download endpoint
|
||||
|
||||
- [ ] NEW endpoint registration: `POST /api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html`
|
||||
- [ ] Fastify route in `apps/server/src/routes/messages.ts` (or new `artifacts.ts` route file — decide during impl)
|
||||
- [ ] Zod schema on `?fmt=` query param
|
||||
- [ ] Resolve message + (for HTML) the `html_artifact` part
|
||||
- [ ] Call `writeMarkdownArtifact` or `writeHtmlArtifact` per `fmt`
|
||||
- [ ] Return `{path, url}`
|
||||
- [ ] Error path: 404 if `fmt=html` requested but no html_artifact part exists
|
||||
|
||||
## B7 — Backend: STOP checkpoint after B3–B6
|
||||
|
||||
- [ ] `npx tsc --noEmit -p apps/server` — 0 errors
|
||||
- [ ] Smoke download endpoint via `curl http://100.114.205.53:9500/api/chats/<id>/messages/<msg_id>/artifacts/download?fmt=md` against a real message
|
||||
- [ ] Hand back diff + curl output
|
||||
|
||||
## B8 — Frontend: Pane discriminated union extension
|
||||
|
||||
- [ ] Extend `Pane` discriminated union with two variants:
|
||||
- `{ kind: 'markdown_artifact', message_id: string }`
|
||||
- `{ kind: 'html_artifact', message_id: string, html_content: string, title: string }`
|
||||
- [ ] Update `validatePanes` to handle the new variants (no-op if message_id still exists)
|
||||
- [ ] Mirror types in `apps/web/src/api/types.ts` (`MessagePart` discriminator + new pane variants)
|
||||
|
||||
## B9 — Frontend: pane components
|
||||
|
||||
- [ ] NEW `apps/web/src/components/MarkdownArtifactPane.tsx`
|
||||
- Header: title + Copy button (raw source via `navigator.clipboard.writeText`) + Download button + close-pane affordance
|
||||
- Body: reuse the same Markdown render component used in `MessageBubble`
|
||||
- [ ] NEW `apps/web/src/components/HtmlArtifactPane.tsx`
|
||||
- Header: title + Download button + close-pane affordance (NO Copy)
|
||||
- Body: `<iframe srcdoc={html_content} sandbox="allow-scripts allow-clipboard-write allow-downloads" className="w-full h-full" />`
|
||||
- [ ] Wire both into the workspace splitter's pane-type registry
|
||||
|
||||
## B10 — Frontend: MessageBubble affordance
|
||||
|
||||
- [ ] Add "Open in pane" icon button to assistant message footer (next to existing copy/regenerate controls)
|
||||
- [ ] On click: dispatch workspace-pane action
|
||||
- If message has `html_artifact` part → open as html_artifact pane (with title + html_content from the part)
|
||||
- Else → open as markdown_artifact pane
|
||||
- [ ] Mobile tap target: `max-md:min-h-[44px] max-md:min-w-[44px]`
|
||||
|
||||
## B11 — Frontend: API client
|
||||
|
||||
- [ ] `api.messages.downloadArtifact(chatId, msgId, fmt: 'md' | 'html')` → POST to the new endpoint
|
||||
- [ ] Returns `{path, url}` — Copy button uses raw text from the message; Download button uses the returned URL
|
||||
|
||||
## B12 — Frontend: STOP checkpoint after B8–B11
|
||||
|
||||
- [ ] `npx tsc -p apps/web/tsconfig.app.json --noEmit` — 0 errors (root tsc may miss web errors per CLAUDE.md)
|
||||
- [ ] `pnpm -C apps/web build` succeeds (including the `U+2500-259F` guard)
|
||||
- [ ] Hand back diff
|
||||
|
||||
## B13 — AGENTS.md guidance
|
||||
|
||||
- [ ] Add HTML-on-request rule to `data/AGENTS.md`
|
||||
- [ ] Inline "avoid AI slop" design conventions (no centered layouts, no purple gradients, no uniform rounded corners, no Inter font)
|
||||
- [ ] Cite Thariq Shihipar's blog post (May 2026) as the source
|
||||
|
||||
## B14 — Smoke (STOP at end, full report)
|
||||
|
||||
- [ ] Markdown pane happy path (open, render, copy, download)
|
||||
- [ ] HTML pane happy path (open, render, JS executes, download — no Copy button)
|
||||
- [ ] HTML security exfil attempt — `fetch()` blocked by `connect-src 'none'`
|
||||
- [ ] HTML security DOM access — sandbox without `allow-same-origin` enforces opaque origin
|
||||
- [ ] Opt-in opportunistic detection — first reply Markdown, follow-up "render as HTML" produces artifact
|
||||
- [ ] 1MB cap — synthetic test, streaming aborts with friendly error
|
||||
- [ ] Path-guard on download — hand-crafted `../` slug rejected
|
||||
- [ ] Persistence — pane state survives hard reload via `sessions.workspace_panes`
|
||||
|
||||
## B15 — OpenSpec docs + release
|
||||
|
||||
- [ ] Mark this `tasks.md` checkboxes complete after each step
|
||||
- [ ] Append retrospective bullet to bottom of v1.14.x-html section in `boocode_roadmap.md`
|
||||
- [ ] Add `CHANGELOG.md` entry with the assigned tag (e.g. `v1.14.1-html-artifact-panes` — final patch number assigned at ship time depending on order vs v1.14 outer loop)
|
||||
- [ ] Hand back to Sam for tag + commit
|
||||
Reference in New Issue
Block a user