Compare commits
34 Commits
v2.2.2-xml
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a97293b5d9 | |||
| 63adb218e6 | |||
| d0334ca544 | |||
| 024ffc0b92 | |||
| 691eef1b30 | |||
| e92c51578d | |||
| 6d03690a65 | |||
| 21384cce5b | |||
| 920f8b75a6 | |||
| e83d9b7d5b | |||
| f302969c71 | |||
| 2d997ecb6c | |||
| dc3859975d | |||
| 23a33e893a | |||
| 8bf86ecb92 | |||
| fe52250d78 | |||
| 4035aa2b98 | |||
| 35a0aba211 | |||
| 3730dc9341 | |||
| a359a4ab8b | |||
| a8c84ecfe4 | |||
| 547fd70650 | |||
| 990a615b87 | |||
| 5352fd9942 | |||
| 66df410826 | |||
| f89c8f3f15 | |||
| cbef7618b3 | |||
| fcc7c5a86e | |||
| bcfc94fa47 | |||
| 90a6761b07 | |||
| a938cf1d42 | |||
| 6f6b3afb5d | |||
| 154ef78f7c | |||
| 792bbb9da3 |
@@ -21,6 +21,7 @@ out/
|
||||
.opencode/
|
||||
.vscode/
|
||||
.idea/
|
||||
.claude/worktrees/
|
||||
|
||||
# Test artifacts / coverage
|
||||
coverage/
|
||||
|
||||
@@ -11,6 +11,11 @@ POSTGRES_PASSWORD=CHANGE_ME
|
||||
# point BooCode at a different SearXNG instance.
|
||||
SEARXNG_URL=http://100.114.205.53:8888
|
||||
|
||||
# Task model: lightweight model for auto-naming, search rewrite, etc.
|
||||
# Direct llama-server instance (NOT llama-swap). Falls back to LLAMA_SWAP_URL
|
||||
# with FAST_MODEL when unset.
|
||||
# TASK_MODEL_URL=http://100.90.172.55:7995
|
||||
|
||||
# v1.13.15-tools: BOOCODE_TOOLS narrows the tool whitelist sent to the LLM.
|
||||
# Unset (default) → all tools (~21k schema). Useful primarily for single-purpose
|
||||
# sessions where the model only needs read-only filesystem access.
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,3 +16,5 @@ data/*
|
||||
!data/AGENTS.md
|
||||
!data/skills/
|
||||
!data/mcp.json
|
||||
!data/coder-providers.example.json
|
||||
codecontext/fork.tar.gz
|
||||
|
||||
109
AGENTS.md
109
AGENTS.md
@@ -1,109 +0,0 @@
|
||||
# Agent navigation
|
||||
|
||||
Cursor/agent entry point for the BooCode monorepo. **Deep engineering reference:** `CLAUDE.md` (Claude Code). This file is navigation + task routing only.
|
||||
|
||||
Last updated: 2026-05-25
|
||||
|
||||
## Doc map
|
||||
|
||||
| Need | Read |
|
||||
|------|------|
|
||||
| Commands, gotchas, inference, DB, env | `CLAUDE.md` |
|
||||
| Read-only chat behavior | `BOOCHAT.md` |
|
||||
| Write tools, dispatch, pending changes | `BOOCODER.md` |
|
||||
| Shipped vs planned, version order | `boocode_roadmap.md` |
|
||||
| Latest release truth | `CHANGELOG.md` (top entry = current) |
|
||||
| System diagram + data flow | `docs/ARCHITECTURE.md` |
|
||||
| Current focus / blockers | `CURRENT.md` |
|
||||
| Batch convention | `openspec/README.md` |
|
||||
| Shipped batch snapshots | `openspec/changes/archived/` |
|
||||
| Chat agent personas + tool lists | `data/AGENTS.md` |
|
||||
| External repo lift inventory | `boocode_code_review.md` |
|
||||
|
||||
## Monorepo layout (actual)
|
||||
|
||||
Three **surfaces**, four **packages**. There is no `apps/chat/` directory.
|
||||
|
||||
| Surface | Packages | Port | Deploy |
|
||||
|---------|----------|------|--------|
|
||||
| **BooChat** | `apps/server` (API + inference) + `apps/web` (SPA) | `100.114.205.53:9500` | Docker `boocode` container |
|
||||
| **BooTerm** | `apps/booterm` | `100.114.205.53:9501` | Docker `booterm` container |
|
||||
| **BooCoder** | `apps/coder` | host `:9502` | systemd `boocoder.service` (not Docker since v2.1.0) |
|
||||
|
||||
Shared: Postgres 16 — Docker service `boocode_db`, **database name `boochat`**, host port `127.0.0.1:5500`.
|
||||
|
||||
## Task routing
|
||||
|
||||
| Task type | Start here |
|
||||
|-----------|------------|
|
||||
| Chat inference / tools / compaction | `apps/server/src/services/inference/` |
|
||||
| WS frames | `apps/server/src/types/ws-frames.ts` + `apps/web/src/api/ws-frames.ts` (keep in sync) |
|
||||
| Frontend chat UI | `apps/web/src/components/`, hooks in `apps/web/src/hooks/` |
|
||||
| BooCoder write tools / dispatch | `apps/coder/src/` — build server first (`pnpm -C apps/server build`) |
|
||||
| Provider picker / external agents | `apps/coder/src/services/provider-registry.ts`, `dispatcher.ts`, `agent-probe.ts` |
|
||||
| Terminal panes | `apps/booterm/src/`, frontend `TerminalPane.tsx` |
|
||||
| Schema changes | `apps/server/src/schema.sql` + sync `*_STATUSES` in `apps/server/src/types/api.ts` |
|
||||
| New batch / feature | `openspec/changes/<slug>/proposal.md` + `tasks.md` (see below) |
|
||||
|
||||
## Verification (before claiming done)
|
||||
|
||||
```bash
|
||||
pnpm -C apps/server test && pnpm -C apps/server build
|
||||
npx tsc -p apps/web/tsconfig.app.json --noEmit # root tsc can miss web errors
|
||||
curl http://100.114.205.53:9500/api/health # Tailscale IP, not localhost:9500
|
||||
curl http://100.114.205.53:9502/api/health # BooCoder on host
|
||||
```
|
||||
|
||||
Deploy truth beats source-only reads — check running health + `git log --oneline -3`.
|
||||
|
||||
## Hard rules (from CLAUDE.md)
|
||||
|
||||
- **Do not commit or push** unless Sam explicitly asks.
|
||||
- **No app-layer auth** — Authelia at the reverse proxy.
|
||||
- **Parts table is source of truth** — read message tool fields from `messages_with_parts` view, write via `insertParts`.
|
||||
- **New WS frame type** — update server + web schemas; publish via `publishFrame` / `publishUserFrame` only.
|
||||
- **New tool** — own file in `services/`, register in `tools.ts` `ALL_TOOLS`; whitelists derive from there, never hardcoded.
|
||||
- **Typecheck web with per-app tsconfig** — root `tsc --noEmit` uses project references and can miss errors.
|
||||
- **`includeUsage: true`** on `createOpenAICompatible` in `provider.ts` — do not remove.
|
||||
- **Agent dispatch** — direct `spawn`/`exec` on host via `install_path` (v2.1.0+); SSH helpers deprecated.
|
||||
- **Event dedup** — server publishes via broker; frontend must not duplicate `sessionEvents.emit` after API calls that already WS-broadcast.
|
||||
|
||||
## Using openspec with Cursor
|
||||
|
||||
Openspec is a **folder convention**, not a CLI. Use it to give agents a scoped brief before coding.
|
||||
|
||||
### When starting a batch
|
||||
|
||||
1. Create `openspec/changes/<slug>/` (lowercase-hyphenated, e.g. `v2-2-arena-ui`).
|
||||
2. Write `proposal.md` — why, scope, non-goals, dependencies.
|
||||
3. Write `tasks.md` — numbered checkbox steps (build + smoke).
|
||||
4. Optional `design.md` — schema/API decisions that outlive the batch.
|
||||
|
||||
See `openspec/README.md` for the full shape. Shipped pre-v1.13.15 batches live in `openspec/changes/archived/` as snapshots only.
|
||||
|
||||
### Prompting an agent
|
||||
|
||||
```
|
||||
@openspec/changes/<slug>/proposal.md @openspec/changes/<slug>/tasks.md
|
||||
Implement tasks 1–3. Server tests must pass. Do not commit.
|
||||
```
|
||||
|
||||
Attach the spec files with `@` so they load into context. Point at specific code paths when known:
|
||||
|
||||
```
|
||||
@openspec/changes/v2-x/proposal.md
|
||||
Extend apps/coder/src/routes/providers.ts — follow provider-registry.ts patterns.
|
||||
```
|
||||
|
||||
### After shipping
|
||||
|
||||
- Tag: `vMAJOR.MINOR.PATCH-slug`
|
||||
- Add entry to top of `CHANGELOG.md`
|
||||
- Move or snapshot the openspec folder to `archived/` if you want history preserved
|
||||
- Update `CURRENT.md` and `boocode_roadmap.md` shipped table if the batch was roadmap-tracked
|
||||
|
||||
### What not to use openspec for
|
||||
|
||||
- One-line bug fixes — just describe the bug + file.
|
||||
- Exploratory questions — Ask mode + `@CLAUDE.md` is enough.
|
||||
- Duplicating `CLAUDE.md` — openspec is per-batch scope, not permanent conventions.
|
||||
78
BOOCODER.md
78
BOOCODER.md
@@ -37,3 +37,81 @@ Every file modification queues in `pending_changes` before touching disk. The us
|
||||
- Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts.
|
||||
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
|
||||
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
|
||||
|
||||
## Provider lifecycle (v2.3)
|
||||
|
||||
BooCoder's coding agents are a **config-backed registry**: built-ins live in `provider-registry.ts`, and `data/coder-providers.json` layers overrides + custom entries on top. Registration ≠ installation — the config lists what you *want*; a probe reports what's *ready*.
|
||||
|
||||
### Config file: `data/coder-providers.json`
|
||||
|
||||
Resolved from `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`; dev/host path `/opt/boocode/data/coder-providers.json`). It is **gitignored** — it's live runtime config that the coder reads *and writes* (UI toggles `PATCH` it), so tracking it would churn `git status`. The tracked reference is `data/coder-providers.example.json`; copy it to `coder-providers.json` to seed overrides. A missing file, invalid JSON, or a schema mismatch all fall back to built-ins-only — loading never throws at startup.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"goose": { "enabled": false },
|
||||
"amp-acp": {
|
||||
"extends": "acp",
|
||||
"label": "Amp",
|
||||
"description": "ACP wrapper for Amp",
|
||||
"command": ["amp-acp"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per-provider override fields (all optional):
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `extends` | `"acp"` — required for a NEW (custom) provider; built-in overrides omit it |
|
||||
| `label` | Display name (required for custom) |
|
||||
| `description` | Sub-label shown in the picker / settings |
|
||||
| `command` | `[binary, ...args]` to spawn (required for custom; overrides a built-in's default argv) |
|
||||
| `env` | Extra env vars merged into the spawn |
|
||||
| `enabled` | Default `true`; `false` hides it from the composer |
|
||||
| `order` | UI sort key |
|
||||
| `models` / `additionalModels` | Replace / merge onto the discovered model list |
|
||||
|
||||
A PATCH to one provider id **replaces that id's override object wholesale** (per-id shallow merge), so to flip a single field keep the rest; a `null` value for an id deletes its override (reverts to the built-in default).
|
||||
|
||||
### Refresh contract
|
||||
|
||||
The snapshot is cached and a provider's cold ACP probe (tier-2) is **skipped** while `available_agents.last_probed_at` is younger than `PROVIDER_PROBE_TTL_MS` (default `86400000` = 24h). Opening the composer is therefore fast and does not re-probe. To force a cold re-probe (after installing a CLI or editing models): **`POST /api/providers/refresh`** (the Refresh button in the Providers settings tab), which clears the cache and re-probes.
|
||||
|
||||
### Enable / disable
|
||||
|
||||
Two ways:
|
||||
- **Settings → Providers tab** — open the sidebar → **Settings** → **Providers**: toggle a provider on/off, refresh it, or open its diagnostic. (Earlier builds exposed a gear in the composer; that control was moved into Settings.)
|
||||
- **Edit the config** (`"enabled": false`) then `POST /api/providers/refresh`.
|
||||
|
||||
A **disabled** provider leaves the composer's provider picker but stays listed in the Providers tab (status "Disabled") so you can re-enable it. **Native `boocode` is always-on** — an `enabled:false` on it is ignored (with a warn log) and it is never rendered as toggleable.
|
||||
|
||||
### Adding a custom ACP provider
|
||||
|
||||
- **Catalog modal**: Providers tab → **Add provider** → pick an entry → it PATCHes the config (`extends:'acp'` + label + command, enabled) and refreshes that provider.
|
||||
- **Hand-edit** `data/coder-providers.json`: add an id with `extends:'acp'`, `label`, and `command`, then `POST /api/providers/refresh`.
|
||||
|
||||
Either way, **adding to config does NOT install the binary.** Until the CLI is on `PATH` the provider shows **"Not installed"** (status `unavailable`) and does not appear in the composer picker.
|
||||
|
||||
### Known limitation — subset refresh
|
||||
|
||||
`POST /api/providers/refresh` accepts an optional `{ "providers": ["id", ...] }` body and returns a `refreshed` count scoped to that subset — **but the underlying cold re-probe currently covers ALL installed providers**, not just the requested subset. True per-provider force is a future change (it needs a snapshot-internal parameter). This is intentional for now, not a bug: a subset refresh still re-probes everything; only the reported count is scoped.
|
||||
|
||||
### Deploy + smoke
|
||||
|
||||
Two deploy targets:
|
||||
- **Routes (host service):** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
|
||||
- **Web UI (container):** `docker compose up --build -d boocode`
|
||||
|
||||
Green gate (verified across phases 1–5): `pnpm -C apps/coder test` (134 passing) `&& pnpm -C apps/coder build`.
|
||||
|
||||
Smoke (via Tailscale):
|
||||
|
||||
```bash
|
||||
curl http://100.114.205.53:9502/api/providers/snapshot # lists every registered provider
|
||||
curl http://100.114.205.53:9500/api/coder/providers/config # raw config, through the BooChat proxy
|
||||
# Settings → Providers: disable goose → it leaves the composer picker, stays in the tab
|
||||
# POST refresh → models repopulate; Add a catalog entry → it appears after refresh (unavailable until its CLI is installed)
|
||||
```
|
||||
|
||||
56
CHANGELOG.md
56
CHANGELOG.md
@@ -2,6 +2,62 @@
|
||||
|
||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||
|
||||
## v2.5.15-acp-path-guard — 2026-05-29
|
||||
|
||||
Security fix + repo hygiene. Fixes a path-traversal in the ACP filesystem bridge (`acp-client-fs.ts`, flagged by the automated push security review): the worktree guard used an unbounded `startsWith(resolve(worktreePath))`, so a sibling path sharing the worktree as a string prefix (`<worktree>-evil/…`) escaped the scope — and `writeWorktreeTextFile` writes to disk directly (no `pending_changes` gate), so a confused/buggy ACP agent could write outside its worktree. Now uses a separator-bounded check matching `write_guard.ts` (`resolve()` + `startsWith(root + sep)` / `=== root`) via a shared `resolveInWorktree`, with a regression test covering `../` traversal and the sibling-prefix bug. Symlink-swap/`O_NOFOLLOW` hardening was intentionally skipped — consistent with `write_guard`'s no-realpath stance, and the agent already runs with host FS access so this is a containment guard, not a trust boundary. Separately, stops tracking the live `data/coder-providers.json` (it's runtime config the UI reads *and writes* on provider toggles, which churned `git status`) — it's now gitignored with a tracked `data/coder-providers.example.json` reference; the loader falls back to built-ins-only when the live file is absent. The provider-type duplication (coder ↔ web) stays guarded by the existing text-identity `provider-types-parity.test.ts` — a shared package was considered and declined (drift is already prevented; not worth the Docker/build-order risk at solo scale).
|
||||
|
||||
## v2.5.14-claude-md — 2026-05-29
|
||||
|
||||
Docs-only — CLAUDE.md session-learnings update, no code. Adds gotchas surfaced while shipping the v2.3 provider-lifecycle batch: the host `boocoder.service` keeps running the old process after `pnpm -C apps/coder build` (stale-process tell = new routes 404 while old routes 200, restart don't re-debug); the `boocode` container `build: .` deploys the working tree, so web edits are live on the Vite dev server but not production until `docker compose up --build -d boocode`; `PATCH /api/providers/config` replaces a provider's override wholesale (send `{...existing, enabled}` or a custom ACP entry's command is wiped) and `data/coder-providers.json` is live config not to be committed as code; external agents dispatch one-shot with no context/token tracking (only native `boocode` tracks ctx; OpenCode-as-server is the unshipped `v2-6-persistent-agent-sessions` plan); the `ui/` primitive inventory with `button role=switch` / Dialog fallbacks for the absent switch/sheet; and the mobile Dialog-with-list scroll-containment recipe. Also backfills previously-uncommitted doc bullets for the `v2.5.7`–`v2.5.11` coder work (provider-type parity test, async ACP command discovery, AgentComposerBar `installed` filter, provider-registry path disambiguation).
|
||||
|
||||
## v2.5.13-provider-lifecycle-phase5 — 2026-05-29
|
||||
|
||||
Closeout of the v2.3 provider-lifecycle batch — the web UI (Phase 5) plus docs (Phase 6). Provider management moved into **Settings → Providers**: a tab listing every registered provider with a status badge (Available / Disabled / Not installed / Error / Loading), an enable/disable toggle, a per-provider refresh, and a plaintext diagnostic; toggling sends the provider's *full* override (preserving a custom ACP entry's command under the wholesale-replace PATCH merge) then refetches the snapshot. The composer's provider picker now filters to `enabled && (status === 'ready' || 'loading')`, so disabled and unavailable providers drop out of the picker and are managed only in settings (native `boocode` always shows). A curated ACP catalog (`apps/web/src/data/acp-provider-catalog.ts`) + `AddProviderModal` register custom providers via `PATCH /api/providers/config` then a subset refresh, and the web client gained `getProvidersConfig` / `patchProvidersConfig` / `refreshProviders` / `getProviderDiagnostic`. Two mobile fixes ship alongside: the Settings pane is now reachable on phones (opening it pushes `?pane=` atomically so the mobile URL-sync effect keeps it active instead of snapping back to the chat pane), and the Add-provider modal caps to the viewport with a single `overscroll-contain` scroll region so the list scrolls instead of dragging the whole modal. This completes the arc begun in `v2.5.4-provider-lifecycle-phase1` (config-backed registry over the built-ins) → `v2.5.5-provider-lifecycle-phase2` (loading/unavailable snapshot lifecycle + tier-2 probe TTL gate) → `v2.5.6-provider-lifecycle-phase3` (generic `resolveLaunchSpec` ACP dispatch) → `v2.5.12-provider-lifecycle-phase4` (config GET/PATCH, subset refresh, diagnostic HTTP API). Docs landed in `BOOCODER.md` (config file, refresh contract, enable/disable, custom ACP, the honest subset-refresh known limitation) and `docs/DEFERRED-WORK.md` §2 is marked addressed; the remaining Tier-2 follow-ups (WS `provider_snapshot_updated` frame, `available_agents.enabled` column, shared types package, MCP provider tools) stay deferred.
|
||||
|
||||
## v2.5.12-provider-lifecycle-phase4 — 2026-05-29
|
||||
|
||||
Phase 4 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §6): the HTTP API to read, patch, refresh, and diagnose providers. `routes/providers.ts` gains `GET /api/providers/config` (the raw loaded `CoderProvidersFile`), `PATCH /api/providers/config` (a partial providers map — an id's override object is replaced wholesale, a `null` value deletes it), an optional `{ providers?: string[] }` body on `POST /api/providers/refresh` (the `refreshed` count reflects the requested subset; the force probe itself still covers all installed providers, since per-provider force is a snapshot-internal change left to a later phase), and `GET /api/providers/:id/diagnostic` returning JSON `{ diagnostic: string }` — a read-only report (resolved def, install_path, last_probed_at, enabled, `which` availability, last cached probe error) with no probe spawn. PATCH correctness is the whole story: the order is validate→save→reload→clear, a malformed body or an invalid merged config returns 422 without writing the file, and a `save()` failure returns 500 without reloading the registry or clearing the snapshot cache, so on-disk and in-memory state can never diverge. New pure `mergeProviderConfigPatch` + `ProviderConfigPatchSchema` in `provider-config.ts`, a read-only `peekSnapshotEntry` cache accessor (source of the diagnostic's last-error — no probe/cache logic change), and a new `provider-diagnostic.ts` formatter. The web client gains `api.coder.getProvidersConfig` / `patchProvidersConfig` / `refreshProviders(providers?)` / `getProviderDiagnostic`, with mirrored `ProviderOverride` / `CoderProvidersFile` / `ProviderConfigPatch` types; the existing `/api/coder/*` proxy blanket-forwards the new routes with no change. +28 tests (134 coder total: pure merge/validate, the diagnostic formatter, and `app.inject` route tests proving the 422-no-write and save-fail-no-divergence guards). The diagnostic returns JSON rather than the §8 plaintext so it flows through the JSON `request` client helper (reconciling design §6.4's `{ diagnostic }` with §8's string report). No UI (Phase 5). Builds on `v2.5.6-provider-lifecycle-phase3`.
|
||||
|
||||
## v2.5.11-claude-skill-discovery — 2026-05-29
|
||||
|
||||
Surface Claude Code's real enabled commands + plugin skills in the coder slash menu, with icons separating commands from plugin skills. New `claude-command-discovery.ts` reads (user-global scope) `~/.claude/commands/*.md` plus every enabled plugin in `~/.claude/settings.json:enabledPlugins` — each plugin's user-scope install path contributes `skills/<name>/SKILL.md` (kind `skill`) and `commands/*.md` (kind `command`), parsed from frontmatter, bare names, deduped. The snapshot's claude branch discovers these **live** (claude is PTY, no ACP probe; the snapshot cache rate-limits the fs reads). The `/` menu now renders up to three icon'd groups: **`<agent> commands`** (Terminal), **`<agent> skills`** (Puzzle — claude's plugin skills / opencode is all commands), and **BooCoder skills** (Sparkles), via a new optional `icon` on `SlashCommandGroup`. `AgentCommand` gains a `kind` field, added identically to the coder and web copies (the `provider-types-parity` test enforces it); `mergeCommandsByName` is now generic so it preserves the tag. Invocation is unchanged — picking a claude command/skill sends `/name` to claude (PTY), which executes it. Project-local plugins + `<cwd>/.claude/commands` deferred. BooChat unaffected (flat skills). Smoke-test the claude skill slash-execution on the host.
|
||||
|
||||
## v2.5.10-opencode-live-commands — 2026-05-29
|
||||
|
||||
Surface opencode's real (live ACP) command set in the coder slash menu without needing a dispatch. Two fixes: (1) the cold ACP probe (`acp-probe.ts`) captured `available_commands` but read `probedCommands` synchronously right after `newSession` — racing opencode's async `available_commands_update` notification, so it captured **zero** and only the 7-item static manifest showed. The probe now waits briefly (poll up to 3s for the first batch + a 300ms settle, capped under the 30s probe timeout) so the commands are actually captured. (2) Captured commands are persisted to a new `available_agents.commands` JSONB column and served (merged with the manifest) on the tier-2-probe-skip path, so the agent's discovered commands survive once the model list is warm and show without a dispatch. Boot warms this via the `force: true` startup snapshot. apps/coder only (probe + schema + snapshot). Caveat: depends on opencode emitting `available_commands_update` on session creation rather than only after a prompt — to be confirmed on the host. Claude (PTY) disk/plugin discovery deferred.
|
||||
|
||||
## v2.5.9-agent-slash-commands — 2026-05-29
|
||||
|
||||
Segmented per-agent slash menu in the coder pane, plus cross-agent skills. The `/` menu now shows two labeled groups — **the active agent's commands first** (opencode/claude/qwen manifest + live ACP `available_commands`), **BooCoder skills second** — instead of always showing BooCoder's skills regardless of provider. `SlashCommandPicker` gains an opt-in `groups` prop (the flat `items` path is unchanged, so **BooChat's menu is byte-identical** — parity verified: no BooChat caller passes the grouped prop, and the skills lookup / invocation routing are untouched); `ChatInput` takes `slashGroups`; `CoderPane` builds the groups from the selected provider's commands + skills. Skills now **run under the selected agent**: the coder `skill_invoke` route accepts a `provider` and, when external, injects the server-side skill body into a dispatched task (instead of native inference) — so a skill like brainstorming executes through opencode/claude with the body kept server-side, mirroring the messages-route external dispatch. Also folds in the earlier initial-chat fix: invoking a skill on the landing chat now runs the same create-chat → assign-to-pane → invoke transition as a text send (`handleLandingSkill`) rather than invoking invisibly without a pane transition (the blank-screen repro). Web tsc + coder build clean.
|
||||
|
||||
## v2.5.8-mobile-composer-row — 2026-05-29
|
||||
|
||||
Mobile fix for the `AgentComposerBar`: the refresh button was wrapping to a second line. Root cause was layout order, not width — the status dot carried `ml-auto` (pinned to the far-right edge) and the refresh button followed it in DOM order, so it overflowed and wrapped. The dot + refresh are now one right-aligned (`ml-auto`) unit, keeping the refresh on the top line. Additionally, `CompactPicker` gained an `iconOnly` option and the Mode (permission) picker now renders icon-only on mobile (shield + chevron, no "Bypass"/"Plan" text label; `aria-label`/`title` and the tap-to-open list still convey the value) to free row width. Desktop is unchanged (full labels). Web-only change.
|
||||
|
||||
## v2.5.7-claude-models-and-picker-fix — 2026-05-29
|
||||
|
||||
Two provider-layer changes. **(1) Fix the empty provider picker** — a regression from `v2.5.5` (Phase 2): on a cache miss `getProviderSnapshot` returned synchronous `installed:false` `loading` entries, which `AgentComposerBar` filters out (`e.installed && e.status !== 'error'`); with the client-side poll deferred to Phase 5, a single fetch landed on `loading` forever and no providers appeared. `getProviderSnapshot` now awaits the build and returns terminal entries (the sync `loading` return is deferred until Phase 5 ships the poll); builds stay fast via the tier-2 cold-probe skip. **(2) Claude models** — the list was a hardcoded 2-entry static list (Opus 4 / Sonnet 4, May 2025), and the v2.3 config schema's `models`/`additionalModels` were parsed but never wired. `buildResolvedRegistry` now carries config `models` (replace) + `additionalModels` (merge) onto `ResolvedProviderDef`, and `provider-snapshot` applies them to every ready model list — so `/data/coder-providers.json` can add or replace any provider's models with no code change. Claude `staticModels` bumped to `opus`/`sonnet`/`haiku` latest-aliases plus pinned `claude-opus-4-8` / `claude-sonnet-4-6` / `claude-haiku-4-5-20251001` (passed verbatim to `claude --model`; the CLI accepts both aliases and pinned full names). +2 unit tests (109 total). Builds on `v2.5.6-provider-lifecycle-phase3`.
|
||||
|
||||
## v2.5.6-provider-lifecycle-phase3 — 2026-05-29
|
||||
|
||||
Phase 3 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §5): generic ACP dispatch. `acp-spawn.ts` gains `resolveLaunchSpec(resolved, installPath)` — it consults the resolved registry's `launchCommand` (a config override or a custom-ACP entry's command) first, falling back to the kept `resolveAcpSpawnArgs` switch for built-ins. `acp-dispatch.ts` now spawns `spec.binary`/`spec.args` with `env: { ...process.env, ...spec.env }` instead of the hardcoded per-name argv, and `dispatcher.ts` loads the resolved def by `task.agent` and passes it through. This lets config-defined custom ACP providers dispatch with no new switch case. Built-in dispatch (claude/opencode/goose/qwen) is **byte-identical** to pre-v2.3 — proven by a regression test asserting opencode→`['acp']`, goose→`['acp']`, qwen→`['--acp']`, binary=`installPath ?? id`, and empty config env → plain `process.env`. One deliberate deviation from the spec's literal `!installPath → null`: the `installPath ?? id` fallback is preserved so a missing install path still spawns the bare agent name as before. `setSessionMode`/permission/streaming and the dispatcher poll/NOTIFY/running-guard are untouched. 7 new `acp-spawn.test.ts` cases. No routes/UI (Phase 4+). Builds on `v2.5.5-provider-lifecycle-phase2`.
|
||||
|
||||
## v2.5.5-provider-lifecycle-phase2 — 2026-05-29
|
||||
|
||||
Phase 2 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §4). `provider-snapshot.ts` stops returning `null` for uninstalled/disabled providers — it now emits one entry per registered provider with a lifecycle status (`loading | ready | unavailable | error`), an `enabled` flag, and a two-tier probe. Tier-1 is a fast `which`-style availability check (`command-availability.ts`, `execFile`/no-shell); tier-2 — the 5–30s cold ACP probe — is now SKIPPED unless forced (`POST /refresh`), the `available_agents.last_probed_at` row is older than `PROVIDER_PROBE_TTL_MS` (24h default), or the DB model list is empty, which kills snapshot latency on warm reads. A cache miss returns `status:'loading'` synchronously while the build settles in the background (client polling is deferred to Phase 5). `ProviderSnapshotStatus`/`ProviderSnapshotEntry` regained `loading`/`unavailable` and gained `enabled`, `description?`, `fetchedAt?` in both the coder and web copies, guarded by a runtime parity test (`provider-types-parity.test.ts`, mirroring the `ws-frames.test.ts` convention) that fails on any field drift — a compile-time cross-project assignability check was attempted first but blocked by TS6307 (web is a composite tsconfig project). Also tracks the previously-gitignored `data/coder-providers.json` seed via a `.gitignore` exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. Builds on `v2.5.4-provider-lifecycle-phase1`.
|
||||
|
||||
## v2.5.4-provider-lifecycle-phase1 — 2026-05-29
|
||||
|
||||
Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §2–3): a config-backed provider layer merged over the hardcoded built-ins, with no runtime change when no config file exists. Adds `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`); `provider-config.ts` (Zod `ProviderOverride`/`CoderProvidersFile` schemas + a loader that never throws at startup — a missing file, invalid JSON, or schema mismatch all fall back to built-ins-only — plus `save` for the Phase 4 PATCH route); and `provider-config-registry.ts` (`ResolvedProviderDef` + `buildResolvedRegistry` merge: built-in overrides, custom `extends:'acp'` entries requiring label+command, `boocode` always enabled, plus a module singleton). `agent-probe.ts` now iterates the resolved registry instead of the hardcoded list — custom ACP entries resolve their binary from `command[0]` via `execFile` (no shell), disabled providers skip probing without losing their row, and `enabled` is read from memory only (no DB column this phase). Six unit tests, including a regression proving an empty config yields exactly the built-ins. No snapshot/dispatch/route/UI changes (Phase 2+). The `data/coder-providers.json` seed exists on disk but is gitignored (`data/*`). Lands on top of `v2.5.3-remove-cursor-copilot`.
|
||||
|
||||
## v2.5.3-remove-cursor-copilot — 2026-05-29
|
||||
|
||||
Retire the cursor and copilot providers from BooCoder entirely. Removes their `acp-spawn` argv cases, `provider-manifest` mode blocks + manifest keys, `provider-commands` command maps, the `provider-snapshot` cursor model-CLI branch (and the now-orphaned `exec`/`promisify` imports), and the `agent-probe` copilot ACP-detect branch; deletes the dead `cursor-models.ts` module and its test. The `PROVIDERS` registry array already lacked both entries, so only the doc comment needed correcting. Built-ins unchanged: claude, opencode, goose, qwen, native boocode. Standalone cleanup; pairs with `v2.5.4-provider-lifecycle-phase1` which builds on it.
|
||||
|
||||
## v2.5.2-coder-ux-fixes — 2026-05-29
|
||||
|
||||
Working-tree checkpoint bundling this session's fixes with in-progress coder UI work. This session: the BooCoder dispatcher now reacts to new tasks immediately via a Postgres `LISTEN/NOTIFY` (`tasks_new`) AFTER INSERT trigger, with the poll loop kept at 2s as a missed-notification fallback (`dispatcher.ts`, `apps/coder/src/schema.sql`); the mobile nav drawer no longer sticks open after returning to a backgrounded tab — `useViewport` re-syncs on `pageshow`/`visibilitychange`/`resize`/`orientationchange` (iOS reported a stale width on bfcache restore, leaving `isMobile=false`); assistant reasoning renders as a collapsible "Thinking" block in `MessageBubble`, surfacing ACP `agent_thought_chunk` from opencode/goose/qwen and native `reasoning_parts`; paste-to-chip inserts pasted text verbatim instead of wrapping it in a code fence; and a "New file from pasted text" affordance in the RightRail browser queues a `pending_changes` create through the new `POST /api/sessions/:id/pending/create` endpoint, paired with a fix repointing the DiffPanel's dead approve/reject calls to the real `/api/pending/:id/apply` and `/reject` routes. Also carried in the tree but not authored this session: the CoderPane `ChatInput` migration and `AgentComposerBar` refinements, plus backend tweaks to `auto_name`, inference `tool-phase`/`turn`, `secret_guard`, and `provider-registry`. Ships the `v2-6-persistent-agent-sessions` openspec proposal/design/tasks (free agent-switching with per-agent memory, opencode-as-server) as planning docs only — the feature is unimplemented and reserves the `v2.6.0` tag for it. Build green across server/coder/web; server suite 531 passing. (CHANGELOG note: the v2.3–v2.5.1 entries were never backfilled and remain absent above.)
|
||||
|
||||
## v2.2.2-xml-placeholder-reject — 2026-05-26
|
||||
|
||||
Reject placeholder XML tool args at parse time in `extractToolCallBlocks` (`xml-parser.ts`). Drops calls when any string arg is `...`, empty/whitespace, `<path>`, `<file>`, `placeholder`, or angle-bracket sentinels; appends the raw XML block to flushed prose instead of silently deleting it. Fixes qwen3.6 answer-then-spurious-tools tail that caused duplicate assistant rows (full answer + failed `xml_call_*` tools + regenerated answer). Four new tests in `xml-parser.test.ts`. Known nit: rejection logs via `console.debug` instead of pino — filed in `docs/DEFERRED-WORK.md` §6 for a later cleanup.
|
||||
|
||||
31
CLAUDE.md
31
CLAUDE.md
@@ -68,9 +68,9 @@ Key services:
|
||||
- **`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.
|
||||
- **`services/provider-registry.ts`** — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
|
||||
- **`services/agent-probe.ts`** — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
|
||||
- **`routes/providers.ts`** — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference).
|
||||
- **`apps/coder/src/services/provider-registry.ts`** (BooCoder, NOT apps/server) — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
|
||||
- **`apps/coder/src/services/agent-probe.ts`** (BooCoder) — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
|
||||
- **`apps/coder/src/routes/providers.ts`** (BooCoder) — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference). The apps/server side of this flow is the "Provider picker dispatch" bullet below.
|
||||
- **Provider picker dispatch**: when `provider !== 'boocode'`, the message route creates a `tasks` row (with `session_id` set) instead of calling `inference.enqueue`. The dispatcher picks it up and dispatches via ACP or PTY using the agent's `install_path`.
|
||||
|
||||
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
|
||||
@@ -80,12 +80,16 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
|
||||
- Write-capable coding agent. Runs as a **systemd service on the host** (`boocoder.service`), NOT in Docker. Fastify server at port 9502, connects to postgres at `127.0.0.1:5500`.
|
||||
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST.
|
||||
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
|
||||
- After `pnpm -C apps/coder build` the host `boocoder.service` keeps running the OLD process until `sudo systemctl restart boocoder` — a stale process shows **new routes 404 with `{error:'not found'}` while old routes still 200** (the `/api` not-found handler returns that shape). Restart, don't re-debug.
|
||||
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
|
||||
- systemd hardening: only `NoNewPrivileges=true` is safe. `ProtectSystem`, `ProtectHome`, `PrivateTmp` all break agent dispatch (agents need full filesystem access to read configs, write to worktrees).
|
||||
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
|
||||
- Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates).
|
||||
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to the boocoder host service (`BOOCODER_URL` env var, default `http://100.114.205.53:9502`). WS connects directly to `:9502`.
|
||||
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
|
||||
- **Provider snapshot lifecycle** (`apps/coder/src/services/`): `provider-config.ts` (Zod config, never-throws on bad input) → `provider-config-registry.ts` (`buildResolvedRegistry`, singleton) → `provider-snapshot.ts` (two-tier probe: tier-1 fast presence, tier-2 cold ACP probe skipped unless force / stale `PROVIDER_PROBE_TTL_MS` 24h / dbEmpty; cached). Verify live: `curl http://100.114.205.53:9502/api/providers/snapshot` — returns providers + models + commands, the exact shape `AgentComposerBar` renders.
|
||||
- `PATCH /api/providers/config` replaces a provider id's override object **wholesale** (per-id shallow merge) — to flip one field send `{...existing, enabled}`, or a custom ACP entry's `command`/`label` is wiped and it drops out of the resolved registry. `data/coder-providers.json` is **gitignored** (it's live runtime config — the coder reads AND writes it on UI toggles); the tracked reference is `data/coder-providers.example.json`. The loader falls back to `{providers:{}}` (built-ins only) when the live file is absent, so a fresh checkout needs no copy.
|
||||
- External agents dispatch **one-shot** (`opencode acp` / `goose acp` / `qwen --acp`) and report no context-window/token usage; only native `boocode` (llama-swap engine) tracks ctx. OpenCode-as-HTTP-server (warm process + `@opencode-ai/sdk`, the source of a real context bar) is the **planned, unshipped** `openspec/changes/v2-6-persistent-agent-sessions` batch; Paseo's per-provider native clients (design §12) were deliberately not ported.
|
||||
|
||||
### Frontend (`apps/web/src/`)
|
||||
|
||||
@@ -145,10 +149,12 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
||||
- 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).
|
||||
- The `boocode` container is `build: .` — it builds web+server from the **working tree**, so uncommitted changes deploy. Web edits are live on the Vite dev server (HMR) but NOT on production (`:9500` / code.indifferentketchup.com) until `docker compose up --build -d boocode`.
|
||||
- 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.
|
||||
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
||||
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. `psql` is not on the host PATH — for an interactive query use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
||||
- Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.
|
||||
- Frontend blank-screen / runtime crash: get the stack-trace column offset from the browser console, then `cut -c <start>-<end> apps/web/dist/assets/index-*.js | sed -n '<line>p'` to read the exact minified expression that threw. Faster than bisecting source. Watch for `=== null`/`!== null` on optional fields fed an `as unknown as` cast — those bypass tsc.
|
||||
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
|
||||
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
||||
- `node:20-*` base images ship a `node` user at uid/gid 1000 — delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000.
|
||||
@@ -157,8 +163,8 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
||||
- A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` — it's accepted.
|
||||
- `/opt/boolab` hosts a working sibling BooCode terminal at `boocode.indifferentketchup.com`. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (`@tailwind base`); boocode uses v4 — many subtle build differences. Don't assume parity.
|
||||
- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's `/etc/hosts` knows it. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if you ever move the shell to a different machine.
|
||||
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore.template` documents recommended ignore patterns; users copy and adapt to project root manually.
|
||||
- codecontext fork at `/opt/forks/codecontext/` — separate git repo (branch `boocode-ts`), pushed via the same boocode_gitea SSH key to `indifferentketchup/codecontext`. Build: `go build ./...`. Test: `go test ./...`. Docker rebuild: `docker compose build --no-cache codecontext`.
|
||||
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore` at project root is honored when `--respect-gitignore` is passed (enabled in the shim).
|
||||
- codecontext fork at `/opt/forks/codecontext/` — separate git repo (branch `boocode-ts`), pushed via the same boocode_gitea SSH key to `indifferentketchup/codecontext`. Build: `go build ./...`. Test: `go test ./...`. Docker rebuild requires staging the fork source first: `tar -czf codecontext/fork.tar.gz -C /opt/forks/codecontext --exclude=.git --exclude=bin .` then `docker compose build --no-cache codecontext`. The Dockerfile COPYs `fork.tar.gz` into the builder stage (Gitea is behind Authelia, no HTTP clone). `fork.tar.gz` is gitignored.
|
||||
- Go binary: `/snap/go/current/bin/go` (not on PATH by default). Use `export PATH=$PATH:/snap/go/current/bin` or full path for Go commands.
|
||||
- `os/exec` child supervisors must explicitly call `child.Wait()` in a goroutine and `os.Exit` on child death. `Signal(0)` returns nil on zombies and is NOT a liveness check. Without `Wait()`, docker's `restart: unless-stopped` policy never fires because the parent stays alive. The `codecontext/shim.go` implementation is the reference pattern.
|
||||
|
||||
@@ -171,10 +177,12 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
||||
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
||||
- **Adding a new WS frame type** requires updating BOTH the server's `InferenceFrame` (loose `type:` union + optional fields in `services/inference/turn.ts`) AND the web `WsFrame` (strict discriminated union in `apps/web/src/api/types.ts`). Server publish is permissive; the frontend type is the wire-format gate. The `'usage'` frame added in v1.12.2 needed both sides; missing the web side silently drops the frame at JSON-parse.
|
||||
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
|
||||
- `ui/` primitives present: button, card, context-menu, dialog, dropdown-menu, input, label, radio-group, sonner, textarea. No switch/sheet/drawer/badge/checkbox — use a `<button role="switch" aria-checked>` toggle (a hand-rolled `Switch` already lives in `SettingsPane.tsx`) and a Dialog-based panel for "drawers".
|
||||
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
|
||||
- Two UI event buses: `hooks/sessionEvents.ts` for DB-state events (chat_created, session_updated); `lib/events.ts` for ephemeral UI (`sendToTerminal`, `terminalsRegistry`). Don't merge — different subscriber lifecycles.
|
||||
- `vite.config.ts` proxy entries are order-sensitive: more-specific prefixes (`/api/term`, `/ws/term`) must come BEFORE `/api`.
|
||||
- Mobile pane URL sync (`Session.tsx`): the `?pane=<id>` effect resets `activePaneIdx` whenever `panes` changes. New-pane creation on mobile must push `?pane=` atomically — `addPaneAndSwitch` is the wrapper that does this. `addSplitPane` returns the new pane id for callers.
|
||||
- A scrollable list inside a Dialog on mobile: cap `DialogContent` (`max-h-[85vh]` + `grid-rows-[auto_minmax(0,1fr)_auto]`) and make the list the single scroll region with `overscroll-contain` — otherwise touch-scroll drags the whole fixed modal / chains to the page.
|
||||
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.
|
||||
- **New tools** live in their own `services/<name>.ts` file (see `web_search.ts`, `web_fetch.ts`) — exports a pure `executeFoo(input, ...deps)` for direct test access plus a `ToolDef` wrapper that `loadConfig()`s its real dependencies. Register the ToolDef in `tools.ts` `ALL_TOOLS` (and `READ_ONLY_TOOL_NAMES` if applicable). Inject `fetcher: typeof fetch = fetch` rather than `vi.spyOn(globalThis, 'fetch')` — cleanup is simpler and the production call site stays unchanged.
|
||||
- **Sentinels** are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. A new kind requires arms in `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts`, plus a render branch in `apps/web/src/components/MessageBubble.tsx`.
|
||||
@@ -185,3 +193,14 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
||||
- **Workspace dependency pattern** (`apps/coder` → `@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
|
||||
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.
|
||||
- **`payload.ts:loadContext` SELECT**: must include every `Session` field that downstream code reads. The tool phase reads `session.allowed_read_paths`; if the SELECT omits it, cross-repo read grants silently fail. The `Session` TypeScript type doesn't catch this because `sql<Session[]>` doesn't enforce column coverage.
|
||||
- **Sidecar routing** (`services/inference/provider.ts`): `upstreamModel(config, modelId, agent)` routes to `LLAMA_SIDECAR_URL` when agent has `llama_extra_args`, otherwise `LLAMA_SWAP_URL`. `resolveRoute(agent)` returns `{route: 'swap'|'sidecar', flags}`. Sidecar provider created fresh per call (not cached) because `X-Agent-Flags` header varies per agent. Boot-time guard in `index.ts` refuses to start if any agent has `llama_extra_args` but `LLAMA_SIDECAR_URL` is unset.
|
||||
- **Secret guard safe patterns** (`services/secret_guard.ts`): `.env.example`, `.env.sample`, `.env.template`, `.env.defaults` are allowlisted via `SAFE_PATTERNS` set. Do NOT add `.env.production`/`.env.development`/`.env.test` — those can hold real secrets.
|
||||
- **CoderPane uses ChatInput** (`components/panes/CoderPane.tsx`): shares the same `ChatInput` component as BooChat for full parity — attachments, paste-to-chip, auto-grow textarea, queued messages during send. CoderPane's `sendOneMessage` is the send callback; queued messages drain via `useEffect` when `sending` goes false.
|
||||
- **Adding a new `SessionEvent` type**: add the interface, add it to the `SessionEvent` union, add a `case` in `useSidebar.ts` `applyEvent` switch (no-op `return prev` is fine), and subscribe in any hook that needs it (e.g. `useSessionStream` for `refetch_messages`).
|
||||
- **BooCoder provider registry** (`apps/coder/src/services/provider-registry.ts`): static list of provider defs (boocode, opencode, goose, claude, qwen). `PROBED_AGENT_NAMES` derives from it. Adding/removing providers means editing this file, not the frontend.
|
||||
- **AgentComposerBar filters `e.installed`**: provider snapshot entries with `installed:false` (loading/unavailable) are dropped from the dropdown. `getProviderSnapshot` must await the full build — returning synchronous `loading` placeholders makes every provider vanish (the v2.5.7 "no providers showing up" regression); surfacing loading states needs a client poll.
|
||||
- **Coder↔web provider-type parity** (`apps/coder/src/services/provider-types.ts` ↔ `apps/web/src/api/types.ts`): enforced by runtime `provider-types-parity.test.ts` (compile-time cross-import is blocked by TS6307 on web's composite tsconfig). Mirror of the ws-frames parity pattern — edit both copies together or the test fails.
|
||||
- **ACP command discovery is async**: `acp-probe.ts` must poll after `newSession` for `available_commands_update` (commands arrive in a later notification; reading synchronously captures 0). PTY providers (claude) instead discover from disk via `claude-command-discovery.ts` (`~/.claude/commands` + `enabledPlugins` `skills/`+`commands/`, bare names, deduped). `AgentCommand.kind` tags `'command'` vs `'skill'`; `CoderPane`'s `slashGroups` splits them into icon'd groups. `SlashCommandPicker`'s `groups?` prop is opt-in — BooChat passes flat `items` (unchanged).
|
||||
- **Pane header architecture (mobile vs desktop)**: Desktop coder pane header (BooCode label + [+] [×]) lives in `Workspace.tsx` gated by `isCoder && !isMobile`. Mobile coder controls (● ×) live in `Session.tsx` header row next to `MobileTabSwitcher`/`NewPaneMenu`. `AgentComposerBar` (provider/mode/model pickers) renders inside `CoderPane.tsx` on both. The ● status dot is passed via `connected` prop from CoderPane to AgentComposerBar.
|
||||
- **MessageBubble shared between BooChat and BooCoder** (`components/MessageBubble.tsx`): accepts optional `actions?: MessageActions` callbacks (onRegenerate, onResend, onFork, onDelete) and `hideActions?: ('fork'|'delete'|'openInPane')[]`. Defaults use BooChat API; CoderPane overrides via `CoderMessageList` props. `CoderTextBubble` was removed. **`CoderMessageList` passes `CoderMessageWire as unknown as Message`** — the coder wire shape lacks `metadata`/`kind`/`summary`, so those fields are `undefined` (not `null`) on coder messages. Null-guards on any `Message` field MUST use loose `!= null`, not strict `!== null` (`undefined !== null` is `true` → `.kind` throws → blank-screen crash). The `as unknown as` cast hides this from tsc; build + typecheck pass while runtime crashes.
|
||||
- **llama-sidecar** (`/opt/forks/llama-sidecar/`): Go daemon for per-agent llama-server process pool. Cross-compile: `GOOS=windows GOARCH=amd64 /snap/go/current/bin/go build -o bin/llama-sidecar.exe ./cmd/llama-sidecar`. Gitea: `indifferentketchup/llama-sidecar`. Windows child process gotchas: use `context.Background()` for child lifetime (not request ctx), `os.Open(os.DevNull)` for stdin, `os.Pipe()` for stdout with drain goroutine, `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags. SSH to sam-desktop: `ssh samki@100.101.41.16`; use `schtasks` for persistent process spawning (SSH `start /B` doesn't survive session close).
|
||||
|
||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
@@ -23,5 +23,6 @@
|
||||
"@types/pg": "^8.11.10",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
@@ -13,3 +13,4 @@ GITEA_USER=indifferentketchup
|
||||
GITEA_SSH_HOST=100.114.205.53:2222
|
||||
MCP_CONFIG_PATH=/data/mcp.json
|
||||
SKILLS_ROOT=/opt/boocode/data/skills
|
||||
CODER_PROVIDERS_PATH=/opt/boocode/data/coder-providers.json
|
||||
|
||||
@@ -29,5 +29,6 @@
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ const ConfigSchema = z.object({
|
||||
GITEA_TOKEN: z.string().optional(),
|
||||
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
||||
MCP_CONFIG_PATH: z.string().optional(),
|
||||
// v2.3: config-backed provider overrides/custom-ACP entries merged over the
|
||||
// hardcoded built-ins. Missing file = built-ins only (see provider-config.ts).
|
||||
CODER_PROVIDERS_PATH: z.string().default('/data/coder-providers.json'),
|
||||
// v2.3 phase 2: tier-2 (cold ACP probe) is skipped when available_agents was
|
||||
// probed more recently than this. 24h default — stale model lists self-heal
|
||||
// on the next snapshot; an explicit /refresh always re-probes.
|
||||
PROVIDER_PROBE_TTL_MS: z.coerce.number().int().positive().default(86_400_000),
|
||||
// v2.0.5: cheaper model for titles, summaries, labeling.
|
||||
FAST_MODEL: z.string().optional(),
|
||||
// SSH access to the host for external agent dispatch (Phase 5)
|
||||
|
||||
@@ -85,7 +85,9 @@ async function main() {
|
||||
type: 'permission_requested',
|
||||
task_id: prompt.taskId,
|
||||
session_id: prompt.sessionId,
|
||||
kind: prompt.kind,
|
||||
tool_title: prompt.toolTitle,
|
||||
...(prompt.input ? { input: prompt.input } : {}),
|
||||
options: prompt.options.map((o) => ({ option_id: o.optionId, label: o.label })),
|
||||
} as WsFrame);
|
||||
},
|
||||
|
||||
211
apps/coder/src/routes/__tests__/providers.routes.test.ts
Normal file
211
apps/coder/src/routes/__tests__/providers.routes.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { registerProviderRoutes } from '../providers.js';
|
||||
import { load } from '../../services/provider-config.js';
|
||||
import { loadProviderConfig } from '../../services/provider-config-registry.js';
|
||||
import { clearProviderSnapshotCache } from '../../services/provider-snapshot.js';
|
||||
import type { Config } from '../../config.js';
|
||||
import type { Sql } from '../../db.js';
|
||||
|
||||
/** Minimal sql stub: available_agents reads return []. */
|
||||
function mockSql(): Sql {
|
||||
return vi.fn((strings: TemplateStringsArray) => {
|
||||
const q = strings.join('');
|
||||
if (q.includes('available_agents')) return Promise.resolve([]);
|
||||
return Promise.resolve([]);
|
||||
}) as unknown as Sql;
|
||||
}
|
||||
|
||||
let tmpCounter = 0;
|
||||
function freshPath(): string {
|
||||
tmpCounter += 1;
|
||||
return join(tmpdir(), `coder-providers-routes-${process.pid}-${tmpCounter}.json`);
|
||||
}
|
||||
|
||||
function buildApp(providersPath: string): FastifyInstance {
|
||||
const app = Fastify();
|
||||
// Mirror index.ts: tolerate empty JSON bodies.
|
||||
app.removeContentTypeParser(['application/json']);
|
||||
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||||
const str = (body as string) ?? '';
|
||||
if (str.trim().length === 0) return done(null, {});
|
||||
try {
|
||||
done(null, JSON.parse(str));
|
||||
} catch (err) {
|
||||
done(err as Error, undefined);
|
||||
}
|
||||
});
|
||||
const config = {
|
||||
CODER_PROVIDERS_PATH: providersPath,
|
||||
LLAMA_SWAP_URL: 'http://llama-swap.test',
|
||||
PROVIDER_PROBE_TTL_MS: 86_400_000,
|
||||
} as unknown as Config;
|
||||
registerProviderRoutes(app, mockSql(), config);
|
||||
return app;
|
||||
}
|
||||
|
||||
const JSON_HEADERS = { 'content-type': 'application/json' };
|
||||
const createdPaths: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
clearProviderSnapshotCache();
|
||||
loadProviderConfig('/nonexistent-coder-providers.json'); // reset registry to built-ins
|
||||
vi.restoreAllMocks();
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no network in test')));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const p of createdPaths.splice(0)) {
|
||||
try {
|
||||
rmSync(p, { force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET /api/providers/config', () => {
|
||||
it('returns the current config file (built-ins-only when missing)', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
const app = buildApp(path);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/providers/config' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ providers: {} });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('reflects an existing file', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false } } }));
|
||||
const app = buildApp(path);
|
||||
const res = await app.inject({ method: 'GET', url: '/api/providers/config' });
|
||||
expect(res.json()).toEqual({ providers: { goose: { enabled: false } } });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/providers/config', () => {
|
||||
it('valid patch → 200, writes the merged file (order: validate→save→reload→clear)', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
writeFileSync(path, JSON.stringify({ providers: { goose: { label: 'Goose' } } }));
|
||||
const app = buildApp(path);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/providers/config',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: { opencode: { enabled: false } } }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ ok: true });
|
||||
// File written + merged (goose untouched, opencode added).
|
||||
const onDisk = load(path);
|
||||
expect(onDisk.providers).toEqual({
|
||||
goose: { label: 'Goose' },
|
||||
opencode: { enabled: false },
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('null value deletes the override', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false }, opencode: { enabled: false } } }));
|
||||
const app = buildApp(path);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/providers/config',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: { goose: null } }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(load(path).providers).toEqual({ opencode: { enabled: false } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('INVALID body → 422 and the file is NOT written (validate before save)', async () => {
|
||||
const path = freshPath();
|
||||
createdPaths.push(path);
|
||||
const before = JSON.stringify({ providers: { goose: { enabled: true } } });
|
||||
writeFileSync(path, before);
|
||||
const app = buildApp(path);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/providers/config',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: { goose: { enabled: 'yes' } } }), // bad type
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(422);
|
||||
// File must be byte-for-byte unchanged — nothing written on a 422.
|
||||
expect(readFileSync(path, 'utf8')).toBe(before);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('save failure → 500 and the file is NOT created (no state divergence)', async () => {
|
||||
const path = join(tmpdir(), `no-such-dir-${process.pid}-${Date.now()}`, 'coder-providers.json');
|
||||
const app = buildApp(path);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/api/providers/config',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: { goose: { enabled: false } } }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(existsSync(path)).toBe(false);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/providers/refresh', () => {
|
||||
it('no body → refreshes all registered providers', async () => {
|
||||
const app = buildApp(freshPath());
|
||||
const res = await app.inject({ method: 'POST', url: '/api/providers/refresh' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().refreshed).toBeGreaterThan(0);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('subset body → refreshed count reflects only the requested providers', async () => {
|
||||
const app = buildApp(freshPath());
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/providers/refresh',
|
||||
headers: JSON_HEADERS,
|
||||
payload: JSON.stringify({ providers: ['boocode'] }),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ refreshed: 1 });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/providers/:id/diagnostic', () => {
|
||||
it('known provider → 200 JSON { diagnostic }', async () => {
|
||||
const app = buildApp(freshPath());
|
||||
const res = await app.inject({ method: 'GET', url: '/api/providers/boocode/diagnostic' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/json');
|
||||
expect(res.json().diagnostic).toContain('provider: boocode');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('unknown provider → 404', async () => {
|
||||
const app = buildApp(freshPath());
|
||||
const res = await app.inject({ method: 'GET', url: '/api/providers/nope/diagnostic' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,33 @@ import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import { resolveChatId } from './chat-resolve.js';
|
||||
|
||||
const AnswerUserInputBody = z.object({
|
||||
tool_call_id: z.string().min(1),
|
||||
answers: z
|
||||
.array(
|
||||
z.object({
|
||||
question: z.string(),
|
||||
selected_options: z.array(z.string()),
|
||||
free_text: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(3),
|
||||
});
|
||||
|
||||
const AskUserInputArgs = z.object({
|
||||
questions: z
|
||||
.array(
|
||||
z.object({
|
||||
question: z.string(),
|
||||
type: z.enum(['single_select', 'multi_select']),
|
||||
options: z.array(z.string()).min(1),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(3),
|
||||
});
|
||||
|
||||
const SendBody = z.object({
|
||||
content: z.string().min(1).max(64_000),
|
||||
pane_id: z.string().min(1).max(200),
|
||||
@@ -219,6 +246,138 @@ export function registerMessageRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/chats/:id/answer_user_input — answer a pending ask_user_input
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/chats/:id/answer_user_input',
|
||||
async (req, reply) => {
|
||||
const parsed = AnswerUserInputBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid_body', details: parsed.error.flatten() };
|
||||
}
|
||||
const { tool_call_id, answers } = parsed.data;
|
||||
|
||||
const chatRows = await sql<{ id: string; session_id: string }[]>`
|
||||
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;
|
||||
|
||||
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
|
||||
`;
|
||||
if (!callerRows[0]) {
|
||||
reply.code(404);
|
||||
return { error: 'unknown_tool_call_id' };
|
||||
}
|
||||
const foundCall = callerRows[0].payload;
|
||||
if (foundCall.name !== 'ask_user_input') {
|
||||
reply.code(400);
|
||||
return { error: 'tool_call_not_ask_user_input' };
|
||||
}
|
||||
|
||||
const argsParsed = AskUserInputArgs.safeParse(foundCall.args);
|
||||
if (!argsParsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
|
||||
}
|
||||
const questions = argsParsed.data.questions;
|
||||
if (answers.length !== questions.length) {
|
||||
reply.code(400);
|
||||
return { error: 'mismatched_answer_shape', detail: `expected ${questions.length} answer(s), got ${answers.length}` };
|
||||
}
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i]!;
|
||||
const a = answers[i]!;
|
||||
for (const sel of a.selected_options) {
|
||||
if (!q.options.includes(sel)) {
|
||||
reply.code(400);
|
||||
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} option not in question: ${sel}` };
|
||||
}
|
||||
}
|
||||
if (q.type === 'single_select' && a.selected_options.length > 1) {
|
||||
reply.code(400);
|
||||
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} multi on single_select` };
|
||||
}
|
||||
if (a.selected_options.length === 0 && (!a.free_text || !a.free_text.trim())) {
|
||||
reply.code(400);
|
||||
return { error: 'mismatched_answer_shape', detail: `answer ${i + 1} is empty` };
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
`;
|
||||
if (!toolRows[0]) {
|
||||
reply.code(404);
|
||||
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
|
||||
}
|
||||
if (toolRows[0].payload?.output !== null) {
|
||||
reply.code(409);
|
||||
return { error: 'tool_call_already_answered' };
|
||||
}
|
||||
|
||||
const answerSet = { answers };
|
||||
const newToolResults = { tool_call_id, output: answerSet, truncated: false };
|
||||
const toolMessageId = toolRows[0].message_id;
|
||||
|
||||
const result = await sql.begin(async (tx) => {
|
||||
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)})
|
||||
`;
|
||||
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 sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
||||
return { tool_message_id: toolMessageId, assistant_message_id: assistantMsg!.id };
|
||||
});
|
||||
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'tool_result',
|
||||
tool_message_id: result.tool_message_id,
|
||||
tool_call_id,
|
||||
chat_id: chat.id,
|
||||
output: answerSet,
|
||||
truncated: false,
|
||||
} as unknown as WsFrame);
|
||||
inference.enqueue(sessionId, chat.id, result.assistant_message_id, 'default');
|
||||
|
||||
reply.code(202);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/sessions/:sessionId/stop — cancel active inference
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/stop',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import {
|
||||
listPending,
|
||||
@@ -6,7 +7,14 @@ import {
|
||||
applyAll,
|
||||
rejectOne,
|
||||
rewindOne,
|
||||
queueCreate,
|
||||
} from '../services/pending_changes.js';
|
||||
import { WriteGuardError } from '../services/write_guard.js';
|
||||
|
||||
const CreateBody = z.object({
|
||||
file_path: z.string().min(1),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve project root from a session's project path.
|
||||
@@ -51,6 +59,49 @@ export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/sessions/:sessionId/pending/create — queue a new-file create
|
||||
// (manual create from the RightRail file browser; no inference involved).
|
||||
// queueCreate runs resolveWritePath internally, so a path that escapes the
|
||||
// project root or hits a secret file throws WriteGuardError → 422 with the
|
||||
// guard message. Mirrors the { error } 404 shape used by the other routes
|
||||
// and the 422 status used by apply/rewind on failure.
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/pending/create',
|
||||
async (req, reply) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
const parsed = CreateBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const projectRoot = await resolveProjectRoot(sql, sessionId);
|
||||
if (!projectRoot) {
|
||||
reply.code(404);
|
||||
return { error: 'session or project not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const change = await queueCreate(
|
||||
sql,
|
||||
sessionId,
|
||||
null,
|
||||
parsed.data.file_path,
|
||||
parsed.data.content,
|
||||
projectRoot,
|
||||
);
|
||||
return change;
|
||||
} catch (err) {
|
||||
if (err instanceof WriteGuardError) {
|
||||
reply.code(422);
|
||||
return { error: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/pending/apply',
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { getProviderSnapshot, clearProviderSnapshotCache } from '../services/provider-snapshot.js';
|
||||
import {
|
||||
getProviderSnapshot,
|
||||
clearProviderSnapshotCache,
|
||||
peekSnapshotEntry,
|
||||
} from '../services/provider-snapshot.js';
|
||||
import {
|
||||
load,
|
||||
save,
|
||||
CoderProvidersFileSchema,
|
||||
ProviderConfigPatchSchema,
|
||||
mergeProviderConfigPatch,
|
||||
} from '../services/provider-config.js';
|
||||
import {
|
||||
reloadProviderConfig,
|
||||
getResolvedRegistry,
|
||||
} from '../services/provider-config-registry.js';
|
||||
import {
|
||||
getProviderDiagnostic,
|
||||
type DiagnosticAgentRow,
|
||||
} from '../services/provider-diagnostic.js';
|
||||
|
||||
const RefreshBodySchema = z.object({ providers: z.array(z.string()).optional() });
|
||||
|
||||
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
|
||||
app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => {
|
||||
@@ -9,9 +31,97 @@ export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: C
|
||||
return getProviderSnapshot(sql, config, cwd);
|
||||
});
|
||||
|
||||
app.post('/api/providers/refresh', async (_req, _reply) => {
|
||||
// 4.1 — current loaded config file (raw CoderProvidersFile, not the resolved registry).
|
||||
app.get('/api/providers/config', async (_req, _reply) => {
|
||||
return load(config.CODER_PROVIDERS_PATH);
|
||||
});
|
||||
|
||||
// 4.2 — patch the config file (design.md §6.2). Strict order is the whole
|
||||
// correctness story: validate → save → reload → clear. A malformed body or an
|
||||
// invalid merged result returns 422 and NEVER writes; a save failure returns
|
||||
// 500 and leaves in-memory state untouched (no file/registry divergence).
|
||||
app.patch('/api/providers/config', async (req, reply) => {
|
||||
// 1. Validate the PATCH body shape (malformed → 422, never reaches merge).
|
||||
const parsed = ProviderConfigPatchSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(422).send({
|
||||
error: 'invalid provider config patch',
|
||||
issues: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Shallow per-id merge over the current file (null deletes; object replaces).
|
||||
const current = load(config.CODER_PROVIDERS_PATH);
|
||||
const merged = mergeProviderConfigPatch(current, parsed.data);
|
||||
|
||||
// 3. Validate the merged result — refuse to write a config that won't load.
|
||||
const validated = CoderProvidersFileSchema.safeParse(merged);
|
||||
if (!validated.success) {
|
||||
return reply.code(422).send({
|
||||
error: 'merged provider config is invalid',
|
||||
issues: validated.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Persist. If save throws, STOP here — do NOT reload/clear, so the file on
|
||||
// disk and the in-memory resolved registry can never diverge.
|
||||
try {
|
||||
save(config.CODER_PROVIDERS_PATH, validated.data);
|
||||
} catch (err) {
|
||||
req.log.error(
|
||||
{ err: err instanceof Error ? err.message : String(err), path: config.CODER_PROVIDERS_PATH },
|
||||
'provider-config: save failed — in-memory state untouched',
|
||||
);
|
||||
return reply.code(500).send({ error: 'failed to write provider config' });
|
||||
}
|
||||
|
||||
// 5 + 6. Rebuild the in-memory resolved registry from the new file, then drop
|
||||
// the snapshot cache so the next /snapshot reflects the change.
|
||||
reloadProviderConfig();
|
||||
clearProviderSnapshotCache();
|
||||
|
||||
// 7. Return the new config (per §6.2 `{ ok: true }`, plus the merged providers
|
||||
// so the client can update without a follow-up GET).
|
||||
return { ok: true, providers: validated.data.providers };
|
||||
});
|
||||
|
||||
// 4.3 — force a cold probe. Optional { providers?: string[] } narrows the
|
||||
// reported subset (design.md §6.3 Paseo pattern). The force=true snapshot is
|
||||
// the only existing re-probe primitive (per-provider force would be a
|
||||
// snapshot-internal change, out of Phase 4 scope), so the probe runs for all
|
||||
// installed providers; the `refreshed` count reflects the requested subset.
|
||||
app.post('/api/providers/refresh', async (req, reply) => {
|
||||
const parsed = RefreshBodySchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return reply.code(422).send({ error: 'invalid refresh body', issues: parsed.error.flatten() });
|
||||
}
|
||||
const subset = parsed.data.providers;
|
||||
clearProviderSnapshotCache();
|
||||
const entries = await getProviderSnapshot(sql, config, undefined, true);
|
||||
return { refreshed: entries.length };
|
||||
const refreshed =
|
||||
subset && subset.length > 0
|
||||
? entries.filter((e) => subset.includes(e.name)).length
|
||||
: entries.length;
|
||||
return { refreshed };
|
||||
});
|
||||
|
||||
// 4.4 — per-provider diagnostic (design.md §6.4 → JSON `{ diagnostic: string }`).
|
||||
// Read-only: reports cached state (resolved def + available_agents row + warm
|
||||
// snapshot cache for the last probe error) plus a `which` PATH check. No probe
|
||||
// spawn. The report itself is a plaintext block (§8); the route wraps it as JSON.
|
||||
app.get<{ Params: { id: string } }>('/api/providers/:id/diagnostic', async (req, reply) => {
|
||||
const id = req.params.id;
|
||||
const resolved = getResolvedRegistry().get(id);
|
||||
if (!resolved) {
|
||||
return reply.code(404).send({ error: `unknown provider '${id}'` });
|
||||
}
|
||||
const rows = await sql<DiagnosticAgentRow[]>`
|
||||
SELECT name, install_path, supports_acp, models, last_probed_at
|
||||
FROM available_agents WHERE name = ${id}
|
||||
`;
|
||||
const report = await getProviderDiagnostic(resolved, rows[0], {
|
||||
cachedEntry: peekSnapshotEntry(id),
|
||||
});
|
||||
return { diagnostic: report };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ const SkillInvokeBody = z.object({
|
||||
pane_id: z.string().min(1).max(200),
|
||||
skill_name: z.string().min(1),
|
||||
user_message: z.string().max(64_000).nullable().optional(),
|
||||
// v2.5.9: when set to an external provider, the skill runs UNDER that agent —
|
||||
// its body is injected into a dispatched task instead of native inference.
|
||||
provider: z.string().max(100).optional(),
|
||||
model: z.string().max(200).optional(),
|
||||
mode_id: z.string().max(200).optional(),
|
||||
thinking_option_id: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
interface InferenceApi {
|
||||
@@ -39,9 +45,9 @@ export function registerSkillRoutes(
|
||||
}
|
||||
|
||||
const sessionId = req.params.sessionId;
|
||||
const { pane_id, skill_name } = parsed.data;
|
||||
const sessionRows = await sql<{ id: string }[]>`
|
||||
SELECT id FROM sessions WHERE id = ${sessionId}
|
||||
const { pane_id, skill_name, provider, model, mode_id, thinking_option_id } = parsed.data;
|
||||
const sessionRows = await sql<{ id: string; project_id: string }[]>`
|
||||
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
|
||||
`;
|
||||
if (sessionRows.length === 0) {
|
||||
reply.code(404);
|
||||
@@ -69,6 +75,31 @@ export function registerSkillRoutes(
|
||||
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
||||
}
|
||||
|
||||
// v2.5.9: external agent → run the skill UNDER that agent. The skill body
|
||||
// stays server-side (like the native path's tool message) and is injected
|
||||
// into a dispatched task; the agent receives the skill instructions + the
|
||||
// user's text. Mirrors the messages-route external-provider dispatch.
|
||||
if (provider && provider !== 'boocode') {
|
||||
const [userMsg] = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'user', ${userText}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
broker.publishFrame(sessionId, { type: 'message_started', message_id: userMsg!.id, chat_id: chatId, role: 'user' } as WsFrame);
|
||||
broker.publishFrame(sessionId, { type: 'delta', message_id: userMsg!.id, chat_id: chatId, content: userText } as WsFrame);
|
||||
broker.publishFrame(sessionId, { type: 'message_complete', message_id: userMsg!.id, chat_id: chatId } as WsFrame);
|
||||
|
||||
const taskInput = `${body}\n\n---\n\n${userText}`;
|
||||
const [task] = await sql<{ id: string; state: string }[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
|
||||
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
|
||||
RETURNING id, state
|
||||
`;
|
||||
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||
reply.code(202);
|
||||
return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true };
|
||||
}
|
||||
|
||||
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
|
||||
sessionId,
|
||||
chatId,
|
||||
|
||||
@@ -19,6 +19,7 @@ const CreateBody = z.object({
|
||||
|
||||
const PermissionBody = z.object({
|
||||
option_id: z.string().max(200).nullable(),
|
||||
updated_input: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const ListQuery = z.object({
|
||||
@@ -164,7 +165,7 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const ok = respondToPermission(req.params.id, parsed.data.option_id);
|
||||
const ok = respondToPermission(req.params.id, parsed.data.option_id, parsed.data.updated_input as Record<string, unknown> | undefined);
|
||||
if (!ok) {
|
||||
reply.code(404);
|
||||
return { error: 'no pending permission' };
|
||||
|
||||
@@ -66,8 +66,31 @@ CREATE OR REPLACE VIEW human_inbox AS
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
|
||||
-- v2.5.10: persisted ACP available_commands (captured during the cold probe), so
|
||||
-- an agent's live command set survives the tier-2 probe skip and shows without a
|
||||
-- dispatch.
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS commands JSONB DEFAULT '[]'::jsonb;
|
||||
|
||||
-- v2.2.0: Paseo-style session config on tasks.
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
|
||||
|
||||
-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
|
||||
-- new_task tool, arena, MCP server) fires pg_notify('tasks_new') in the same
|
||||
-- transaction, so the dispatcher reacts immediately instead of waiting for the
|
||||
-- fallback poll. Postgres holds the notification until COMMIT, so the listener
|
||||
-- always sees the committed row. A trigger covers all insert paths with no
|
||||
-- app-code drift. Idempotent: re-applied on every startup.
|
||||
CREATE OR REPLACE FUNCTION notify_tasks_new() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('tasks_new', '');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS tasks_notify_new ON tasks;
|
||||
CREATE TRIGGER tasks_notify_new
|
||||
AFTER INSERT ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION notify_tasks_new();
|
||||
|
||||
50
apps/coder/src/services/__tests__/acp-client-fs.test.ts
Normal file
50
apps/coder/src/services/__tests__/acp-client-fs.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { readWorktreeTextFile, writeWorktreeTextFile } from '../acp-client-fs.js';
|
||||
|
||||
const created: string[] = [];
|
||||
function freshWorktree(): string {
|
||||
const wt = mkdtempSync(join(tmpdir(), 'acp-wt-'));
|
||||
created.push(wt);
|
||||
return wt;
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const d of created.splice(0)) {
|
||||
try {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
rmSync(`${d}-evil`, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('acp-client-fs worktree scoping', () => {
|
||||
it('writes then reads a file inside the worktree', async () => {
|
||||
const wt = freshWorktree();
|
||||
await writeWorktreeTextFile(wt, 'sub/dir/note.txt', 'hello');
|
||||
expect(await readWorktreeTextFile(wt, 'sub/dir/note.txt')).toBe('hello');
|
||||
});
|
||||
|
||||
it('rejects ../ traversal on read', async () => {
|
||||
const wt = freshWorktree();
|
||||
await expect(readWorktreeTextFile(wt, '../../etc/passwd')).rejects.toThrow(/escapes worktree/);
|
||||
});
|
||||
|
||||
it('rejects ../ traversal on write', async () => {
|
||||
const wt = freshWorktree();
|
||||
await expect(writeWorktreeTextFile(wt, '../escape.txt', 'x')).rejects.toThrow(/escapes worktree/);
|
||||
});
|
||||
|
||||
it('rejects a sibling-prefix path (the unbounded-startsWith bug)', async () => {
|
||||
const wt = freshWorktree();
|
||||
// Absolute path that shares the worktree as a STRING prefix but is a sibling
|
||||
// dir: `<wt>-evil/...`. A bare `startsWith(<wt>)` wrongly admits it.
|
||||
await expect(readWorktreeTextFile(wt, `${wt}-evil/secret.txt`)).rejects.toThrow(/escapes worktree/);
|
||||
await expect(writeWorktreeTextFile(wt, `${wt}-evil/secret.txt`, 'x')).rejects.toThrow(
|
||||
/escapes worktree/,
|
||||
);
|
||||
});
|
||||
});
|
||||
73
apps/coder/src/services/__tests__/acp-spawn.test.ts
Normal file
73
apps/coder/src/services/__tests__/acp-spawn.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveLaunchSpec, resolveAcpSpawnArgs } from '../acp-spawn.js';
|
||||
import { buildResolvedRegistry } from '../provider-config-registry.js';
|
||||
import type { CoderProvidersFile } from '../provider-config.js';
|
||||
import { PROVIDERS } from '../provider-registry.js';
|
||||
|
||||
/** Resolved def for a provider id under the given config (default: no override). */
|
||||
function builtin(name: string, providers: CoderProvidersFile['providers'] = {}) {
|
||||
const def = buildResolvedRegistry(PROVIDERS, { providers }).get(name);
|
||||
if (!def) throw new Error(`no resolved def for ${name}`);
|
||||
return def;
|
||||
}
|
||||
|
||||
describe('resolveLaunchSpec', () => {
|
||||
// --- byte-identical built-in regression (the HARD CONSTRAINT) ---------------
|
||||
// These argv values are the pre-v2.3 resolveAcpSpawnArgs switch outputs and
|
||||
// MUST NOT change. spawn() is `spawn(spec.binary, spec.args, ...)`, so argv
|
||||
// parity here is dispatch parity.
|
||||
it('opencode (no override) → byte-identical argv ["acp"], binary = installPath', () => {
|
||||
const spec = resolveLaunchSpec(builtin('opencode'), '/usr/bin/opencode');
|
||||
expect(spec).not.toBeNull();
|
||||
expect(spec!.args).toEqual(['acp']); // pre-v2.3 value
|
||||
expect(spec!.binary).toBe('/usr/bin/opencode');
|
||||
expect(spec!.env).toBeUndefined();
|
||||
// cross-check against the switch source-of-truth
|
||||
expect(spec!.args).toEqual(resolveAcpSpawnArgs('opencode'));
|
||||
});
|
||||
|
||||
it('goose → ["acp"], qwen → ["--acp"] (byte-identical)', () => {
|
||||
expect(resolveLaunchSpec(builtin('goose'), '/usr/bin/goose')!.args).toEqual(['acp']);
|
||||
expect(resolveLaunchSpec(builtin('qwen'), '/usr/bin/qwen')!.args).toEqual(['--acp']);
|
||||
});
|
||||
|
||||
it('built-in with null installPath falls back to the bare id (pre-v2.3 `installPath ?? agent`)', () => {
|
||||
const spec = resolveLaunchSpec(builtin('opencode'), null);
|
||||
expect(spec!.binary).toBe('opencode');
|
||||
expect(spec!.args).toEqual(['acp']);
|
||||
});
|
||||
|
||||
it('non-ACP / unknown provider → null (claude has no ACP argv)', () => {
|
||||
expect(resolveLaunchSpec(builtin('claude'), '/usr/bin/claude')).toBeNull();
|
||||
expect(resolveLaunchSpec(builtin('boocode'), null)).toBeNull();
|
||||
});
|
||||
|
||||
// --- config-driven launch (the new capability) ------------------------------
|
||||
it('custom ACP entry → configured command + env reach the spec', () => {
|
||||
const def = builtin('amp-acp', {
|
||||
'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp', '--acp'], env: { AMP_KEY: 'x' } },
|
||||
});
|
||||
const spec = resolveLaunchSpec(def, '/usr/local/bin/amp-acp');
|
||||
expect(spec).not.toBeNull();
|
||||
expect(spec!.binary).toBe('amp-acp'); // command[0], not the resolved install path
|
||||
expect(spec!.args).toEqual(['--acp']); // command.slice(1)
|
||||
expect(spec!.env).toEqual({ AMP_KEY: 'x' });
|
||||
});
|
||||
|
||||
it('built-in WITH a config command override uses the override, not the switch default', () => {
|
||||
const def = builtin('opencode', { opencode: { command: ['opencode', 'acp', '--verbose'], env: { DEBUG: '1' } } });
|
||||
const spec = resolveLaunchSpec(def, '/usr/bin/opencode');
|
||||
expect(spec!.binary).toBe('opencode');
|
||||
expect(spec!.args).toEqual(['acp', '--verbose']);
|
||||
expect(spec!.env).toEqual({ DEBUG: '1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('acp-dispatch spawn wiring (documented pass-through)', () => {
|
||||
// dispatchViaAcp spawns `spawn(spec.binary, spec.args, { env: { ...process.env, ...spec.env } })`.
|
||||
// The env merge layers config env over process.env; for a built-in with no
|
||||
// config env, spec.env is undefined → { ...process.env } (byte-identical).
|
||||
it('built-in with no config env yields an undefined spec.env (→ plain process.env at spawn)', () => {
|
||||
expect(resolveLaunchSpec(builtin('opencode'), '/usr/bin/opencode')!.env).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseCursorAgentModelsOutput } from '../cursor-models.js';
|
||||
|
||||
describe('parseCursorAgentModelsOutput', () => {
|
||||
it('parses cursor-agent models output with default marker', () => {
|
||||
const output = `
|
||||
Available models
|
||||
claude-4-sonnet - Claude 4 Sonnet (default)
|
||||
gpt-4.1 - GPT-4.1
|
||||
Tip: use cursor-agent models for full list
|
||||
`.trim();
|
||||
|
||||
const models = parseCursorAgentModelsOutput(output);
|
||||
|
||||
expect(models).toEqual([
|
||||
{ id: 'claude-4-sonnet', label: 'Claude 4 Sonnet', isDefault: true },
|
||||
{ id: 'gpt-4.1', label: 'GPT-4.1', isDefault: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses current marker when no default', () => {
|
||||
const output = `
|
||||
model-a - Model A (current)
|
||||
model-b - Model B
|
||||
`.trim();
|
||||
|
||||
const models = parseCursorAgentModelsOutput(output);
|
||||
|
||||
expect(models.find((m) => m.id === 'model-a')?.isDefault).toBe(true);
|
||||
expect(models.find((m) => m.id === 'model-b')?.isDefault).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to first model when no markers', () => {
|
||||
const output = 'alpha - Alpha\nbeta - Beta';
|
||||
const models = parseCursorAgentModelsOutput(output);
|
||||
|
||||
expect(models[0]?.isDefault).toBe(true);
|
||||
expect(models[1]?.isDefault).toBe(false);
|
||||
});
|
||||
|
||||
it('skips malformed lines', () => {
|
||||
const output = 'no-separator\nvalid - Valid';
|
||||
const models = parseCursorAgentModelsOutput(output);
|
||||
|
||||
expect(models).toEqual([{ id: 'valid', label: 'Valid', isDefault: true }]);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { getManifestCommands, mergeCommands, PROVIDER_COMMANDS } from '../provid
|
||||
|
||||
describe('provider-commands', () => {
|
||||
it('defines commands for every external harness', () => {
|
||||
for (const name of ['claude', 'opencode', 'cursor', 'goose', 'qwen', 'copilot']) {
|
||||
for (const name of ['claude', 'opencode', 'goose', 'qwen']) {
|
||||
expect(getManifestCommands(name).length, name).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { buildResolvedRegistry } from '../provider-config-registry.js';
|
||||
import { PROVIDERS } from '../provider-registry.js';
|
||||
import type { CoderProvidersFile } from '../provider-config.js';
|
||||
|
||||
describe('buildResolvedRegistry', () => {
|
||||
it('applies a built-in override (goose label)', () => {
|
||||
const config: CoderProvidersFile = { providers: { goose: { label: 'Goosey' } } };
|
||||
const reg = buildResolvedRegistry(PROVIDERS, config);
|
||||
const goose = reg.get('goose');
|
||||
expect(goose).toBeDefined();
|
||||
expect(goose!.label).toBe('Goosey');
|
||||
expect(goose!.configLabel).toBe('Goosey');
|
||||
expect(goose!.enabled).toBe(true);
|
||||
expect(goose!.isBuiltin).toBe(true);
|
||||
expect(goose!.isCustomAcp).toBe(false);
|
||||
});
|
||||
|
||||
it('adds a custom ACP entry (extends:acp + label + command)', () => {
|
||||
const config: CoderProvidersFile = {
|
||||
providers: {
|
||||
'amp-acp': { extends: 'acp', label: 'Amp', description: 'ACP wrapper', command: ['amp-acp', '--acp'], env: { AMP: '1' } },
|
||||
},
|
||||
};
|
||||
const reg = buildResolvedRegistry(PROVIDERS, config);
|
||||
const amp = reg.get('amp-acp');
|
||||
expect(amp).toBeDefined();
|
||||
expect(amp!.isCustomAcp).toBe(true);
|
||||
expect(amp!.isBuiltin).toBe(false);
|
||||
expect(amp!.transport).toBe('acp');
|
||||
expect(amp!.modelSource).toBe('probe');
|
||||
expect(amp!.launchCommand).toEqual(['amp-acp', '--acp']);
|
||||
expect(amp!.env).toEqual({ AMP: '1' });
|
||||
expect(amp!.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a disabled built-in in the registry flagged disabled (goose)', () => {
|
||||
const config: CoderProvidersFile = { providers: { goose: { enabled: false } } };
|
||||
const reg = buildResolvedRegistry(PROVIDERS, config);
|
||||
expect(reg.has('goose')).toBe(true);
|
||||
expect(reg.get('goose')!.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('skips a custom id without extends (no throw)', () => {
|
||||
const config: CoderProvidersFile = { providers: { weird: { label: 'Weird', command: ['weird'] } } };
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const reg = buildResolvedRegistry(PROVIDERS, config);
|
||||
expect(reg.has('weird')).toBe(false);
|
||||
// built-ins untouched
|
||||
expect(reg.size).toBe(PROVIDERS.length);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('ignores enabled:false on boocode and warns', () => {
|
||||
const config: CoderProvidersFile = { providers: { boocode: { enabled: false } } };
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const reg = buildResolvedRegistry(PROVIDERS, config);
|
||||
expect(reg.get('boocode')!.enabled).toBe(true);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('carries config models + additionalModels onto built-in and custom defs', () => {
|
||||
const reg = buildResolvedRegistry(PROVIDERS, {
|
||||
providers: {
|
||||
claude: { models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }] },
|
||||
'amp-acp': {
|
||||
extends: 'acp',
|
||||
label: 'Amp',
|
||||
command: ['amp-acp'],
|
||||
additionalModels: [{ id: 'amp-1', label: 'Amp 1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(reg.get('claude')!.configModels).toEqual([{ id: 'claude-opus-4-8', label: 'Opus 4.8' }]);
|
||||
expect(reg.get('amp-acp')!.configAdditionalModels).toEqual([{ id: 'amp-1', label: 'Amp 1' }]);
|
||||
});
|
||||
|
||||
it('REGRESSION: empty config returns exactly the built-ins, all enabled', () => {
|
||||
const reg = buildResolvedRegistry(PROVIDERS, { providers: {} });
|
||||
expect(reg.size).toBe(PROVIDERS.length);
|
||||
expect([...reg.keys()]).toEqual(PROVIDERS.map((p) => p.name));
|
||||
for (const def of PROVIDERS) {
|
||||
const r = reg.get(def.name)!;
|
||||
expect(r.enabled).toBe(true);
|
||||
expect(r.isBuiltin).toBe(true);
|
||||
expect(r.isCustomAcp).toBe(false);
|
||||
expect(r.launchCommand).toBeNull();
|
||||
expect(r.label).toBe(def.label);
|
||||
}
|
||||
});
|
||||
});
|
||||
96
apps/coder/src/services/__tests__/provider-config.test.ts
Normal file
96
apps/coder/src/services/__tests__/provider-config.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
mergeProviderConfigPatch,
|
||||
ProviderConfigPatchSchema,
|
||||
CoderProvidersFileSchema,
|
||||
type CoderProvidersFile,
|
||||
} from '../provider-config.js';
|
||||
|
||||
describe('ProviderConfigPatchSchema', () => {
|
||||
it('accepts a per-provider override patch', () => {
|
||||
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: { enabled: false } } });
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a null value (delete-the-override sentinel)', () => {
|
||||
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: null } });
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults providers to {} on an empty body', () => {
|
||||
const parsed = ProviderConfigPatchSchema.safeParse({});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) expect(parsed.data.providers).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects a malformed override (wrong field type)', () => {
|
||||
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: { enabled: 'yes' } } });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a non-object providers map', () => {
|
||||
const parsed = ProviderConfigPatchSchema.safeParse({ providers: 123 });
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeProviderConfigPatch', () => {
|
||||
const current: CoderProvidersFile = {
|
||||
providers: {
|
||||
goose: { enabled: true, label: 'Goose' },
|
||||
opencode: { enabled: true },
|
||||
},
|
||||
};
|
||||
|
||||
it('replaces an existing override object wholesale (not deep-merge)', () => {
|
||||
const merged = mergeProviderConfigPatch(current, { providers: { goose: { enabled: false } } });
|
||||
// Whole override replaced — the prior `label` is gone, only `enabled` remains.
|
||||
expect(merged.providers.goose).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('adds a brand-new override id', () => {
|
||||
const merged = mergeProviderConfigPatch(current, {
|
||||
providers: { 'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp'] } },
|
||||
});
|
||||
expect(merged.providers['amp-acp']).toEqual({ extends: 'acp', label: 'Amp', command: ['amp-acp'] });
|
||||
});
|
||||
|
||||
it('deletes an override when the value is null', () => {
|
||||
const merged = mergeProviderConfigPatch(current, { providers: { goose: null } });
|
||||
expect(merged.providers.goose).toBeUndefined();
|
||||
expect(Object.keys(merged.providers)).toEqual(['opencode']);
|
||||
});
|
||||
|
||||
it('leaves ids absent from the patch untouched', () => {
|
||||
const merged = mergeProviderConfigPatch(current, { providers: { goose: { enabled: false } } });
|
||||
expect(merged.providers.opencode).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('does not mutate the input config', () => {
|
||||
const snapshot = JSON.parse(JSON.stringify(current));
|
||||
mergeProviderConfigPatch(current, { providers: { goose: null, opencode: { enabled: false } } });
|
||||
expect(current).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('empty patch returns an equivalent config', () => {
|
||||
const merged = mergeProviderConfigPatch(current, { providers: {} });
|
||||
expect(merged).toEqual(current);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoderProvidersFileSchema (validate-before-save guard)', () => {
|
||||
it('accepts a clean merged config', () => {
|
||||
const merged = mergeProviderConfigPatch(
|
||||
{ providers: {} },
|
||||
{ providers: { goose: { enabled: false } } },
|
||||
);
|
||||
expect(CoderProvidersFileSchema.safeParse(merged).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a config carrying an invalid override (never written)', () => {
|
||||
// A merged object that somehow holds a bad override must fail validation
|
||||
// so the PATCH route returns 422 and never calls save().
|
||||
const invalid = { providers: { goose: { enabled: 'nope' } } };
|
||||
expect(CoderProvidersFileSchema.safeParse(invalid).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getProviderDiagnostic, type DiagnosticAgentRow } from '../provider-diagnostic.js';
|
||||
import { buildResolvedRegistry } from '../provider-config-registry.js';
|
||||
import { PROVIDERS } from '../provider-registry.js';
|
||||
import type { ProviderSnapshotEntry } from '../provider-types.js';
|
||||
|
||||
const registry = buildResolvedRegistry(PROVIDERS, {
|
||||
providers: {
|
||||
goose: { enabled: false },
|
||||
'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp', '--acp'] },
|
||||
},
|
||||
});
|
||||
|
||||
const alwaysAvailable = () => Promise.resolve(true);
|
||||
const neverAvailable = () => Promise.resolve(false);
|
||||
|
||||
describe('getProviderDiagnostic', () => {
|
||||
it('reports a disabled built-in (enabled:false, no install)', async () => {
|
||||
const report = await getProviderDiagnostic(registry.get('goose')!, undefined, {
|
||||
checkAvailable: neverAvailable,
|
||||
});
|
||||
expect(report).toContain('provider: goose');
|
||||
expect(report).toContain('enabled: false');
|
||||
expect(report).toContain('installed: false');
|
||||
expect(report).toMatch(/command_available:\s*false/);
|
||||
});
|
||||
|
||||
it('reports an installed built-in with its install_path, last_probed_at, model count', async () => {
|
||||
const agentRow: DiagnosticAgentRow = {
|
||||
name: 'opencode',
|
||||
install_path: '/usr/bin/opencode',
|
||||
supports_acp: true,
|
||||
models: [
|
||||
{ id: 'm1', label: 'M1' },
|
||||
{ id: 'm2', label: 'M2' },
|
||||
],
|
||||
last_probed_at: '2026-05-29T12:00:00.000Z',
|
||||
};
|
||||
const report = await getProviderDiagnostic(registry.get('opencode')!, agentRow, {
|
||||
checkAvailable: alwaysAvailable,
|
||||
});
|
||||
expect(report).toContain('install_path: /usr/bin/opencode');
|
||||
expect(report).toContain('2026-05-29T12:00:00.000Z');
|
||||
expect(report).toContain('installed: true');
|
||||
expect(report).toMatch(/models_in_db:\s*2/);
|
||||
expect(report).toMatch(/command_available:\s*true/);
|
||||
});
|
||||
|
||||
it('reports a custom ACP launch command + its binary', async () => {
|
||||
const report = await getProviderDiagnostic(registry.get('amp-acp')!, undefined, {
|
||||
checkAvailable: alwaysAvailable,
|
||||
});
|
||||
expect(report).toContain('provider: amp-acp');
|
||||
expect(report).toContain('amp-acp --acp');
|
||||
expect(report).toContain('customAcp: true');
|
||||
});
|
||||
|
||||
it('surfaces the last probe error from a cached snapshot entry', async () => {
|
||||
const cachedEntry: ProviderSnapshotEntry = {
|
||||
name: 'opencode',
|
||||
label: 'OpenCode',
|
||||
transport: 'acp',
|
||||
status: 'error',
|
||||
enabled: true,
|
||||
installed: true,
|
||||
models: [],
|
||||
modes: [],
|
||||
defaultModeId: null,
|
||||
commands: [],
|
||||
error: 'ACP initialize timed out',
|
||||
};
|
||||
const report = await getProviderDiagnostic(registry.get('opencode')!, undefined, {
|
||||
cachedEntry,
|
||||
checkAvailable: alwaysAvailable,
|
||||
});
|
||||
expect(report).toContain('ACP initialize timed out');
|
||||
});
|
||||
|
||||
it('reports no error when none is cached', async () => {
|
||||
const report = await getProviderDiagnostic(registry.get('opencode')!, undefined, {
|
||||
checkAvailable: alwaysAvailable,
|
||||
});
|
||||
expect(report).toMatch(/last_probe_error:\s*\(none/);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
mergeModels,
|
||||
prefixLlamaSwapModels,
|
||||
clearProviderSnapshotCache,
|
||||
getProviderSnapshot,
|
||||
peekSnapshotEntry,
|
||||
} from '../provider-snapshot.js';
|
||||
import { loadProviderConfig } from '../provider-config-registry.js';
|
||||
|
||||
vi.mock('../acp-probe.js', () => ({
|
||||
probeAcpProvider: vi.fn(),
|
||||
@@ -14,6 +19,13 @@ import { probeAcpProvider } from '../acp-probe.js';
|
||||
|
||||
const mockProbe = vi.mocked(probeAcpProvider);
|
||||
|
||||
/** Write a temp coder-providers.json and point the resolved registry at it. */
|
||||
function loadConfigFixture(providers: Record<string, unknown>): void {
|
||||
const path = join(tmpdir(), `coder-providers-test-${providers ? Object.keys(providers).join('-') || 'empty' : 'empty'}.json`);
|
||||
writeFileSync(path, JSON.stringify({ providers }), 'utf8');
|
||||
loadProviderConfig(path);
|
||||
}
|
||||
|
||||
function mockSql(agents: Array<{
|
||||
name: string;
|
||||
install_path: string | null;
|
||||
@@ -21,6 +33,7 @@ function mockSql(agents: Array<{
|
||||
models: Array<{ id: string; label: string }> | null;
|
||||
label: string | null;
|
||||
transport: string | null;
|
||||
last_probed_at?: string | null;
|
||||
}>) {
|
||||
return vi.fn((strings: TemplateStringsArray) => {
|
||||
const query = strings.join('');
|
||||
@@ -36,6 +49,7 @@ function mockSql(agents: Array<{
|
||||
|
||||
const config = {
|
||||
LLAMA_SWAP_URL: 'http://llama-swap.test',
|
||||
PROVIDER_PROBE_TTL_MS: 86_400_000,
|
||||
} as import('../config.js').Config;
|
||||
|
||||
describe('prefixLlamaSwapModels', () => {
|
||||
@@ -68,6 +82,8 @@ describe('mergeModels', () => {
|
||||
describe('getProviderSnapshot', () => {
|
||||
beforeEach(() => {
|
||||
clearProviderSnapshotCache();
|
||||
// Reset the resolved registry to built-ins-only (missing path → {} config).
|
||||
loadProviderConfig('/nonexistent-coder-providers.json');
|
||||
vi.restoreAllMocks();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
@@ -165,4 +181,190 @@ describe('getProviderSnapshot', () => {
|
||||
expect(claude?.modes.length).toBeGreaterThan(0);
|
||||
expect(claude?.commands.some((c) => c.name === 'help')).toBe(true);
|
||||
});
|
||||
|
||||
it('disabled provider → unavailable + enabled:false, WITHOUT spawning a probe', async () => {
|
||||
loadConfigFixture({ goose: { enabled: false } });
|
||||
mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });
|
||||
|
||||
const sql = mockSql([
|
||||
{
|
||||
name: 'goose',
|
||||
install_path: '/usr/bin/goose',
|
||||
supports_acp: true,
|
||||
models: [{ id: 'g1', label: 'G1' }],
|
||||
label: 'Goose',
|
||||
transport: 'acp',
|
||||
last_probed_at: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||
const goose = entries.find((e) => e.name === 'goose');
|
||||
|
||||
expect(goose?.status).toBe('unavailable');
|
||||
expect(goose?.enabled).toBe(false);
|
||||
expect(goose?.installed).toBe(false);
|
||||
expect(mockProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uninstalled provider → unavailable + enabled:true + installed:false', async () => {
|
||||
loadConfigFixture({});
|
||||
mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });
|
||||
|
||||
const sql = mockSql([]); // nothing probed/installed
|
||||
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||
const opencode = entries.find((e) => e.name === 'opencode');
|
||||
|
||||
expect(opencode?.status).toBe('unavailable');
|
||||
expect(opencode?.enabled).toBe(true);
|
||||
expect(opencode?.installed).toBe(false);
|
||||
expect(mockProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fresh DB within TTL → tier-2 cold probe SKIPPED (serves DB models)', async () => {
|
||||
loadConfigFixture({});
|
||||
// If this were wrongly called, cached-goose would be replaced and the
|
||||
// not.toHaveBeenCalled assertion would fail.
|
||||
mockProbe.mockResolvedValue({
|
||||
ok: true,
|
||||
models: [{ id: 'SHOULD-NOT-APPEAR', label: 'nope' }],
|
||||
modes: [],
|
||||
defaultModeId: null,
|
||||
commands: [],
|
||||
});
|
||||
|
||||
const sql = mockSql([
|
||||
{
|
||||
name: 'goose',
|
||||
install_path: '/usr/bin/goose',
|
||||
supports_acp: true,
|
||||
models: [{ id: 'cached-goose', label: 'Cached Goose' }],
|
||||
label: 'Goose',
|
||||
transport: 'acp',
|
||||
last_probed_at: new Date().toISOString(), // fresh
|
||||
},
|
||||
]);
|
||||
|
||||
// force=false → cache-miss returns loading; second call joins the build / cache.
|
||||
await getProviderSnapshot(sql, config, '/tmp/cwd', false);
|
||||
const entries = await getProviderSnapshot(sql, config, '/tmp/cwd', false);
|
||||
const goose = entries.find((e) => e.name === 'goose');
|
||||
|
||||
expect(goose?.status).toBe('ready');
|
||||
expect(goose?.installed).toBe(true);
|
||||
expect(goose?.models.map((m) => m.id)).toContain('cached-goose');
|
||||
expect(goose?.models.map((m) => m.id)).not.toContain('SHOULD-NOT-APPEAR');
|
||||
expect(mockProbe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('force refresh → tier-2 cold probe RUNS even when DB is fresh', async () => {
|
||||
loadConfigFixture({});
|
||||
mockProbe.mockResolvedValue({
|
||||
ok: true,
|
||||
models: [{ id: 'fresh-probe', label: 'Fresh' }],
|
||||
modes: [],
|
||||
defaultModeId: null,
|
||||
commands: [],
|
||||
});
|
||||
|
||||
const sql = mockSql([
|
||||
{
|
||||
name: 'goose',
|
||||
install_path: '/usr/bin/goose',
|
||||
supports_acp: true,
|
||||
models: [{ id: 'cached-goose', label: 'Cached' }],
|
||||
label: 'Goose',
|
||||
transport: 'acp',
|
||||
last_probed_at: new Date().toISOString(), // fresh, but force overrides
|
||||
},
|
||||
]);
|
||||
|
||||
await getProviderSnapshot(sql, config, '/tmp/cwd', true);
|
||||
expect(mockProbe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('native boocode → ready, enabled, installed', async () => {
|
||||
loadConfigFixture({});
|
||||
const sql = mockSql([]);
|
||||
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||
const boocode = entries.find((e) => e.name === 'boocode');
|
||||
|
||||
expect(boocode?.status).toBe('ready');
|
||||
expect(boocode?.enabled).toBe(true);
|
||||
expect(boocode?.installed).toBe(true);
|
||||
});
|
||||
|
||||
it('config models REPLACE the claude static list; additionalModels merge (+ thinking)', async () => {
|
||||
loadConfigFixture({
|
||||
claude: {
|
||||
models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }],
|
||||
additionalModels: [{ id: 'sonnet', label: 'Sonnet (latest)' }],
|
||||
},
|
||||
});
|
||||
|
||||
const sql = mockSql([
|
||||
{
|
||||
name: 'claude',
|
||||
install_path: '/usr/bin/claude',
|
||||
supports_acp: false,
|
||||
models: [{ id: 'old-static', label: 'Old' }],
|
||||
label: 'Claude Code',
|
||||
transport: 'pty',
|
||||
last_probed_at: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
|
||||
const claude = entries.find((e) => e.name === 'claude');
|
||||
const ids = claude!.models.map((m) => m.id);
|
||||
|
||||
expect(ids).toContain('claude-opus-4-8'); // config models replaced the DB/static list
|
||||
expect(ids).toContain('sonnet'); // additionalModels merged on top
|
||||
expect(ids).not.toContain('old-static'); // replaced, not appended
|
||||
// thinking options still attach to the config-provided models
|
||||
expect(claude!.models.find((m) => m.id === 'claude-opus-4-8')?.thinkingOptions?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('peekSnapshotEntry returns a cached entry (read-only) and undefined when cold/unknown', async () => {
|
||||
loadConfigFixture({});
|
||||
// Cold cache → undefined (no build triggered).
|
||||
expect(peekSnapshotEntry('boocode', '/tmp/peek')).toBeUndefined();
|
||||
|
||||
const sql = mockSql([]);
|
||||
await getProviderSnapshot(sql, config, '/tmp/peek', true);
|
||||
|
||||
expect(peekSnapshotEntry('boocode', '/tmp/peek')?.name).toBe('boocode');
|
||||
expect(peekSnapshotEntry('does-not-exist', '/tmp/peek')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
|
||||
loadConfigFixture({});
|
||||
mockProbe.mockResolvedValue({
|
||||
ok: true,
|
||||
models: [{ id: 'm1', label: 'M1' }],
|
||||
modes: [],
|
||||
defaultModeId: null,
|
||||
commands: [],
|
||||
});
|
||||
|
||||
const sql = mockSql([
|
||||
{
|
||||
name: 'goose',
|
||||
install_path: '/usr/bin/goose',
|
||||
supports_acp: true,
|
||||
models: null,
|
||||
label: 'Goose',
|
||||
transport: 'acp',
|
||||
last_probed_at: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await getProviderSnapshot(sql, config, '/tmp/cwd', true); // cold populate
|
||||
const probeCallsAfterFirst = mockProbe.mock.calls.length;
|
||||
await getProviderSnapshot(sql, config, '/tmp/cwd', false); // warm read
|
||||
const probeCallsAfterSecond = mockProbe.mock.calls.length;
|
||||
|
||||
// Success criterion: second snapshot is served from cache with no ACP spawns.
|
||||
expect(probeCallsAfterSecond - probeCallsAfterFirst).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Parity guard between the two copies of the provider snapshot types:
|
||||
* apps/coder/src/services/provider-types.ts (backend source of truth)
|
||||
* apps/web/src/api/types.ts (web wire copy)
|
||||
*
|
||||
* APPROACH: text-identity of each shared type block (mirrors the repo's existing
|
||||
* ws-frames.test.ts byte-parity convention). A compile-time bidirectional-
|
||||
* assignability check was attempted first (a web-side file importing coder's
|
||||
* import-free provider-types.ts), but apps/web/tsconfig.app.json is a composite
|
||||
* project and rejects out-of-include files with TS6307 — so cross-project type
|
||||
* import is structurally blocked. This runtime guard FAILS on any field
|
||||
* add/remove/rename/loosen in either copy, including the nested model/mode/
|
||||
* command types that ProviderSnapshotEntry references. Single-source-of-truth
|
||||
* (shared workspace package) is deferred as a Tier-2 follow-up.
|
||||
*/
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const coderSrc = readFileSync(resolve(here, '../provider-types.ts'), 'utf8');
|
||||
const webSrc = readFileSync(resolve(here, '../../../../web/src/api/types.ts'), 'utf8');
|
||||
|
||||
function extractBlock(src: string, name: string): string {
|
||||
const iface = src.match(new RegExp(`export interface ${name} \\{[\\s\\S]*?\\n\\}`));
|
||||
const alias = src.match(new RegExp(`export type ${name} =[^;]*;`));
|
||||
const block = iface?.[0] ?? alias?.[0];
|
||||
if (!block) throw new Error(`type block '${name}' not found`);
|
||||
// Normalize to type structure: drop blank + comment lines (//, /* */, *),
|
||||
// trim each line. Field add/remove/rename/loosen still changes a field line.
|
||||
return block
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(
|
||||
(l) =>
|
||||
l.length > 0 &&
|
||||
!l.startsWith('//') &&
|
||||
!l.startsWith('/*') &&
|
||||
!l.startsWith('*'),
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
describe('provider snapshot type parity (coder ↔ web)', () => {
|
||||
// Includes the nested types ProviderSnapshotEntry references, so structural
|
||||
// drift anywhere in the snapshot surface is caught.
|
||||
const names = [
|
||||
'ProviderSnapshotStatus',
|
||||
'ProviderSnapshotEntry',
|
||||
'ProviderModel',
|
||||
'ProviderMode',
|
||||
'ThinkingOption',
|
||||
'AgentCommand',
|
||||
];
|
||||
for (const name of names) {
|
||||
it(`${name} is identical in both copies`, () => {
|
||||
expect(
|
||||
extractBlock(webSrc, name),
|
||||
`${name} drifted between apps/coder/src/services/provider-types.ts and apps/web/src/api/types.ts`,
|
||||
).toBe(extractBlock(coderSrc, name));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,25 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { dirname, isAbsolute, resolve, sep } from 'node:path';
|
||||
|
||||
/**
|
||||
* Resolve an ACP-supplied path against the agent worktree and reject anything
|
||||
* that escapes it. Mirrors `write_guard.ts`'s check: `resolve()` to normalize
|
||||
* `../` segments, then a **separator-bounded** prefix test — a bare
|
||||
* `startsWith(root)` wrongly admits a sibling dir like `<root>-evil/...`.
|
||||
*
|
||||
* No realpath (consistent with `write_guard.ts`: the target may not exist yet on
|
||||
* write). This is a containment guard for the ACP fs bridge, not a hard trust
|
||||
* boundary — the agent process already runs with host FS access; symlink-swap
|
||||
* hardening (`O_NOFOLLOW`/realpath) is out of scope here.
|
||||
*/
|
||||
function resolveInWorktree(worktreePath: string, filePath: string): string {
|
||||
const root = resolve(worktreePath);
|
||||
const absolute = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
|
||||
if (absolute !== root && !absolute.startsWith(root + sep)) {
|
||||
throw new Error(`path escapes worktree: ${filePath}`);
|
||||
}
|
||||
return absolute;
|
||||
}
|
||||
|
||||
/** Resolve an ACP path against the agent worktree and read a slice of lines. */
|
||||
export async function readWorktreeTextFile(
|
||||
@@ -8,10 +28,7 @@ export async function readWorktreeTextFile(
|
||||
line?: number | null,
|
||||
limit?: number | null,
|
||||
): Promise<string> {
|
||||
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
||||
if (!absolute.startsWith(resolve(worktreePath))) {
|
||||
throw new Error(`path escapes worktree: ${filePath}`);
|
||||
}
|
||||
const absolute = resolveInWorktree(worktreePath, filePath);
|
||||
const raw = await fs.readFile(absolute, 'utf8');
|
||||
if (!line && !limit) return raw;
|
||||
const lines = raw.split(/\r?\n/);
|
||||
@@ -26,10 +43,7 @@ export async function writeWorktreeTextFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
||||
if (!absolute.startsWith(resolve(worktreePath))) {
|
||||
throw new Error(`path escapes worktree: ${filePath}`);
|
||||
}
|
||||
const absolute = resolveInWorktree(worktreePath, filePath);
|
||||
await fs.mkdir(dirname(absolute), { recursive: true });
|
||||
await fs.writeFile(absolute, content, 'utf8');
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
type WriteTextFileResponse,
|
||||
type CreateTerminalRequest,
|
||||
type CreateTerminalResponse,
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
type SessionConfigOption,
|
||||
type ClientSideConnection as ConnectionType,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
@@ -24,9 +26,10 @@ import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { findThoughtLevelConfigId } from './acp-derive.js';
|
||||
import { resolveAcpSpawnArgs } from './acp-spawn.js';
|
||||
import { resolveLaunchSpec } from './acp-spawn.js';
|
||||
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
|
||||
import { createAcpNdJsonStream } from './acp-stream.js';
|
||||
import { waitForPermissionResponse, cancelPendingPermission } from './permission-waiter.js';
|
||||
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
|
||||
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||
import { readWorktreeTextFile, writeWorktreeTextFile } from './acp-client-fs.js';
|
||||
import {
|
||||
@@ -57,6 +60,9 @@ export interface AcpDispatchOpts {
|
||||
messageId?: string;
|
||||
broker?: Broker;
|
||||
installPath?: string;
|
||||
/** v2.3 phase 3: resolved registry def for launch-spec resolution. The
|
||||
* dispatcher loads this by task.agent; falls back to a registry lookup here. */
|
||||
resolved?: ResolvedProviderDef;
|
||||
signal?: AbortSignal;
|
||||
log: FastifyBaseLogger;
|
||||
}
|
||||
@@ -254,6 +260,12 @@ class AcpStreamContext {
|
||||
createTerminal: async (_params: CreateTerminalRequest): Promise<CreateTerminalResponse> => {
|
||||
return { terminalId: 'noop' };
|
||||
},
|
||||
unstable_createElicitation: async (params: CreateElicitationRequest): Promise<CreateElicitationResponse> => {
|
||||
if (taskId && sessionId) {
|
||||
return waitForElicitationResponse(taskId, sessionId, agent, modeId, params);
|
||||
}
|
||||
return { action: 'decline' };
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -274,8 +286,12 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
|
||||
broker,
|
||||
} = opts;
|
||||
|
||||
const args = resolveAcpSpawnArgs(agent);
|
||||
if (!args) {
|
||||
// v2.3 phase 3: launch from the resolved registry def (config override /
|
||||
// custom-ACP command) with the built-in switch as the fallback. The dispatcher
|
||||
// passes `resolved`; fall back to a registry lookup if it didn't.
|
||||
const resolved = opts.resolved ?? getResolvedRegistry().get(agent);
|
||||
const spec = resolved ? resolveLaunchSpec(resolved, installPath ?? null) : null;
|
||||
if (!spec) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
output: `Agent '${agent}' does not support ACP.`,
|
||||
@@ -285,12 +301,11 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
|
||||
};
|
||||
}
|
||||
|
||||
const binary = installPath ?? agent;
|
||||
log.info({ agent, binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
|
||||
const child = spawn(binary, args, {
|
||||
log.info({ agent, binary: spec.binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
|
||||
const child = spawn(spec.binary, spec.args, {
|
||||
cwd: worktreePath,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
env: { ...process.env, ...spec.env },
|
||||
});
|
||||
|
||||
const streamCtx = new AcpStreamContext(
|
||||
|
||||
@@ -130,6 +130,17 @@ export async function probeAcpProvider(
|
||||
});
|
||||
|
||||
const session = await connection.newSession({ cwd, mcpServers: [] });
|
||||
// available_commands_update is an async session notification opencode sends
|
||||
// shortly AFTER newSession resolves — reading probedCommands synchronously
|
||||
// here races it and captures nothing. Wait briefly for the first batch, then
|
||||
// a short settle for any stragglers (capped well under PROBE_TIMEOUT_MS).
|
||||
const deadline = Date.now() + 3_000;
|
||||
while (probedCommands.length === 0 && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
if (probedCommands.length > 0) {
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
const result = parseSessionResponse(session, agent);
|
||||
result.commands = probedCommands;
|
||||
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { ResolvedProviderDef } from './provider-config-registry.js';
|
||||
|
||||
/**
|
||||
* Resolve ACP spawn argv per provider (host-probe verified 2026-05-25).
|
||||
* Resolve ACP spawn argv per built-in provider (host-probe verified 2026-05-25).
|
||||
* Source of truth for built-in default argv — resolveLaunchSpec wraps these; it
|
||||
* does NOT replace them.
|
||||
*/
|
||||
export function resolveAcpSpawnArgs(agent: string): string[] | null {
|
||||
switch (agent) {
|
||||
case 'opencode':
|
||||
case 'goose':
|
||||
return ['acp'];
|
||||
case 'cursor':
|
||||
return ['acp'];
|
||||
case 'copilot':
|
||||
return ['--acp'];
|
||||
case 'qwen':
|
||||
return ['--acp'];
|
||||
default:
|
||||
@@ -17,13 +17,34 @@ export function resolveAcpSpawnArgs(agent: string): string[] | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAcpProbeBinaries(agent: string): string[] {
|
||||
switch (agent) {
|
||||
case 'cursor':
|
||||
return ['cursor-agent', 'agent'];
|
||||
case 'copilot':
|
||||
return ['copilot'];
|
||||
default:
|
||||
return [agent];
|
||||
/**
|
||||
* v2.3 phase 3: resolve the launch spec for an ACP dispatch (design.md §5.1).
|
||||
* Consults the resolved registry's launchCommand (config override or custom-ACP
|
||||
* entry) first; otherwise falls back to the built-in default argv above.
|
||||
*
|
||||
* Byte-identical to pre-v2.3 for built-ins with no override: binary is
|
||||
* `installPath ?? id` and args come from resolveAcpSpawnArgs — exactly the
|
||||
* `binary = installPath ?? agent` + `resolveAcpSpawnArgs(agent)` the dispatcher
|
||||
* used before. (Deliberate deviation from design §5.1's `!installPath → null`:
|
||||
* the old path spawned the bare agent name when install_path was missing, so we
|
||||
* preserve the `?? id` fallback rather than fail.)
|
||||
*/
|
||||
export function resolveLaunchSpec(
|
||||
resolved: ResolvedProviderDef,
|
||||
installPath: string | null,
|
||||
): { binary: string; args: string[]; env?: Record<string, string> } | null {
|
||||
if (resolved.launchCommand) {
|
||||
return {
|
||||
binary: resolved.launchCommand[0],
|
||||
args: resolved.launchCommand.slice(1),
|
||||
env: resolved.env,
|
||||
};
|
||||
}
|
||||
const args = resolveAcpSpawnArgs(resolved.id);
|
||||
if (!args) return null;
|
||||
return { binary: installPath ?? resolved.id, args, env: resolved.env };
|
||||
}
|
||||
|
||||
export function resolveAcpProbeBinaries(agent: string): string[] {
|
||||
return [agent];
|
||||
}
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import type { Sql } from '../db.js';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { exec as execCb } from 'node:child_process';
|
||||
import { exec as execCb, execFile as execFileCb } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { PROVIDERS_BY_NAME, PROBED_AGENT_NAMES } from './provider-registry.js';
|
||||
import { PROVIDERS_BY_NAME } from './provider-registry.js';
|
||||
import { resolveAcpProbeBinaries } from './acp-spawn.js';
|
||||
import { clearProviderSnapshotCache } from './provider-snapshot.js';
|
||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||
import { loadConfig } from '../config.js';
|
||||
import { loadProviderConfig } from './provider-config-registry.js';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
// `which` via execFile (no shell) — the binary name can come from the config
|
||||
// file (custom ACP entries), so avoid interpolating it into a shell string.
|
||||
async function whichBinary(bin: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFile('which', [bin], { timeout: 10_000 });
|
||||
const path = stdout.trim();
|
||||
return path || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveInstallPath(agentName: string): Promise<string | null> {
|
||||
const candidates = resolveAcpProbeBinaries(agentName);
|
||||
for (const bin of candidates) {
|
||||
try {
|
||||
const { stdout } = await exec(`which ${bin}`, { timeout: 10_000 });
|
||||
const path = stdout.trim();
|
||||
if (path) return path;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
const path = await whichBinary(bin);
|
||||
if (path) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -27,15 +37,6 @@ async function detectAcpSupport(agentName: string, installPath: string): Promise
|
||||
const transport = PROVIDERS_BY_NAME.get(agentName)?.transport;
|
||||
if (transport !== 'acp') return false;
|
||||
|
||||
if (agentName === 'copilot') {
|
||||
try {
|
||||
const { stdout } = await exec(`"${installPath}" --help`, { timeout: 10_000 });
|
||||
return stdout.includes('--acp');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (agentName === 'qwen') {
|
||||
try {
|
||||
const { stdout } = await exec(`"${installPath}" --help`, { timeout: 10_000 });
|
||||
@@ -55,14 +56,37 @@ async function detectAcpSupport(agentName: string, installPath: string): Promise
|
||||
|
||||
/**
|
||||
* Probe for available agents on the HOST.
|
||||
*
|
||||
* v2.3: iterates the resolved provider registry (built-ins + config-backed
|
||||
* custom ACP entries) rather than the hardcoded `PROBED_AGENT_NAMES`. Native
|
||||
* boocode is not probed; disabled providers are skipped (their `available_agents`
|
||||
* row is kept, not deleted). `enabled` is read from the in-memory registry only —
|
||||
* no DB column in Phase 1 (design.md §3.3).
|
||||
*/
|
||||
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
|
||||
clearProviderSnapshotCache();
|
||||
log.info('agent-probe: scanning for known agents');
|
||||
|
||||
for (const agentName of PROBED_AGENT_NAMES) {
|
||||
const registry = loadProviderConfig(loadConfig().CODER_PROVIDERS_PATH);
|
||||
|
||||
for (const resolved of registry.values()) {
|
||||
const agentName = resolved.id;
|
||||
|
||||
// Native boocode is not a probed host agent.
|
||||
if (resolved.transport === 'native') continue;
|
||||
|
||||
// Disabled providers: skip the probe, keep any existing row.
|
||||
if (!resolved.enabled) {
|
||||
log.info({ agent: agentName }, 'agent-probe: skipping disabled provider');
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const installPath = await resolveInstallPath(agentName);
|
||||
// Custom ACP entries resolve their binary from command[0]; built-ins use
|
||||
// the per-agent probe binaries.
|
||||
const installPath = resolved.isCustomAcp && resolved.launchCommand
|
||||
? await whichBinary(resolved.launchCommand[0])
|
||||
: await resolveInstallPath(agentName);
|
||||
if (!installPath) continue;
|
||||
|
||||
let version: string | null = null;
|
||||
@@ -73,24 +97,34 @@ export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<voi
|
||||
/* optional */
|
||||
}
|
||||
|
||||
const providerDef = PROVIDERS_BY_NAME.get(agentName);
|
||||
let supportsAcp = providerDef?.transport === 'acp';
|
||||
if (supportsAcp) {
|
||||
supportsAcp = await detectAcpSupport(agentName, installPath);
|
||||
// Custom ACP entries are ACP by declaration; built-ins detect support.
|
||||
let supportsAcp: boolean;
|
||||
if (resolved.isCustomAcp) {
|
||||
supportsAcp = true;
|
||||
} else {
|
||||
supportsAcp = resolved.transport === 'acp';
|
||||
if (supportsAcp) {
|
||||
supportsAcp = await detectAcpSupport(agentName, installPath);
|
||||
}
|
||||
}
|
||||
|
||||
let models: Array<{ id: string; label: string }> = [];
|
||||
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
|
||||
models = providerDef.staticModels;
|
||||
if (!resolved.isCustomAcp) {
|
||||
const providerDef = PROVIDERS_BY_NAME.get(agentName);
|
||||
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
|
||||
models = providerDef.staticModels;
|
||||
}
|
||||
if (agentName === 'qwen') {
|
||||
models = await readQwenSettingsModels();
|
||||
}
|
||||
}
|
||||
|
||||
if (agentName === 'qwen') {
|
||||
models = await readQwenSettingsModels();
|
||||
}
|
||||
|
||||
const label = providerDef?.label ?? agentName;
|
||||
const transport =
|
||||
providerDef?.transport === 'acp' && !supportsAcp ? 'pty' : (providerDef?.transport ?? 'pty');
|
||||
const label = resolved.configLabel ?? resolved.label;
|
||||
const transport = resolved.isCustomAcp
|
||||
? 'acp'
|
||||
: resolved.transport === 'acp' && !supportsAcp
|
||||
? 'pty'
|
||||
: (resolved.transport ?? 'pty');
|
||||
|
||||
await sql`
|
||||
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
|
||||
|
||||
108
apps/coder/src/services/claude-command-discovery.ts
Normal file
108
apps/coder/src/services/claude-command-discovery.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* v2.5.11: discover Claude Code's real, enabled commands + plugin skills from
|
||||
* disk so the coder slash menu shows them (claude is PTY — no ACP discovery).
|
||||
*
|
||||
* Scope (v1): user-global only — `~/.claude/commands/*.md` plus the enabled
|
||||
* plugins listed in `~/.claude/settings.json:enabledPlugins` (user-scope install
|
||||
* paths from `~/.claude/plugins/.../installed_plugins.json`). Project-local
|
||||
* plugins and `<cwd>/.claude/commands` are deferred. Names are bare.
|
||||
*/
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { AgentCommand } from './provider-types.js';
|
||||
|
||||
/** Minimal frontmatter reader — single-line `key: value` between `---` fences. */
|
||||
function frontmatterField(content: string, field: string): string | undefined {
|
||||
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!block?.[1]) return undefined;
|
||||
const m = block[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
|
||||
return m?.[1]?.trim().replace(/^["']|["']$/g, '') || undefined;
|
||||
}
|
||||
|
||||
function readCommandDir(dir: string): AgentCommand[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: AgentCommand[] = [];
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.md')) continue;
|
||||
let description: string | undefined;
|
||||
try {
|
||||
description = frontmatterField(readFileSync(join(dir, f), 'utf8'), 'description');
|
||||
} catch {
|
||||
/* unreadable — still list the command by name */
|
||||
}
|
||||
out.push({ name: f.slice(0, -3), kind: 'command', ...(description ? { description } : {}) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readSkillDir(dir: string): AgentCommand[] {
|
||||
if (!existsSync(dir)) return [];
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: AgentCommand[] = [];
|
||||
for (const sub of entries) {
|
||||
const skillMd = join(dir, sub, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) continue;
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
name: frontmatterField(content, 'name') ?? sub,
|
||||
kind: 'skill',
|
||||
...(() => {
|
||||
const d = frontmatterField(content, 'description');
|
||||
return d ? { description: d } : {};
|
||||
})(),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function discoverClaudeCommands(): AgentCommand[] {
|
||||
const root = join(homedir(), '.claude');
|
||||
const out: AgentCommand[] = [];
|
||||
|
||||
// User custom commands.
|
||||
out.push(...readCommandDir(join(root, 'commands')));
|
||||
|
||||
// Enabled plugins (user-scope installs).
|
||||
try {
|
||||
const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as {
|
||||
enabledPlugins?: Record<string, boolean>;
|
||||
};
|
||||
const installed = JSON.parse(
|
||||
readFileSync(join(root, 'plugins', 'installed_plugins.json'), 'utf8'),
|
||||
) as { plugins?: Record<string, Array<{ scope?: string; installPath?: string }>> };
|
||||
|
||||
const enabled = settings.enabledPlugins ?? {};
|
||||
const plugins = installed.plugins ?? {};
|
||||
for (const [key, on] of Object.entries(enabled)) {
|
||||
if (!on) continue;
|
||||
const installs = plugins[key] ?? [];
|
||||
const installPath = (installs.find((i) => i.scope === 'user') ?? installs[0])?.installPath;
|
||||
if (!installPath || !existsSync(installPath)) continue;
|
||||
out.push(...readSkillDir(join(installPath, 'skills')));
|
||||
out.push(...readCommandDir(join(installPath, 'commands')));
|
||||
}
|
||||
} catch {
|
||||
/* missing/unreadable plugin config → user commands only */
|
||||
}
|
||||
|
||||
// Dedupe by name (first wins).
|
||||
const seen = new Set<string>();
|
||||
return out.filter((c) => (seen.has(c.name) ? false : (seen.add(c.name), true)));
|
||||
}
|
||||
22
apps/coder/src/services/command-availability.ts
Normal file
22
apps/coder/src/services/command-availability.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* v2.3 phase 2: tier-1 fast availability check — is a binary on PATH?
|
||||
*
|
||||
* Uses execFile (NO shell) because the binary name can come from the provider
|
||||
* config file (custom ACP entries) — mirrors the Phase 1 agent-probe hardening.
|
||||
* Note: agent-probe's `whichBinary` returns the resolved path (it needs it for
|
||||
* `install_path`); this returns a boolean. Kept separate rather than over-
|
||||
* refactored into one helper — different return contracts, two short call sites.
|
||||
*/
|
||||
import { execFile as execFileCb } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
export async function isCommandAvailable(binary: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFile('which', [binary], { timeout: 10_000 });
|
||||
return stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Cursor model list parser — lifted from Paseo cursor-acp-agent.ts
|
||||
*/
|
||||
import type { ProviderModel } from './provider-types.js';
|
||||
|
||||
const CURSOR_MODEL_MARKER_PATTERN = /\s+\((?:default|current)\)$/;
|
||||
|
||||
export function parseCursorAgentModelsOutput(output: string): ProviderModel[] {
|
||||
const parsed = output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && line !== 'Available models' && !line.startsWith('Tip:'))
|
||||
.map((line) => {
|
||||
const separatorIndex = line.indexOf(' - ');
|
||||
if (separatorIndex <= 0) return null;
|
||||
|
||||
const id = line.slice(0, separatorIndex).trim();
|
||||
const rawLabel = line.slice(separatorIndex + 3).trim();
|
||||
if (!id || !rawLabel) return null;
|
||||
|
||||
let marker: 'default' | 'current' | null = null;
|
||||
if (rawLabel.endsWith(' (default)')) marker = 'default';
|
||||
else if (rawLabel.endsWith(' (current)')) marker = 'current';
|
||||
|
||||
return { id, label: rawLabel.replace(CURSOR_MODEL_MARKER_PATTERN, ''), marker };
|
||||
})
|
||||
.filter((m): m is { id: string; label: string; marker: 'default' | 'current' | null } => m !== null);
|
||||
|
||||
const defaultModelId =
|
||||
parsed.find((m) => m.marker === 'default')?.id ??
|
||||
parsed.find((m) => m.marker === 'current')?.id ??
|
||||
parsed[0]?.id;
|
||||
|
||||
return parsed.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
isDefault: model.id === defaultModelId,
|
||||
}));
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import type { Config } from '../config.js';
|
||||
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
|
||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||
import { getResolvedRegistry } from './provider-config-registry.js';
|
||||
import { dispatchViaPty } from './pty-dispatch.js';
|
||||
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
|
||||
import { getManifestCommands } from './provider-commands.js';
|
||||
@@ -24,16 +25,29 @@ interface Deps {
|
||||
config: Config;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
// LISTEN/NOTIFY ('tasks_new') is the fast path — the dispatcher reacts to new
|
||||
// tasks immediately. The poll is only a safety net for notifications missed
|
||||
// during a listen-connection drop (porsager auto-reconnects), so it can stay slow.
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
const COMPLETION_POLL_MS = 2_000;
|
||||
|
||||
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
|
||||
const { sql, inference, broker, log, config } = deps;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let listener: { unlisten: () => Promise<void> } | null = null;
|
||||
let running = false;
|
||||
let stopping = false;
|
||||
let inflightPromise: Promise<void> | null = null;
|
||||
|
||||
// Shared entry point for both the poll timer and the NOTIFY listener. poll()'s
|
||||
// `running`/`stopping` guard makes this safe to call concurrently — a notify
|
||||
// arriving mid-task returns immediately and never double-dispatches.
|
||||
function triggerPoll(reason: string): void {
|
||||
poll().catch((err) => {
|
||||
log.error({ err, reason }, 'dispatcher: poll error');
|
||||
});
|
||||
}
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
if (running || stopping) return;
|
||||
|
||||
@@ -327,6 +341,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
if (supportsAcp) {
|
||||
const result = await dispatchViaAcp({
|
||||
agent,
|
||||
resolved: getResolvedRegistry().get(agent),
|
||||
task: task.input,
|
||||
worktreePath,
|
||||
installPath: installPath ?? undefined,
|
||||
@@ -463,12 +478,28 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
|
||||
return {
|
||||
start() {
|
||||
log.info('dispatcher: starting poll loop');
|
||||
timer = setInterval(() => {
|
||||
poll().catch((err) => {
|
||||
log.error({ err }, 'dispatcher: poll error');
|
||||
log.info('dispatcher: starting poll loop + tasks_new listener');
|
||||
|
||||
// Fallback poll — catches notifications missed while the listen connection
|
||||
// was down. The fast path is the NOTIFY listener below.
|
||||
timer = setInterval(() => triggerPoll('interval'), POLL_INTERVAL_MS);
|
||||
|
||||
// Fast path: react immediately to new tasks. porsager reserves a dedicated
|
||||
// connection and auto-resubscribes on reconnect; the onlisten callback
|
||||
// fires on each (re)subscribe, so we kick a catch-up poll there too to
|
||||
// sweep up anything inserted during a disconnect.
|
||||
sql
|
||||
.listen(
|
||||
'tasks_new',
|
||||
() => triggerPoll('notify'),
|
||||
() => triggerPoll('listen-subscribed'),
|
||||
)
|
||||
.then((meta) => {
|
||||
listener = meta;
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error({ err }, 'dispatcher: failed to LISTEN tasks_new — relying on poll fallback');
|
||||
});
|
||||
}, POLL_INTERVAL_MS);
|
||||
},
|
||||
|
||||
async stop() {
|
||||
@@ -477,6 +508,12 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (listener) {
|
||||
await listener.unlisten().catch((err) => {
|
||||
log.error({ err }, 'dispatcher: unlisten error');
|
||||
});
|
||||
listener = null;
|
||||
}
|
||||
if (inflightPromise) {
|
||||
log.info('dispatcher: waiting for in-flight task');
|
||||
await inflightPromise;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Blocks ACP dispatch on permission prompts until the user responds via API.
|
||||
* Blocks ACP dispatch on permission/elicitation prompts until the user responds via API.
|
||||
*/
|
||||
import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk';
|
||||
import type { RequestPermissionRequest, RequestPermissionResponse, CreateElicitationRequest, CreateElicitationResponse } from '@agentclientprotocol/sdk';
|
||||
import { isUnattendedMode } from './provider-manifest.js';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
|
||||
interface PendingPermission {
|
||||
type: 'permission';
|
||||
request: RequestPermissionRequest;
|
||||
sessionId: string;
|
||||
resolve: (response: RequestPermissionResponse) => void;
|
||||
@@ -14,11 +15,27 @@ interface PendingPermission {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const pendingByTask = new Map<string, PendingPermission>();
|
||||
interface PendingElicitation {
|
||||
type: 'elicitation';
|
||||
request: CreateElicitationRequest;
|
||||
sessionId: string;
|
||||
resolve: (response: CreateElicitationResponse) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
type PendingEntry = PendingPermission | PendingElicitation;
|
||||
|
||||
const pendingByTask = new Map<string, PendingEntry>();
|
||||
|
||||
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
|
||||
|
||||
export interface PermissionPrompt {
|
||||
taskId: string;
|
||||
kind: PermissionKind;
|
||||
toolTitle?: string;
|
||||
description?: string;
|
||||
input?: Record<string, unknown>;
|
||||
options: Array<{ optionId: string; label: string }>;
|
||||
}
|
||||
|
||||
@@ -33,10 +50,25 @@ export function setPermissionHooks(next: PermissionHooks): void {
|
||||
hooks = next;
|
||||
}
|
||||
|
||||
function resolveKind(params: RequestPermissionRequest): PermissionKind {
|
||||
const input = params.toolCall?.rawInput;
|
||||
if (input && typeof input === 'object' && !Array.isArray(input) && 'questions' in input && Array.isArray((input as Record<string, unknown>).questions)) {
|
||||
return 'question';
|
||||
}
|
||||
return 'tool';
|
||||
}
|
||||
|
||||
function toPrompt(taskId: string, params: RequestPermissionRequest): PermissionPrompt {
|
||||
const kind = resolveKind(params);
|
||||
const rawInput = params.toolCall?.rawInput;
|
||||
const input = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
|
||||
? rawInput as Record<string, unknown>
|
||||
: undefined;
|
||||
return {
|
||||
taskId,
|
||||
kind,
|
||||
toolTitle: params.toolCall?.title ?? undefined,
|
||||
...(input ? { input } : {}),
|
||||
options: params.options.map((o) => ({
|
||||
optionId: o.optionId,
|
||||
label: o.name,
|
||||
@@ -73,24 +105,33 @@ export function waitForPermissionResponse(
|
||||
resolve({ outcome: { outcome: 'cancelled' } });
|
||||
}, timeoutMs);
|
||||
|
||||
pendingByTask.set(taskId, { request: params, sessionId, resolve, reject, timer });
|
||||
pendingByTask.set(taskId, { type: 'permission', request: params, sessionId, resolve, reject, timer });
|
||||
|
||||
const prompt = toPrompt(taskId, params);
|
||||
void hooks.onPrompt?.({ ...prompt, sessionId });
|
||||
});
|
||||
}
|
||||
|
||||
export function respondToPermission(taskId: string, optionId: string | null): boolean {
|
||||
export function respondToPermission(taskId: string, optionId: string | null, updatedInput?: Record<string, unknown>): boolean {
|
||||
const pending = pendingByTask.get(taskId);
|
||||
if (!pending) return false;
|
||||
|
||||
clearTimeout(pending.timer);
|
||||
pendingByTask.delete(taskId);
|
||||
|
||||
if (optionId) {
|
||||
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
||||
if (pending.type === 'elicitation') {
|
||||
if (updatedInput) {
|
||||
const content = updatedInput as { [key: string]: string | number | boolean | string[] };
|
||||
pending.resolve({ action: 'accept', content });
|
||||
} else {
|
||||
pending.resolve({ action: 'decline' });
|
||||
}
|
||||
} else {
|
||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
if (optionId) {
|
||||
pending.resolve({ outcome: { outcome: 'selected', optionId } });
|
||||
} else {
|
||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
}
|
||||
}
|
||||
|
||||
void hooks.onResolved?.(taskId, pending.sessionId);
|
||||
@@ -100,14 +141,67 @@ export function respondToPermission(taskId: string, optionId: string | null): bo
|
||||
export function getPendingPermission(taskId: string): PermissionPrompt | null {
|
||||
const pending = pendingByTask.get(taskId);
|
||||
if (!pending) return null;
|
||||
if (pending.type === 'elicitation') {
|
||||
return elicitationToPrompt(taskId, pending.request);
|
||||
}
|
||||
return toPrompt(taskId, pending.request);
|
||||
}
|
||||
|
||||
function elicitationToPrompt(taskId: string, params: CreateElicitationRequest): PermissionPrompt {
|
||||
const input: Record<string, unknown> = { message: params.message };
|
||||
if ('requestedSchema' in params) {
|
||||
input.requestedSchema = params.requestedSchema;
|
||||
}
|
||||
return {
|
||||
taskId,
|
||||
kind: 'elicitation',
|
||||
toolTitle: params.message,
|
||||
input,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function waitForElicitationResponse(
|
||||
taskId: string,
|
||||
sessionId: string,
|
||||
provider: string,
|
||||
modeId: string | undefined,
|
||||
params: CreateElicitationRequest,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<CreateElicitationResponse> {
|
||||
if (isUnattendedMode(provider, modeId)) {
|
||||
return Promise.resolve({ action: 'decline' });
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const existing = pendingByTask.get(taskId);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer);
|
||||
existing.reject(new Error('superseded by newer elicitation request'));
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pendingByTask.delete(taskId);
|
||||
void hooks.onResolved?.(taskId, sessionId);
|
||||
resolve({ action: 'cancel' });
|
||||
}, timeoutMs);
|
||||
|
||||
pendingByTask.set(taskId, { type: 'elicitation', request: params, sessionId, resolve, reject, timer });
|
||||
|
||||
const prompt = elicitationToPrompt(taskId, params);
|
||||
void hooks.onPrompt?.({ ...prompt, sessionId });
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelPendingPermission(taskId: string): void {
|
||||
const pending = pendingByTask.get(taskId);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
pendingByTask.delete(taskId);
|
||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
if (pending.type === 'elicitation') {
|
||||
pending.resolve({ action: 'cancel' });
|
||||
} else {
|
||||
pending.resolve({ outcome: { outcome: 'cancelled' } });
|
||||
}
|
||||
void hooks.onResolved?.(taskId, pending.sessionId);
|
||||
}
|
||||
|
||||
@@ -27,13 +27,6 @@ const OPENCODE_COMMANDS: AgentCommand[] = [
|
||||
{ name: 'export', description: 'Export session' },
|
||||
];
|
||||
|
||||
const CURSOR_COMMANDS: AgentCommand[] = [
|
||||
{ name: 'help', description: 'Show available slash commands' },
|
||||
{ name: 'clear', description: 'Clear conversation' },
|
||||
{ name: 'compact', description: 'Compact context' },
|
||||
{ name: 'resume', description: 'Resume a prior session' },
|
||||
];
|
||||
|
||||
const GOOSE_COMMANDS: AgentCommand[] = [
|
||||
{ name: 'help', description: 'Show available commands' },
|
||||
{ name: 'clear', description: 'Clear conversation' },
|
||||
@@ -49,23 +42,12 @@ const QWEN_COMMANDS: AgentCommand[] = [
|
||||
{ name: 'review', description: 'Review changes' },
|
||||
];
|
||||
|
||||
const COPILOT_COMMANDS: AgentCommand[] = [
|
||||
{ name: 'help', description: 'Show available commands' },
|
||||
{ name: 'explain', description: 'Explain selected code' },
|
||||
{ name: 'fix', description: 'Fix issues in context' },
|
||||
{ name: 'tests', description: 'Generate or run tests' },
|
||||
{ name: 'doc', description: 'Generate documentation' },
|
||||
{ name: 'clear', description: 'Clear conversation' },
|
||||
];
|
||||
|
||||
/** boocode harness uses /api/skills — merged on the frontend. */
|
||||
export const PROVIDER_COMMANDS: Record<string, AgentCommand[]> = {
|
||||
claude: CLAUDE_COMMANDS,
|
||||
opencode: OPENCODE_COMMANDS,
|
||||
cursor: CURSOR_COMMANDS,
|
||||
goose: GOOSE_COMMANDS,
|
||||
qwen: QWEN_COMMANDS,
|
||||
copilot: COPILOT_COMMANDS,
|
||||
boocode: [],
|
||||
};
|
||||
|
||||
|
||||
133
apps/coder/src/services/provider-config-registry.ts
Normal file
133
apps/coder/src/services/provider-config-registry.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* v2.3 resolved provider registry — single in-memory source of truth after
|
||||
* merging the hardcoded built-ins (provider-registry.ts) with the config file
|
||||
* (provider-config.ts). Mirrors Paseo's buildProviderRegistry/addDerivedProviders.
|
||||
*
|
||||
* Phase 1 scope: build + expose the resolved registry. `launchCommand` is null
|
||||
* for built-ins (the default argv is resolved at dispatch time in Phase 3) and
|
||||
* is the config `command` for custom ACP entries. No DB columns (design.md §3.3);
|
||||
* `enabled` lives in memory only.
|
||||
*/
|
||||
import type { ProviderDef } from './provider-registry.js';
|
||||
import { PROVIDERS } from './provider-registry.js';
|
||||
import { load, type CoderProvidersFile } from './provider-config.js';
|
||||
|
||||
export interface ResolvedProviderDef extends ProviderDef {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
isBuiltin: boolean;
|
||||
isCustomAcp: boolean;
|
||||
/** Full argv for spawn: [binary, ...args]. Null for built-ins (resolved at dispatch). */
|
||||
launchCommand: [string, ...string[]] | null;
|
||||
env: Record<string, string> | undefined;
|
||||
configLabel?: string;
|
||||
configDescription?: string;
|
||||
/** Config `models` — REPLACES the discovered/static model list when present. */
|
||||
configModels?: Array<{ id: string; label: string }>;
|
||||
/** Config `additionalModels` — MERGED on top of the resolved model list. */
|
||||
configAdditionalModels?: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge built-ins with config overrides into the resolved registry.
|
||||
* Algorithm verbatim from design.md §3.1.
|
||||
*/
|
||||
export function buildResolvedRegistry(
|
||||
builtins: ProviderDef[],
|
||||
config: CoderProvidersFile,
|
||||
): Map<string, ResolvedProviderDef> {
|
||||
const out = new Map<string, ResolvedProviderDef>();
|
||||
const overrides = config.providers ?? {};
|
||||
const builtinNames = new Set(builtins.map((b) => b.name));
|
||||
|
||||
// 1. Built-ins, applying a config override if one is present.
|
||||
for (const def of builtins) {
|
||||
const ov = overrides[def.name];
|
||||
let enabled = ov?.enabled !== false;
|
||||
|
||||
// 3. boocode is always enabled; an enabled:false override is ignored + warned.
|
||||
if (def.name === 'boocode' && ov?.enabled === false) {
|
||||
console.warn("provider-config: ignoring enabled:false for built-in 'boocode' (always enabled)");
|
||||
enabled = true;
|
||||
}
|
||||
|
||||
const launchCommand =
|
||||
ov?.command && ov.command.length > 0 ? (ov.command as [string, ...string[]]) : null;
|
||||
|
||||
out.set(def.name, {
|
||||
...def,
|
||||
label: ov?.label ?? def.label,
|
||||
id: def.name,
|
||||
enabled,
|
||||
isBuiltin: true,
|
||||
isCustomAcp: false,
|
||||
launchCommand,
|
||||
env: ov?.env,
|
||||
configLabel: ov?.label,
|
||||
configDescription: ov?.description,
|
||||
configModels: ov?.models,
|
||||
configAdditionalModels: ov?.additionalModels,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Config ids that are not built-ins → custom ACP entries.
|
||||
for (const [id, ov] of Object.entries(overrides)) {
|
||||
if (builtinNames.has(id)) continue;
|
||||
// §2.2 rules: "New id without extends → Reject at load with log."
|
||||
if (ov.extends !== 'acp' || !ov.label || !ov.command || ov.command.length === 0) {
|
||||
console.warn(
|
||||
`provider-config: skipping custom provider '${id}' — requires extends:'acp', label, and command`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
out.set(id, {
|
||||
name: id,
|
||||
label: ov.label,
|
||||
transport: 'acp',
|
||||
modelSource: 'probe',
|
||||
id,
|
||||
enabled: ov.enabled !== false,
|
||||
isBuiltin: false,
|
||||
isCustomAcp: true,
|
||||
launchCommand: ov.command as [string, ...string[]],
|
||||
env: ov.env,
|
||||
configLabel: ov.label,
|
||||
configDescription: ov.description,
|
||||
configModels: ov.models,
|
||||
configAdditionalModels: ov.additionalModels,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Module singleton ---------------------------------------------------------
|
||||
|
||||
let cachedRegistry: Map<string, ResolvedProviderDef> | null = null;
|
||||
let cachedPath: string | null = null;
|
||||
|
||||
/** Load the config file at `path`, rebuild, and cache the resolved registry. */
|
||||
export function loadProviderConfig(path: string): Map<string, ResolvedProviderDef> {
|
||||
cachedPath = path;
|
||||
cachedRegistry = buildResolvedRegistry(PROVIDERS, load(path));
|
||||
return cachedRegistry;
|
||||
}
|
||||
|
||||
/** Re-read the last-loaded config file and rebuild (Phase 4 calls this after PATCH). */
|
||||
export function reloadProviderConfig(): Map<string, ResolvedProviderDef> {
|
||||
if (cachedPath == null) {
|
||||
cachedRegistry = buildResolvedRegistry(PROVIDERS, { providers: {} });
|
||||
return cachedRegistry;
|
||||
}
|
||||
return loadProviderConfig(cachedPath);
|
||||
}
|
||||
|
||||
/** The cached resolved registry (built-ins only if nothing has been loaded yet). */
|
||||
export function getResolvedRegistry(): Map<string, ResolvedProviderDef> {
|
||||
return cachedRegistry ?? buildResolvedRegistry(PROVIDERS, { providers: {} });
|
||||
}
|
||||
|
||||
/** Resolved provider ids in registry order. */
|
||||
export function getResolvedProviderIds(): string[] {
|
||||
return [...getResolvedRegistry().keys()];
|
||||
}
|
||||
100
apps/coder/src/services/provider-config.ts
Normal file
100
apps/coder/src/services/provider-config.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* v2.3 provider config file (`/data/coder-providers.json`) — schema + loader.
|
||||
*
|
||||
* Layers config-backed overrides/custom-ACP entries over the hardcoded built-ins
|
||||
* (see provider-config-registry.ts). Loading NEVER throws at startup (design.md
|
||||
* §2.1): a missing file, invalid JSON, or schema mismatch all fall back to
|
||||
* `{ providers: {} }` (built-ins only, all enabled).
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schemas verbatim from design.md §2.2.
|
||||
export const ProviderOverrideSchema = z.object({
|
||||
extends: z.enum(['acp']).optional(), // v2.3: only 'acp' for custom; built-ins omit extends
|
||||
label: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
command: z.array(z.string().min(1)).min(1).optional(), // [binary, ...args]
|
||||
env: z.record(z.string()).optional(),
|
||||
enabled: z.boolean().optional(), // default true
|
||||
order: z.number().int().optional(), // UI sort key
|
||||
models: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
|
||||
additionalModels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
|
||||
});
|
||||
|
||||
export const CoderProvidersFileSchema = z.object({
|
||||
providers: z.record(ProviderOverrideSchema).default({}),
|
||||
});
|
||||
|
||||
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
|
||||
export type CoderProvidersFile = z.infer<typeof CoderProvidersFileSchema>;
|
||||
|
||||
/**
|
||||
* PATCH body schema (design.md §6.2). A partial providers map where each value
|
||||
* is either a full override object (REPLACES that id's override) or `null`
|
||||
* (DELETES the override → revert to the built-in default). Ids absent from the
|
||||
* patch are left untouched. The route validates the body against this first
|
||||
* (malformed → 422) so a bad shape can never reach the merge/save step.
|
||||
*/
|
||||
export const ProviderConfigPatchSchema = z.object({
|
||||
providers: z.record(ProviderOverrideSchema.nullable()).default({}),
|
||||
});
|
||||
|
||||
export type ProviderConfigPatch = z.infer<typeof ProviderConfigPatchSchema>;
|
||||
|
||||
/**
|
||||
* Shallow per-id merge (design.md §6.2 / Paseo `patchConfig`). Each key in
|
||||
* `patch.providers` REPLACES that id's override object wholesale (NOT a deep
|
||||
* field merge); a `null` value DELETES the override. Returns a new object —
|
||||
* never mutates `current`. The result is a plain CoderProvidersFile (no nulls),
|
||||
* which the route re-validates against CoderProvidersFileSchema before save.
|
||||
*/
|
||||
export function mergeProviderConfigPatch(
|
||||
current: CoderProvidersFile,
|
||||
patch: ProviderConfigPatch,
|
||||
): CoderProvidersFile {
|
||||
const providers: Record<string, ProviderOverride> = { ...current.providers };
|
||||
for (const [id, override] of Object.entries(patch.providers)) {
|
||||
if (override === null) {
|
||||
delete providers[id];
|
||||
} else {
|
||||
providers[id] = override;
|
||||
}
|
||||
}
|
||||
return { providers };
|
||||
}
|
||||
|
||||
/** Read + parse + validate. Falls back to built-ins-only on any failure; never throws. */
|
||||
export function load(path: string): CoderProvidersFile {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path, 'utf8');
|
||||
} catch {
|
||||
// Missing file → built-ins only. Expected, not an error.
|
||||
return { providers: {} };
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error(`provider-config: invalid JSON in ${path} — using built-ins only`, err);
|
||||
return { providers: {} };
|
||||
}
|
||||
|
||||
const parsed = CoderProvidersFileSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.error(
|
||||
`provider-config: schema validation failed for ${path} — using built-ins only`,
|
||||
parsed.error.flatten(),
|
||||
);
|
||||
return { providers: {} };
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/** Write the config back to disk (used by the Phase 4 PATCH route). */
|
||||
export function save(path: string, config: CoderProvidersFile): void {
|
||||
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
71
apps/coder/src/services/provider-diagnostic.ts
Normal file
71
apps/coder/src/services/provider-diagnostic.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* v2.3 Phase 4 (design.md §8) — per-provider plaintext diagnostic report.
|
||||
*
|
||||
* Read-only by default: reports CACHED state (resolved registry def + the
|
||||
* available_agents row + the warm snapshot-cache entry) plus a `which`-style
|
||||
* PATH check for the launch binary. It does NOT spawn an ACP probe — §8 lists
|
||||
* the live initialize probe as optional, and the route defaults to cached state.
|
||||
*
|
||||
* A template string is the whole formatter (no Paseo diagnostic-utils port).
|
||||
*/
|
||||
import type { ResolvedProviderDef } from './provider-config-registry.js';
|
||||
import type { ProviderSnapshotEntry, ProviderModel } from './provider-types.js';
|
||||
import { isCommandAvailable } from './command-availability.js';
|
||||
|
||||
/** The subset of an `available_agents` row the diagnostic reads. */
|
||||
export interface DiagnosticAgentRow {
|
||||
name: string;
|
||||
install_path: string | null;
|
||||
supports_acp?: boolean;
|
||||
models?: ProviderModel[] | null;
|
||||
last_probed_at?: string | Date | null;
|
||||
}
|
||||
|
||||
interface DiagnosticOpts {
|
||||
/** Warm snapshot-cache entry (read-only peek) — source of the last probe error. */
|
||||
cachedEntry?: ProviderSnapshotEntry;
|
||||
/** Injectable PATH check (defaults to the real `which`); stubbed in tests. */
|
||||
checkAvailable?: (binary: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Resolve the binary the dispatcher would launch (for the PATH check + report). */
|
||||
function resolveBinary(resolved: ResolvedProviderDef, agentRow: DiagnosticAgentRow | undefined): string {
|
||||
return resolved.launchCommand?.[0] ?? agentRow?.install_path ?? resolved.id;
|
||||
}
|
||||
|
||||
export async function getProviderDiagnostic(
|
||||
resolved: ResolvedProviderDef,
|
||||
agentRow: DiagnosticAgentRow | undefined,
|
||||
opts: DiagnosticOpts = {},
|
||||
): Promise<string> {
|
||||
const checkAvailable = opts.checkAvailable ?? isCommandAvailable;
|
||||
const installed = agentRow?.install_path != null;
|
||||
const binary = resolveBinary(resolved, agentRow);
|
||||
// boocode is native (no binary to launch) — short-circuit the PATH check.
|
||||
const commandAvailable = resolved.transport === 'native' ? true : await checkAvailable(binary);
|
||||
const lastProbedAt =
|
||||
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).toISOString() : '(never)';
|
||||
const modelCount = agentRow?.models?.length ?? 0;
|
||||
const launchCommand = resolved.launchCommand
|
||||
? resolved.launchCommand.join(' ')
|
||||
: '(built-in default, resolved at dispatch)';
|
||||
const lastError = opts.cachedEntry?.error ?? '(none recorded)';
|
||||
|
||||
return [
|
||||
`provider: ${resolved.id}`,
|
||||
`label: ${resolved.configLabel ?? resolved.label}`,
|
||||
`transport: ${resolved.transport}`,
|
||||
`enabled: ${resolved.enabled}`,
|
||||
`builtin: ${resolved.isBuiltin}`,
|
||||
`customAcp: ${resolved.isCustomAcp}`,
|
||||
`installed: ${installed}`,
|
||||
`install_path: ${agentRow?.install_path ?? '(none)'}`,
|
||||
`binary: ${binary}`,
|
||||
`command_available: ${commandAvailable}`,
|
||||
`launch_command: ${launchCommand}`,
|
||||
`supports_acp: ${agentRow?.supports_acp ?? '(unknown)'}`,
|
||||
`last_probed_at: ${lastProbedAt}`,
|
||||
`models_in_db: ${modelCount}`,
|
||||
`last_probe_error: ${lastError}`,
|
||||
].join('\n');
|
||||
}
|
||||
@@ -24,31 +24,6 @@ const OPENCODE_MODES: ProviderMode[] = [
|
||||
{ id: 'full-access', label: 'Full Access', description: 'Auto-approves all tool prompts', isUnattended: true },
|
||||
];
|
||||
|
||||
const COPILOT_MODES: ProviderMode[] = [
|
||||
{
|
||||
id: 'https://agentclientprotocol.com/protocol/session-modes#agent',
|
||||
label: 'Agent',
|
||||
description: 'Default agent mode',
|
||||
},
|
||||
{
|
||||
id: 'https://agentclientprotocol.com/protocol/session-modes#plan',
|
||||
label: 'Plan',
|
||||
description: 'Plan mode for multi-step work',
|
||||
},
|
||||
{
|
||||
id: 'allow-all',
|
||||
label: 'Allow All',
|
||||
description: 'Automatically approves all tool, path, and URL requests',
|
||||
isUnattended: true,
|
||||
},
|
||||
];
|
||||
|
||||
const CURSOR_CLI_MODES: ProviderMode[] = [
|
||||
{ id: 'agent', label: 'Agent', description: 'Full agent capabilities with tool access' },
|
||||
{ id: 'plan', label: 'Plan', description: 'Read-only planning mode' },
|
||||
{ id: 'ask', label: 'Ask', description: 'Q&A read-only mode' },
|
||||
];
|
||||
|
||||
const QWEN_PTY_MODES: ProviderMode[] = [
|
||||
{ id: 'default', label: 'Default', description: 'Prompt for approval' },
|
||||
{ id: 'plan', label: 'Plan', description: 'Plan only — no edits' },
|
||||
@@ -75,14 +50,6 @@ export const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> = {
|
||||
defaultModeId: 'build',
|
||||
modes: OPENCODE_MODES,
|
||||
},
|
||||
copilot: {
|
||||
defaultModeId: 'https://agentclientprotocol.com/protocol/session-modes#agent',
|
||||
modes: COPILOT_MODES,
|
||||
},
|
||||
cursor: {
|
||||
defaultModeId: 'agent',
|
||||
modes: CURSOR_CLI_MODES,
|
||||
},
|
||||
goose: {
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
|
||||
@@ -13,8 +13,7 @@ export interface ProviderDef {
|
||||
* - boocode: llama-swap only
|
||||
* - opencode: ACP probe + mergeLlamaSwap (prefixed llama-swap/* ids)
|
||||
* - qwen: ACP probe + merge ~/.qwen/settings.json; PTY fallback reads settings only
|
||||
* - cursor: ACP probe + cursor-agent models CLI fallback
|
||||
* - goose / copilot: ACP probe only
|
||||
* - goose: ACP probe only
|
||||
* - claude: static manifest models + thinking options
|
||||
*/
|
||||
export const PROVIDERS: ProviderDef[] = [
|
||||
@@ -24,12 +23,6 @@ export const PROVIDERS: ProviderDef[] = [
|
||||
transport: 'native',
|
||||
modelSource: 'llama-swap',
|
||||
},
|
||||
{
|
||||
name: 'cursor',
|
||||
label: 'Cursor Agent',
|
||||
transport: 'acp',
|
||||
modelSource: 'probe',
|
||||
},
|
||||
{
|
||||
name: 'opencode',
|
||||
label: 'OpenCode',
|
||||
@@ -48,9 +41,18 @@ export const PROVIDERS: ProviderDef[] = [
|
||||
label: 'Claude Code',
|
||||
transport: 'pty',
|
||||
modelSource: 'static',
|
||||
// Passed verbatim to `claude --model <id>` (PTY dispatch). The CLI accepts a
|
||||
// latest-alias ('opus'/'sonnet'/'haiku') or a pinned full name
|
||||
// ('claude-opus-4-8'). Aliases never go stale; pinned IDs let you select an
|
||||
// exact version. Extend/replace per-install via data/coder-providers.json
|
||||
// (models / additionalModels) without a code change.
|
||||
staticModels: [
|
||||
{ id: 'claude-opus-4-20250514', label: 'Opus 4' },
|
||||
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
|
||||
{ id: 'opus', label: 'Opus (latest)' },
|
||||
{ id: 'claude-opus-4-8', label: 'Opus 4.8' },
|
||||
{ id: 'sonnet', label: 'Sonnet (latest)' },
|
||||
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
|
||||
{ id: 'haiku', label: 'Haiku (latest)' },
|
||||
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -59,12 +61,6 @@ export const PROVIDERS: ProviderDef[] = [
|
||||
transport: 'acp',
|
||||
modelSource: 'probe',
|
||||
},
|
||||
{
|
||||
name: 'copilot',
|
||||
label: 'GitHub Copilot',
|
||||
transport: 'acp',
|
||||
modelSource: 'probe',
|
||||
},
|
||||
];
|
||||
|
||||
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));
|
||||
|
||||
@@ -2,32 +2,31 @@
|
||||
* Provider snapshot cache — cold ACP probe per provider + static manifest merge.
|
||||
*/
|
||||
import { homedir } from 'node:os';
|
||||
import { exec as execCb } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
|
||||
import {
|
||||
getManifestDefaultModeId,
|
||||
getManifestModes,
|
||||
PROVIDER_MANIFEST,
|
||||
} from './provider-manifest.js';
|
||||
import { probeAcpProvider } from './acp-probe.js';
|
||||
import { parseCursorAgentModelsOutput } from './cursor-models.js';
|
||||
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
|
||||
import type { ProviderModel, ProviderSnapshotEntry, AgentCommand } from './provider-types.js';
|
||||
import { getManifestCommands, mergeCommands } from './provider-commands.js';
|
||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
|
||||
import { isCommandAvailable } from './command-availability.js';
|
||||
import { discoverClaudeCommands } from './claude-command-discovery.js';
|
||||
|
||||
interface AgentRow {
|
||||
name: string;
|
||||
install_path: string | null;
|
||||
supports_acp: boolean;
|
||||
models: ProviderModel[] | null;
|
||||
commands: AgentCommand[] | null;
|
||||
label: string | null;
|
||||
transport: string | null;
|
||||
last_probed_at: string | Date | null;
|
||||
}
|
||||
|
||||
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
||||
@@ -41,15 +40,6 @@ async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCursorModelsCli(installPath: string): Promise<ProviderModel[]> {
|
||||
try {
|
||||
const { stdout } = await exec(`"${installPath}" models`, { timeout: 15_000, maxBuffer: 1024 * 1024 });
|
||||
return parseCursorAgentModelsOutput(stdout);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Prefix llama-swap model ids so they don't collide with provider-native models. */
|
||||
export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[] {
|
||||
return models.map((m) => ({
|
||||
@@ -82,112 +72,155 @@ export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
|
||||
}
|
||||
|
||||
async function buildProviderEntry(
|
||||
provider: ProviderDef,
|
||||
resolved: ResolvedProviderDef,
|
||||
agentRow: AgentRow | undefined,
|
||||
llamaModels: ProviderModel[],
|
||||
cwd: string,
|
||||
): Promise<ProviderSnapshotEntry | null> {
|
||||
const isNative = provider.name === 'boocode';
|
||||
const installed = isNative || !!agentRow;
|
||||
if (!installed) return null;
|
||||
ttlMs: number,
|
||||
force: boolean,
|
||||
): Promise<ProviderSnapshotEntry> {
|
||||
const name = resolved.id;
|
||||
const isNative = resolved.transport === 'native';
|
||||
const fallbackModes = getManifestModes(name);
|
||||
const defaultModeId = getManifestDefaultModeId(name);
|
||||
const manifestCommands = getManifestCommands(name);
|
||||
// Manifest + persisted live ACP commands (captured on a prior cold probe), so
|
||||
// the agent's discovered commands show even when the tier-2 probe is skipped.
|
||||
const dbCommands = mergeCommands(manifestCommands, agentRow?.commands ?? []);
|
||||
const label = agentRow?.label ?? resolved.configLabel ?? resolved.label;
|
||||
const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
|
||||
|
||||
let transport = provider.transport;
|
||||
if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) {
|
||||
// v2.3: config `models` REPLACES the discovered/static list; `additionalModels`
|
||||
// MERGES on top. Applied to every ready/installed model list below.
|
||||
const withConfigModels = (m: ProviderModel[]): ProviderModel[] => {
|
||||
let out = resolved.configModels && resolved.configModels.length > 0 ? resolved.configModels : m;
|
||||
if (resolved.configAdditionalModels && resolved.configAdditionalModels.length > 0) {
|
||||
out = mergeModels(out, resolved.configAdditionalModels);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// ACP built-ins fall back to PTY transport when the installed binary lacks ACP.
|
||||
let transport = resolved.transport;
|
||||
if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) {
|
||||
transport = 'pty';
|
||||
}
|
||||
|
||||
const fallbackModes = getManifestModes(provider.name);
|
||||
const defaultModeId = getManifestDefaultModeId(provider.name);
|
||||
|
||||
if (isNative) {
|
||||
// 1. Disabled → unavailable, no probe.
|
||||
if (!resolved.enabled) {
|
||||
return {
|
||||
name: provider.name,
|
||||
label: provider.label,
|
||||
transport,
|
||||
status: 'ready',
|
||||
installed: true,
|
||||
models: llamaModels,
|
||||
modes: [],
|
||||
defaultModeId: null,
|
||||
commands: getManifestCommands(provider.name),
|
||||
name, label, ...descr, transport, status: 'unavailable',
|
||||
enabled: false, installed: false, models: [], modes: fallbackModes,
|
||||
defaultModeId, commands: manifestCommands,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Native boocode → always ready (llama-swap models).
|
||||
if (isNative) {
|
||||
return {
|
||||
name, label: resolved.label, transport, status: 'ready',
|
||||
enabled: true, installed: true, models: withConfigModels(llamaModels), modes: [],
|
||||
defaultModeId: null, commands: manifestCommands,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Tier-1 fast availability: installed iff a probed install_path exists or
|
||||
// the launch binary is on PATH. No spawn beyond a `which` for custom entries.
|
||||
const fast =
|
||||
agentRow?.install_path != null ||
|
||||
(resolved.launchCommand ? await isCommandAvailable(resolved.launchCommand[0]) : false);
|
||||
|
||||
if (!fast) {
|
||||
return {
|
||||
name, label, ...descr, transport, status: 'unavailable',
|
||||
enabled: true, installed: false, models: [], modes: fallbackModes,
|
||||
defaultModeId, commands: manifestCommands,
|
||||
};
|
||||
}
|
||||
|
||||
// Baseline model precedence (used by claude + non-probe fallbacks).
|
||||
let models: ProviderModel[] = [];
|
||||
if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) {
|
||||
if (resolved.modelSource === 'llama-swap' && resolved.mergeLlamaSwap) {
|
||||
models = llamaModels;
|
||||
} else if (agentRow?.models?.length) {
|
||||
models = agentRow.models;
|
||||
} else if (provider.staticModels) {
|
||||
models = provider.staticModels.map((m) => ({ id: m.id, label: m.label }));
|
||||
} else if (resolved.staticModels) {
|
||||
models = resolved.staticModels.map((m) => ({ id: m.id, label: m.label }));
|
||||
}
|
||||
|
||||
if (provider.name === 'claude') {
|
||||
models = attachClaudeThinking(models);
|
||||
// claude: static models + thinking options, no ACP probe (unchanged from v2.2).
|
||||
if (name === 'claude') {
|
||||
// claude is PTY (no ACP discovery) — read its enabled commands + plugin
|
||||
// skills from disk live (the snapshot cache rate-limits the fs reads).
|
||||
return {
|
||||
name: provider.name,
|
||||
label: agentRow?.label ?? provider.label,
|
||||
transport,
|
||||
status: 'ready',
|
||||
installed: true,
|
||||
models,
|
||||
modes: fallbackModes,
|
||||
defaultModeId,
|
||||
commands: getManifestCommands(provider.name),
|
||||
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||
models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
|
||||
commands: mergeCommands(manifestCommands, discoverClaudeCommands()),
|
||||
};
|
||||
}
|
||||
|
||||
if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) {
|
||||
const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd);
|
||||
if (probe.models.length > 0) {
|
||||
models = probe.models;
|
||||
} else if (provider.name === 'cursor' && agentRow.install_path) {
|
||||
models = await fetchCursorModelsCli(agentRow.install_path);
|
||||
} else if (provider.modelSource === 'llama-swap') {
|
||||
models = llamaModels;
|
||||
const canProbeAcp =
|
||||
transport === 'acp' &&
|
||||
((agentRow?.install_path != null && agentRow.supports_acp) ||
|
||||
(resolved.isCustomAcp && resolved.launchCommand != null));
|
||||
|
||||
if (canProbeAcp) {
|
||||
// Tier-2 gate (§4.3): cold ACP probe only on force, staleness, or empty DB
|
||||
// models. Otherwise serve DB models + manifest modes/commands — no spawn.
|
||||
const lastProbedMs =
|
||||
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).getTime() : NaN;
|
||||
const stale = Number.isNaN(lastProbedMs) || Date.now() - lastProbedMs > ttlMs;
|
||||
const dbEmpty = !(agentRow?.models && agentRow.models.length > 0);
|
||||
const runTier2 = force || stale || dbEmpty;
|
||||
|
||||
if (!runTier2) {
|
||||
let skipModels = agentRow?.models ?? [];
|
||||
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
|
||||
skipModels = mergeModels(skipModels, prefixLlamaSwapModels(llamaModels));
|
||||
} else if (resolved.modelSource === 'llama-swap' && skipModels.length === 0) {
|
||||
skipModels = llamaModels;
|
||||
}
|
||||
return {
|
||||
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||
models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: dbCommands,
|
||||
};
|
||||
}
|
||||
|
||||
if (provider.name === 'qwen') {
|
||||
const settingsModels = await readQwenSettingsModels();
|
||||
models = mergeModels(models, settingsModels);
|
||||
}
|
||||
const probeTarget =
|
||||
resolved.isCustomAcp && resolved.launchCommand
|
||||
? resolved.launchCommand[0]
|
||||
: agentRow!.install_path!;
|
||||
const probe = await probeAcpProvider(name, probeTarget, cwd);
|
||||
|
||||
if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') {
|
||||
const nativeModels = probe.models.length > 0 ? probe.models : models;
|
||||
models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
|
||||
let probeModels = probe.models.length > 0 ? probe.models : models;
|
||||
if (name === 'qwen') {
|
||||
probeModels = mergeModels(probeModels, await readQwenSettingsModels());
|
||||
}
|
||||
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
|
||||
const nativeModels = probe.models.length > 0 ? probe.models : probeModels;
|
||||
probeModels = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
|
||||
}
|
||||
|
||||
return {
|
||||
name: provider.name,
|
||||
label: agentRow.label ?? provider.label,
|
||||
transport,
|
||||
name, label, transport,
|
||||
status: probe.ok ? 'ready' : 'error',
|
||||
installed: true,
|
||||
models,
|
||||
enabled: true, installed: true,
|
||||
models: withConfigModels(probeModels),
|
||||
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
|
||||
defaultModeId: probe.defaultModeId ?? defaultModeId,
|
||||
commands: mergeCommands(getManifestCommands(provider.name), probe.commands),
|
||||
error: probe.error,
|
||||
commands: mergeCommands(manifestCommands, probe.commands),
|
||||
...(probe.error ? { error: probe.error } : {}),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// PTY-only providers (qwen fallback when ACP unavailable)
|
||||
if (provider.name === 'qwen') {
|
||||
if (models.length === 0) {
|
||||
models = await readQwenSettingsModels();
|
||||
}
|
||||
// PTY-only fallback (e.g. qwen without ACP) — installed + ready.
|
||||
if (name === 'qwen' && models.length === 0) {
|
||||
models = await readQwenSettingsModels();
|
||||
}
|
||||
|
||||
return {
|
||||
name: provider.name,
|
||||
label: agentRow?.label ?? provider.label,
|
||||
transport,
|
||||
status: 'ready',
|
||||
installed: true,
|
||||
models,
|
||||
modes: fallbackModes,
|
||||
defaultModeId,
|
||||
commands: getManifestCommands(provider.name),
|
||||
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||
models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: dbCommands,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,16 +249,16 @@ export async function getProviderSnapshot(
|
||||
const build = async (): Promise<ProviderSnapshotEntry[]> => {
|
||||
const llamaModels = await fetchLlamaSwapModels(config);
|
||||
const agents = await sql<AgentRow[]>`
|
||||
SELECT name, install_path, supports_acp, models, label, transport FROM available_agents
|
||||
SELECT name, install_path, supports_acp, models, commands, label, transport, last_probed_at FROM available_agents
|
||||
`;
|
||||
const agentMap = new Map(agents.map((a) => [a.name, a]));
|
||||
const ttlMs = config.PROVIDER_PROBE_TTL_MS;
|
||||
|
||||
const built = await Promise.all(
|
||||
PROVIDERS.map((provider) =>
|
||||
buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd),
|
||||
const entries = await Promise.all(
|
||||
[...getResolvedRegistry().values()].map((resolved) =>
|
||||
buildProviderEntry(resolved, agentMap.get(resolved.id), llamaModels, resolvedCwd, ttlMs, force),
|
||||
),
|
||||
);
|
||||
const entries = built.filter((entry): entry is ProviderSnapshotEntry => entry !== null);
|
||||
|
||||
snapshotCache.set(cacheKey, { at: Date.now(), entries });
|
||||
return entries;
|
||||
@@ -235,6 +268,13 @@ export async function getProviderSnapshot(
|
||||
snapshotInflight.delete(cacheKey);
|
||||
});
|
||||
snapshotInflight.set(cacheKey, promise);
|
||||
|
||||
// Await the build (force or cache-miss) and return terminal entries. The sync
|
||||
// `loading` return (design §4.4) is DEFERRED until Phase 5 ships the client
|
||||
// poll that resolves it: without that poll, a single fetch lands on
|
||||
// installed:false `loading` entries, which AgentComposerBar filters out
|
||||
// (`e.installed && ...`) → empty picker. Builds stay fast via the tier-2 skip
|
||||
// once available_agents.models is warm.
|
||||
return promise;
|
||||
}
|
||||
|
||||
@@ -243,6 +283,16 @@ export function clearProviderSnapshotCache(): void {
|
||||
snapshotInflight.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only peek into the warm snapshot cache for one provider (no build, no
|
||||
* probe). Used by the diagnostic route to report the last computed probe error
|
||||
* without spawning anything. Returns undefined on a cold cache / unknown name.
|
||||
*/
|
||||
export function peekSnapshotEntry(name: string, cwd?: string): ProviderSnapshotEntry | undefined {
|
||||
const resolvedCwd = cwd?.trim() || homedir();
|
||||
return snapshotCache.get(resolvedCwd)?.entries.find((e) => e.name === name);
|
||||
}
|
||||
|
||||
/** Persist probed model lists back to available_agents for fast legacy reads. */
|
||||
export async function persistProbedModels(
|
||||
sql: Sql,
|
||||
@@ -251,16 +301,34 @@ export async function persistProbedModels(
|
||||
): Promise<void> {
|
||||
let count = 0;
|
||||
for (const entry of entries) {
|
||||
if (entry.name === 'boocode' || entry.models.length === 0) continue;
|
||||
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
|
||||
await sql`
|
||||
UPDATE available_agents
|
||||
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
|
||||
WHERE name = ${entry.name}
|
||||
`;
|
||||
count++;
|
||||
if (entry.name === 'boocode') continue;
|
||||
let persisted = false;
|
||||
if (entry.models.length > 0) {
|
||||
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
|
||||
await sql`
|
||||
UPDATE available_agents
|
||||
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
|
||||
WHERE name = ${entry.name}
|
||||
`;
|
||||
persisted = true;
|
||||
}
|
||||
// Persist captured ACP commands so they survive the tier-2 probe skip and
|
||||
// show without a dispatch. Only when non-empty — never clobber a prior set.
|
||||
if (entry.commands.length > 0) {
|
||||
const flatCommands = entry.commands.map((c) => ({
|
||||
name: c.name,
|
||||
...(c.description ? { description: c.description } : {}),
|
||||
}));
|
||||
await sql`
|
||||
UPDATE available_agents
|
||||
SET commands = ${sql.json(flatCommands as never)}, last_probed_at = clock_timestamp()
|
||||
WHERE name = ${entry.name}
|
||||
`;
|
||||
persisted = true;
|
||||
}
|
||||
if (persisted) count++;
|
||||
}
|
||||
if (count > 0) {
|
||||
log.info({ count }, 'provider-snapshot: persisted models to available_agents');
|
||||
log.info({ count }, 'provider-snapshot: persisted models/commands to available_agents');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,24 +23,34 @@ export interface ProviderModel {
|
||||
defaultThinkingOptionId?: string;
|
||||
}
|
||||
|
||||
export type ProviderSnapshotStatus = 'ready' | 'error';
|
||||
// v2.3 phase 2: 'loading' (cache-miss, probe in flight) + 'unavailable'
|
||||
// (disabled or not installed) restored alongside the terminal 'ready' | 'error'.
|
||||
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||
|
||||
export interface AgentCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
|
||||
// Drives the icon split in the coder slash menu. Undefined → command.
|
||||
kind?: 'command' | 'skill';
|
||||
}
|
||||
|
||||
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is
|
||||
// enforced by __tests__/provider-types-parity.test.ts (fails on any field drift).
|
||||
export interface ProviderSnapshotEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
transport: string;
|
||||
status: ProviderSnapshotStatus;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
models: ProviderModel[];
|
||||
modes: ProviderMode[];
|
||||
defaultModeId: string | null;
|
||||
commands: AgentCommand[];
|
||||
error?: string;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Project, Session, Chat, Message, PendingChange } from './types';
|
||||
import type { Project, Session, Chat, Message, PendingChange, AskUserAnswer } from './types';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
@@ -52,6 +52,14 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body ?? {}),
|
||||
}),
|
||||
answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) =>
|
||||
request<{ tool_message_id: string; assistant_message_id: string }>(
|
||||
`/api/chats/${chatId}/answer_user_input`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
|
||||
},
|
||||
),
|
||||
},
|
||||
|
||||
messages: {
|
||||
|
||||
@@ -32,16 +32,37 @@ export interface Chat {
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
tool_call_id: string;
|
||||
output: string;
|
||||
output: unknown;
|
||||
truncated?: boolean;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
|
||||
// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the
|
||||
// same order. AskUserInputCard renders questions and POSTs answers.
|
||||
export type AskUserQuestionType = 'single_select' | 'multi_select';
|
||||
|
||||
export interface AskUserQuestion {
|
||||
question: string;
|
||||
type: AskUserQuestionType;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
export interface AskUserAnswer {
|
||||
question: string;
|
||||
selected_options: string[];
|
||||
free_text: string | null;
|
||||
}
|
||||
|
||||
export interface AskUserAnswerSet {
|
||||
answers: AskUserAnswer[];
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
session_id: string;
|
||||
|
||||
323
apps/coder/web/src/components/AskUserInputCard.tsx
Normal file
323
apps/coder/web/src/components/AskUserInputCard.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
AskUserAnswer,
|
||||
AskUserAnswerSet,
|
||||
AskUserQuestion,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
} from '@/api/types';
|
||||
|
||||
// Batch 9.7: Inline interactive picker. Renders inside MessageList in place of
|
||||
// the standard ToolCallLine when the assistant emits an ask_user_input tool
|
||||
// call. While the tool result is null (server pre-stamps a sentinel with
|
||||
// output=null), shows the form; once the WS tool_result frame arrives with a
|
||||
// real AnswerSet, flips to read-only review mode.
|
||||
|
||||
interface Props {
|
||||
toolCall: ToolCall;
|
||||
toolResult: ToolResult | null;
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
function parseQuestions(raw: unknown): AskUserQuestion[] {
|
||||
if (!raw || typeof raw !== 'object' || !('questions' in raw)) return [];
|
||||
const arr = (raw as { questions: unknown }).questions;
|
||||
if (!Array.isArray(arr)) return [];
|
||||
const out: AskUserQuestion[] = [];
|
||||
for (const item of arr) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const q = item as { question?: unknown; type?: unknown; options?: unknown };
|
||||
if (typeof q.question !== 'string') continue;
|
||||
if (q.type !== 'single_select' && q.type !== 'multi_select') continue;
|
||||
if (!Array.isArray(q.options)) continue;
|
||||
const opts = q.options.filter((o): o is string => typeof o === 'string');
|
||||
if (opts.length < 2) continue;
|
||||
out.push({ question: q.question, type: q.type, options: opts });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
|
||||
if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null;
|
||||
const arr = (raw as { answers: unknown }).answers;
|
||||
if (!Array.isArray(arr)) return null;
|
||||
const answers: AskUserAnswer[] = [];
|
||||
for (const item of arr) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown };
|
||||
if (typeof a.question !== 'string') continue;
|
||||
if (!Array.isArray(a.selected_options)) continue;
|
||||
if (a.free_text !== null && typeof a.free_text !== 'string') continue;
|
||||
const sel = a.selected_options.filter((s): s is string => typeof s === 'string');
|
||||
answers.push({
|
||||
question: a.question,
|
||||
selected_options: sel,
|
||||
free_text: (a.free_text as string | null) ?? null,
|
||||
});
|
||||
}
|
||||
return { answers };
|
||||
}
|
||||
|
||||
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
|
||||
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
|
||||
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
|
||||
ask_user_input: malformed tool args
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tool result with a non-null output means the answer is already submitted.
|
||||
// The pending sentinel uses output=null, so this branch only triggers after
|
||||
// the real WS tool_result frame lands.
|
||||
const answered = toolResult && toolResult.output !== null;
|
||||
if (answered) {
|
||||
const answerSet = parseAnswerSet(toolResult!.output);
|
||||
return <AnsweredView questions={questions} answers={answerSet} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
|
||||
);
|
||||
}
|
||||
|
||||
function PendingView({
|
||||
questions,
|
||||
toolCallId,
|
||||
chatId,
|
||||
}: {
|
||||
questions: AskUserQuestion[];
|
||||
toolCallId: string;
|
||||
chatId: string;
|
||||
}) {
|
||||
// Per-question selections + free text. Selections are option arrays so the
|
||||
// multi_select case is uniform; single_select just constrains to length 1.
|
||||
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
|
||||
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const singleQuestion = questions.length === 1;
|
||||
const anyFreeText = freeTexts.some((t) => t.trim().length > 0);
|
||||
|
||||
// Submit button shows when:
|
||||
// - more than one question (always batched), OR
|
||||
// - one question and the user has typed free text (committing it needs an
|
||||
// explicit Submit so an accidental Tab/click doesn't lose it).
|
||||
// For one question with no free text, clicking an option submits inline.
|
||||
const showSubmitButton = !singleQuestion || anyFreeText;
|
||||
|
||||
// Every question must have at least one of (option, free text).
|
||||
const allComplete = questions.every((_, i) => {
|
||||
return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0;
|
||||
});
|
||||
|
||||
function buildAnswers(): AskUserAnswer[] {
|
||||
return questions.map((q, i) => {
|
||||
const freeText = freeTexts[i]!.trim();
|
||||
return {
|
||||
question: q.question,
|
||||
selected_options: selections[i]!,
|
||||
free_text: freeText.length > 0 ? freeText : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function submit(answers: AskUserAnswer[]) {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.chats.answerUserInput(chatId, toolCallId, answers);
|
||||
// Card stays mounted; the incoming WS tool_result frame will flip it
|
||||
// into AnsweredView via the parent prop change.
|
||||
} catch (err) {
|
||||
console.error('ask_user_input submit failed:', err instanceof Error ? err.message : err);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pickSingle(qIdx: number, option: string) {
|
||||
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
|
||||
// Immediate submit for the single-question single-select shortcut. Only
|
||||
// fires when no free text exists anywhere — once the user typed, the
|
||||
// Submit button takes over so the typed text isn't silently dropped.
|
||||
if (singleQuestion && !anyFreeText) {
|
||||
const answers: AskUserAnswer[] = [
|
||||
{
|
||||
question: questions[0]!.question,
|
||||
selected_options: [option],
|
||||
free_text: null,
|
||||
},
|
||||
];
|
||||
void submit(answers);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMulti(qIdx: number, option: string) {
|
||||
setSelections((prev) =>
|
||||
prev.map((arr, i) => {
|
||||
if (i !== qIdx) return arr;
|
||||
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function setFreeText(qIdx: number, value: string) {
|
||||
setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 text-sm">
|
||||
<div className="px-4 py-3 space-y-4">
|
||||
{questions.map((q, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
{questions.length > 1 && (
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
Question {i + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-medium leading-snug">{q.question}</div>
|
||||
{q.type === 'single_select' ? (
|
||||
<RadioGroup
|
||||
value={selections[i]![0] ?? ''}
|
||||
onValueChange={(v) => pickSingle(i, v)}
|
||||
disabled={submitting}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{q.options.map((opt, j) => {
|
||||
const id = `q${i}-opt${j}`;
|
||||
return (
|
||||
<label
|
||||
key={j}
|
||||
htmlFor={id}
|
||||
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
|
||||
>
|
||||
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
) : (
|
||||
<div className="grid gap-1.5">
|
||||
{q.options.map((opt, j) => {
|
||||
const id = `q${i}-opt${j}`;
|
||||
const checked = selections[i]!.includes(opt);
|
||||
return (
|
||||
<label
|
||||
key={j}
|
||||
htmlFor={id}
|
||||
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={submitting}
|
||||
onChange={() => toggleMulti(i, opt)}
|
||||
className="mt-1 size-3.5 rounded border-input accent-primary"
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-1 space-y-1">
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
Or type a custom answer
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={freeTexts[i]}
|
||||
disabled={submitting}
|
||||
placeholder="Free text…"
|
||||
onChange={(e) => setFreeText(i, e.target.value)}
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showSubmitButton && (
|
||||
<div className="flex justify-end gap-2 border-t px-4 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!allComplete || submitting}
|
||||
onClick={() => void submit(buildAnswers())}
|
||||
>
|
||||
{submitting ? 'Submitting…' : 'Submit'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnsweredView({
|
||||
questions,
|
||||
answers,
|
||||
}: {
|
||||
questions: AskUserQuestion[];
|
||||
answers: AskUserAnswerSet | null;
|
||||
}) {
|
||||
if (!answers) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 text-xs px-4 py-3 text-muted-foreground">
|
||||
ask_user_input: answers unavailable
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/10 text-sm">
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{questions.map((q, i) => {
|
||||
const a = answers.answers[i];
|
||||
if (!a) return null;
|
||||
return (
|
||||
<div key={i} className="space-y-1.5">
|
||||
{questions.length > 1 && (
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
Question {i + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-medium leading-snug">{q.question}</div>
|
||||
<div className="space-y-0.5">
|
||||
{q.options.map((opt, j) => {
|
||||
const selected = a.selected_options.includes(opt);
|
||||
return (
|
||||
<div
|
||||
key={j}
|
||||
className={
|
||||
selected
|
||||
? 'flex items-start gap-2 text-sm leading-snug text-foreground'
|
||||
: 'flex items-start gap-2 text-sm leading-snug text-muted-foreground/60 line-through'
|
||||
}
|
||||
>
|
||||
<span className="mt-0.5 size-3.5 shrink-0 inline-flex items-center justify-center">
|
||||
{selected && <Check className="size-3 text-primary" />}
|
||||
</span>
|
||||
<span>{opt}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{a.free_text && (
|
||||
<div className="rounded bg-background border px-2 py-1 text-xs font-mono whitespace-pre-wrap">
|
||||
{a.free_text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Square } from 'lucide-react';
|
||||
import type { Message } from '@/api/types';
|
||||
import type { Message, ToolResult } from '@/api/types';
|
||||
import { api } from '@/api/client';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
|
||||
@@ -66,6 +66,14 @@ export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }
|
||||
// Filter out system messages for display (sentinels)
|
||||
const visibleMessages = messages.filter((m) => m.role !== 'system');
|
||||
|
||||
// Build a lookup map from tool_call_id -> ToolResult for all messages
|
||||
const toolResultsMap: Record<string, ToolResult> = {};
|
||||
for (const msg of messages) {
|
||||
if (msg.tool_results) {
|
||||
toolResultsMap[msg.tool_results.tool_call_id] = msg.tool_results;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Connection indicator */}
|
||||
@@ -88,7 +96,7 @@ export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }
|
||||
</div>
|
||||
)}
|
||||
{visibleMessages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
<MessageBubble key={msg.id} message={msg} chatId={msg.chat_id} toolResultsMap={toolResultsMap} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { Message } from '@/api/types';
|
||||
import type { Message, ToolResult } from '@/api/types';
|
||||
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { AskUserInputCard } from './AskUserInputCard';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
chatId: string;
|
||||
toolResultsMap: Record<string, ToolResult>;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: Props) {
|
||||
export function MessageBubble({ message, chatId }: Props) {
|
||||
if (message.role === 'tool') {
|
||||
return <ToolResultBubble message={message} />;
|
||||
}
|
||||
@@ -34,18 +37,31 @@ export function MessageBubble({ message }: Props) {
|
||||
|
||||
{message.tool_calls && message.tool_calls.length > 0 && (
|
||||
<div className="mb-2 space-y-1">
|
||||
{message.tool_calls.map((tc) => (
|
||||
<div
|
||||
key={tc.id}
|
||||
className="flex items-center gap-1.5 text-xs text-zinc-400 bg-zinc-900/50 rounded px-2 py-1"
|
||||
>
|
||||
<Wrench size={11} />
|
||||
<span className="font-mono">{tc.name}</span>
|
||||
<span className="text-zinc-500 truncate max-w-[200px]">
|
||||
{truncateArgs(tc.arguments)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{message.tool_calls.map((tc) => {
|
||||
if (tc.name === 'ask_user_input') {
|
||||
const result = message.tool_results ?? null;
|
||||
return (
|
||||
<AskUserInputCard
|
||||
key={tc.id}
|
||||
toolCall={tc}
|
||||
toolResult={result}
|
||||
chatId={chatId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={tc.id}
|
||||
className="flex items-center gap-1.5 text-xs text-zinc-400 bg-zinc-900/50 rounded px-2 py-1"
|
||||
>
|
||||
<Wrench size={11} />
|
||||
<span className="font-mono">{tc.name}</span>
|
||||
<span className="text-zinc-500 truncate max-w-[200px]">
|
||||
{truncateArgs(tc.args)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -70,12 +86,12 @@ export function MessageBubble({ message }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function ToolResultBubble({ message }: Props) {
|
||||
function ToolResultBubble({ message }: { message: Message }) {
|
||||
const result = message.tool_results;
|
||||
if (!result) return null;
|
||||
|
||||
const isError = result.error;
|
||||
const output = result.output || '';
|
||||
const output = result.output != null ? String(result.output) : '';
|
||||
const displayOutput =
|
||||
output.length > 300 ? output.slice(0, 300) + '...' : output;
|
||||
|
||||
@@ -99,17 +115,21 @@ function ToolResultBubble({ message }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function truncateArgs(args: string): string {
|
||||
function truncateArgs(args: unknown): string {
|
||||
if (!args) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
const keys = Object.keys(parsed);
|
||||
if (keys.length === 0) return '';
|
||||
const first = keys[0]!;
|
||||
const val = String(parsed[first]);
|
||||
const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
|
||||
return `${first}: ${display}`;
|
||||
if (typeof args === 'object' && args !== null) {
|
||||
const obj = args as Record<string, unknown>;
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0) return '';
|
||||
const first = keys[0]!;
|
||||
const val = String(obj[first] ?? '');
|
||||
const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
|
||||
return `${first}: ${display}`;
|
||||
}
|
||||
const str = String(args);
|
||||
return str.length > 50 ? str.slice(0, 50) + '...' : str;
|
||||
} catch {
|
||||
return args.length > 50 ? args.slice(0, 50) + '...' : args;
|
||||
return String(args).length > 50 ? String(args).slice(0, 50) + '...' : String(args);
|
||||
}
|
||||
}
|
||||
|
||||
35
apps/coder/web/src/components/ui/button.tsx
Normal file
35
apps/coder/web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
}
|
||||
|
||||
const variantClasses: Record<string, string> = {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<string, string> = {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
};
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'default', size = 'default', ...props }, ref) => {
|
||||
const base =
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-60';
|
||||
const cls = [base, variantClasses[variant] ?? '', sizeClasses[size] ?? '', className ?? ''].join(' ');
|
||||
return <button className={cls} ref={ref} {...props} />;
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
56
apps/coder/web/src/components/ui/radio-group.tsx
Normal file
56
apps/coder/web/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
|
||||
const RadioGroupContext = React.createContext<{
|
||||
value: string | undefined;
|
||||
onValueChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
interface RadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
|
||||
({ className, value, onValueChange, disabled, ...props }, ref) => {
|
||||
const ctx = React.useMemo(() => ({ value, onValueChange: onValueChange ?? (() => {}), disabled }), [value, onValueChange, disabled]);
|
||||
return (
|
||||
<RadioGroupContext.Provider value={ctx}>
|
||||
<div
|
||||
ref={ref}
|
||||
role="radiogroup"
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
</RadioGroupContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
RadioGroup.displayName = 'RadioGroup';
|
||||
|
||||
interface RadioGroupItemProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const RadioGroupItem = React.forwardRef<HTMLInputElement, RadioGroupItemProps>(
|
||||
({ className, value, ...props }, ref) => {
|
||||
const ctx = React.useContext(RadioGroupContext);
|
||||
if (!ctx) return <input ref={ref} type="radio" className={className} value={value} {...props} />;
|
||||
const checked = ctx.value === value;
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="radio"
|
||||
checked={checked}
|
||||
disabled={ctx.disabled}
|
||||
onChange={() => ctx.onValueChange(value)}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
RadioGroupItem.displayName = 'RadioGroupItem';
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
@@ -5,23 +5,74 @@
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"exports": {
|
||||
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"./inference": { "types": "./dist/services/inference/index.d.ts", "default": "./dist/services/inference/index.js" },
|
||||
"./tools": { "types": "./dist/services/tools.d.ts", "default": "./dist/services/tools.js" },
|
||||
"./broker": { "types": "./dist/services/broker.d.ts", "default": "./dist/services/broker.js" },
|
||||
"./compaction": { "types": "./dist/services/compaction.d.ts", "default": "./dist/services/compaction.js" },
|
||||
"./model-context": { "types": "./dist/services/model-context.d.ts", "default": "./dist/services/model-context.js" },
|
||||
"./system-prompt": { "types": "./dist/services/system-prompt.d.ts", "default": "./dist/services/system-prompt.js" },
|
||||
"./agents": { "types": "./dist/services/agents.d.ts", "default": "./dist/services/agents.js" },
|
||||
"./truncate": { "types": "./dist/services/truncate.d.ts", "default": "./dist/services/truncate.js" },
|
||||
"./path-guard": { "types": "./dist/services/path_guard.d.ts", "default": "./dist/services/path_guard.js" },
|
||||
"./file-ops": { "types": "./dist/services/file_ops.d.ts", "default": "./dist/services/file_ops.js" },
|
||||
"./types": { "types": "./dist/types/api.d.ts", "default": "./dist/types/api.js" },
|
||||
"./ws-frames": { "types": "./dist/types/ws-frames.d.ts", "default": "./dist/types/ws-frames.js" },
|
||||
"./db": { "types": "./dist/db.d.ts", "default": "./dist/db.js" },
|
||||
"./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" },
|
||||
"./skills": { "types": "./dist/services/skills.d.ts", "default": "./dist/services/skills.js" },
|
||||
"./skill-invoke": { "types": "./dist/services/skill-invoke.d.ts", "default": "./dist/services/skill-invoke.js" }
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./inference": {
|
||||
"types": "./dist/services/inference/index.d.ts",
|
||||
"default": "./dist/services/inference/index.js"
|
||||
},
|
||||
"./tools": {
|
||||
"types": "./dist/services/tools.d.ts",
|
||||
"default": "./dist/services/tools.js"
|
||||
},
|
||||
"./broker": {
|
||||
"types": "./dist/services/broker.d.ts",
|
||||
"default": "./dist/services/broker.js"
|
||||
},
|
||||
"./compaction": {
|
||||
"types": "./dist/services/compaction.d.ts",
|
||||
"default": "./dist/services/compaction.js"
|
||||
},
|
||||
"./model-context": {
|
||||
"types": "./dist/services/model-context.d.ts",
|
||||
"default": "./dist/services/model-context.js"
|
||||
},
|
||||
"./system-prompt": {
|
||||
"types": "./dist/services/system-prompt.d.ts",
|
||||
"default": "./dist/services/system-prompt.js"
|
||||
},
|
||||
"./agents": {
|
||||
"types": "./dist/services/agents.d.ts",
|
||||
"default": "./dist/services/agents.js"
|
||||
},
|
||||
"./truncate": {
|
||||
"types": "./dist/services/truncate.d.ts",
|
||||
"default": "./dist/services/truncate.js"
|
||||
},
|
||||
"./path-guard": {
|
||||
"types": "./dist/services/path_guard.d.ts",
|
||||
"default": "./dist/services/path_guard.js"
|
||||
},
|
||||
"./file-ops": {
|
||||
"types": "./dist/services/file_ops.d.ts",
|
||||
"default": "./dist/services/file_ops.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types/api.d.ts",
|
||||
"default": "./dist/types/api.js"
|
||||
},
|
||||
"./ws-frames": {
|
||||
"types": "./dist/types/ws-frames.d.ts",
|
||||
"default": "./dist/types/ws-frames.js"
|
||||
},
|
||||
"./db": {
|
||||
"types": "./dist/db.d.ts",
|
||||
"default": "./dist/db.js"
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/config.d.ts",
|
||||
"default": "./dist/config.js"
|
||||
},
|
||||
"./skills": {
|
||||
"types": "./dist/services/skills.d.ts",
|
||||
"default": "./dist/services/skills.js"
|
||||
},
|
||||
"./skill-invoke": {
|
||||
"types": "./dist/services/skill-invoke.d.ts",
|
||||
"default": "./dist/services/skill-invoke.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
@@ -36,6 +87,7 @@
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"ai": "^6.0.190",
|
||||
"fastify": "^4.28.1",
|
||||
"parse5": "^8.0.1",
|
||||
"postgres": "^3.4.4",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
@@ -46,5 +98,6 @@
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ const ConfigSchema = z.object({
|
||||
// v2.0.5: cheaper model for titles, summaries, labeling. Falls back to
|
||||
// session model (auto_name) or DEFAULT_MODEL when unset.
|
||||
FAST_MODEL: z.string().optional(),
|
||||
TASK_MODEL_URL: z.string().url().optional(),
|
||||
LLAMA_SIDECAR_URL: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
@@ -28,7 +28,7 @@ import { cleanupTruncations } from './services/truncate.js';
|
||||
import { loadMcpConfig } from './services/mcp-config.js';
|
||||
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
|
||||
import { appendMcpTools } from './services/tools.js';
|
||||
import { refreshToolNames } from './services/agents.js';
|
||||
import { refreshToolNames, getAgentsForProject } from './services/agents.js';
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
@@ -91,6 +91,20 @@ async function main() {
|
||||
}
|
||||
app.addHook('onClose', async () => { await shutdownMcp(); });
|
||||
|
||||
// Boot-time guard: if any agent has llama_extra_args but LLAMA_SIDECAR_URL
|
||||
// is unset, fail fast. Silent fallback would defeat per-agent flags.
|
||||
if (!config.LLAMA_SIDECAR_URL) {
|
||||
const { agents } = await getAgentsForProject('');
|
||||
const offending = agents.find(a => a.llama_extra_args && a.llama_extra_args.length > 0);
|
||||
if (offending) {
|
||||
app.log.fatal(
|
||||
{ agent: offending.name },
|
||||
`Agent "${offending.name}" has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
app.get('/api/health', async () => {
|
||||
|
||||
@@ -344,6 +344,7 @@ INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (k
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS tags TEXT[] DEFAULT '{}';
|
||||
|
||||
-- v1.11: anchored rolling compaction.
|
||||
-- compacted_at — marks rows that are "behind the curtain" of the latest
|
||||
@@ -366,3 +367,39 @@ ALTER TABLE messages ADD COLUMN IF NOT EXISTS summary BOOLEAN NOT NULL DEFAULT F
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES messages(id) ON DELETE SET NULL;
|
||||
ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);
|
||||
|
||||
-- tasks table (provider dispatch, arena)
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
session_id UUID REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
parent_task_id UUID REFERENCES tasks(id),
|
||||
arena_id UUID,
|
||||
state TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (state IN ('pending','running','completed','failed','blocked','cancelled')),
|
||||
input TEXT NOT NULL,
|
||||
output_summary TEXT,
|
||||
agent TEXT,
|
||||
model TEXT,
|
||||
mode_id TEXT,
|
||||
thinking_option_id TEXT,
|
||||
feature_values JSONB,
|
||||
execution_path TEXT CHECK (execution_path IS NULL OR execution_path IN ('native','acp','pty','qwen')),
|
||||
worktree_path TEXT,
|
||||
cost_tokens INTEGER,
|
||||
started_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
-- Fix tasks FK to cascade on session delete (existing tables without CASCADE)
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'tasks_session_id_fkey'
|
||||
AND confdeltype != 'c'
|
||||
) THEN
|
||||
ALTER TABLE tasks DROP CONSTRAINT tasks_session_id_fkey;
|
||||
ALTER TABLE tasks ADD CONSTRAINT tasks_session_id_fkey
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
107
apps/server/src/services/__tests__/agent-allowlist.test.ts
Normal file
107
apps/server/src/services/__tests__/agent-allowlist.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseAgentsMd, matchToolGlob } from '../agents.js';
|
||||
import { toolJsonSchemas } from '../tools.js';
|
||||
|
||||
describe('agent tool allowlist', () => {
|
||||
const plannerMd = `# Agents
|
||||
|
||||
## Planner
|
||||
---
|
||||
temperature: 0.6
|
||||
tools: [view_file, grep, list_dir, find_files]
|
||||
description: Read-only planner
|
||||
---
|
||||
You plan.
|
||||
`;
|
||||
|
||||
it('parses an agent with a restricted tool allowlist', () => {
|
||||
const { agents, errors } = parseAgentsMd(plannerMd);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(agents).toHaveLength(1);
|
||||
const planner = agents[0]!;
|
||||
expect(planner.name).toBe('Planner');
|
||||
expect(planner.tools).toEqual(['view_file', 'grep', 'list_dir', 'find_files']);
|
||||
});
|
||||
|
||||
it('stream-phase filter: agent allowlist excludes tools not in the list', () => {
|
||||
const { agents } = parseAgentsMd(plannerMd);
|
||||
const planner = agents[0]!;
|
||||
const allSchemas = toolJsonSchemas();
|
||||
const filtered = allSchemas.filter((t) =>
|
||||
matchToolGlob(t.function.name, planner.tools),
|
||||
);
|
||||
const filteredNames = filtered.map((t) => t.function.name);
|
||||
expect(filteredNames).toContain('view_file');
|
||||
expect(filteredNames).toContain('grep');
|
||||
expect(filteredNames).not.toContain('edit_file');
|
||||
expect(filteredNames).not.toContain('web_search');
|
||||
expect(filteredNames).not.toContain('get_codebase_overview');
|
||||
expect(filtered).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('tool-phase guard: rejects tool call not in agent allowlist', () => {
|
||||
const { agents } = parseAgentsMd(plannerMd);
|
||||
const planner = agents[0]!;
|
||||
expect(matchToolGlob('edit_file', planner.tools)).toBe(false);
|
||||
expect(matchToolGlob('create_file', planner.tools)).toBe(false);
|
||||
expect(matchToolGlob('delete_file', planner.tools)).toBe(false);
|
||||
expect(matchToolGlob('web_search', planner.tools)).toBe(false);
|
||||
});
|
||||
|
||||
it('tool-phase guard: allows tool call in agent allowlist', () => {
|
||||
const { agents } = parseAgentsMd(plannerMd);
|
||||
const planner = agents[0]!;
|
||||
expect(matchToolGlob('view_file', planner.tools)).toBe(true);
|
||||
expect(matchToolGlob('grep', planner.tools)).toBe(true);
|
||||
expect(matchToolGlob('list_dir', planner.tools)).toBe(true);
|
||||
expect(matchToolGlob('find_files', planner.tools)).toBe(true);
|
||||
});
|
||||
|
||||
it('null/absent tools field defaults to all tools (no regression)', () => {
|
||||
const noToolsMd = `# Agents
|
||||
|
||||
## Default
|
||||
---
|
||||
temperature: 0.7
|
||||
description: Uses all tools
|
||||
---
|
||||
Default agent.
|
||||
`;
|
||||
const { agents } = parseAgentsMd(noToolsMd);
|
||||
const agent = agents[0]!;
|
||||
const allSchemas = toolJsonSchemas();
|
||||
const filtered = allSchemas.filter((t) =>
|
||||
matchToolGlob(t.function.name, agent.tools),
|
||||
);
|
||||
expect(filtered.length).toBe(allSchemas.length);
|
||||
});
|
||||
|
||||
it('builder agent: write tools filtered out when not in ALL_TOOLS (BooChat context)', () => {
|
||||
const builderMd = `# Agents
|
||||
|
||||
## Builder
|
||||
---
|
||||
temperature: 0.6
|
||||
tools: [view_file, grep, list_dir, find_files, edit_file, create_file, delete_file, apply_pending, rewind]
|
||||
description: Read and write tools
|
||||
---
|
||||
You build.
|
||||
`;
|
||||
const { agents } = parseAgentsMd(builderMd);
|
||||
const builder = agents[0]!;
|
||||
expect(matchToolGlob('view_file', builder.tools)).toBe(true);
|
||||
expect(matchToolGlob('grep', builder.tools)).toBe(true);
|
||||
// Write tools not in server's ALL_TOOLS are silently filtered during parsing.
|
||||
// In BooCoder context (where ALL_TOOLS includes write tools), they'd be retained.
|
||||
expect(builder.tools).not.toContain('edit_file');
|
||||
expect(builder.tools).not.toContain('create_file');
|
||||
expect(matchToolGlob('web_search', builder.tools)).toBe(false);
|
||||
});
|
||||
|
||||
it('matchToolGlob rejects hallucinated tool against exact allowlist', () => {
|
||||
const allowlist = ['view_file', 'grep', 'list_dir'];
|
||||
expect(matchToolGlob('edit_file', allowlist)).toBe(false);
|
||||
expect(matchToolGlob('rm_rf', allowlist)).toBe(false);
|
||||
expect(matchToolGlob('view_file_extended', allowlist)).toBe(false);
|
||||
});
|
||||
});
|
||||
223
apps/server/src/services/__tests__/html-to-md.test.ts
Normal file
223
apps/server/src/services/__tests__/html-to-md.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { htmlToMarkdown } from '../web/html-to-md.js';
|
||||
|
||||
describe('htmlToMarkdown', () => {
|
||||
it('converts h1 heading', () => {
|
||||
expect(htmlToMarkdown('<h1>Title</h1>')).toBe('# Title');
|
||||
});
|
||||
|
||||
it('converts h1 through h6', () => {
|
||||
const html = '<h1>One</h1><h2>Two</h2><h3>Three</h3><h4>Four</h4><h5>Five</h5><h6>Six</h6>';
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain('# One');
|
||||
expect(md).toContain('## Two');
|
||||
expect(md).toContain('### Three');
|
||||
expect(md).toContain('#### Four');
|
||||
expect(md).toContain('##### Five');
|
||||
expect(md).toContain('###### Six');
|
||||
});
|
||||
|
||||
it('converts anchor with href', () => {
|
||||
expect(htmlToMarkdown('<a href="https://example.com">click here</a>'))
|
||||
.toBe('[click here](https://example.com)');
|
||||
});
|
||||
|
||||
it('converts anchor without href to plain text', () => {
|
||||
expect(htmlToMarkdown('<a>just text</a>')).toBe('just text');
|
||||
});
|
||||
|
||||
it('converts bold and italic', () => {
|
||||
expect(htmlToMarkdown('<strong>bold</strong>')).toBe('**bold**');
|
||||
expect(htmlToMarkdown('<b>bold</b>')).toBe('**bold**');
|
||||
expect(htmlToMarkdown('<em>italic</em>')).toBe('*italic*');
|
||||
expect(htmlToMarkdown('<i>italic</i>')).toBe('*italic*');
|
||||
});
|
||||
|
||||
it('handles combined bold+italic', () => {
|
||||
const md = htmlToMarkdown('<strong><em>bold italic</em></strong>');
|
||||
expect(md).toBe('***bold italic***');
|
||||
});
|
||||
|
||||
it('converts unordered list', () => {
|
||||
const html = '<ul><li>one</li><li>two</li><li>three</li></ul>';
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain('* one');
|
||||
expect(md).toContain('* two');
|
||||
expect(md).toContain('* three');
|
||||
});
|
||||
|
||||
it('converts ordered list', () => {
|
||||
const html = '<ol><li>first</li><li>second</li></ol>';
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain('1. first');
|
||||
expect(md).toContain('2. second');
|
||||
});
|
||||
|
||||
it('handles nested lists', () => {
|
||||
const html = '<ul><li>outer<ul><li>inner</li></ul></li></ul>';
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain('* outer');
|
||||
expect(md).toContain(' * inner');
|
||||
});
|
||||
|
||||
it('converts 3-column GFM table with header', () => {
|
||||
const html = `
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Age</th><th>City</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Alice</td><td>30</td><td>NYC</td></tr>
|
||||
<tr><td>Bob</td><td>25</td><td>LA</td></tr>
|
||||
</tbody>
|
||||
</table>`;
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain('| Name | Age | City |');
|
||||
expect(md).toContain('| --- | --- | --- |');
|
||||
expect(md).toContain('| Alice | 30 | NYC |');
|
||||
expect(md).toContain('| Bob | 25 | LA |');
|
||||
});
|
||||
|
||||
it('escapes pipe characters in table cells', () => {
|
||||
const html = '<table><tr><th>A</th></tr><tr><td>x | y</td></tr></table>';
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain('x \\| y');
|
||||
});
|
||||
|
||||
it('converts blockquote', () => {
|
||||
const html = '<blockquote><p>quoted text</p></blockquote>';
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain('> quoted text');
|
||||
});
|
||||
|
||||
it('converts multi-line blockquote', () => {
|
||||
const html = '<blockquote><p>line one</p><p>line two</p></blockquote>';
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain('> line one');
|
||||
expect(md).toContain('> line two');
|
||||
});
|
||||
|
||||
it('converts fenced code block', () => {
|
||||
const html = '<pre><code>const x = 1;</code></pre>';
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain('```\nconst x = 1;\n```');
|
||||
});
|
||||
|
||||
it('preserves language hint from code class', () => {
|
||||
const html = '<pre><code class="language-py">print("hello")</code></pre>';
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain('```py\nprint("hello")\n```');
|
||||
});
|
||||
|
||||
it('converts inline code', () => {
|
||||
expect(htmlToMarkdown('use <code>npm install</code> to install'))
|
||||
.toContain('`npm install`');
|
||||
});
|
||||
|
||||
it('decodes HTML entities', () => {
|
||||
expect(htmlToMarkdown('& < > "')).toBe('& < > "');
|
||||
});
|
||||
|
||||
it('decodes numeric character references', () => {
|
||||
expect(htmlToMarkdown(''')).toBe("'");
|
||||
});
|
||||
|
||||
it('decodes as space', () => {
|
||||
const md = htmlToMarkdown('hello world');
|
||||
expect(md).toMatch(/hello\s+world/);
|
||||
});
|
||||
|
||||
it('skips script content', () => {
|
||||
const html = '<p>before</p><script>alert("xss")</script><p>after</p>';
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).not.toContain('alert');
|
||||
expect(md).toContain('before');
|
||||
expect(md).toContain('after');
|
||||
});
|
||||
|
||||
it('skips style content', () => {
|
||||
const html = '<p>text</p><style>body { color: red }</style>';
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).not.toContain('color');
|
||||
expect(md).toContain('text');
|
||||
});
|
||||
|
||||
it('does not throw on malformed HTML', () => {
|
||||
expect(() => htmlToMarkdown('<p>unclosed <b>bold <i>italic')).not.toThrow();
|
||||
const md = htmlToMarkdown('<p>unclosed <b>bold <i>italic');
|
||||
expect(md).toContain('bold');
|
||||
expect(md).toContain('italic');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(htmlToMarkdown('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for whitespace-only input', () => {
|
||||
expect(htmlToMarkdown(' \n\n ')).toBe('');
|
||||
});
|
||||
|
||||
it('converts hr to horizontal rule', () => {
|
||||
const md = htmlToMarkdown('<p>above</p><hr><p>below</p>');
|
||||
expect(md).toContain('---');
|
||||
});
|
||||
|
||||
it('converts br to newline', () => {
|
||||
const md = htmlToMarkdown('line one<br>line two');
|
||||
expect(md).toContain('line one\nline two');
|
||||
});
|
||||
|
||||
it('handles ol with start attribute', () => {
|
||||
const html = '<ol start="5"><li>five</li><li>six</li></ol>';
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain('5. five');
|
||||
expect(md).toContain('6. six');
|
||||
});
|
||||
|
||||
it('collapses excessive blank lines', () => {
|
||||
const html = '<p>one</p><p></p><p></p><p></p><p>two</p>';
|
||||
const md = htmlToMarkdown(html);
|
||||
const blankRuns = md.match(/\n{3,}/g);
|
||||
expect(blankRuns).toBeNull();
|
||||
});
|
||||
|
||||
// Golden test: small Hacker News-style snippet
|
||||
it('golden: HN-style snippet produces structured markdown', () => {
|
||||
const html = `
|
||||
<html>
|
||||
<head><title>Test Page</title></head>
|
||||
<body>
|
||||
<h1>Welcome</h1>
|
||||
<p>This is a <strong>test</strong> page with <a href="https://example.com">a link</a>.</p>
|
||||
<h2>Features</h2>
|
||||
<ul>
|
||||
<li>Fast</li>
|
||||
<li>Reliable</li>
|
||||
<li>Secure</li>
|
||||
</ul>
|
||||
<h2>Data</h2>
|
||||
<table>
|
||||
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Uptime</td><td>99.9%</td></tr>
|
||||
<tr><td>Latency</td><td>42ms</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<blockquote><p>This tool is amazing.</p></blockquote>
|
||||
<pre><code class="language-js">console.log("hello");</code></pre>
|
||||
<script>evil();</script>
|
||||
</body>
|
||||
</html>`;
|
||||
const md = htmlToMarkdown(html);
|
||||
expect(md).toContain('# Welcome');
|
||||
expect(md).toContain('**test**');
|
||||
expect(md).toContain('[a link](https://example.com)');
|
||||
expect(md).toContain('## Features');
|
||||
expect(md).toContain('* Fast');
|
||||
expect(md).toContain('| Metric | Value |');
|
||||
expect(md).toContain('| --- | --- |');
|
||||
expect(md).toContain('| Uptime | 99.9% |');
|
||||
expect(md).toContain('> This tool is amazing.');
|
||||
expect(md).toContain('```js\nconsole.log("hello");\n```');
|
||||
expect(md).not.toContain('evil');
|
||||
expect(md).not.toContain('<title>');
|
||||
});
|
||||
});
|
||||
160
apps/server/src/services/__tests__/llama-args-validator.test.ts
Normal file
160
apps/server/src/services/__tests__/llama-args-validator.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
validateExtraArgs,
|
||||
isManagedFlag,
|
||||
stripShadowingFlags,
|
||||
} from '../inference/llama-args-validator.js';
|
||||
import { parseAgentsMd } from '../agents.js';
|
||||
|
||||
describe('validateExtraArgs', () => {
|
||||
describe('deny list — each alias rejected', () => {
|
||||
const denied = [
|
||||
'-m', '--model',
|
||||
'-mu', '--model-url',
|
||||
'-dr', '--docker-repo',
|
||||
'-hf', '-hfr', '--hf-repo',
|
||||
'-hff', '--hf-file',
|
||||
'-hfv', '-hfrv', '--hf-repo-v',
|
||||
'-hffv', '--hf-file-v',
|
||||
'-hft', '--hf-token',
|
||||
'-mm', '--mmproj',
|
||||
'-mmu', '--mmproj-url',
|
||||
'--host', '--port', '--path', '--api-prefix', '--reuse-port',
|
||||
'--api-key', '--api-key-file',
|
||||
'--ssl-key-file', '--ssl-cert-file',
|
||||
'--webui', '--no-webui', '--ui', '--no-ui',
|
||||
'--ui-config', '--ui-config-file',
|
||||
'--ui-mcp-proxy', '--no-ui-mcp-proxy',
|
||||
'--models-dir', '--models-preset', '--models-max',
|
||||
'--models-autoload', '--no-models-autoload',
|
||||
];
|
||||
for (const flag of denied) {
|
||||
it(`rejects ${flag}`, () => {
|
||||
expect(() => validateExtraArgs([flag])).toThrow(/managed/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('safe flags accepted', () => {
|
||||
const safe = [
|
||||
'-c', '--ctx-size', '-ngl', '--gpu-layers',
|
||||
'--top-k', '--cache-type-k', '--jinja', '--no-jinja',
|
||||
'--spec-draft-n-max', '-fa', '--flash-attn',
|
||||
'-t', '--threads', '-np', '--parallel',
|
||||
];
|
||||
for (const flag of safe) {
|
||||
it(`accepts ${flag}`, () => {
|
||||
expect(() => validateExtraArgs([flag])).not.toThrow();
|
||||
expect(validateExtraArgs([flag])).toEqual([flag]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('handles --flag=value shape (denies the flag part)', () => {
|
||||
expect(() => validateExtraArgs(['--model=evil.gguf'])).toThrow(/managed/);
|
||||
});
|
||||
|
||||
it('handles --flag=value shape (accepts safe flag)', () => {
|
||||
expect(validateExtraArgs(['--ctx-size=4096'])).toEqual(['--ctx-size=4096']);
|
||||
});
|
||||
|
||||
it('returns empty array for undefined input', () => {
|
||||
expect(validateExtraArgs(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(validateExtraArgs([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats negative numbers as values, not flags', () => {
|
||||
expect(validateExtraArgs(['--seed', '-1'])).toEqual(['--seed', '-1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isManagedFlag', () => {
|
||||
it('returns true for denied flags', () => {
|
||||
expect(isManagedFlag('--model')).toBe(true);
|
||||
expect(isManagedFlag('-m')).toBe(true);
|
||||
expect(isManagedFlag('--api-key')).toBe(true);
|
||||
expect(isManagedFlag('--port')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for safe flags', () => {
|
||||
expect(isManagedFlag('-c')).toBe(false);
|
||||
expect(isManagedFlag('--ctx-size')).toBe(false);
|
||||
expect(isManagedFlag('--top-k')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripShadowingFlags', () => {
|
||||
it('strips auto -c when user supplies -c', () => {
|
||||
const result = stripShadowingFlags(['-c', '4096', '--top-k', '40']);
|
||||
expect(result).toEqual(['--top-k', '40']);
|
||||
});
|
||||
|
||||
it('retains both when no overlap', () => {
|
||||
const result = stripShadowingFlags(['--top-k', '40', '--top-p', '0.95']);
|
||||
expect(result).toEqual(['--top-k', '40', '--top-p', '0.95']);
|
||||
});
|
||||
|
||||
it('strips --ctx-size=value form', () => {
|
||||
const result = stripShadowingFlags(['--ctx-size=4096']);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips boolean --jinja flag (no value consumed)', () => {
|
||||
const result = stripShadowingFlags(['--jinja', '--top-k', '40']);
|
||||
expect(result).toEqual(['--top-k', '40']);
|
||||
});
|
||||
|
||||
it('respects stripContext=false to keep context flags', () => {
|
||||
const result = stripShadowingFlags(['-c', '4096'], { stripContext: false });
|
||||
expect(result).toEqual(['-c', '4096']);
|
||||
});
|
||||
|
||||
it('strips cache flags by default', () => {
|
||||
const result = stripShadowingFlags(['--cache-type-k', 'q8_0']);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips spec flags by default', () => {
|
||||
const result = stripShadowingFlags(['--spec-draft-n-max', '16']);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AGENTS.md frontmatter validation', () => {
|
||||
it('rejects agent with managed flag in llama_extra_args', () => {
|
||||
const md = `## Evil Agent
|
||||
---
|
||||
llama_extra_args: ["--model", "evil.gguf"]
|
||||
---
|
||||
You are evil.`;
|
||||
const { agents, errors } = parseAgentsMd(md);
|
||||
expect(agents).toHaveLength(0);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]!.reason).toContain('managed');
|
||||
});
|
||||
|
||||
it('accepts agent with safe llama_extra_args', () => {
|
||||
const md = `## Good Agent
|
||||
---
|
||||
llama_extra_args: ["--top-k", "20"]
|
||||
---
|
||||
You are good.`;
|
||||
const { agents, errors } = parseAgentsMd(md);
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0]!.llama_extra_args).toEqual(['--top-k', '20']);
|
||||
});
|
||||
|
||||
it('agent without llama_extra_args has null field', () => {
|
||||
const md = `## Simple Agent
|
||||
---
|
||||
temperature: 0.5
|
||||
---
|
||||
You are simple.`;
|
||||
const { agents } = parseAgentsMd(md);
|
||||
expect(agents[0]!.llama_extra_args).toBeNull();
|
||||
});
|
||||
});
|
||||
58
apps/server/src/services/__tests__/provider.test.ts
Normal file
58
apps/server/src/services/__tests__/provider.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveRoute, upstreamModel } from '../inference/provider.js';
|
||||
|
||||
describe('resolveRoute', () => {
|
||||
it('routes to swap when agent is null', () => {
|
||||
expect(resolveRoute(null)).toEqual({ route: 'swap', flags: null });
|
||||
});
|
||||
|
||||
it('routes to swap when agent has no llama_extra_args', () => {
|
||||
expect(resolveRoute({ llama_extra_args: null })).toEqual({ route: 'swap', flags: null });
|
||||
});
|
||||
|
||||
it('routes to swap when agent has empty llama_extra_args', () => {
|
||||
expect(resolveRoute({ llama_extra_args: [] })).toEqual({ route: 'swap', flags: null });
|
||||
});
|
||||
|
||||
it('routes to sidecar when agent has llama_extra_args', () => {
|
||||
const result = resolveRoute({ llama_extra_args: ['--top-k', '20'] });
|
||||
expect(result.route).toBe('sidecar');
|
||||
expect(result.flags).toEqual(['--top-k', '20']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upstreamModel', () => {
|
||||
const swapConfig = { LLAMA_SWAP_URL: 'http://localhost:8401' };
|
||||
const fullConfig = {
|
||||
LLAMA_SWAP_URL: 'http://localhost:8401',
|
||||
LLAMA_SIDECAR_URL: 'http://localhost:8402',
|
||||
};
|
||||
|
||||
it('returns a model for swap route (no agent)', () => {
|
||||
const model = upstreamModel(swapConfig, 'test-model');
|
||||
expect(model).toBeDefined();
|
||||
expect((model as any).modelId).toBe('test-model');
|
||||
});
|
||||
|
||||
it('returns a model for swap route (agent without extra args)', () => {
|
||||
const model = upstreamModel(swapConfig, 'test-model', { llama_extra_args: null });
|
||||
expect(model).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns a model for sidecar route', () => {
|
||||
const model = upstreamModel(fullConfig, 'test-model', { llama_extra_args: ['--top-k', '20'] });
|
||||
expect(model).toBeDefined();
|
||||
expect((model as any).modelId).toBe('test-model');
|
||||
});
|
||||
|
||||
it('throws when sidecar route requested but URL missing', () => {
|
||||
expect(() =>
|
||||
upstreamModel(swapConfig, 'test-model', { llama_extra_args: ['--top-k', '20'] }),
|
||||
).toThrow(/LLAMA_SIDECAR_URL/);
|
||||
});
|
||||
|
||||
it('routes to swap for empty llama_extra_args array', () => {
|
||||
const model = upstreamModel(swapConfig, 'test-model', { llama_extra_args: [] });
|
||||
expect(model).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,24 @@
|
||||
// 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,
|
||||
parseToolCallsFromText,
|
||||
stripToolMarkup,
|
||||
hasToolSignal,
|
||||
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';
|
||||
TOOL_XML_SIGNALS,
|
||||
BUDGET_EXHAUSTED_NUDGE,
|
||||
DUPLICATE_CALL_NUDGE,
|
||||
TOOL_ERROR_NUDGE,
|
||||
TOOL_ERROR_PREFIXES,
|
||||
} from '../inference/tool-call-parser.js';
|
||||
|
||||
// ── Ported from xml-parser.test.ts ───────────────────────────────────────
|
||||
|
||||
describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
|
||||
it('parses a well-formed single-parameter call', () => {
|
||||
@@ -66,7 +65,6 @@ describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -75,7 +73,6 @@ describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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({
|
||||
@@ -84,7 +81,6 @@ describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Spec case 3
|
||||
it('tolerates newlines and spaces in attributes (spec case 3)', () => {
|
||||
const block = `<invoke
|
||||
name="view_file"
|
||||
@@ -99,7 +95,6 @@ describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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({
|
||||
@@ -187,7 +182,6 @@ describe('partialXmlOpenerStart (v1.13.16 — both flavors)', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -196,7 +190,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
||||
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);
|
||||
@@ -215,7 +208,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
||||
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);
|
||||
@@ -224,7 +216,6 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
||||
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);
|
||||
@@ -310,86 +301,245 @@ describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('levenshtein', () => {
|
||||
it('returns 0 for identical strings', () => {
|
||||
expect(levenshtein('view_file', 'view_file')).toBe(0);
|
||||
// ── New tests: Unsloth-ported functions ──────────────────────────────────
|
||||
|
||||
describe('hasToolSignal', () => {
|
||||
it('returns true for <tool_call>', () => {
|
||||
expect(hasToolSignal('prefix <tool_call> suffix')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns the length when one string is empty', () => {
|
||||
expect(levenshtein('', 'view_file')).toBe(9);
|
||||
expect(levenshtein('view_file', '')).toBe(9);
|
||||
it('returns true for <function=', () => {
|
||||
expect(hasToolSignal('prefix <function=view_file> suffix')).toBe(true);
|
||||
});
|
||||
|
||||
it('computes a small distance for a single-character substitution', () => {
|
||||
expect(levenshtein('cat', 'bat')).toBe(1);
|
||||
it('returns true for <invoke', () => {
|
||||
expect(hasToolSignal('prefix <invoke name="x"> suffix')).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
it('returns false for near-miss <tool>', () => {
|
||||
expect(hasToolSignal('prefix <tool> suffix')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for near-miss <function>', () => {
|
||||
expect(hasToolSignal('prefix <function> suffix')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for near-miss <tool_call_thing>', () => {
|
||||
expect(hasToolSignal('<tool_call_thing>')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for plain text', () => {
|
||||
expect(hasToolSignal('just some text')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
describe('stripToolMarkup', () => {
|
||||
it('strips closed <tool_call> blocks', () => {
|
||||
const input = 'before <tool_call>{"name":"x"}</tool_call> after';
|
||||
expect(stripToolMarkup(input)).toBe('before after');
|
||||
});
|
||||
|
||||
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('strips closed <function=...> blocks', () => {
|
||||
const input = 'before <function=x><parameter=y>z</parameter></function> after';
|
||||
expect(stripToolMarkup(input)).toBe('before after');
|
||||
});
|
||||
|
||||
it('returns null when nothing is close', () => {
|
||||
expect(suggestToolName('xxxx_yyyy_zzzz', tools)).toBeNull();
|
||||
it('strips closed <invoke> blocks', () => {
|
||||
const input = 'before <invoke name="x"><parameter name="y">z</parameter></invoke> after';
|
||||
expect(stripToolMarkup(input)).toBe('before after');
|
||||
});
|
||||
|
||||
it('is case-insensitive in the distance check', () => {
|
||||
expect(suggestToolName('VIEW_FILE', tools)).toBe('view_file');
|
||||
it('leaves trailing unclosed block when final=false', () => {
|
||||
const input = 'text <tool_call>{"name":"x"';
|
||||
expect(stripToolMarkup(input)).toBe('text <tool_call>{"name":"x"');
|
||||
});
|
||||
|
||||
it('strips trailing unclosed <tool_call> when final=true', () => {
|
||||
const input = 'text <tool_call>{"name":"x"';
|
||||
expect(stripToolMarkup(input, { final: true })).toBe('text');
|
||||
});
|
||||
|
||||
it('strips trailing unclosed <function= when final=true', () => {
|
||||
const input = 'text <function=run_bash><parameter=command>ls';
|
||||
expect(stripToolMarkup(input, { final: true })).toBe('text');
|
||||
});
|
||||
|
||||
it('strips trailing unclosed <invoke when final=true', () => {
|
||||
const input = 'text <invoke name="x"><parameter name="y">val';
|
||||
expect(stripToolMarkup(input, { final: true })).toBe('text');
|
||||
});
|
||||
|
||||
it('trims whitespace when final=true', () => {
|
||||
const input = ' text <tool_call>partial';
|
||||
expect(stripToolMarkup(input, { final: true })).toBe('text');
|
||||
});
|
||||
|
||||
it('strips multiple closed blocks', () => {
|
||||
const input = '<tool_call>a</tool_call> mid <tool_call>b</tool_call>';
|
||||
expect(stripToolMarkup(input)).toBe(' mid ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUnknownToolError (v1.13.16)', () => {
|
||||
const tools = ['view_file', 'list_dir', 'grep', 'find_files'];
|
||||
describe('parseToolCallsFromText', () => {
|
||||
describe('pattern 1: <tool_call>{json}</tool_call>', () => {
|
||||
it('parses a well-formed JSON tool call', () => {
|
||||
const input = '<tool_call>{"name":"web_search","arguments":{"query":"hello"}}</tool_call>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.id).toBe('call_0');
|
||||
expect(calls[0]!.type).toBe('function');
|
||||
expect(calls[0]!.function.name).toBe('web_search');
|
||||
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ query: 'hello' });
|
||||
});
|
||||
|
||||
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('handles string arguments field', () => {
|
||||
const input = '<tool_call>{"name":"x","arguments":"already a string"}</tool_call>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls[0]!.function.arguments).toBe('already a string');
|
||||
});
|
||||
|
||||
it('handles balanced braces inside JSON strings', () => {
|
||||
const input = '<tool_call>{"name":"x","arguments":{"q":"} { extra "}}</tool_call>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
const parsed = JSON.parse(calls[0]!.function.arguments);
|
||||
expect(parsed.q).toBe('} { extra ');
|
||||
});
|
||||
|
||||
it('respects idOffset', () => {
|
||||
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call>';
|
||||
const calls = parseToolCallsFromText(input, { idOffset: 5 });
|
||||
expect(calls[0]!.id).toBe('call_5');
|
||||
});
|
||||
|
||||
it('parses multiple JSON tool calls', () => {
|
||||
const input =
|
||||
'<tool_call>{"name":"a","arguments":{}}</tool_call>' +
|
||||
'<tool_call>{"name":"b","arguments":{}}</tool_call>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]!.id).toBe('call_0');
|
||||
expect(calls[1]!.id).toBe('call_1');
|
||||
});
|
||||
|
||||
it('skips malformed JSON', () => {
|
||||
const input = '<tool_call>{not json}</tool_call>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles missing closing tag', () => {
|
||||
const input = '<tool_call>{"name":"x","arguments":{"q":"hello"}}';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('x');
|
||||
});
|
||||
});
|
||||
|
||||
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?');
|
||||
describe('pattern 2: <function=name><parameter=key>value', () => {
|
||||
it('parses a single-parameter function call', () => {
|
||||
const input = '<function=view_file><parameter=path>/tmp/foo</parameter></function>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('view_file');
|
||||
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||
});
|
||||
|
||||
it('single-param fast path preserves embedded </parameter>', () => {
|
||||
const input = '<function=run_bash><parameter=command>echo "</parameter>"</parameter></function>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(JSON.parse(calls[0]!.function.arguments).command).toBe('echo "</parameter>"');
|
||||
});
|
||||
|
||||
it('multi-param: value of first stops at start of second', () => {
|
||||
const input = '<function=grep><parameter=pattern>foo</parameter><parameter=path>src/</parameter></function>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
const args = JSON.parse(calls[0]!.function.arguments);
|
||||
expect(args.pattern).toBe('foo');
|
||||
expect(args.path).toBe('src/');
|
||||
});
|
||||
|
||||
it('tolerates missing closing tags', () => {
|
||||
const input = '<function=view_file><parameter=path>/tmp/foo';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('view_file');
|
||||
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||
});
|
||||
|
||||
it('does not fire when pattern 1 found results', () => {
|
||||
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call><function=b><parameter=x>y</parameter></function>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
describe('pattern 3: <invoke name="..."><parameter name="...">value (Anthropic)', () => {
|
||||
it('parses a single-parameter invoke call', () => {
|
||||
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('view_file');
|
||||
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||
});
|
||||
|
||||
// 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');
|
||||
it('parses multi-parameter invoke call', () => {
|
||||
const input = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
const args = JSON.parse(calls[0]!.function.arguments);
|
||||
expect(args.pattern).toBe('foo');
|
||||
expect(args.path).toBe('src/');
|
||||
});
|
||||
|
||||
it('does not fire when pattern 1 found results', () => {
|
||||
const input = '<tool_call>{"name":"a","arguments":{}}</tool_call><invoke name="b"><parameter name="x">y</parameter></invoke>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('a');
|
||||
});
|
||||
|
||||
it('does not fire when pattern 2 found results', () => {
|
||||
const input = '<function=a><parameter=x>y</parameter></function><invoke name="b"><parameter name="x">y</parameter></invoke>';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('a');
|
||||
});
|
||||
|
||||
it('tolerates missing closing tags', () => {
|
||||
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo';
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' });
|
||||
});
|
||||
|
||||
it('supports single-quoted attributes', () => {
|
||||
const input = "<invoke name='view_file'><parameter name='path'>/tmp/foo</parameter></invoke>";
|
||||
const calls = parseToolCallsFromText(input);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.function.name).toBe('view_file');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('constants', () => {
|
||||
it('TOOL_XML_SIGNALS includes all three signal prefixes', () => {
|
||||
expect(TOOL_XML_SIGNALS).toContain('<tool_call>');
|
||||
expect(TOOL_XML_SIGNALS).toContain('<function=');
|
||||
expect(TOOL_XML_SIGNALS).toContain('<invoke');
|
||||
});
|
||||
|
||||
it('nudge constants are non-empty strings', () => {
|
||||
expect(BUDGET_EXHAUSTED_NUDGE.length).toBeGreaterThan(0);
|
||||
expect(DUPLICATE_CALL_NUDGE.length).toBeGreaterThan(0);
|
||||
expect(TOOL_ERROR_NUDGE.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('TOOL_ERROR_PREFIXES is a non-empty tuple', () => {
|
||||
expect(TOOL_ERROR_PREFIXES.length).toBeGreaterThan(0);
|
||||
expect(TOOL_ERROR_PREFIXES).toContain('Error');
|
||||
});
|
||||
});
|
||||
82
apps/server/src/services/__tests__/tool-suggestions.test.ts
Normal file
82
apps/server/src/services/__tests__/tool-suggestions.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
levenshtein,
|
||||
suggestToolName,
|
||||
formatUnknownToolError,
|
||||
} from '../inference/tool-suggestions.js';
|
||||
|
||||
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', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
|
||||
import { ALL_TOOLS, resolveToolTier } from './tools.js';
|
||||
import { validateExtraArgs } from './inference/llama-args-validator.js';
|
||||
|
||||
// v1.8.1: global agents live at /data/AGENTS.md inside the container
|
||||
// (./data:/data:ro mount on the host). Per-project AGENTS.md at the project
|
||||
@@ -83,6 +84,10 @@ export function slugify(name: string): string {
|
||||
|
||||
interface ParsedFrontmatter {
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
top_k?: number;
|
||||
min_p?: number;
|
||||
presence_penalty?: number;
|
||||
tools?: string[];
|
||||
description?: string;
|
||||
model?: string;
|
||||
@@ -93,6 +98,7 @@ interface ParsedFrontmatter {
|
||||
// (200) in the outer loop. Integer ≥ 0; steps: 0 means "no tool calls
|
||||
// allowed" — the model responds text-only.
|
||||
steps?: number;
|
||||
llama_extra_args?: string[];
|
||||
}
|
||||
|
||||
function stripQuotes(s: string): string {
|
||||
@@ -132,6 +138,46 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
|
||||
const n = Number(valueRaw);
|
||||
if (Number.isFinite(n)) data.temperature = n;
|
||||
else errors.push(`temperature must be a number (got "${valueRaw}")`);
|
||||
} else if (key === 'top_p') {
|
||||
const n = Number(valueRaw);
|
||||
if (Number.isFinite(n)) {
|
||||
data.top_p = n;
|
||||
if (n < 0 || n > 1) {
|
||||
console.warn(`agents: top_p ${n} out of range 0-1, ignoring (falling back to default)`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`top_p must be a number (got "${valueRaw}")`);
|
||||
}
|
||||
} else if (key === 'top_k') {
|
||||
const n = Number(valueRaw);
|
||||
if (Number.isInteger(n)) {
|
||||
data.top_k = n;
|
||||
if (n < 0 || n > 200) {
|
||||
console.warn(`agents: top_k ${n} out of range 0-200, ignoring (falling back to default)`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`top_k must be an integer (got "${valueRaw}")`);
|
||||
}
|
||||
} else if (key === 'min_p') {
|
||||
const n = Number(valueRaw);
|
||||
if (Number.isFinite(n)) {
|
||||
data.min_p = n;
|
||||
if (n < 0 || n > 1) {
|
||||
console.warn(`agents: min_p ${n} out of range 0-1, ignoring (falling back to default)`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`min_p must be a number (got "${valueRaw}")`);
|
||||
}
|
||||
} else if (key === 'presence_penalty') {
|
||||
const n = Number(valueRaw);
|
||||
if (Number.isFinite(n)) {
|
||||
data.presence_penalty = n;
|
||||
if (n < -2 || n > 2) {
|
||||
console.warn(`agents: presence_penalty ${n} out of range -2-2, ignoring (falling back to default)`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`presence_penalty must be a number (got "${valueRaw}")`);
|
||||
}
|
||||
} else if (key === 'tools') {
|
||||
if (valueRaw === '') {
|
||||
data.tools = [];
|
||||
@@ -183,6 +229,34 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
|
||||
} else {
|
||||
errors.push(`steps must be a non-negative integer (got "${valueRaw}")`);
|
||||
}
|
||||
} else if (key === 'llama_extra_args') {
|
||||
if (valueRaw === '') {
|
||||
data.llama_extra_args = [];
|
||||
// No arrayKey support — llama_extra_args uses inline list only.
|
||||
} else if (valueRaw.startsWith('[') && valueRaw.endsWith(']')) {
|
||||
const inner = valueRaw.slice(1, -1);
|
||||
const parsed = inner
|
||||
.split(',')
|
||||
.map((s) => stripQuotes(s.trim()))
|
||||
.filter((s) => s.length > 0);
|
||||
try {
|
||||
validateExtraArgs(parsed);
|
||||
data.llama_extra_args = parsed;
|
||||
} catch (err) {
|
||||
errors.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} else {
|
||||
const parsed = valueRaw
|
||||
.split(',')
|
||||
.map((s) => stripQuotes(s.trim()))
|
||||
.filter((s) => s.length > 0);
|
||||
try {
|
||||
validateExtraArgs(parsed);
|
||||
data.llama_extra_args = parsed;
|
||||
} catch (err) {
|
||||
errors.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unknown keys silently ignored — forward-compat.
|
||||
}
|
||||
@@ -276,10 +350,15 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
||||
description: fm.description ?? '',
|
||||
system_prompt: systemPrompt,
|
||||
temperature: typeof fm.temperature === 'number' ? fm.temperature : DEFAULT_TEMPERATURE,
|
||||
top_p: typeof fm.top_p === 'number' ? fm.top_p : null,
|
||||
top_k: typeof fm.top_k === 'number' ? fm.top_k : null,
|
||||
min_p: typeof fm.min_p === 'number' ? fm.min_p : null,
|
||||
presence_penalty: typeof fm.presence_penalty === 'number' ? fm.presence_penalty : null,
|
||||
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,
|
||||
llama_extra_args: Array.isArray(fm.llama_extra_args) ? fm.llama_extra_args : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { InferenceContext } from './inference/index.js';
|
||||
import { taskModelCompletion } from './task-model.js';
|
||||
|
||||
const NAMING_SYSTEM_PROMPT =
|
||||
'You name chat sessions based on what the assistant did. Summarize the topic or outcome — do NOT copy the first few words verbatim. Reply directly with no thinking, reasoning, or explanation. Output ONLY the title, 4 words max, no quotes, no punctuation, no prefix like "Title:".';
|
||||
'You name chat sessions. Reply with ONLY the title. 4 to 6 words. No quotes, no punctuation, no prefix.';
|
||||
|
||||
const MAX_TITLE_CHARS = 60;
|
||||
const MAX_TITLE_CHARS = 80;
|
||||
|
||||
function cleanTitle(raw: string): string {
|
||||
let name = raw.trim();
|
||||
@@ -18,27 +19,7 @@ function cleanTitle(raw: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
interface NamingResponse {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
function pickTitleSource(data: NamingResponse): string {
|
||||
const choice = data.choices?.[0]?.message;
|
||||
if (!choice) return '';
|
||||
if (choice.content && choice.content.trim().length > 0) return choice.content;
|
||||
const reasoning = choice.reasoning_content ?? '';
|
||||
if (reasoning.length === 0) return '';
|
||||
const lines = reasoning
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
return lines[lines.length - 1] ?? '';
|
||||
}
|
||||
// TODO: wire suggestTags after task model validation
|
||||
|
||||
export async function maybeAutoNameChat(
|
||||
ctx: InferenceContext,
|
||||
@@ -64,52 +45,29 @@ export async function maybeAutoNameChat(
|
||||
if (!chat) return;
|
||||
if (chat.name !== null && chat.name !== '') return;
|
||||
|
||||
const sessionRows = await ctx.sql<{ model: string }[]>`
|
||||
SELECT model FROM sessions WHERE id = ${sessionId}
|
||||
`;
|
||||
// v2.0.5: prefer FAST_MODEL for cheap LLM calls (titles, summaries).
|
||||
const model = ctx.config.FAST_MODEL ?? sessionRows[0]?.model;
|
||||
if (!model) return;
|
||||
|
||||
const assistantMsg = await ctx.sql<{ content: string }[]>`
|
||||
SELECT content FROM messages
|
||||
const firstMsgs = await ctx.sql<{ role: string; content: string }[]>`
|
||||
SELECT role, content FROM messages
|
||||
WHERE chat_id = ${chatId}
|
||||
AND role = 'assistant'
|
||||
AND status = 'complete'
|
||||
AND role IN ('user', 'assistant')
|
||||
AND status IN ('complete', 'ok')
|
||||
AND content <> ''
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
LIMIT 2
|
||||
`;
|
||||
if (!assistantMsg[0]) return;
|
||||
const userMsg = firstMsgs.find(m => m.role === 'user');
|
||||
const assistantMsg = firstMsgs.find(m => m.role === 'assistant');
|
||||
if (!assistantMsg) return;
|
||||
|
||||
const assistantText = assistantMsg[0].content.slice(0, 2000);
|
||||
let namingInput = '';
|
||||
if (userMsg) namingInput += `User: ${userMsg.content.slice(0, 1000)}\n\n`;
|
||||
namingInput += `Assistant: ${assistantMsg.content.slice(0, 1000)}`;
|
||||
|
||||
const body = {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: NAMING_SYSTEM_PROMPT },
|
||||
{
|
||||
role: 'user',
|
||||
content: assistantText,
|
||||
},
|
||||
],
|
||||
max_tokens: 30,
|
||||
const raw = await taskModelCompletion({
|
||||
system: NAMING_SYSTEM_PROMPT,
|
||||
user: namingInput,
|
||||
maxTokens: 30,
|
||||
temperature: 0.3,
|
||||
stream: false,
|
||||
chat_template_kwargs: { enable_thinking: false },
|
||||
};
|
||||
|
||||
const res = await fetch(`${ctx.config.LLAMA_SWAP_URL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`naming request failed: ${res.status} ${text.slice(0, 200)}`);
|
||||
}
|
||||
const data = (await res.json()) as NamingResponse;
|
||||
const raw = pickTitleSource(data);
|
||||
const name = cleanTitle(raw);
|
||||
if (!name) {
|
||||
ctx.log.warn({ chatId, raw }, 'auto-name: empty title from model');
|
||||
|
||||
@@ -18,9 +18,9 @@ import { READ_ONLY_TOOL_NAMES } from '../tools.js';
|
||||
// turns + deeper exploration without changing the safety floor materially —
|
||||
// the doom-loop guard (3 identical calls → abort) catches the actual failure
|
||||
// mode this cap was guarding against.
|
||||
export const BUDGET_READ_ONLY = 50;
|
||||
export const BUDGET_NON_READ_ONLY = 10;
|
||||
export const BUDGET_NO_AGENT = 50;
|
||||
export const BUDGET_READ_ONLY = 100;
|
||||
export const BUDGET_NON_READ_ONLY = 100;
|
||||
export const BUDGET_NO_AGENT = 100;
|
||||
|
||||
const READ_ONLY_SET: ReadonlySet<string> = new Set(READ_ONLY_TOOL_NAMES);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { stripToolMarkup } from './tool-call-parser.js';
|
||||
import type { InferenceContext, StreamResult, TurnArgs } from './turn.js';
|
||||
|
||||
export async function handleAbortOrError(
|
||||
@@ -21,6 +22,7 @@ export async function handleAbortOrError(
|
||||
const isAbort = err instanceof Error && err.name === 'AbortError';
|
||||
const finalStatus = isAbort ? 'cancelled' : 'failed';
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
accumulated = stripToolMarkup(accumulated, { final: true });
|
||||
// v1.8.2: persist a structured error metadata blob on genuine failures so
|
||||
// the bubble can render the reason on reload without re-deriving from the
|
||||
// (one-shot) WS error frame. User-initiated abort skips this — there's no
|
||||
@@ -101,7 +103,8 @@ export async function finalizeCompletion(
|
||||
session: Session
|
||||
): Promise<void> {
|
||||
const { sessionId, chatId, assistantMessageId } = args;
|
||||
const { content, finishReason, promptTokens, completionTokens } = result;
|
||||
const content = stripToolMarkup(result.content, { final: true });
|
||||
const { finishReason, promptTokens, completionTokens } = result;
|
||||
|
||||
// v1.11.3: see executeToolPhase for the rationale.
|
||||
const mctx = await modelContext.getModelContext(session.model);
|
||||
|
||||
142
apps/server/src/services/inference/llama-args-validator.ts
Normal file
142
apps/server/src/services/inference/llama-args-validator.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
// Ported from studio/backend/core/inference/llama_server_args.py.
|
||||
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/llama_server_args.py
|
||||
|
||||
// Each group is the full set of aliases (short + long) for one hard-denied
|
||||
// flag, taken from the llama-server README. Flags NOT in this list pass
|
||||
// through and override auto-set values via llama.cpp's last-wins CLI parsing.
|
||||
const DENYLIST_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
|
||||
// Model identity
|
||||
new Set(['-m', '--model']),
|
||||
new Set(['-mu', '--model-url']),
|
||||
new Set(['-dr', '--docker-repo']),
|
||||
new Set(['-hf', '-hfr', '--hf-repo']),
|
||||
new Set(['-hff', '--hf-file']),
|
||||
new Set(['-hfv', '-hfrv', '--hf-repo-v']),
|
||||
new Set(['-hffv', '--hf-file-v']),
|
||||
new Set(['-hft', '--hf-token']),
|
||||
new Set(['-mm', '--mmproj']),
|
||||
new Set(['-mmu', '--mmproj-url']),
|
||||
// Networking
|
||||
new Set(['--host']),
|
||||
new Set(['--port']),
|
||||
new Set(['--path']),
|
||||
new Set(['--api-prefix']),
|
||||
new Set(['--reuse-port']),
|
||||
// Auth / TLS
|
||||
new Set(['--api-key']),
|
||||
new Set(['--api-key-file']),
|
||||
new Set(['--ssl-key-file']),
|
||||
new Set(['--ssl-cert-file']),
|
||||
// Single-model server / UI
|
||||
new Set(['--webui', '--no-webui']),
|
||||
new Set(['--ui', '--no-ui']),
|
||||
new Set(['--ui-config']),
|
||||
new Set(['--ui-config-file']),
|
||||
new Set(['--ui-mcp-proxy', '--no-ui-mcp-proxy']),
|
||||
new Set(['--models-dir']),
|
||||
new Set(['--models-preset']),
|
||||
new Set(['--models-max']),
|
||||
new Set(['--models-autoload', '--no-models-autoload']),
|
||||
];
|
||||
|
||||
const DENYLIST: ReadonlySet<string> = new Set(
|
||||
DENYLIST_GROUPS.flatMap((g) => [...g]),
|
||||
);
|
||||
|
||||
function flagName(token: string): string | null {
|
||||
if (!token.startsWith('-') || token === '-' || token === '--') return null;
|
||||
if (token.length >= 2 && (token[1]!.match(/\d/) || token[1] === '.')) return null;
|
||||
return token.split('=', 1)[0]!;
|
||||
}
|
||||
|
||||
export function validateExtraArgs(args?: Iterable<string>): string[] {
|
||||
if (!args) return [];
|
||||
const out: string[] = [];
|
||||
for (const raw of args) {
|
||||
const token = String(raw);
|
||||
const flag = flagName(token);
|
||||
if (flag !== null && DENYLIST.has(flag)) {
|
||||
throw new Error(
|
||||
`llama-server flag '${flag}' is managed and cannot be passed as an extra arg`,
|
||||
);
|
||||
}
|
||||
out.push(token);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isManagedFlag(flag: string): boolean {
|
||||
return DENYLIST.has(flag);
|
||||
}
|
||||
|
||||
// Shadowing flag groups: pass-through flags that shadow first-class settings.
|
||||
const CONTEXT_FLAGS = new Set(['-c', '--ctx-size']);
|
||||
const CACHE_FLAGS = new Set(['-ctk', '--cache-type-k', '-ctv', '--cache-type-v']);
|
||||
const SPEC_FLAGS = new Set([
|
||||
'--spec-default',
|
||||
'--spec-type',
|
||||
'--spec-ngram-size-n',
|
||||
'--spec-ngram-size',
|
||||
'--draft-min',
|
||||
'--draft-max',
|
||||
'--spec-draft-n-max',
|
||||
'--spec-draft-n-min',
|
||||
'--spec-draft-p-min',
|
||||
'--spec-draft-p-split',
|
||||
'--spec-ngram-mod-n-match',
|
||||
'--spec-ngram-mod-n-min',
|
||||
'--spec-ngram-mod-n-max',
|
||||
]);
|
||||
const TEMPLATE_FLAGS = new Set([
|
||||
'--chat-template',
|
||||
'--chat-template-file',
|
||||
'--chat-template-kwargs',
|
||||
'--jinja',
|
||||
'--no-jinja',
|
||||
]);
|
||||
|
||||
const BOOLEAN_SHADOWING_FLAGS = new Set([
|
||||
'--spec-default', '--jinja', '--no-jinja',
|
||||
]);
|
||||
|
||||
export interface StripOptions {
|
||||
stripContext?: boolean;
|
||||
stripCache?: boolean;
|
||||
stripSpec?: boolean;
|
||||
stripTemplate?: boolean;
|
||||
}
|
||||
|
||||
export function stripShadowingFlags(
|
||||
args: Iterable<string>,
|
||||
opts?: StripOptions,
|
||||
): string[] {
|
||||
const shadowing = new Set<string>();
|
||||
if (opts?.stripContext !== false) for (const f of CONTEXT_FLAGS) shadowing.add(f);
|
||||
if (opts?.stripCache !== false) for (const f of CACHE_FLAGS) shadowing.add(f);
|
||||
if (opts?.stripSpec !== false) for (const f of SPEC_FLAGS) shadowing.add(f);
|
||||
if (opts?.stripTemplate !== false) for (const f of TEMPLATE_FLAGS) shadowing.add(f);
|
||||
|
||||
const tokens = [...args].map(String);
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
const n = tokens.length;
|
||||
while (i < n) {
|
||||
const tok = tokens[i]!;
|
||||
const flag = flagName(tok);
|
||||
if (flag === null || !shadowing.has(flag)) {
|
||||
out.push(tok);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (BOOLEAN_SHADOWING_FLAGS.has(flag) || tok.includes('=')) {
|
||||
i++;
|
||||
} else if (i + 1 < n && flagName(tokens[i + 1]!) === null) {
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -6,29 +6,79 @@ import type { LanguageModel } from 'ai';
|
||||
// upstream without touching env vars. No apiKey — llama-swap is unauth in our
|
||||
// Tailscale topology and exposing it over the public internet is gated by
|
||||
// Authelia at the Caddy layer, not by API keys.
|
||||
//
|
||||
// v2.4.1-sidecar: when the agent has llama_extra_args, route through
|
||||
// llama-sidecar instead. A fresh provider is created per call (not cached)
|
||||
// because the X-Agent-Flags header varies per agent. The llama-swap path
|
||||
// stays cached since it has no per-request headers.
|
||||
|
||||
const cache = new Map<string, ReturnType<typeof createOpenAICompatible>>();
|
||||
const swapCache = new Map<string, ReturnType<typeof createOpenAICompatible>>();
|
||||
|
||||
function getProvider(baseURL: string): ReturnType<typeof createOpenAICompatible> {
|
||||
let provider = cache.get(baseURL);
|
||||
function getSwapProvider(baseURL: string): ReturnType<typeof createOpenAICompatible> {
|
||||
let provider = swapCache.get(baseURL);
|
||||
if (!provider) {
|
||||
provider = createOpenAICompatible({
|
||||
name: 'llama-swap',
|
||||
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
||||
// v1.13.7: @ai-sdk/openai-compatible defaults includeUsage=false, which
|
||||
// omits `stream_options.include_usage` from the request body. Without
|
||||
// it, llama.cpp / llama-swap never emits the trailing usage block, so
|
||||
// `result.usage` resolves with inputTokens=outputTokens=undefined and
|
||||
// tokens_used / ctx_used land as NULL in every messages row. Setting
|
||||
// true here re-enables the per-stream usage payload across all models
|
||||
// served via the llama-swap provider.
|
||||
includeUsage: true,
|
||||
});
|
||||
cache.set(baseURL, provider);
|
||||
swapCache.set(baseURL, provider);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
export function upstreamModel(baseURL: string, modelId: string): LanguageModel {
|
||||
return getProvider(baseURL).chatModel(modelId);
|
||||
function sidecarProvider(
|
||||
baseURL: string,
|
||||
flags: string[],
|
||||
): ReturnType<typeof createOpenAICompatible> {
|
||||
return createOpenAICompatible({
|
||||
name: 'llama-sidecar',
|
||||
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
||||
includeUsage: true,
|
||||
headers: {
|
||||
'X-Agent-Flags': flags.join(' '),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type InferenceRoute = 'swap' | 'sidecar';
|
||||
|
||||
export interface RoutingInfo {
|
||||
route: InferenceRoute;
|
||||
flags: string[] | null;
|
||||
}
|
||||
|
||||
interface AgentLike {
|
||||
llama_extra_args: string[] | null;
|
||||
}
|
||||
|
||||
interface ConfigLike {
|
||||
LLAMA_SWAP_URL: string;
|
||||
LLAMA_SIDECAR_URL?: string;
|
||||
}
|
||||
|
||||
export function resolveRoute(agent: AgentLike | null): RoutingInfo {
|
||||
const flags = agent?.llama_extra_args;
|
||||
if (flags && flags.length > 0) {
|
||||
return { route: 'sidecar', flags };
|
||||
}
|
||||
return { route: 'swap', flags: null };
|
||||
}
|
||||
|
||||
export function upstreamModel(
|
||||
config: ConfigLike,
|
||||
modelId: string,
|
||||
agent?: AgentLike | null,
|
||||
): LanguageModel {
|
||||
const { route, flags } = resolveRoute(agent ?? null);
|
||||
if (route === 'sidecar') {
|
||||
const url = config.LLAMA_SIDECAR_URL;
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
`Agent has llama_extra_args but LLAMA_SIDECAR_URL is not set`,
|
||||
);
|
||||
}
|
||||
return sidecarProvider(url, flags!).chatModel(modelId);
|
||||
}
|
||||
return getSwapProvider(config.LLAMA_SWAP_URL).chatModel(modelId);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function runCapHitSummary(
|
||||
ctx,
|
||||
session.model,
|
||||
messages,
|
||||
{ tools: null, temperature: agent?.temperature },
|
||||
{ tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined },
|
||||
(delta) => {
|
||||
accumulated += delta;
|
||||
ctx.publish(sessionId, {
|
||||
@@ -346,7 +346,7 @@ export async function runDoomLoopSummary(
|
||||
ctx,
|
||||
session.model,
|
||||
messages,
|
||||
{ tools: null, temperature: agent?.temperature },
|
||||
{ tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined },
|
||||
(delta) => {
|
||||
accumulated += delta;
|
||||
ctx.publish(sessionId, {
|
||||
@@ -545,7 +545,7 @@ export async function runStepCapSummary(
|
||||
ctx,
|
||||
session.model,
|
||||
messages,
|
||||
{ tools: null, temperature: agent?.temperature },
|
||||
{ tools: null, temperature: agent?.temperature, top_p: agent?.top_p ?? undefined, top_k: agent?.top_k ?? undefined, min_p: agent?.min_p ?? undefined, presence_penalty: agent?.presence_penalty ?? undefined },
|
||||
(delta) => {
|
||||
accumulated += delta;
|
||||
ctx.publish(sessionId, {
|
||||
|
||||
@@ -7,9 +7,7 @@ import * as modelContext from '../model-context.js';
|
||||
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
|
||||
import { matchToolGlob } from '../agents.js';
|
||||
import type { OpenAiMessage } from './payload.js';
|
||||
// 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 { extractToolCallBlocks } from './tool-call-parser.js';
|
||||
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
|
||||
import type {
|
||||
InferenceContext,
|
||||
@@ -31,6 +29,10 @@ interface StreamOptions {
|
||||
// (rare; we still omit from the request body to avoid OpenAI 400).
|
||||
tools: ToolJsonSchema[] | null;
|
||||
temperature?: number;
|
||||
top_p?: number | null;
|
||||
top_k?: number | null;
|
||||
min_p?: number | null;
|
||||
presence_penalty?: number | null;
|
||||
}
|
||||
|
||||
// v1.13.1-A: convert BooCode's OpenAI-shaped history into AI SDK
|
||||
@@ -155,7 +157,8 @@ export async function streamCompletion(
|
||||
opts: StreamOptions,
|
||||
onDelta: (content: string) => void,
|
||||
onUsage: ((prompt: number | null, completion: number | null) => void) | undefined,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
agent?: Agent | null,
|
||||
): Promise<StreamResult> {
|
||||
const aiMessages = toModelMessages(messages);
|
||||
const hasTools = opts.tools !== null && opts.tools.length > 0;
|
||||
@@ -193,12 +196,15 @@ export async function streamCompletion(
|
||||
};
|
||||
|
||||
const result = streamText({
|
||||
model: upstreamModel(ctx.config.LLAMA_SWAP_URL, model),
|
||||
model: upstreamModel(ctx.config, model, agent ?? null),
|
||||
messages: aiMessages,
|
||||
...(aiTools
|
||||
? { tools: aiTools, toolChoice: 'auto' as const, experimental_repairToolCall: repairToolCall }
|
||||
: {}),
|
||||
...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
|
||||
...(typeof opts.top_p === 'number' ? { topP: opts.top_p } : {}),
|
||||
...(typeof opts.top_k === 'number' ? { topK: opts.top_k } : {}),
|
||||
...(typeof opts.presence_penalty === 'number' ? { presencePenalty: opts.presence_penalty } : {}),
|
||||
abortSignal: signal,
|
||||
});
|
||||
|
||||
@@ -388,6 +394,10 @@ export async function executeStreamPhase(
|
||||
: toolJsonSchemas()
|
||||
).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name));
|
||||
const effectiveTemperature = agent?.temperature;
|
||||
const effectiveTopP = agent?.top_p ?? undefined;
|
||||
const effectiveTopK = agent?.top_k ?? undefined;
|
||||
const effectiveMinP = agent?.min_p ?? undefined;
|
||||
const effectivePresencePenalty = agent?.presence_penalty ?? undefined;
|
||||
|
||||
// v1.12.2: ctx_max lookup is cached after the first hit per model, so this
|
||||
// is a Map probe in steady state. We capture nCtx once at the top of the
|
||||
@@ -425,7 +435,7 @@ export async function executeStreamPhase(
|
||||
ctx,
|
||||
session.model,
|
||||
messages,
|
||||
{ tools: effectiveTools, temperature: effectiveTemperature },
|
||||
{ tools: effectiveTools, temperature: effectiveTemperature, top_p: effectiveTopP, top_k: effectiveTopK, min_p: effectiveMinP, presence_penalty: effectivePresencePenalty },
|
||||
(delta) => {
|
||||
state.accumulated += delta;
|
||||
ctx.publish(sessionId, {
|
||||
@@ -449,7 +459,8 @@ export async function executeStreamPhase(
|
||||
}, USAGE_THROTTLE_MS - elapsed);
|
||||
}
|
||||
},
|
||||
signal
|
||||
signal,
|
||||
agent,
|
||||
);
|
||||
} finally {
|
||||
if (pendingFlushTimer) {
|
||||
|
||||
426
apps/server/src/services/inference/tool-call-parser.ts
Normal file
426
apps/server/src/services/inference/tool-call-parser.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
// Ported from studio/backend/core/inference/tool_call_parser.py.
|
||||
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/tool_call_parser.py
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
export const XML_TOOL_OPEN = '<tool_call>';
|
||||
export const XML_TOOL_CLOSE = '</tool_call>';
|
||||
export const INVOKE_TOOL_OPEN = '<invoke';
|
||||
export const INVOKE_TOOL_CLOSE = '</invoke>';
|
||||
|
||||
export const TOOL_XML_SIGNALS = [XML_TOOL_OPEN, '<function=', INVOKE_TOOL_OPEN] as const;
|
||||
|
||||
export const TOOL_ERROR_PREFIXES = [
|
||||
'Error',
|
||||
'Search failed',
|
||||
'Execution error',
|
||||
'Blocked:',
|
||||
'Exit code',
|
||||
'Failed to fetch',
|
||||
'Failed to resolve',
|
||||
'No query provided',
|
||||
] as const;
|
||||
|
||||
export const DUPLICATE_CALL_NUDGE =
|
||||
'You already made this exact call. Do not repeat the same tool ' +
|
||||
'call. Try a different approach: fetch a URL from previous ' +
|
||||
'results, use Python to process data you already have, or ' +
|
||||
'provide your final answer now.';
|
||||
|
||||
export const TOOL_ERROR_NUDGE =
|
||||
'\n\nThe tool call encountered an issue. Please try a different ' +
|
||||
'approach or rephrase your request.';
|
||||
|
||||
export const BUDGET_EXHAUSTED_NUDGE =
|
||||
'You have used all available tool calls. Based on everything you ' +
|
||||
'have found so far, provide your final answer now. Do not call ' +
|
||||
'any more tools.';
|
||||
|
||||
// ── Strip patterns ───────────────────────────────────────────────────────
|
||||
|
||||
const TOOL_CLOSED_PATS = [
|
||||
/<tool_call>.*?<\/tool_call>/gs,
|
||||
/<function=\w+>.*?<\/function>/gs,
|
||||
/<invoke\s[^>]*>.*?<\/invoke>/gs,
|
||||
];
|
||||
|
||||
const TOOL_ALL_PATS = [
|
||||
...TOOL_CLOSED_PATS,
|
||||
/<tool_call>.*$/gs,
|
||||
/<function=\w+>.*$/gs,
|
||||
/<invoke\s[^>]*>.*$/gs,
|
||||
];
|
||||
|
||||
// ── Strip / signal ───────────────────────────────────────────────────────
|
||||
|
||||
export function stripToolMarkup(text: string, opts?: { final?: boolean }): string {
|
||||
const pats = opts?.final ? TOOL_ALL_PATS : TOOL_CLOSED_PATS;
|
||||
for (const pat of pats) {
|
||||
text = text.replace(pat, '');
|
||||
}
|
||||
return opts?.final ? text.trim() : text;
|
||||
}
|
||||
|
||||
export function hasToolSignal(text: string): boolean {
|
||||
return TOOL_XML_SIGNALS.some((s) => text.includes(s));
|
||||
}
|
||||
|
||||
// ── parseToolCallsFromText (Unsloth port + Anthropic extension) ──────────
|
||||
|
||||
export interface OpenAiToolCall {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: { name: string; arguments: string };
|
||||
}
|
||||
|
||||
const TC_JSON_START_RE = /<tool_call>\s*\{/g;
|
||||
const TC_FUNC_START_RE = /<function=(\w+)>\s*/g;
|
||||
const TC_END_TAG_RE = /<\/tool_call>/;
|
||||
const TC_FUNC_CLOSE_RE = /\s*<\/function>\s*$/;
|
||||
const TC_PARAM_START_RE = /<parameter=(\w+)>\s*/g;
|
||||
const TC_PARAM_CLOSE_RE = /\s*<\/parameter>\s*$/;
|
||||
|
||||
const TC_INVOKE_START_RE = /<invoke\s+name\s*=\s*(?:"([^"]*)"|'([^']*)')\s*>/g;
|
||||
const TC_INVOKE_CLOSE_RE = /\s*<\/invoke>\s*$/;
|
||||
const TC_INVOKE_PARAM_RE = /<parameter\s+name\s*=\s*(?:"([^"]*)"|'([^']*)')\s*>/g;
|
||||
const TC_INVOKE_PARAM_CLOSE_RE = /\s*<\/parameter>\s*$/;
|
||||
|
||||
function scanBalancedBraces(content: string, start: number): number {
|
||||
let depth = 0;
|
||||
let i = start;
|
||||
let inString = false;
|
||||
while (i < content.length) {
|
||||
const ch = content[i]!;
|
||||
if (inString) {
|
||||
if (ch === '\\' && i + 1 < content.length) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') inString = false;
|
||||
} else if (ch === '"') {
|
||||
inString = true;
|
||||
} else if (ch === '{') {
|
||||
depth++;
|
||||
} else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function parseToolCallsFromText(
|
||||
content: string,
|
||||
opts?: { idOffset?: number },
|
||||
): OpenAiToolCall[] {
|
||||
const toolCalls: OpenAiToolCall[] = [];
|
||||
const idOffset = opts?.idOffset ?? 0;
|
||||
|
||||
// Pattern 1: <tool_call>{json}</tool_call> -- balanced-brace JSON scanner.
|
||||
// Skips braces inside JSON strings so nested objects parse correctly.
|
||||
TC_JSON_START_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = TC_JSON_START_RE.exec(content)) !== null) {
|
||||
const braceStart = m.index + m[0].length - 1;
|
||||
const braceEnd = scanBalancedBraces(content, braceStart);
|
||||
if (braceEnd === -1) continue;
|
||||
const jsonStr = content.slice(braceStart, braceEnd + 1);
|
||||
try {
|
||||
const obj = JSON.parse(jsonStr) as Record<string, unknown>;
|
||||
const name = typeof obj.name === 'string' ? obj.name : '';
|
||||
let args: string;
|
||||
const rawArgs = obj.arguments ?? {};
|
||||
if (typeof rawArgs === 'string') {
|
||||
args = rawArgs;
|
||||
} else {
|
||||
args = JSON.stringify(rawArgs);
|
||||
}
|
||||
toolCalls.push({
|
||||
id: `call_${idOffset + toolCalls.length}`,
|
||||
type: 'function',
|
||||
function: { name, arguments: args },
|
||||
});
|
||||
} catch {
|
||||
// malformed JSON -- skip
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 2: <function=name><parameter=key>value -- closing tags optional.
|
||||
// Body boundary uses </tool_call> or next <function= (not </function>,
|
||||
// because code parameter values can contain that literal).
|
||||
if (toolCalls.length === 0) {
|
||||
TC_FUNC_START_RE.lastIndex = 0;
|
||||
const funcStarts: Array<{ match: RegExpExecArray; name: string }> = [];
|
||||
while ((m = TC_FUNC_START_RE.exec(content)) !== null) {
|
||||
funcStarts.push({ match: m, name: m[1]! });
|
||||
}
|
||||
for (let idx = 0; idx < funcStarts.length; idx++) {
|
||||
const { match: fm, name: funcName } = funcStarts[idx]!;
|
||||
const bodyStart = fm.index + fm[0].length;
|
||||
const nextFunc = idx + 1 < funcStarts.length
|
||||
? funcStarts[idx + 1]!.match.index
|
||||
: content.length;
|
||||
const endTag = TC_END_TAG_RE.exec(content.slice(bodyStart));
|
||||
let bodyEnd = endTag ? bodyStart + endTag.index : content.length;
|
||||
bodyEnd = Math.min(bodyEnd, nextFunc);
|
||||
let body = content.slice(bodyStart, bodyEnd);
|
||||
body = body.replace(TC_FUNC_CLOSE_RE, '');
|
||||
|
||||
const args: Record<string, string> = {};
|
||||
TC_PARAM_START_RE.lastIndex = 0;
|
||||
const paramStarts: Array<{ match: RegExpExecArray; name: string }> = [];
|
||||
let pm: RegExpExecArray | null;
|
||||
while ((pm = TC_PARAM_START_RE.exec(body)) !== null) {
|
||||
paramStarts.push({ match: pm, name: pm[1]! });
|
||||
}
|
||||
if (paramStarts.length === 1) {
|
||||
// Single param: take everything to body end so embedded
|
||||
// </parameter> in code strings is preserved.
|
||||
const p = paramStarts[0]!;
|
||||
let val = body.slice(p.match.index + p.match[0].length);
|
||||
val = val.replace(TC_PARAM_CLOSE_RE, '');
|
||||
args[p.name] = val.trim();
|
||||
} else {
|
||||
for (let pidx = 0; pidx < paramStarts.length; pidx++) {
|
||||
const p = paramStarts[pidx]!;
|
||||
const valStart = p.match.index + p.match[0].length;
|
||||
const nextParam = pidx + 1 < paramStarts.length
|
||||
? paramStarts[pidx + 1]!.match.index
|
||||
: body.length;
|
||||
let val = body.slice(valStart, nextParam);
|
||||
val = val.replace(TC_PARAM_CLOSE_RE, '');
|
||||
args[p.name] = val.trim();
|
||||
}
|
||||
}
|
||||
|
||||
toolCalls.push({
|
||||
id: `call_${idOffset + toolCalls.length}`,
|
||||
type: 'function',
|
||||
function: { name: funcName, arguments: JSON.stringify(args) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 3: <invoke name="..."><parameter name="...">value -- Anthropic
|
||||
// shape that qwen3.6 drifts to from Claude Code documentation residue.
|
||||
// Closing tags optional; same single-param fast path as pattern 2.
|
||||
if (toolCalls.length === 0) {
|
||||
TC_INVOKE_START_RE.lastIndex = 0;
|
||||
const invokeStarts: Array<{ match: RegExpExecArray; name: string }> = [];
|
||||
while ((m = TC_INVOKE_START_RE.exec(content)) !== null) {
|
||||
const name = (m[1] ?? m[2] ?? '').trim();
|
||||
if (name) invokeStarts.push({ match: m, name });
|
||||
}
|
||||
for (let idx = 0; idx < invokeStarts.length; idx++) {
|
||||
const { match: im, name: invokeName } = invokeStarts[idx]!;
|
||||
const bodyStart = im.index + im[0].length;
|
||||
const nextInvoke = idx + 1 < invokeStarts.length
|
||||
? invokeStarts[idx + 1]!.match.index
|
||||
: content.length;
|
||||
const closeTag = content.slice(bodyStart).match(/<\/invoke>/);
|
||||
let bodyEnd = closeTag ? bodyStart + (closeTag.index ?? 0) : content.length;
|
||||
bodyEnd = Math.min(bodyEnd, nextInvoke);
|
||||
let body = content.slice(bodyStart, bodyEnd);
|
||||
body = body.replace(TC_INVOKE_CLOSE_RE, '');
|
||||
|
||||
const args: Record<string, string> = {};
|
||||
TC_INVOKE_PARAM_RE.lastIndex = 0;
|
||||
const paramStarts: Array<{ match: RegExpExecArray; name: string }> = [];
|
||||
let pm: RegExpExecArray | null;
|
||||
while ((pm = TC_INVOKE_PARAM_RE.exec(body)) !== null) {
|
||||
const pname = (pm[1] ?? pm[2] ?? '').trim();
|
||||
if (pname) paramStarts.push({ match: pm, name: pname });
|
||||
}
|
||||
if (paramStarts.length === 1) {
|
||||
const p = paramStarts[0]!;
|
||||
let val = body.slice(p.match.index + p.match[0].length);
|
||||
val = val.replace(TC_INVOKE_PARAM_CLOSE_RE, '');
|
||||
args[p.name] = val.trim();
|
||||
} else {
|
||||
for (let pidx = 0; pidx < paramStarts.length; pidx++) {
|
||||
const p = paramStarts[pidx]!;
|
||||
const valStart = p.match.index + p.match[0].length;
|
||||
const nextParam = pidx + 1 < paramStarts.length
|
||||
? paramStarts[pidx + 1]!.match.index
|
||||
: body.length;
|
||||
let val = body.slice(valStart, nextParam);
|
||||
val = val.replace(TC_INVOKE_PARAM_CLOSE_RE, '');
|
||||
args[p.name] = val.trim();
|
||||
}
|
||||
}
|
||||
|
||||
toolCalls.push({
|
||||
id: `call_${idOffset + toolCalls.length}`,
|
||||
type: 'function',
|
||||
function: { name: invokeName, arguments: JSON.stringify(args) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return toolCalls;
|
||||
}
|
||||
|
||||
// ── BooCode streaming helpers ────────────────────────────────────────────
|
||||
|
||||
export interface ParsedCall {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const PLACEHOLDER_LITERALS = new Set(['...', 'placeholder', '<path>', '<file>']);
|
||||
const ANGLE_BRACKET_SENTINEL_RE = /^<[^>]+>$/;
|
||||
|
||||
export function isPlaceholderArgValue(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return false;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return true;
|
||||
if (PLACEHOLDER_LITERALS.has(trimmed)) return true;
|
||||
if (ANGLE_BRACKET_SENTINEL_RE.test(trimmed)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasPlaceholderArgs(args: Record<string, unknown>): boolean {
|
||||
for (const value of Object.values(args)) {
|
||||
if (isPlaceholderArgValue(value)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function logRejectedPlaceholder(parsed: ParsedCall): void {
|
||||
console.debug(
|
||||
{ toolName: parsed.name, args: parsed.args },
|
||||
'rejected placeholder tool call at parse time',
|
||||
);
|
||||
}
|
||||
|
||||
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> = {};
|
||||
for (const m of block.matchAll(QWEN_PARAM_RE)) {
|
||||
const key = (m[1] ?? '').trim();
|
||||
if (!key) continue;
|
||||
const raw = (m[2] ?? '').trim();
|
||||
try {
|
||||
args[key] = JSON.parse(raw);
|
||||
} catch {
|
||||
args[key] = raw;
|
||||
}
|
||||
}
|
||||
return { name, args };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
const ALL_OPENERS = [XML_TOOL_OPEN, INVOKE_TOOL_OPEN] as const;
|
||||
|
||||
export function partialXmlOpenerStart(s: string): number {
|
||||
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);
|
||||
for (const op of ALL_OPENERS) {
|
||||
if (op.startsWith(suffix) && suffix.length < op.length) return lastLt;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (hasPlaceholderArgs(parsed.args)) {
|
||||
logRejectedPlaceholder(parsed);
|
||||
flushed += block;
|
||||
} else {
|
||||
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) };
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Session, ToolCall } from '../../types/api.js';
|
||||
import type { Agent, Session, ToolCall } from '../../types/api.js';
|
||||
import * as modelContext from '../model-context.js';
|
||||
import { PathScopeError } from '../path_guard.js';
|
||||
import { TOOLS_BY_NAME } from '../tools.js';
|
||||
import { matchToolGlob } from '../agents.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
|
||||
@@ -14,6 +15,7 @@ import { formatUnknownToolError } from './tool-suggestions.js';
|
||||
// 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 { stripToolMarkup } from './tool-call-parser.js';
|
||||
import type {
|
||||
InferenceContext,
|
||||
StreamResult,
|
||||
@@ -97,10 +99,12 @@ export async function executeToolPhase(
|
||||
result: StreamResult,
|
||||
startedAt: string | null,
|
||||
session: Session,
|
||||
projectRoot: string
|
||||
projectRoot: string,
|
||||
agent?: Agent | null,
|
||||
): Promise<ToolPhaseResult> {
|
||||
const { sessionId, chatId, assistantMessageId } = args;
|
||||
const { content, toolCalls, promptTokens, completionTokens } = result;
|
||||
const content = stripToolMarkup(result.content, { final: true });
|
||||
const { toolCalls, promptTokens, completionTokens } = result;
|
||||
|
||||
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/props, not the
|
||||
// streaming completion (which doesn't emit n_ctx). getModelContext caches
|
||||
@@ -260,6 +264,31 @@ export async function executeToolPhase(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (agent && !matchToolGlob(tc.name, agent.tools)) {
|
||||
const stored = {
|
||||
tool_call_id: tc.id,
|
||||
output: null,
|
||||
truncated: false,
|
||||
error: `tool '${tc.name}' is not allowed for agent '${agent.name}'`,
|
||||
};
|
||||
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,
|
||||
error: stored.error,
|
||||
});
|
||||
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 } : {}) });
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import { ALL_TOOLS } from '../tools.js';
|
||||
import { resolveProjectRoot } from '../path_guard.js';
|
||||
import { maybeAutoNameChat } from '../auto_name.js';
|
||||
import { rewriteSearchQuery } from '../task-search-rewrite.js';
|
||||
import { getAgentById } from '../agents.js';
|
||||
import * as compaction from '../compaction.js';
|
||||
import type { Broker } from '../broker.js';
|
||||
@@ -254,6 +255,16 @@ export async function runAssistantTurn(
|
||||
const webToolsEnabled =
|
||||
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
|
||||
|
||||
if (stepNumber === 0 && webToolsEnabled && messages.length >= 2) {
|
||||
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
|
||||
if (lastUserMsg?.content) {
|
||||
const hint = await rewriteSearchQuery(lastUserMsg.content);
|
||||
if (hint && messages[0]?.role === 'system' && messages[0].content) {
|
||||
messages[0].content += `\n\nThe user's search intent can be summarized as: "${hint}"`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
||||
let result: StreamResult;
|
||||
@@ -281,7 +292,7 @@ export async function runAssistantTurn(
|
||||
// ---- tool phase ----
|
||||
let toolPhaseResult: ToolPhaseResult;
|
||||
try {
|
||||
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot);
|
||||
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent);
|
||||
} catch (err) {
|
||||
// Tool phase errors are unexpected (individual tool failures are
|
||||
// caught inside executeToolPhase). Log and break.
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
// 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 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>';
|
||||
|
||||
// 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>;
|
||||
}
|
||||
|
||||
const PLACEHOLDER_LITERALS = new Set(['...', 'placeholder', '<path>', '<file>']);
|
||||
const ANGLE_BRACKET_SENTINEL_RE = /^<[^>]+>$/;
|
||||
|
||||
/** True when a string arg looks like a model placeholder, not a real path/value. */
|
||||
export function isPlaceholderArgValue(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return false;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return true;
|
||||
if (PLACEHOLDER_LITERALS.has(trimmed)) return true;
|
||||
if (ANGLE_BRACKET_SENTINEL_RE.test(trimmed)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasPlaceholderArgs(args: Record<string, unknown>): boolean {
|
||||
for (const value of Object.values(args)) {
|
||||
if (isPlaceholderArgValue(value)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function logRejectedPlaceholder(parsed: ParsedCall): void {
|
||||
// Pure helper — no Fastify logger here (stream-phase.ts stays unchanged).
|
||||
console.debug(
|
||||
{ toolName: parsed.name, args: parsed.args },
|
||||
'rejected placeholder tool call at parse time',
|
||||
);
|
||||
}
|
||||
|
||||
// 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> = {};
|
||||
for (const m of block.matchAll(QWEN_PARAM_RE)) {
|
||||
const key = (m[1] ?? '').trim();
|
||||
if (!key) continue;
|
||||
const raw = (m[2] ?? '').trim();
|
||||
try {
|
||||
args[key] = JSON.parse(raw);
|
||||
} catch {
|
||||
args[key] = raw;
|
||||
}
|
||||
}
|
||||
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 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
|
||||
// block before reaching this check.
|
||||
const ALL_OPENERS = [XML_TOOL_OPEN, INVOKE_TOOL_OPEN] as const;
|
||||
|
||||
export function partialXmlOpenerStart(s: string): number {
|
||||
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);
|
||||
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) {
|
||||
if (hasPlaceholderArgs(parsed.args)) {
|
||||
logRejectedPlaceholder(parsed);
|
||||
flushed += block;
|
||||
} else {
|
||||
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) };
|
||||
}
|
||||
@@ -163,6 +163,13 @@ const COMPILED: ReadonlyArray<CompiledPattern> = DEFAULT_SECURITY_IGNORE_FILETYP
|
||||
// Returns true when `relPath` matches a known-secret pattern. Case-insensitive
|
||||
// (regex 'i' flag). Always normalize path separators to `/` so Windows-origin
|
||||
// paths match the same patterns. Empty or root-only paths return false.
|
||||
const SAFE_PATTERNS: ReadonlySet<string> = new Set([
|
||||
'.env.example',
|
||||
'.env.sample',
|
||||
'.env.template',
|
||||
'.env.defaults',
|
||||
]);
|
||||
|
||||
export function isSecretPath(relPath: string): boolean {
|
||||
if (!relPath) return false;
|
||||
const normalized = relPath.replace(/\\/g, '/');
|
||||
@@ -170,6 +177,8 @@ export function isSecretPath(relPath: string): boolean {
|
||||
if (segments.length === 0) return false;
|
||||
const base = segments[segments.length - 1]!;
|
||||
|
||||
if (SAFE_PATTERNS.has(base.toLowerCase())) return false;
|
||||
|
||||
for (const compiled of COMPILED) {
|
||||
if (compiled.mode === 'basename') {
|
||||
if (compiled.regex.test(base)) return true;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createHash } from 'node:crypto';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import type { Agent, Project, Session } from '../types/api.js';
|
||||
import { getAgentsMtimes } from './agents.js';
|
||||
import { resolveRoute } from './inference/provider.js';
|
||||
|
||||
const BASE_SYSTEM_PROMPT = (projectPath: string) =>
|
||||
`You are BooCode Chat, a code investigation assistant. The user is working on a project located at ${projectPath}. Use the file-read tools (view_file, list_dir, grep, find_files) to investigate code when needed. Be concise. Cite file paths and line numbers when discussing code. Do not hallucinate file contents — read the file first. Tool results may be truncated; if so, narrow your query rather than guessing.`;
|
||||
@@ -98,6 +99,7 @@ export interface PrefixFingerprint {
|
||||
has_agent_system_prompt: boolean;
|
||||
has_session_override: boolean;
|
||||
has_project_override: boolean;
|
||||
route: 'swap' | 'sidecar';
|
||||
}
|
||||
|
||||
export interface PrefixDrift {
|
||||
@@ -125,6 +127,7 @@ interface ObservedInputs {
|
||||
has_agent_system_prompt: boolean;
|
||||
has_session_override: boolean;
|
||||
has_project_override: boolean;
|
||||
route: 'swap' | 'sidecar';
|
||||
}
|
||||
|
||||
interface ObserverEntry {
|
||||
@@ -183,6 +186,7 @@ export async function buildSystemPromptWithFingerprint(
|
||||
has_agent_system_prompt: !!(agent && agent.system_prompt.trim().length > 0),
|
||||
has_session_override: sessionPrompt.length > 0,
|
||||
has_project_override: projectPrompt.length > 0,
|
||||
route: resolveRoute(agent).route,
|
||||
};
|
||||
|
||||
const fingerprint: PrefixFingerprint = {
|
||||
@@ -199,6 +203,7 @@ export async function buildSystemPromptWithFingerprint(
|
||||
has_agent_system_prompt: inputs.has_agent_system_prompt,
|
||||
has_session_override: inputs.has_session_override,
|
||||
has_project_override: inputs.has_project_override,
|
||||
route: inputs.route,
|
||||
};
|
||||
|
||||
let drift: PrefixDrift | null = null;
|
||||
|
||||
68
apps/server/src/services/task-model.ts
Normal file
68
apps/server/src/services/task-model.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { loadConfig, type Config } from '../config.js';
|
||||
|
||||
const TIMEOUT_MS = 10_000;
|
||||
|
||||
export async function taskModelCompletion(opts: {
|
||||
system: string;
|
||||
user: string;
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
fallbackModel?: string;
|
||||
}): Promise<string> {
|
||||
const config = loadConfig();
|
||||
const maxTokens = opts.maxTokens ?? 30;
|
||||
const temperature = opts.temperature ?? 0.3;
|
||||
|
||||
const { url, model } = resolveEndpoint(config, opts.fallbackModel);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${url}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: opts.system },
|
||||
{ role: 'user', content: opts.user },
|
||||
],
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
stream: false,
|
||||
chat_template_kwargs: { enable_thinking: false },
|
||||
}),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
console.warn(`task-model: ${res.status} ${text.slice(0, 200)}`);
|
||||
return '';
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
choices?: Array<{
|
||||
message?: { content?: string; reasoning_content?: string };
|
||||
}>;
|
||||
};
|
||||
const choice = data.choices?.[0]?.message;
|
||||
if (!choice) return '';
|
||||
const content = (choice.content ?? '').trim();
|
||||
if (content.length > 0) return content;
|
||||
const reasoning = choice.reasoning_content ?? '';
|
||||
if (reasoning.length === 0) return '';
|
||||
const lines = reasoning.split('\n').map((l) => l.trim()).filter((l) => l.length > 0);
|
||||
return lines[lines.length - 1] ?? '';
|
||||
} catch (err) {
|
||||
console.warn('task-model: request failed', err);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEndpoint(
|
||||
config: Config,
|
||||
fallbackModel?: string,
|
||||
): { url: string; model: string } {
|
||||
if (config.TASK_MODEL_URL) {
|
||||
return { url: config.TASK_MODEL_URL, model: 'gemma-3-270m-it' };
|
||||
}
|
||||
const model = config.FAST_MODEL ?? fallbackModel ?? config.DEFAULT_MODEL;
|
||||
return { url: config.LLAMA_SWAP_URL, model };
|
||||
}
|
||||
19
apps/server/src/services/task-search-rewrite.ts
Normal file
19
apps/server/src/services/task-search-rewrite.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { taskModelCompletion } from './task-model.js';
|
||||
|
||||
const SYSTEM_PROMPT =
|
||||
'You rewrite user messages into concise web search queries. Reply with ONLY the search query. 3 to 6 words. No quotes, no explanation.';
|
||||
|
||||
const MAX_INPUT_CHARS = 500;
|
||||
const FALLBACK_CHARS = 60;
|
||||
|
||||
export async function rewriteSearchQuery(userMessage: string): Promise<string> {
|
||||
const input = userMessage.slice(0, MAX_INPUT_CHARS);
|
||||
const result = await taskModelCompletion({
|
||||
system: SYSTEM_PROMPT,
|
||||
user: input,
|
||||
maxTokens: 20,
|
||||
temperature: 0.2,
|
||||
});
|
||||
if (result.length > 0) return result;
|
||||
return userMessage.slice(0, FALLBACK_CHARS).trim();
|
||||
}
|
||||
24
apps/server/src/services/task-summary.ts
Normal file
24
apps/server/src/services/task-summary.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { taskModelCompletion } from './task-model.js';
|
||||
|
||||
const SYSTEM_PROMPT =
|
||||
'Summarize this conversation in one sentence, 15 words max. No quotes, no prefix.';
|
||||
|
||||
const MAX_INPUT_CHARS = 1000;
|
||||
|
||||
export async function oneLineSummary(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
): Promise<string> {
|
||||
const lastPairs = messages.slice(-6);
|
||||
let input = lastPairs
|
||||
.map((m) => `${m.role}: ${m.content}`)
|
||||
.join('\n');
|
||||
if (input.length > MAX_INPUT_CHARS) {
|
||||
input = input.slice(0, MAX_INPUT_CHARS);
|
||||
}
|
||||
return taskModelCompletion({
|
||||
system: SYSTEM_PROMPT,
|
||||
user: input,
|
||||
maxTokens: 30,
|
||||
temperature: 0.3,
|
||||
});
|
||||
}
|
||||
22
apps/server/src/services/task-tags.ts
Normal file
22
apps/server/src/services/task-tags.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { taskModelCompletion } from './task-model.js';
|
||||
|
||||
const SYSTEM_PROMPT =
|
||||
'You tag chat sessions. Reply with 1 to 3 lowercase tags separated by commas. Tags should describe the topic. No explanation. Examples: "docker, deployment", "python, debugging", "react, styling".';
|
||||
|
||||
export async function suggestTags(
|
||||
userMessage: string,
|
||||
assistantReply: string,
|
||||
): Promise<string[]> {
|
||||
const input = `User: ${userMessage.slice(0, 300)}\nAssistant: ${assistantReply.slice(0, 300)}`;
|
||||
const result = await taskModelCompletion({
|
||||
system: SYSTEM_PROMPT,
|
||||
user: input,
|
||||
maxTokens: 30,
|
||||
temperature: 0.3,
|
||||
});
|
||||
if (result.length === 0) return [];
|
||||
return result
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter((t) => t.length > 0 && t.length <= 30);
|
||||
}
|
||||
347
apps/server/src/services/web/html-to-md.ts
Normal file
347
apps/server/src/services/web/html-to-md.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
// Ported from studio/backend/core/inference/_html_to_md.py.
|
||||
// Original: https://github.com/unslothai/unsloth/blob/main/studio/backend/core/inference/_html_to_md.py
|
||||
|
||||
import { parse, type DefaultTreeAdapterTypes } from 'parse5';
|
||||
|
||||
type Document = DefaultTreeAdapterTypes.Document;
|
||||
type ChildNode = DefaultTreeAdapterTypes.ChildNode;
|
||||
type Element = DefaultTreeAdapterTypes.Element;
|
||||
type TextNode = DefaultTreeAdapterTypes.TextNode;
|
||||
|
||||
const SKIP_TAGS = new Set([
|
||||
'script', 'style', 'head', 'noscript', 'svg', 'math', 'nav', 'footer',
|
||||
]);
|
||||
|
||||
const BLOCK_TAGS = new Set([
|
||||
'p', 'div', 'section', 'article', 'main', 'aside', 'figure',
|
||||
'figcaption', 'details', 'summary', 'dl', 'dt', 'dd',
|
||||
]);
|
||||
|
||||
const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
||||
|
||||
const INLINE_EMPHASIS: Record<string, string> = {
|
||||
strong: '**', b: '**', em: '*', i: '*',
|
||||
};
|
||||
|
||||
function isElement(node: ChildNode): node is Element {
|
||||
return 'tagName' in node;
|
||||
}
|
||||
|
||||
function isText(node: ChildNode): node is TextNode {
|
||||
return node.nodeName === '#text';
|
||||
}
|
||||
|
||||
class MarkdownRenderer {
|
||||
private out: string[] = [];
|
||||
|
||||
private inLink = false;
|
||||
private linkHref: string | null = null;
|
||||
private linkTextParts: string[] = [];
|
||||
|
||||
private listStack: string[] = [];
|
||||
private olCounter: number[] = [];
|
||||
|
||||
private inTable = false;
|
||||
private currentRow: string[] = [];
|
||||
private cellParts: string[] = [];
|
||||
private inCell = false;
|
||||
private headerRowDone = false;
|
||||
private rowHasTh = false;
|
||||
private isFirstRow = false;
|
||||
|
||||
private inPre = false;
|
||||
private preParts: string[] = [];
|
||||
private preLanguage: string | null = null;
|
||||
private inInlineCode = false;
|
||||
|
||||
private bqStack: string[][] = [];
|
||||
|
||||
private emit(text: string): void {
|
||||
if (this.inLink) {
|
||||
this.linkTextParts.push(text);
|
||||
} else if (this.inCell) {
|
||||
this.cellParts.push(text);
|
||||
} else if (this.inPre) {
|
||||
this.preParts.push(text);
|
||||
} else if (this.bqStack.length > 0) {
|
||||
this.bqStack[this.bqStack.length - 1]!.push(text);
|
||||
} else {
|
||||
this.out.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
private prefixBlockquote(content: string): string {
|
||||
content = content.replace(/[ \t]+$/gm, '');
|
||||
content = content.replace(/\n{3,}/g, '\n\n').trim();
|
||||
if (!content) return '';
|
||||
return content.split('\n').map(line =>
|
||||
line.trim() ? '> ' + line : '>'
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
private finishCell(): void {
|
||||
if (!this.inCell) return;
|
||||
this.inCell = false;
|
||||
let cellText = this.cellParts.join('').trim().replace(/\n/g, ' ');
|
||||
cellText = cellText.replace(/\|/g, '\\|');
|
||||
this.currentRow.push(cellText);
|
||||
this.cellParts = [];
|
||||
}
|
||||
|
||||
private finishRow(): void {
|
||||
if (this.currentRow.length === 0) return;
|
||||
const line = '| ' + this.currentRow.join(' | ') + ' |';
|
||||
this.emit(line + '\n');
|
||||
if (!this.headerRowDone && (this.rowHasTh || this.isFirstRow)) {
|
||||
const sep = '| ' + this.currentRow.map(() => '---').join(' | ') + ' |';
|
||||
this.emit(sep + '\n');
|
||||
this.headerRowDone = true;
|
||||
}
|
||||
this.isFirstRow = false;
|
||||
this.currentRow = [];
|
||||
this.rowHasTh = false;
|
||||
}
|
||||
|
||||
private finishLink(): void {
|
||||
const text = this.linkTextParts.join('').replace(/\s+/g, ' ').trim();
|
||||
const href = this.linkHref ?? '';
|
||||
this.inLink = false;
|
||||
if (href && text) {
|
||||
this.emit(`[${text}](${href})`);
|
||||
} else if (text) {
|
||||
this.emit(text);
|
||||
}
|
||||
}
|
||||
|
||||
private getAttr(el: Element, name: string): string | undefined {
|
||||
return el.attrs.find(a => a.name === name)?.value;
|
||||
}
|
||||
|
||||
private handleOpen(el: Element): void {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
|
||||
if (HEADING_TAGS.has(tag)) {
|
||||
const level = parseInt(tag[1]!, 10);
|
||||
this.emit('\n\n' + '#'.repeat(level) + ' ');
|
||||
} else if (tag === 'a') {
|
||||
this.linkHref = this.getAttr(el, 'href') ?? null;
|
||||
this.linkTextParts = [];
|
||||
this.inLink = true;
|
||||
} else if (tag in INLINE_EMPHASIS) {
|
||||
this.emit(INLINE_EMPHASIS[tag]!);
|
||||
} else if (tag === 'br') {
|
||||
this.emit('\n');
|
||||
} else if (BLOCK_TAGS.has(tag)) {
|
||||
this.emit('\n\n');
|
||||
} else if (tag === 'hr') {
|
||||
this.emit('\n\n---\n\n');
|
||||
} else if (tag === 'blockquote') {
|
||||
this.emit('\n\n');
|
||||
this.bqStack.push([]);
|
||||
} else if (tag === 'ul') {
|
||||
this.listStack.push('ul');
|
||||
this.emit('\n');
|
||||
} else if (tag === 'ol') {
|
||||
this.listStack.push('ol');
|
||||
const startAttr = this.getAttr(el, 'start');
|
||||
let start = 1;
|
||||
if (startAttr != null) {
|
||||
const parsed = parseInt(startAttr, 10);
|
||||
if (!isNaN(parsed)) start = parsed;
|
||||
}
|
||||
this.olCounter.push(start - 1);
|
||||
this.emit('\n');
|
||||
} else if (tag === 'li') {
|
||||
const indent = ' '.repeat(Math.max(0, this.listStack.length - 1));
|
||||
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ol') {
|
||||
if (this.olCounter.length > 0) {
|
||||
this.olCounter[this.olCounter.length - 1]!++;
|
||||
this.emit(`\n${indent}${this.olCounter[this.olCounter.length - 1]}. `);
|
||||
} else {
|
||||
this.emit(`\n${indent}1. `);
|
||||
}
|
||||
} else {
|
||||
this.emit(`\n${indent}* `);
|
||||
}
|
||||
} else if (tag === 'pre') {
|
||||
this.preParts = [];
|
||||
this.inPre = true;
|
||||
this.preLanguage = null;
|
||||
const codeChild = el.childNodes.find(
|
||||
(c): c is Element => isElement(c) && c.tagName === 'code'
|
||||
);
|
||||
if (codeChild) {
|
||||
const cls = this.getAttr(codeChild, 'class') ?? '';
|
||||
const langMatch = cls.match(/(?:^|\s)language-(\S+)/);
|
||||
if (langMatch) this.preLanguage = langMatch[1]!;
|
||||
}
|
||||
} else if (tag === 'code' && !this.inPre) {
|
||||
this.inInlineCode = true;
|
||||
this.emit('`');
|
||||
} else if (tag === 'table') {
|
||||
this.inTable = true;
|
||||
this.headerRowDone = false;
|
||||
this.isFirstRow = true;
|
||||
this.emit('\n\n');
|
||||
} else if (tag === 'tr') {
|
||||
this.finishCell();
|
||||
this.finishRow();
|
||||
} else if (tag === 'th' || tag === 'td') {
|
||||
this.finishCell();
|
||||
this.cellParts = [];
|
||||
this.inCell = true;
|
||||
if (tag === 'th') this.rowHasTh = true;
|
||||
}
|
||||
}
|
||||
|
||||
private handleClose(tag: string): void {
|
||||
tag = tag.toLowerCase();
|
||||
|
||||
if (HEADING_TAGS.has(tag)) {
|
||||
this.emit('\n\n');
|
||||
} else if (tag === 'a') {
|
||||
this.finishLink();
|
||||
} else if (tag in INLINE_EMPHASIS) {
|
||||
this.emit(INLINE_EMPHASIS[tag]!);
|
||||
} else if (BLOCK_TAGS.has(tag)) {
|
||||
this.emit('\n\n');
|
||||
} else if (tag === 'blockquote') {
|
||||
if (this.bqStack.length > 0) {
|
||||
const content = this.bqStack.pop()!.join('');
|
||||
const prefixed = this.prefixBlockquote(content);
|
||||
if (prefixed) this.emit('\n\n' + prefixed + '\n\n');
|
||||
}
|
||||
} else if (tag === 'ul') {
|
||||
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ul') {
|
||||
this.listStack.pop();
|
||||
}
|
||||
this.emit('\n');
|
||||
} else if (tag === 'ol') {
|
||||
if (this.listStack.length > 0 && this.listStack[this.listStack.length - 1] === 'ol') {
|
||||
this.listStack.pop();
|
||||
if (this.olCounter.length > 0) this.olCounter.pop();
|
||||
}
|
||||
this.emit('\n');
|
||||
} else if (tag === 'pre') {
|
||||
const raw = this.preParts.join('');
|
||||
this.inPre = false;
|
||||
const lang = this.preLanguage ?? '';
|
||||
const block = '```' + lang + '\n' + raw + '\n```';
|
||||
this.emit('\n\n' + block + '\n\n');
|
||||
this.preLanguage = null;
|
||||
} else if (tag === 'code' && !this.inPre) {
|
||||
this.inInlineCode = false;
|
||||
this.emit('`');
|
||||
} else if (tag === 'th' || tag === 'td') {
|
||||
this.finishCell();
|
||||
} else if (tag === 'tr') {
|
||||
this.finishCell();
|
||||
this.finishRow();
|
||||
} else if (tag === 'table') {
|
||||
this.finishCell();
|
||||
this.finishRow();
|
||||
this.inTable = false;
|
||||
this.emit('\n');
|
||||
}
|
||||
}
|
||||
|
||||
private handleText(data: string): void {
|
||||
if (this.inPre) {
|
||||
this.preParts.push(data);
|
||||
return;
|
||||
}
|
||||
if (this.inInlineCode) {
|
||||
this.emit(data);
|
||||
return;
|
||||
}
|
||||
const text = data.replace(/\s+/g, ' ');
|
||||
if (this.inTable && !this.inCell && !text.trim()) return;
|
||||
this.emit(text);
|
||||
}
|
||||
|
||||
walk(node: ChildNode | Document): void {
|
||||
if (isText(node as ChildNode)) {
|
||||
this.handleText((node as TextNode).value);
|
||||
return;
|
||||
}
|
||||
if (node.nodeName === '#comment') return;
|
||||
|
||||
if (isElement(node as ChildNode)) {
|
||||
const el = node as Element;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SKIP_TAGS.has(tag)) return;
|
||||
if (tag === 'img') return;
|
||||
|
||||
this.handleOpen(el);
|
||||
|
||||
if (tag === 'pre') {
|
||||
for (const child of el.childNodes) {
|
||||
if (isElement(child) && child.tagName === 'code') {
|
||||
for (const grandchild of child.childNodes) {
|
||||
this.walk(grandchild);
|
||||
}
|
||||
} else {
|
||||
this.walk(child);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const child of el.childNodes) {
|
||||
this.walk(child);
|
||||
}
|
||||
}
|
||||
|
||||
this.handleClose(tag);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('childNodes' in node) {
|
||||
for (const child of (node as Document).childNodes) {
|
||||
this.walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getOutput(): string {
|
||||
return this.out.join('');
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup(text: string): string {
|
||||
const lines = text.split('\n');
|
||||
const out: string[] = [];
|
||||
let inFence = false;
|
||||
let blankRun = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const stripped = line.replace(/[ \t]+$/, '');
|
||||
if (stripped.startsWith('```')) {
|
||||
inFence = !inFence;
|
||||
blankRun = 0;
|
||||
out.push(stripped);
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
if (!stripped) {
|
||||
blankRun++;
|
||||
if (blankRun <= 1) out.push('');
|
||||
continue;
|
||||
}
|
||||
blankRun = 0;
|
||||
out.push(stripped);
|
||||
}
|
||||
|
||||
return out.join('\n').trim();
|
||||
}
|
||||
|
||||
export function htmlToMarkdown(sourceHtml: string): string {
|
||||
sourceHtml = sourceHtml.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const doc = parse(sourceHtml);
|
||||
const renderer = new MarkdownRenderer();
|
||||
renderer.walk(doc);
|
||||
return cleanup(renderer.getOutput());
|
||||
}
|
||||
1
apps/server/src/services/web/index.ts
Normal file
1
apps/server/src/services/web/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { htmlToMarkdown } from './html-to-md.js';
|
||||
@@ -12,6 +12,7 @@ import { z } from 'zod';
|
||||
import { isPublicUrl } from './url_guard.js';
|
||||
import type { ToolDef } from './tools.js';
|
||||
import { truncateIfNeeded } from './truncate.js';
|
||||
import { htmlToMarkdown } from './web/index.js';
|
||||
|
||||
const WebFetchInput = z.object({
|
||||
url: z.string().min(1).max(2048),
|
||||
@@ -38,29 +39,9 @@ export type WebFetchOutput =
|
||||
}
|
||||
| { error: string; reason: string; content_type?: string };
|
||||
|
||||
function stripHtml(html: string): { text: string; title: string | undefined } {
|
||||
// Title first, before we destroy the markup. Trim collapsed whitespace.
|
||||
function extractTitle(html: string): string | undefined {
|
||||
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
const title = titleMatch?.[1]?.replace(/\s+/g, ' ').trim() || undefined;
|
||||
// Drop script + style + comments entirely (their CONTENT must not leak —
|
||||
// a regex tag stripper alone would expose inline JS as plain text).
|
||||
const text = html
|
||||
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<noscript\b[^>]*>[\s\S]*?<\/noscript>/gi, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
// Minimal entity decode — full coverage would need a table; covering
|
||||
// the five common ones plus is enough for snippet readability.
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return { text, title };
|
||||
return titleMatch?.[1]?.replace(/\s+/g, ' ').trim() || undefined;
|
||||
}
|
||||
|
||||
// v1.11.10: streaming body reader. Aborts the response stream the instant
|
||||
@@ -211,9 +192,8 @@ export async function executeWebFetch(
|
||||
let textRaw: string;
|
||||
let title: string | undefined;
|
||||
if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
|
||||
const stripped = stripHtml(body);
|
||||
textRaw = stripped.text;
|
||||
title = stripped.title;
|
||||
title = extractTitle(body);
|
||||
textRaw = htmlToMarkdown(body);
|
||||
} else if (
|
||||
contentType.includes('text/plain') ||
|
||||
contentType.includes('text/markdown') ||
|
||||
|
||||
@@ -99,6 +99,10 @@ export interface Agent {
|
||||
description: string;
|
||||
system_prompt: string;
|
||||
temperature: number;
|
||||
top_p: number | null; // null means omit from request body
|
||||
top_k: number | null; // null means omit from request body
|
||||
min_p: number | null; // null means omit from request body
|
||||
presence_penalty: number | null; // null means omit from request body
|
||||
tools: string[]; // whitelist of tool names; empty = no tools allowed
|
||||
model: string | null; // null means "session.model wins"
|
||||
source: AgentSource;
|
||||
@@ -109,6 +113,7 @@ export interface Agent {
|
||||
// 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;
|
||||
llama_extra_args: string[] | null;
|
||||
}
|
||||
|
||||
// One entry per malformed `## Name` block. Per-block errors don't fail the
|
||||
|
||||
@@ -272,7 +272,9 @@ export const PermissionRequestedFrame = z.object({
|
||||
type: z.literal('permission_requested'),
|
||||
task_id: Uuid,
|
||||
session_id: Uuid,
|
||||
kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
|
||||
tool_title: z.string().optional(),
|
||||
input: z.record(z.unknown()).optional(),
|
||||
options: z.array(PermissionOptionShape),
|
||||
});
|
||||
|
||||
|
||||
@@ -43,5 +43,6 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ function RightRailForSession({ sessionId }: { sessionId: string }) {
|
||||
// a right-side drawer toggled by the header's FolderTree button (via
|
||||
// useRightRailDrawer). On desktop, it renders inline as before with its
|
||||
// own internal open/close state.
|
||||
return <RightRail projectId={projectId} />;
|
||||
return <RightRail projectId={projectId} sessionId={sessionId} />;
|
||||
}
|
||||
|
||||
function MobileBackdrop() {
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
AskUserAnswer,
|
||||
ToolCostStat,
|
||||
ProviderSnapshotEntry,
|
||||
CoderProvidersFile,
|
||||
ProviderConfigPatch,
|
||||
CoderSendMessageBody,
|
||||
CoderSendMessageResponse,
|
||||
CoderMessageWire,
|
||||
@@ -310,8 +312,23 @@ export const api = {
|
||||
const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
|
||||
return request<ProviderSnapshotEntry[]>(`/api/coder/providers/snapshot${qs}`);
|
||||
},
|
||||
refreshProviders: () =>
|
||||
request<{ refreshed: number }>('/api/coder/providers/refresh', { method: 'POST' }),
|
||||
// v2.3 Phase 4: optional subset narrows the reported `refreshed` count.
|
||||
refreshProviders: (providers?: string[]) =>
|
||||
request<{ refreshed: number }>('/api/coder/providers/refresh', {
|
||||
method: 'POST',
|
||||
...(providers && providers.length > 0 ? { body: JSON.stringify({ providers }) } : {}),
|
||||
}),
|
||||
// v2.3 Phase 4: read/patch the provider config file. PATCH returns the new
|
||||
// config; a `null` value in the patch deletes that id's override.
|
||||
getProvidersConfig: () => request<CoderProvidersFile>('/api/coder/providers/config'),
|
||||
patchProvidersConfig: (patch: ProviderConfigPatch) =>
|
||||
request<{ ok: true } & CoderProvidersFile>('/api/coder/providers/config', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
// v2.3 Phase 4: per-provider diagnostic — JSON { diagnostic: string } (§6.4).
|
||||
getProviderDiagnostic: (id: string) =>
|
||||
request<{ diagnostic: string }>(`/api/coder/providers/${encodeURIComponent(id)}/diagnostic`),
|
||||
sendMessage: (sessionId: string, body: CoderSendMessageBody) =>
|
||||
request<CoderSendMessageResponse>(`/api/coder/sessions/${sessionId}/messages`, {
|
||||
method: 'POST',
|
||||
@@ -319,10 +336,10 @@ export const api = {
|
||||
}),
|
||||
getTaskPermission: (taskId: string) =>
|
||||
request<PermissionPrompt>(`/api/coder/tasks/${taskId}/permission`),
|
||||
respondTaskPermission: (taskId: string, optionId: string | null) =>
|
||||
respondTaskPermission: (taskId: string, optionId: string | null, updatedInput?: Record<string, unknown>) =>
|
||||
request<{ ok: boolean }>(`/api/coder/tasks/${taskId}/permission`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ option_id: optionId }),
|
||||
body: JSON.stringify({ option_id: optionId, ...(updatedInput ? { updated_input: updatedInput } : {}) }),
|
||||
}),
|
||||
getTaskCommands: (taskId: string) =>
|
||||
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
|
||||
@@ -332,20 +349,51 @@ export const api = {
|
||||
request<CoderMessageWire[]>(
|
||||
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
||||
),
|
||||
skillInvoke: (sessionId: string, paneId: string, skillName: string, userMessage: string | null) =>
|
||||
skillInvoke: (
|
||||
sessionId: string,
|
||||
paneId: string,
|
||||
skillName: string,
|
||||
userMessage: string | null,
|
||||
// v2.5.9: when the active provider is external, the skill runs under that
|
||||
// agent (body injected into a dispatched task) → response carries task_id.
|
||||
config?: { provider?: string; model?: string; mode_id?: string; thinking_option_id?: string },
|
||||
) =>
|
||||
request<{
|
||||
user_message_id: string;
|
||||
assistant_message_id: string;
|
||||
synth_assistant_id: string;
|
||||
tool_message_id: string;
|
||||
assistant_message_id?: string;
|
||||
synth_assistant_id?: string;
|
||||
tool_message_id?: string;
|
||||
task_id?: string;
|
||||
dispatched?: boolean;
|
||||
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
pane_id: paneId,
|
||||
skill_name: skillName,
|
||||
user_message: userMessage,
|
||||
...(config?.provider ? { provider: config.provider } : {}),
|
||||
...(config?.model ? { model: config.model } : {}),
|
||||
...(config?.mode_id ? { mode_id: config.mode_id } : {}),
|
||||
...(config?.thinking_option_id ? { thinking_option_id: config.thinking_option_id } : {}),
|
||||
}),
|
||||
}),
|
||||
// Queue a new-file create from the RightRail browser → BooCoder
|
||||
// pending_changes (operation='create'). Surfaces in the CoderPane DiffPanel
|
||||
// for explicit apply. A WriteGuardError comes back as a 422 whose { error }
|
||||
// body ApiError exposes as .message for inline display.
|
||||
createPendingFile: (sessionId: string, file_path: string, content: string) =>
|
||||
request<{
|
||||
id: string;
|
||||
session_id: string;
|
||||
task_id: string | null;
|
||||
file_path: string;
|
||||
operation: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}>(`/api/coder/sessions/${sessionId}/pending/create`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ file_path, content }),
|
||||
}),
|
||||
},
|
||||
|
||||
agents: {
|
||||
|
||||
@@ -182,10 +182,14 @@ export interface Message {
|
||||
// majority of messages.
|
||||
metadata: MessageMetadata | null;
|
||||
// v1.13.1-C: reasoning content captured from models that stream reasoning
|
||||
// tokens separately (qwen3.6 etc.). Backend populates from message_parts;
|
||||
// optional on the wire — frontend doesn't render this yet (reserved for
|
||||
// a v1.14 UI surface).
|
||||
// tokens separately (qwen3.6 etc.) and from external agents over ACP
|
||||
// (agent_thought_chunk). Backend populates from message_parts; rendered by
|
||||
// MessageBubble as a collapsible "Thinking" block.
|
||||
reasoning_parts?: Array<{ text: string }> | null;
|
||||
// Coder wire shape pre-joins reasoning_parts into a single string
|
||||
// (CoderPane/CoderMessageList) and streams it live via reasoning_delta
|
||||
// frames. MessageBubble reads whichever of the two is present.
|
||||
reasoning_text?: string | null;
|
||||
// v1.11: anchored rolling compaction fields. Optional on the wire so that
|
||||
// older API responses (or test fixtures) parse without explicit nulls.
|
||||
// summary — true on the assistant row that holds the active
|
||||
@@ -228,19 +232,50 @@ export interface ThinkingOption {
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export type ProviderSnapshotStatus = 'ready' | 'error';
|
||||
// v2.3 phase 2: 'loading' + 'unavailable' restored alongside 'ready' | 'error'.
|
||||
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||
|
||||
// KEEP IN SYNC with apps/coder/src/services/provider-types.ts ProviderSnapshotEntry
|
||||
// — parity is enforced by coder __tests__/provider-types-parity.test.ts (field drift fails it).
|
||||
export interface ProviderSnapshotEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
transport: string;
|
||||
status: ProviderSnapshotStatus;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
models: ProviderModel[];
|
||||
modes: ProviderMode[];
|
||||
defaultModeId: string | null;
|
||||
commands: AgentCommand[];
|
||||
error?: string;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
// v2.3 Phase 4: provider config file wire types. Mirror of the Zod-inferred
|
||||
// ProviderOverride / CoderProvidersFile in apps/coder/src/services/provider-config.ts
|
||||
// (web can't cross-import the coder package — TS6307 on the composite project).
|
||||
export interface ProviderOverride {
|
||||
extends?: 'acp';
|
||||
label?: string;
|
||||
description?: string;
|
||||
command?: string[];
|
||||
env?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
order?: number;
|
||||
models?: Array<{ id: string; label: string }>;
|
||||
additionalModels?: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface CoderProvidersFile {
|
||||
providers: Record<string, ProviderOverride>;
|
||||
}
|
||||
|
||||
// PATCH body: a partial providers map. A `null` value deletes that id's
|
||||
// override (revert to built-in default); an object replaces it wholesale.
|
||||
export interface ProviderConfigPatch {
|
||||
providers: Record<string, ProviderOverride | null>;
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
@@ -250,15 +285,22 @@ export interface AgentSessionConfig {
|
||||
thinkingOptionId: string | null;
|
||||
}
|
||||
|
||||
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
|
||||
|
||||
export interface PermissionPrompt {
|
||||
taskId: string;
|
||||
kind?: PermissionKind;
|
||||
toolTitle?: string;
|
||||
input?: Record<string, unknown>;
|
||||
options: Array<{ optionId: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface AgentCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
|
||||
// Drives the icon split in the coder slash menu. Undefined → command.
|
||||
kind?: 'command' | 'skill';
|
||||
}
|
||||
|
||||
export interface CoderSendMessageBody {
|
||||
|
||||
@@ -272,7 +272,9 @@ export const PermissionRequestedFrame = z.object({
|
||||
type: z.literal('permission_requested'),
|
||||
task_id: Uuid,
|
||||
session_id: Uuid,
|
||||
kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
|
||||
tool_title: z.string().optional(),
|
||||
input: z.record(z.unknown()).optional(),
|
||||
options: z.array(PermissionOptionShape),
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ interface Props {
|
||||
|
||||
export function AgentCommandsHint({ commands }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
if (commands.length === 0) return null;
|
||||
|
||||
@@ -25,10 +26,19 @@ export function AgentCommandsHint({ commands }: Props) {
|
||||
{open && (
|
||||
<ul className="px-2 pb-2 space-y-1 border-t border-border/40 max-h-48 overflow-y-auto overscroll-contain touch-pan-y">
|
||||
{commands.map((cmd) => (
|
||||
<li key={cmd.name} className="font-mono">
|
||||
<span className="text-primary/80">/{cmd.name}</span>
|
||||
<li
|
||||
key={cmd.name}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setExpanded((v) => v === cmd.name ? null : cmd.name)}
|
||||
>
|
||||
<span className="font-mono text-primary/80">/{cmd.name}</span>
|
||||
{cmd.description && (
|
||||
<span className="ml-1.5 text-muted-foreground font-sans line-clamp-1">{cmd.description}</span>
|
||||
<span className={cn(
|
||||
'ml-1.5 text-muted-foreground font-sans',
|
||||
expanded === cmd.name ? '' : 'line-clamp-2',
|
||||
)}>
|
||||
{cmd.description}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, ChevronDown, RefreshCw, Shield, Cpu, Brain } from 'lucide-react';
|
||||
import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
|
||||
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
|
||||
import { api } from '@/api/client';
|
||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||
@@ -91,9 +92,11 @@ interface PickerProps {
|
||||
options: Array<{ id: string; label: string }>;
|
||||
onPick: (id: string) => void;
|
||||
icon?: React.ReactNode;
|
||||
/** Mobile: render icon + chevron only (no value label) to save row width. */
|
||||
iconOnly?: boolean;
|
||||
}
|
||||
|
||||
function CompactPicker({ label, value, disabled, options, onPick, icon }: PickerProps) {
|
||||
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly }: PickerProps) {
|
||||
const { isMobile } = useViewport();
|
||||
const [open, setOpen] = useState(false);
|
||||
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
|
||||
@@ -125,9 +128,11 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`${label}: ${currentLabel}`}
|
||||
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
className="inline-flex items-center gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
{icon ?? <Cpu className="size-4" />}
|
||||
{icon}
|
||||
{!iconOnly && <span className="truncate max-w-[120px]">{currentLabel}</span>}
|
||||
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
||||
</button>
|
||||
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
|
||||
<div className="px-2">{list}</div>
|
||||
@@ -142,16 +147,16 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60 disabled:opacity-40 max-w-[140px]"
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60 disabled:opacity-40"
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">{currentLabel}</span>
|
||||
<span className="truncate max-w-[180px]">{currentLabel}</span>
|
||||
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]">
|
||||
{options.map((o) => (
|
||||
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="font-mono text-xs">
|
||||
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="text-xs">
|
||||
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
|
||||
{o.label}
|
||||
</DropdownMenuItem>
|
||||
@@ -166,12 +171,17 @@ interface Props {
|
||||
value: AgentSessionConfig;
|
||||
onChange: (next: AgentSessionConfig) => void;
|
||||
onProviderCommandsChange?: (commands: AgentCommand[]) => void;
|
||||
connected?: boolean;
|
||||
}
|
||||
|
||||
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange }: Props) {
|
||||
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
|
||||
const allEntries = useProviderSnapshot(projectPath);
|
||||
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
|
||||
// still loading). Disabled (enabled:false) and unavailable/error providers are
|
||||
// hidden here and managed in Settings → Providers. Native boocode is always
|
||||
// enabled+ready, so it always appears.
|
||||
const entries = useMemo(
|
||||
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
|
||||
() => allEntries?.filter((e) => e.enabled && (e.status === 'ready' || e.status === 'loading')) ?? null,
|
||||
[allEntries],
|
||||
);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -194,6 +204,35 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
onChange(resolveConfig(entry, prefs));
|
||||
}, [entries, onChange, value.provider]);
|
||||
|
||||
// If the active provider is disabled in the settings drawer it drops out of
|
||||
// `entries` (the 5.5 filter) — fall back to boocode so the composer never
|
||||
// strands on an unselectable provider with empty model/mode pickers.
|
||||
useEffect(() => {
|
||||
if (!entries?.length) return;
|
||||
if (entries.some((e) => e.name === value.provider)) return;
|
||||
const fallback = entries.find((e) => e.name === 'boocode') ?? entries[0];
|
||||
if (!fallback) return;
|
||||
onChange(resolveConfig(fallback, loadPrefs()));
|
||||
}, [entries, value.provider, onChange]);
|
||||
|
||||
// 5.6 — loading poll: while any entry is loading (Phase 2's sync cache-miss
|
||||
// return), refetch until terminal. Capped; no provider_snapshot_updated WS
|
||||
// frame (deferred Tier-2). Dormant today since the snapshot awaits the build.
|
||||
const pollsRef = useRef(0);
|
||||
useEffect(() => {
|
||||
const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false;
|
||||
if (!anyLoading) {
|
||||
pollsRef.current = 0;
|
||||
return;
|
||||
}
|
||||
if (pollsRef.current >= 10) return;
|
||||
const t = setTimeout(() => {
|
||||
pollsRef.current += 1;
|
||||
void refreshProviderSnapshot(projectPath);
|
||||
}, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [allEntries, projectPath]);
|
||||
|
||||
const currentEntry = useMemo(
|
||||
() => entries?.find((e) => e.name === value.provider),
|
||||
[entries, value.provider],
|
||||
@@ -255,6 +294,16 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
);
|
||||
}
|
||||
|
||||
const providerIcon = (name: string) => {
|
||||
switch (name) {
|
||||
case 'claude': return <ClaudeIcon size={13} className="shrink-0" />;
|
||||
case 'opencode': return <OpenCodeIcon size={13} className="shrink-0" />;
|
||||
case 'goose': return <Bird size={13} className="shrink-0" />;
|
||||
case 'qwen': return <TermIcon size={13} className="shrink-0" />;
|
||||
default: return <Dog size={13} className="shrink-0" />;
|
||||
}
|
||||
};
|
||||
|
||||
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
|
||||
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
|
||||
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
|
||||
@@ -267,7 +316,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
value={value.provider}
|
||||
options={providerOptions}
|
||||
onPick={pickProvider}
|
||||
icon={<Cpu className="size-3 shrink-0" />}
|
||||
icon={
|
||||
currentEntry?.status === 'loading'
|
||||
? <Loader2 size={13} className="shrink-0 animate-spin" />
|
||||
: providerIcon(value.provider)
|
||||
}
|
||||
/>
|
||||
<CompactPicker
|
||||
label="Mode"
|
||||
@@ -276,6 +329,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
options={modeOptions}
|
||||
onPick={(modeId) => persist({ ...value, modeId })}
|
||||
icon={<Shield className="size-3 shrink-0" />}
|
||||
iconOnly
|
||||
/>
|
||||
<CompactPicker
|
||||
label="Model"
|
||||
@@ -283,6 +337,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
disabled={modelOptions.length === 0}
|
||||
options={modelOptions}
|
||||
onPick={pickModel}
|
||||
icon={<Bot size={13} className="shrink-0" />}
|
||||
/>
|
||||
{thinkingOpts.length > 0 && (
|
||||
<CompactPicker
|
||||
@@ -293,16 +348,26 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
icon={<Brain className="size-3 shrink-0" />}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefresh()}
|
||||
disabled={refreshing}
|
||||
className="ml-auto inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
aria-label="Refresh provider list"
|
||||
title="Refresh providers"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
||||
</button>
|
||||
{/* Status dot + refresh as one right-aligned unit so the refresh button
|
||||
stays on the top line instead of wrapping past the edge-pinned dot. */}
|
||||
<div className="ml-auto flex items-center gap-1 shrink-0">
|
||||
{connected !== undefined && (
|
||||
<span
|
||||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
|
||||
title={connected ? 'Connected' : 'Disconnected'}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefresh()}
|
||||
disabled={refreshing}
|
||||
className="inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
aria-label="Refresh provider list"
|
||||
title="Refresh providers"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
|
||||
<ChevronDown className="size-3 opacity-70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-80 overflow-y-auto w-72">
|
||||
<DropdownMenuContent align="start" className="max-h-80 overflow-y-auto w-96">
|
||||
{error && (
|
||||
<div className="px-2 py-1.5 text-xs text-destructive">{error}</div>
|
||||
)}
|
||||
@@ -128,7 +128,7 @@ export function AgentPicker({ projectId, value, onChange }: Props) {
|
||||
<span className="font-medium">{a.name}</span>
|
||||
</div>
|
||||
{a.description && (
|
||||
<span className="text-muted-foreground pl-[18px] truncate w-full">
|
||||
<span className="text-muted-foreground pl-[18px] line-clamp-2 w-full">
|
||||
{a.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
@@ -22,6 +21,7 @@ interface Props {
|
||||
toolCall: ToolCall;
|
||||
toolResult: ToolResult | null;
|
||||
chatId: string;
|
||||
apiPrefix?: string;
|
||||
}
|
||||
|
||||
function parseQuestions(raw: unknown): AskUserQuestion[] {
|
||||
@@ -63,7 +63,7 @@ function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
|
||||
return { answers };
|
||||
}
|
||||
|
||||
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
|
||||
export function AskUserInputCard({ toolCall, toolResult, chatId, apiPrefix = '' }: Props) {
|
||||
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
|
||||
|
||||
if (questions.length === 0) {
|
||||
@@ -74,9 +74,6 @@ export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// Tool result with a non-null output means the answer is already submitted.
|
||||
// The pending sentinel uses output=null, so this branch only triggers after
|
||||
// the real WS tool_result frame lands.
|
||||
const answered = toolResult && toolResult.output !== null;
|
||||
if (answered) {
|
||||
const answerSet = parseAnswerSet(toolResult!.output);
|
||||
@@ -84,7 +81,7 @@ export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
|
||||
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} apiPrefix={apiPrefix} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,10 +89,12 @@ function PendingView({
|
||||
questions,
|
||||
toolCallId,
|
||||
chatId,
|
||||
apiPrefix = '',
|
||||
}: {
|
||||
questions: AskUserQuestion[];
|
||||
toolCallId: string;
|
||||
chatId: string;
|
||||
apiPrefix?: string;
|
||||
}) {
|
||||
// Per-question selections + free text. Selections are option arrays so the
|
||||
// multi_select case is uniform; single_select just constrains to length 1.
|
||||
@@ -133,9 +132,16 @@ function PendingView({
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.chats.answerUserInput(chatId, toolCallId, answers);
|
||||
// Card stays mounted; the incoming WS tool_result frame will flip it
|
||||
// into AnsweredView via the parent prop change.
|
||||
const url = `${apiPrefix}/api/chats/${chatId}/answer_user_input`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string; detail?: string };
|
||||
throw new Error(body.detail ?? body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'submit failed');
|
||||
setSubmitting(false);
|
||||
|
||||
@@ -22,8 +22,9 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
|
||||
import { FileMentionPopover } from '@/components/FileMentionPopover';
|
||||
import { DropOverlay } from '@/components/DropOverlay';
|
||||
import { AgentPicker } from '@/components/AgentPicker';
|
||||
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
||||
import { ContextBar } from '@/components/ContextBar';
|
||||
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
||||
import { SlashCommandPicker, type SlashCommandGroup } from '@/components/SlashCommandPicker';
|
||||
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
||||
import { api } from '@/api/client';
|
||||
import type { Message } from '@/api/types';
|
||||
@@ -55,6 +56,13 @@ interface Props {
|
||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||
// disables slash-command dispatch (input is sent as literal text).
|
||||
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
||||
// v2.5.9: segmented slash-command DISPLAY source for the picker + hint. When
|
||||
// provided (e.g. CoderPane passing [agent commands, skills]), these labeled
|
||||
// groups are shown instead of the BooChat skills. Invocation routing still
|
||||
// uses the skills lookup — names not in skills (opencode's /help etc.) fall
|
||||
// through and are sent to the agent as literal text. Omitted → BooChat skills
|
||||
// (flat, unchanged — parity).
|
||||
slashGroups?: SlashCommandGroup[];
|
||||
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
|
||||
// registers in chatInputsRegistry so the terminal floating menu can list
|
||||
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
|
||||
@@ -70,7 +78,7 @@ interface Props {
|
||||
modelContextLimit?: number | null;
|
||||
}
|
||||
|
||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const [value, setValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -99,6 +107,15 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
for (const s of skills) m.set(s.name, true);
|
||||
return m;
|
||||
}, [skills]);
|
||||
// Flat display source for the hint (and the picker's no-groups fallback):
|
||||
// caller-provided groups flattened, else the BooChat skills.
|
||||
const slashItems = useMemo(
|
||||
() =>
|
||||
slashGroups
|
||||
? slashGroups.flatMap((g) => g.items)
|
||||
: skills.map((s) => ({ name: s.name, description: s.description })),
|
||||
[slashGroups, skills],
|
||||
);
|
||||
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
@@ -560,6 +577,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{slashItems.length > 0 && (
|
||||
<AgentCommandsHint commands={slashItems} />
|
||||
)}
|
||||
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1
|
||||
inlines ContextBar in the same row so the bar lives next to the
|
||||
picker rather than as a separate header above it. The row renders
|
||||
@@ -657,11 +677,12 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
{slashState && (
|
||||
<SlashCommandPicker
|
||||
query={slashState.query}
|
||||
items={skills}
|
||||
items={slashItems}
|
||||
groups={slashGroups}
|
||||
inputRef={textareaRef}
|
||||
onSelect={handleSlashSelect}
|
||||
onClose={() => setSlashState(null)}
|
||||
emptyLabel="No skills available"
|
||||
emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -181,7 +181,7 @@ export function ChatTabBar({
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-40">
|
||||
<DropdownMenuContent align="end" className="w-fit">
|
||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||
<MessageSquare size={14} /> New BooChat
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen, Brain } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||
import { api, ApiError } from '@/api/client';
|
||||
@@ -117,12 +117,20 @@ function deriveMarkdownTitle(content: string): string {
|
||||
return 'Markdown artifact';
|
||||
}
|
||||
|
||||
export interface MessageActions {
|
||||
onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
|
||||
onResend?: (chatId: string, content: string) => Promise<void>;
|
||||
onFork?: (chatId: string, messageId: string) => Promise<void>;
|
||||
onDelete?: (chatId: string, messageId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
sessionChats?: Chat[];
|
||||
// v1.8.2: passed by MessageList's render-item pass for cap-hit sentinels.
|
||||
// Only the most recent sentinel shows the Continue button.
|
||||
capHitInfo?: { position: number; isLatest: boolean };
|
||||
actions?: MessageActions;
|
||||
/** Hide actions that don't apply (fork, delete, open-in-pane). */
|
||||
hideActions?: ('fork' | 'delete' | 'openInPane')[];
|
||||
}
|
||||
|
||||
function StatsLine({ message }: { message: Message }) {
|
||||
@@ -157,8 +165,12 @@ function StatsLine({ message }: { message: Message }) {
|
||||
|
||||
function ActionRow({
|
||||
message,
|
||||
actions,
|
||||
hiddenSet,
|
||||
}: {
|
||||
message: Message;
|
||||
actions?: MessageActions;
|
||||
hiddenSet: Set<string>;
|
||||
}) {
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
@@ -180,7 +192,11 @@ function ActionRow({
|
||||
if (regenerating || message.status === 'streaming') return;
|
||||
setRegenerating(true);
|
||||
try {
|
||||
await api.messages.regenerate(message.chat_id, message.id);
|
||||
if (actions?.onRegenerate) {
|
||||
await actions.onRegenerate(message.chat_id, message.id);
|
||||
} else {
|
||||
await api.messages.regenerate(message.chat_id, message.id);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'regenerate failed');
|
||||
} finally {
|
||||
@@ -188,12 +204,30 @@ function ActionRow({
|
||||
}
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
if (!canResend) return;
|
||||
try {
|
||||
if (actions?.onResend) {
|
||||
await actions.onResend(message.chat_id, message.content!);
|
||||
} else {
|
||||
await api.messages.send(message.chat_id, message.content!);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'resend failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function fork() {
|
||||
if (forking || message.status !== 'complete') return;
|
||||
setForking(true);
|
||||
try {
|
||||
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
||||
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
|
||||
if (actions?.onFork) {
|
||||
await actions.onFork(message.chat_id, message.id);
|
||||
} else {
|
||||
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
||||
sessionEvents.emit({ type: 'refetch_messages' });
|
||||
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'fork failed');
|
||||
} finally {
|
||||
@@ -205,7 +239,11 @@ function ActionRow({
|
||||
if (deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.messages.remove(message.chat_id, message.id);
|
||||
if (actions?.onDelete) {
|
||||
await actions.onDelete(message.chat_id, message.id);
|
||||
} else {
|
||||
await api.messages.remove(message.chat_id, message.id);
|
||||
}
|
||||
setDeleteOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'delete failed');
|
||||
@@ -215,7 +253,9 @@ function ActionRow({
|
||||
}
|
||||
|
||||
const isAssistant = message.role === 'assistant';
|
||||
const isUser = message.role === 'user';
|
||||
const canRegen = isAssistant && message.status !== 'streaming';
|
||||
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
||||
const canFork = message.status === 'complete';
|
||||
const canDelete = message.status !== 'streaming';
|
||||
const [openingPane, setOpeningPane] = useState(false);
|
||||
@@ -279,7 +319,18 @@ function ActionRow({
|
||||
>
|
||||
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
</button>
|
||||
{isAssistant && (
|
||||
{canResend && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void resend()}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Resend message"
|
||||
title="Resend"
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
{isAssistant && !hiddenSet.has('openInPane') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openInPane()}
|
||||
@@ -303,26 +354,30 @@ function ActionRow({
|
||||
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fork()}
|
||||
disabled={!canFork || forking}
|
||||
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="Fork from here"
|
||||
title="Fork from here"
|
||||
>
|
||||
<GitFork className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
disabled={!canDelete}
|
||||
{!hiddenSet.has('fork') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fork()}
|
||||
disabled={!canFork || forking}
|
||||
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="Fork from here"
|
||||
title="Fork from here"
|
||||
>
|
||||
<GitFork className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
{!hiddenSet.has('delete') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
disabled={!canDelete}
|
||||
className="inline-flex items-center justify-center size-6 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]"
|
||||
aria-label="Delete message"
|
||||
title="Delete message"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Dialog
|
||||
open={deleteOpen}
|
||||
@@ -536,7 +591,39 @@ function SummaryCard({ message }: { message: Message }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
||||
// Collapsible "Thinking" block for assistant reasoning. Fed by either
|
||||
// reasoning_text (coder wire / live reasoning_delta stream) or reasoning_parts
|
||||
// (native inference, persisted from message_parts). Auto-expands while the turn
|
||||
// is still streaming so the user watches it think (Paseo-style), then stays
|
||||
// where the user left it once the turn completes — initial state is captured
|
||||
// once at mount, so we never fight a manual collapse on later re-renders.
|
||||
function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) {
|
||||
const [expanded, setExpanded] = useState(() => streaming);
|
||||
return (
|
||||
<div className="max-w-[90%] rounded-lg border bg-muted/30 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-left text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<Brain size={13} />
|
||||
<span className="text-xs font-medium">Thinking</span>
|
||||
{streaming && (
|
||||
<span className="ml-1 inline-block w-1.5 h-3 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="px-3 pb-2.5 pt-0.5 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap break-words border-t">
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, sessionChats, capHitInfo, actions, hideActions }: Props) {
|
||||
const hiddenSet = new Set(hideActions ?? []);
|
||||
// v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
|
||||
// branch because summary=true never coexists with kind='compact' (new
|
||||
// compactions emit role='assistant' rows with kind='message'+summary=true).
|
||||
@@ -585,7 +672,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
||||
{message.content}
|
||||
</div>
|
||||
</SendToTerminalMenu>
|
||||
<ActionRow message={message} />
|
||||
<ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -595,16 +682,26 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
||||
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
|
||||
// assistant turn doesn't render an empty bubble + dangling ActionRow.
|
||||
const hasContent = message.content.trim().length > 0;
|
||||
// Reasoning arrives as a pre-joined string (coder wire) or as parts (native
|
||||
// inference). Read whichever is present; loose ?? chain tolerates the coder
|
||||
// shape where reasoning_parts is undefined (see CLAUDE.md null-guard note).
|
||||
const reasoningText = (
|
||||
message.reasoning_text ??
|
||||
message.reasoning_parts?.map((p) => p.text ?? '').join('') ??
|
||||
''
|
||||
).trim();
|
||||
const hasReasoning = reasoningText.length > 0;
|
||||
// v1.8.2: if metadata stamps an error reason, surface it inline under the
|
||||
// generic "message failed" line. Keeps the user's eye where it already is
|
||||
// rather than introducing a separate banner.
|
||||
const errorMeta =
|
||||
message.metadata !== null && message.metadata.kind === 'error'
|
||||
message.metadata != null && message.metadata.kind === 'error'
|
||||
? message.metadata
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="group flex flex-col gap-2">
|
||||
{hasReasoning && <ReasoningBlock text={reasoningText} streaming={isStreaming} />}
|
||||
{(hasContent || isStreaming) && (
|
||||
<SendToTerminalMenu>
|
||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
||||
@@ -627,7 +724,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
||||
</div>
|
||||
)}
|
||||
{!isStreaming && <StatsLine message={message} />}
|
||||
{!isStreaming && hasContent && <ActionRow message={message} />}
|
||||
{!isStreaming && hasContent && <ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ export function MobileTabSwitcher({
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align="end" className="min-w-44">
|
||||
{chat && (
|
||||
<DropdownMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
||||
<Edit2 size={14} /> Rename chat
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user