Compare commits
14 Commits
v2.3.0-sam
...
v2.5.3-rem
| Author | SHA1 | Date | |
|---|---|---|---|
| a359a4ab8b | |||
| a8c84ecfe4 | |||
| 547fd70650 | |||
| 990a615b87 | |||
| 5352fd9942 | |||
| 66df410826 | |||
| f89c8f3f15 | |||
| cbef7618b3 | |||
| fcc7c5a86e | |||
| bcfc94fa47 | |||
| 90a6761b07 | |||
| a938cf1d42 | |||
| 6f6b3afb5d | |||
| 154ef78f7c |
@@ -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.
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,3 +16,4 @@ data/*
|
||||
!data/AGENTS.md
|
||||
!data/skills/
|
||||
!data/mcp.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.
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||
|
||||
## v2.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.
|
||||
|
||||
15
CLAUDE.md
15
CLAUDE.md
@@ -147,8 +147,9 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
||||
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
||||
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||
- 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 +158,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.
|
||||
|
||||
@@ -185,3 +186,11 @@ 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.
|
||||
- **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"
|
||||
}
|
||||
|
||||
@@ -29,5 +29,6 @@
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -71,3 +71,22 @@ ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pt
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
type WriteTextFileResponse,
|
||||
type CreateTerminalRequest,
|
||||
type CreateTerminalResponse,
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
type SessionConfigOption,
|
||||
type ClientSideConnection as ConnectionType,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
@@ -26,7 +28,7 @@ import { spawn } from 'node:child_process';
|
||||
import { findThoughtLevelConfigId } from './acp-derive.js';
|
||||
import { resolveAcpSpawnArgs } from './acp-spawn.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 {
|
||||
@@ -254,6 +256,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' };
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ export function resolveAcpSpawnArgs(agent: string): string[] | null {
|
||||
case 'opencode':
|
||||
case 'goose':
|
||||
return ['acp'];
|
||||
case 'cursor':
|
||||
return ['acp'];
|
||||
case 'copilot':
|
||||
return ['--acp'];
|
||||
case 'qwen':
|
||||
return ['--acp'];
|
||||
default:
|
||||
@@ -18,12 +14,5 @@ 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];
|
||||
}
|
||||
return [agent];
|
||||
}
|
||||
|
||||
@@ -27,15 +27,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 });
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -24,16 +24,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;
|
||||
|
||||
@@ -463,12 +476,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 +506,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: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -59,12 +52,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,8 +2,6 @@
|
||||
* 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';
|
||||
@@ -14,13 +12,10 @@ import {
|
||||
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 { getManifestCommands, mergeCommands } from './provider-commands.js';
|
||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
|
||||
interface AgentRow {
|
||||
name: string;
|
||||
install_path: string | null;
|
||||
@@ -41,15 +36,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) => ({
|
||||
@@ -141,8 +127,6 @@ async function buildProviderEntry(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -97,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 {
|
||||
@@ -227,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.
|
||||
}
|
||||
@@ -328,6 +358,7 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -159,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;
|
||||
@@ -197,7 +196,7 @@ 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 }
|
||||
@@ -460,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') ||
|
||||
|
||||
@@ -113,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() {
|
||||
|
||||
@@ -319,10 +319,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`),
|
||||
@@ -346,6 +346,23 @@ export const api = {
|
||||
user_message: userMessage,
|
||||
}),
|
||||
}),
|
||||
// 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
|
||||
@@ -250,9 +254,13 @@ 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 }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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, 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';
|
||||
@@ -125,9 +126,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}
|
||||
<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 +145,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,9 +169,10 @@ 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);
|
||||
const entries = useMemo(
|
||||
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
|
||||
@@ -255,6 +259,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 +281,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
value={value.provider}
|
||||
options={providerOptions}
|
||||
onPick={pickProvider}
|
||||
icon={<Cpu className="size-3 shrink-0" />}
|
||||
icon={providerIcon(value.provider)}
|
||||
/>
|
||||
<CompactPicker
|
||||
label="Mode"
|
||||
@@ -283,6 +297,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,11 +308,17 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
icon={<Brain className="size-3 shrink-0" />}
|
||||
/>
|
||||
)}
|
||||
{connected !== undefined && (
|
||||
<span
|
||||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0 ml-auto', connected ? 'bg-green-500' : 'bg-red-500')}
|
||||
title={connected ? 'Connected' : 'Disconnected'}
|
||||
/>
|
||||
)}
|
||||
<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"
|
||||
className={cn('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', connected === undefined && 'ml-auto')}
|
||||
aria-label="Refresh provider list"
|
||||
title="Refresh providers"
|
||||
>
|
||||
|
||||
@@ -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,6 +22,7 @@ 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 { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
||||
@@ -560,6 +561,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{skills.length > 0 && (
|
||||
<AgentCommandsHint commands={skills.map((s) => ({ name: s.name, description: s.description }))} />
|
||||
)}
|
||||
{/* 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,7 +27,7 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) {
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align="end" className="w-fit">
|
||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||
<MessageSquare size={14} /> New BooChat
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,14 +1,105 @@
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ShieldAlert, MessageCircleQuestion } from 'lucide-react';
|
||||
import type { PermissionPrompt } from '@/api/types';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
prompt: PermissionPrompt;
|
||||
onRespond: (optionId: string | null) => void;
|
||||
onRespond: (optionId: string | null, updatedInput?: Record<string, unknown>) => void;
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Question detection — ACP's RequestPermissionRequest carries the tool input
|
||||
// in `input`. Claude Code's AskUserQuestion puts { questions: [...] } there.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Question {
|
||||
question: string;
|
||||
header?: string;
|
||||
options: string[];
|
||||
multiSelect: boolean;
|
||||
}
|
||||
|
||||
function parseQuestions(input: Record<string, unknown> | undefined): Question[] | null {
|
||||
if (!input) return null;
|
||||
const raw = input.questions;
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const out: Question[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const q = item as { question?: unknown; header?: unknown; options?: unknown; multiSelect?: unknown };
|
||||
if (typeof q.question !== 'string') continue;
|
||||
const opts = Array.isArray(q.options)
|
||||
? q.options.filter((o): o is string => typeof o === 'string')
|
||||
: [];
|
||||
out.push({
|
||||
question: q.question,
|
||||
header: typeof q.header === 'string' ? q.header : undefined,
|
||||
options: opts,
|
||||
multiSelect: q.multiSelect === true,
|
||||
});
|
||||
}
|
||||
return out.length > 0 ? out : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Elicitation detection — ACP's createElicitation carries a JSON Schema in
|
||||
// `input.requestedSchema`. For now, render each property as a text input.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ElicitationField {
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
enumValues?: string[];
|
||||
}
|
||||
|
||||
function parseElicitation(input: Record<string, unknown> | undefined): { message: string; fields: ElicitationField[] } | null {
|
||||
if (!input) return null;
|
||||
const schema = input.requestedSchema;
|
||||
if (!schema || typeof schema !== 'object') return null;
|
||||
const s = schema as Record<string, unknown>;
|
||||
const props = s.properties;
|
||||
if (!props || typeof props !== 'object') return null;
|
||||
const fields: ElicitationField[] = [];
|
||||
for (const [key, val] of Object.entries(props as Record<string, unknown>)) {
|
||||
if (!val || typeof val !== 'object') continue;
|
||||
const p = val as Record<string, unknown>;
|
||||
fields.push({
|
||||
key,
|
||||
title: typeof p.title === 'string' ? p.title : key,
|
||||
description: typeof p.description === 'string' ? p.description : undefined,
|
||||
type: typeof p.type === 'string' ? p.type : 'string',
|
||||
enumValues: Array.isArray(p.enum) ? p.enum.filter((e): e is string => typeof e === 'string') : undefined,
|
||||
});
|
||||
}
|
||||
if (fields.length === 0) return null;
|
||||
return { message: typeof input.message === 'string' ? input.message : '', fields };
|
||||
}
|
||||
|
||||
export function PermissionCard({ prompt, onRespond, busy }: Props) {
|
||||
const isQuestion = prompt.kind === 'question';
|
||||
const isElicitation = prompt.kind === 'elicitation';
|
||||
|
||||
if (isQuestion) {
|
||||
const questions = parseQuestions(prompt.input);
|
||||
if (questions) {
|
||||
return <QuestionView questions={questions} prompt={prompt} onRespond={onRespond} busy={busy} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (isElicitation) {
|
||||
const elicitation = parseElicitation(prompt.input);
|
||||
if (elicitation) {
|
||||
return <ElicitationView elicitation={elicitation} prompt={prompt} onRespond={onRespond} busy={busy} />;
|
||||
}
|
||||
}
|
||||
|
||||
// Standard tool permission — approve/deny buttons
|
||||
return (
|
||||
<div className="mx-3 my-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -47,3 +138,286 @@ export function PermissionCard({ prompt, onRespond, busy }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QuestionView — renders Claude's AskUserQuestion as interactive radio/checkbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function QuestionView({
|
||||
questions,
|
||||
prompt,
|
||||
onRespond,
|
||||
busy,
|
||||
}: {
|
||||
questions: Question[];
|
||||
prompt: PermissionPrompt;
|
||||
onRespond: Props['onRespond'];
|
||||
busy?: boolean;
|
||||
}) {
|
||||
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
|
||||
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const disabled = busy || submitting;
|
||||
|
||||
const allComplete = questions.every((_, i) =>
|
||||
selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0,
|
||||
);
|
||||
|
||||
function buildAnswers(): Record<string, string> {
|
||||
const answers: Record<string, string> = {};
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i]!;
|
||||
const key = q.question;
|
||||
const selected = selections[i]!;
|
||||
const free = freeTexts[i]!.trim();
|
||||
if (free) {
|
||||
answers[key] = free;
|
||||
} else if (selected.length > 0) {
|
||||
answers[key] = selected.join(', ');
|
||||
}
|
||||
}
|
||||
return answers;
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!allComplete || submitting) return;
|
||||
setSubmitting(true);
|
||||
const answers = buildAnswers();
|
||||
const firstAllow = prompt.options.find((o) =>
|
||||
o.label.toLowerCase().includes('allow') || o.label.toLowerCase().includes('yes'),
|
||||
);
|
||||
onRespond(firstAllow?.optionId ?? prompt.options[0]?.optionId ?? null, {
|
||||
...prompt.input,
|
||||
answers,
|
||||
});
|
||||
}
|
||||
|
||||
function pickSingle(qIdx: number, option: string) {
|
||||
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
|
||||
if (questions.length === 1 && !freeTexts[0]!.trim()) {
|
||||
setSubmitting(true);
|
||||
const firstAllow = prompt.options.find((o) =>
|
||||
o.label.toLowerCase().includes('allow') || o.label.toLowerCase().includes('yes'),
|
||||
);
|
||||
onRespond(firstAllow?.optionId ?? prompt.options[0]?.optionId ?? null, {
|
||||
...prompt.input,
|
||||
answers: { [questions[0]!.question]: option },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-3 my-2 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">
|
||||
{q.header ?? `Question ${i + 1}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-medium leading-snug">{q.question}</div>
|
||||
{q.options.length > 0 && !q.multiSelect && (
|
||||
<RadioGroup
|
||||
value={selections[i]![0] ?? ''}
|
||||
onValueChange={(v) => pickSingle(i, v)}
|
||||
disabled={disabled}
|
||||
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>
|
||||
)}
|
||||
{q.options.length > 0 && q.multiSelect && (
|
||||
<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={disabled}
|
||||
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={disabled}
|
||||
placeholder="Free text…"
|
||||
onChange={(e) =>
|
||||
setFreeTexts((prev) => prev.map((t, idx) => (idx === i ? e.target.value : t)))
|
||||
}
|
||||
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>
|
||||
{(questions.length > 1 || freeTexts.some((t) => t.trim())) && (
|
||||
<div className="flex justify-between items-center border-t px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onRespond(null)}
|
||||
className="text-xs text-destructive hover:underline disabled:opacity-40"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!allComplete || disabled}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting ? 'Submitting…' : 'Submit'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ElicitationView — renders ACP elicitation forms (JSON Schema-driven)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ElicitationView({
|
||||
elicitation,
|
||||
prompt,
|
||||
onRespond,
|
||||
busy,
|
||||
}: {
|
||||
elicitation: { message: string; fields: ElicitationField[] };
|
||||
prompt: PermissionPrompt;
|
||||
onRespond: Props['onRespond'];
|
||||
busy?: boolean;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {};
|
||||
for (const f of elicitation.fields) init[f.key] = '';
|
||||
return init;
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const disabled = busy || submitting;
|
||||
|
||||
const allFilled = elicitation.fields.every((f) => (values[f.key] ?? '').trim().length > 0);
|
||||
|
||||
function handleSubmit() {
|
||||
if (!allFilled || submitting) return;
|
||||
setSubmitting(true);
|
||||
const content: Record<string, unknown> = {};
|
||||
for (const f of elicitation.fields) {
|
||||
const raw = values[f.key]!.trim();
|
||||
if (f.type === 'number' || f.type === 'integer') {
|
||||
content[f.key] = Number(raw);
|
||||
} else if (f.type === 'boolean') {
|
||||
content[f.key] = raw === 'true' || raw === 'yes' || raw === '1';
|
||||
} else {
|
||||
content[f.key] = raw;
|
||||
}
|
||||
}
|
||||
const firstAllow = prompt.options[0];
|
||||
onRespond(firstAllow?.optionId ?? null, content);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-3 my-2 rounded-lg border bg-muted/20 text-sm">
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageCircleQuestion className="size-4 text-blue-500 shrink-0 mt-0.5" />
|
||||
<p className="font-medium leading-snug">{elicitation.message}</p>
|
||||
</div>
|
||||
{elicitation.fields.map((f) => (
|
||||
<div key={f.key} className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">{f.title}</label>
|
||||
{f.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70">{f.description}</p>
|
||||
)}
|
||||
{f.enumValues ? (
|
||||
<RadioGroup
|
||||
value={values[f.key] ?? ''}
|
||||
onValueChange={(v) => setValues((prev) => ({ ...prev, [f.key]: v }))}
|
||||
disabled={disabled}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{f.enumValues.map((opt, j) => {
|
||||
const id = `e-${f.key}-${j}`;
|
||||
return (
|
||||
<label key={j} htmlFor={id} className="flex items-start gap-2 text-sm 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>
|
||||
) : (
|
||||
<input
|
||||
type={f.type === 'number' || f.type === 'integer' ? 'number' : 'text'}
|
||||
value={values[f.key] ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setValues((prev) => ({ ...prev, [f.key]: 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 className="flex justify-between items-center border-t px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onRespond(null)}
|
||||
className="text-xs text-destructive hover:underline disabled:opacity-40"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!allFilled || disabled}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting ? 'Submitting…' : 'Submit'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronRight, ChevronDown, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import { ChevronRight, ChevronDown, FilePlus, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
||||
import { api, ApiError } from '@/api/client';
|
||||
import type { FileEntry } from '@/api/types';
|
||||
import { inferLanguage } from '@/lib/attachments';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
@@ -8,10 +8,22 @@ import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'boocode.rightrail';
|
||||
@@ -27,7 +39,7 @@ function joinPath(parent: string, name: string): string {
|
||||
return `${parent}/${name}`;
|
||||
}
|
||||
|
||||
export function RightRail({ projectId }: Props) {
|
||||
export function RightRail({ projectId, sessionId }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
|
||||
const [open, setOpen] = useState(() => {
|
||||
@@ -39,6 +51,39 @@ export function RightRail({ projectId }: Props) {
|
||||
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
||||
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
|
||||
|
||||
// New-file-from-pasted-text modal. Queues a pending_changes create via
|
||||
// BooCoder; it then shows in the CoderPane DiffPanel for explicit apply.
|
||||
const [newFileOpen, setNewFileOpen] = useState(false);
|
||||
const [newFilePath, setNewFilePath] = useState('');
|
||||
const [newFileContent, setNewFileContent] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const openNewFile = useCallback(() => {
|
||||
setNewFilePath('');
|
||||
setNewFileContent('');
|
||||
setCreateError(null);
|
||||
setNewFileOpen(true);
|
||||
}, []);
|
||||
|
||||
const submitNewFile = useCallback(async () => {
|
||||
const path = newFilePath.trim();
|
||||
if (!path || creating) return;
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
await api.coder.createPendingFile(sessionId, path, newFileContent);
|
||||
setNewFileOpen(false);
|
||||
setNewFilePath('');
|
||||
setNewFileContent('');
|
||||
} catch (err) {
|
||||
// 422 WriteGuardError surfaces via ApiError.message (the route's { error }).
|
||||
setCreateError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : 'Failed to create file');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [sessionId, newFilePath, newFileContent, creating]);
|
||||
|
||||
// Combined open state: on mobile use the global drawer state (toggled by
|
||||
// the Session header's FolderTree button); on desktop use the persistent
|
||||
// internal state.
|
||||
@@ -163,6 +208,15 @@ export function RightRail({ projectId }: Props) {
|
||||
<aside className={asideCls}>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
||||
<span className="text-xs font-medium flex-1">Files</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewFile}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="New file from pasted text"
|
||||
title="New file"
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeRail}
|
||||
@@ -225,6 +279,48 @@ export function RightRail({ projectId }: Props) {
|
||||
onNavigate={(path) => void openFile(path)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={newFileOpen} onOpenChange={setNewFileOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New file from pasted text</DialogTitle>
|
||||
<DialogDescription>
|
||||
Queues a new file as a pending change. Review and apply it from the Coder pane.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-file-path" className="text-xs">Path (relative to project root)</Label>
|
||||
<Input
|
||||
id="new-file-path"
|
||||
value={newFilePath}
|
||||
onChange={(e) => setNewFilePath(e.target.value)}
|
||||
placeholder="src/example.ts"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new-file-content" className="text-xs">Content</Label>
|
||||
<Textarea
|
||||
id="new-file-content"
|
||||
value={newFileContent}
|
||||
onChange={(e) => setNewFileContent(e.target.value)}
|
||||
placeholder="Paste file contents here…"
|
||||
autoFocus
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
{createError && <p className="text-xs text-destructive">{createError}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setNewFileOpen(false)} disabled={creating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => void submitNewFile()} disabled={creating || !newFilePath.trim()}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,185 +1,27 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Chat } from '@/api/types';
|
||||
import { api } from '@/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChatInput } from '@/components/ChatInput';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { formatTokens } from '@/lib/format';
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
chats: Chat[];
|
||||
onOpenChat: (chatId: string) => void;
|
||||
sessionId: string;
|
||||
agentId?: string | null;
|
||||
onAgentChange?: (agentId: string | null) => void | Promise<void>;
|
||||
onSend: (content: string) => void;
|
||||
/** Create a chat and return its id. Used by slash-command handler. */
|
||||
createChat: () => Promise<{ id: string }>;
|
||||
onReopenChat: (chatId: string) => Promise<void>;
|
||||
onArchiveChat: (chatId: string) => Promise<void>;
|
||||
onRenameChat: (chatId: string, name: string) => Promise<void>;
|
||||
onDeleteChat: (chatId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function relTime(iso: string): string {
|
||||
const now = Date.now();
|
||||
const t = Date.parse(iso);
|
||||
if (Number.isNaN(t)) return '';
|
||||
const sec = Math.max(0, Math.floor((now - t) / 1000));
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return `${day}d ago`;
|
||||
}
|
||||
|
||||
interface ChatRowProps {
|
||||
chat: Chat;
|
||||
onClick: () => void;
|
||||
dimmed?: boolean;
|
||||
trailing?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
renamingId: string | null;
|
||||
renameValue: string;
|
||||
setRenameValue: (s: string) => void;
|
||||
onFinishRename: () => void;
|
||||
onCancelRename: () => void;
|
||||
onContextStartRename: () => void;
|
||||
onContextArchive: () => void;
|
||||
onContextDelete: () => void;
|
||||
showContextMenu: boolean;
|
||||
}
|
||||
|
||||
function ChatRow({
|
||||
chat,
|
||||
onClick,
|
||||
dimmed,
|
||||
trailing,
|
||||
actions,
|
||||
renamingId,
|
||||
renameValue,
|
||||
setRenameValue,
|
||||
onFinishRename,
|
||||
onCancelRename,
|
||||
onContextStartRename,
|
||||
onContextArchive,
|
||||
onContextDelete,
|
||||
showContextMenu,
|
||||
}: ChatRowProps) {
|
||||
const meta: string[] = [relTime(chat.updated_at)];
|
||||
if (chat.message_count !== undefined && chat.message_count > 0) {
|
||||
meta.push(`${chat.message_count} msg`);
|
||||
}
|
||||
const tokens = formatTokens(chat.effective_context_tokens);
|
||||
if (tokens) meta.push(tokens);
|
||||
const preview = chat.last_message_preview;
|
||||
const isRenaming = renamingId === chat.id;
|
||||
|
||||
const inner = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="w-full flex flex-col gap-0.5 px-3 py-2 hover:bg-muted/50 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<MessageSquare className={`size-3.5 shrink-0 ${dimmed ? 'opacity-40' : 'opacity-70'}`} />
|
||||
{isRenaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={() => onFinishRename()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onFinishRename();
|
||||
if (e.key === 'Escape') onCancelRename();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
|
||||
/>
|
||||
) : (
|
||||
<span className={`truncate text-sm flex-1 ${dimmed ? 'text-muted-foreground' : ''}`}>
|
||||
{chat.name ?? 'New chat'}
|
||||
</span>
|
||||
)}
|
||||
{trailing && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">{trailing}</span>
|
||||
)}
|
||||
{actions && (
|
||||
<div className="flex items-center gap-0.5 shrink-0">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-5 text-xs text-muted-foreground tabular-nums">
|
||||
{meta.join(' · ')}
|
||||
</div>
|
||||
{preview && (
|
||||
<div className="ml-5 text-xs italic text-muted-foreground truncate">
|
||||
{preview}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!showContextMenu) return inner;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{inner}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={onClick}>Open</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onContextStartRename}>Rename</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={onContextArchive}>Archive</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem variant="destructive" onSelect={onContextDelete}>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionLandingPage({
|
||||
chats,
|
||||
onOpenChat,
|
||||
onSend,
|
||||
projectId,
|
||||
sessionId,
|
||||
agentId,
|
||||
onAgentChange,
|
||||
onSend,
|
||||
createChat,
|
||||
onReopenChat,
|
||||
onArchiveChat,
|
||||
onRenameChat,
|
||||
onDeleteChat,
|
||||
}: Props) {
|
||||
const [composerValue, setComposerValue] = useState('');
|
||||
const [chatId, setChatId] = useState<string | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(null);
|
||||
|
||||
const openChats = chats
|
||||
.filter((c) => c.status === 'open')
|
||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
||||
const archivedChats = chats
|
||||
.filter((c) => c.status === 'archived')
|
||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
||||
|
||||
// Create a chat lazily on first send or slash command.
|
||||
const ensureChat = useCallback(async (): Promise<string> => {
|
||||
if (chatId) return chatId;
|
||||
try {
|
||||
@@ -192,207 +34,46 @@ export function SessionLandingPage({
|
||||
}
|
||||
}, [chatId, createChat]);
|
||||
|
||||
async function handleSend() {
|
||||
const text = composerValue.trim();
|
||||
const handleSend = useCallback(async (content: string) => {
|
||||
const text = content.trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
const cid = await ensureChat();
|
||||
await ensureChat();
|
||||
onSend(text);
|
||||
setComposerValue('');
|
||||
} catch {
|
||||
// Error already surfaced via toast.
|
||||
}
|
||||
}
|
||||
}, [ensureChat, onSend]);
|
||||
|
||||
// v2.3: slash-command dispatch on landing page. Creates a chat first if
|
||||
// one doesn't exist, then invokes the skill on that chat.
|
||||
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
|
||||
try {
|
||||
const cid = await ensureChat();
|
||||
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null);
|
||||
setComposerValue('');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
|
||||
}
|
||||
}, [ensureChat]);
|
||||
|
||||
function startRename(chat: Chat) {
|
||||
setRenamingId(chat.id);
|
||||
setRenameValue(chat.name ?? '');
|
||||
}
|
||||
|
||||
async function finishRename() {
|
||||
if (renamingId && renameValue.trim()) {
|
||||
await onRenameChat(renamingId, renameValue.trim());
|
||||
}
|
||||
setRenamingId(null);
|
||||
}
|
||||
|
||||
// TODO: Landing page chat counts are a snapshot at mount. New messages in
|
||||
// visible chats won't update the per-row stats until next mount/navigation.
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
{openChats.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-2">Open chats</h3>
|
||||
<ul className="divide-y rounded-md border">
|
||||
{openChats.map((chat) => (
|
||||
<li key={chat.id}>
|
||||
<ChatRow
|
||||
chat={chat}
|
||||
onClick={() => onOpenChat(chat.id)}
|
||||
renamingId={renamingId}
|
||||
renameValue={renameValue}
|
||||
setRenameValue={setRenameValue}
|
||||
onFinishRename={() => void finishRename()}
|
||||
onCancelRename={() => setRenamingId(null)}
|
||||
onContextStartRename={() => startRename(chat)}
|
||||
onContextArchive={() => setArchiveConfirm(chat)}
|
||||
onContextDelete={() => setDeleteConfirm(chat)}
|
||||
showContextMenu
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Archive chat"
|
||||
title="Archive chat"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setArchiveConfirm(chat);
|
||||
}}
|
||||
>
|
||||
<Archive size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Delete chat"
|
||||
title="Delete chat"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteConfirm(chat);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{archivedChats.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
|
||||
>
|
||||
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
Archived chats ({archivedChats.length})
|
||||
</button>
|
||||
{showArchived && (
|
||||
<ul className="divide-y rounded-md border">
|
||||
{archivedChats.map((chat) => (
|
||||
<li key={chat.id}>
|
||||
<ChatRow
|
||||
chat={chat}
|
||||
onClick={() => void onReopenChat(chat.id)}
|
||||
dimmed
|
||||
trailing={<><RotateCcw size={10} className="inline mr-1" />Restore</>}
|
||||
renamingId={null}
|
||||
renameValue=""
|
||||
setRenameValue={() => {}}
|
||||
onFinishRename={() => {}}
|
||||
onCancelRename={() => {}}
|
||||
onContextStartRename={() => {}}
|
||||
onContextArchive={() => {}}
|
||||
onContextDelete={() => {}}
|
||||
showContextMenu={false}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openChats.length === 0 && archivedChats.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
No chats yet. Type below to start a conversation.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-center px-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send a message to start.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* v2.3: ChatInput with slash-command support replaces the bare Textarea.
|
||||
chatId is created lazily on first send/slash. */}
|
||||
<div className="border-t px-4 py-3 shrink-0">
|
||||
<ChatInput
|
||||
projectId={projectId}
|
||||
onSend={handleSend}
|
||||
onSlashCommand={handleSlashCommand}
|
||||
chatId={chatId ?? undefined}
|
||||
chatLabel={chatId ? undefined : 'Chat'}
|
||||
disabled={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={archiveConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveConfirm(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Archive chat?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Moves {archiveConfirm ? `"${archiveConfirm.name ?? 'New chat'}"` : 'this chat'} to the Archived chats section. You can restore it any time.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button variant="outline" onClick={() => setArchiveConfirm(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (archiveConfirm) void onArchiveChat(archiveConfirm.id);
|
||||
setArchiveConfirm(null);
|
||||
}}
|
||||
>
|
||||
Archive
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete chat?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Permanently delete{' '}
|
||||
<span className="font-mono font-medium text-foreground">{deleteConfirm?.name || '(unnamed)'}</span>
|
||||
{' '}and all its messages. This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (deleteConfirm) void onDeleteChat(deleteConfirm.id);
|
||||
setDeleteConfirm(null);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ChatInput
|
||||
disabled={false}
|
||||
projectId={projectId}
|
||||
sessionId={sessionId}
|
||||
agentId={agentId ?? null}
|
||||
onAgentChange={onAgentChange}
|
||||
onSend={handleSend}
|
||||
onSlashCommand={handleSlashCommand}
|
||||
chatId={chatId ?? undefined}
|
||||
chatLabel="Chat"
|
||||
messages={[]}
|
||||
modelContextLimit={null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ interface Props {
|
||||
project: Project | null;
|
||||
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */
|
||||
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||
onCoderConnectedChange?: (paneId: string, connected: boolean) => void;
|
||||
}
|
||||
|
||||
export function Workspace({
|
||||
@@ -48,6 +49,7 @@ export function Workspace({
|
||||
chatsHook,
|
||||
session,
|
||||
project,
|
||||
onCoderConnectedChange,
|
||||
onAddPane,
|
||||
}: Props) {
|
||||
const {
|
||||
@@ -141,6 +143,7 @@ export function Workspace({
|
||||
|
||||
// Per-coder-pane WS connection (status dot lives in the pane header).
|
||||
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
|
||||
const [coderLabels, setCoderLabels] = useState<Record<string, string>>({});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
@@ -212,24 +215,23 @@ export function Workspace({
|
||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||
/>
|
||||
)}
|
||||
{isCoder && (
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
|
||||
{isCoder && !isMobile && (
|
||||
<div className="flex items-center gap-1 border-b border-border px-2 py-1 shrink-0">
|
||||
<Code size={12} className="text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">BooCode</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="New pane"
|
||||
title="New pane"
|
||||
>
|
||||
<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>
|
||||
@@ -241,23 +243,12 @@ export function Workspace({
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block w-1.5 h-1.5 rounded-full shrink-0',
|
||||
coderConnected[pane.id] ? 'bg-green-500' : 'bg-red-500',
|
||||
)}
|
||||
title={coderConnected[pane.id] ? 'Connected' : 'Disconnected'}
|
||||
/>
|
||||
{panes.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removePane(idx);
|
||||
}}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
|
||||
aria-label="Close BooCode pane"
|
||||
title="Close BooCode pane"
|
||||
onClick={(e) => { e.stopPropagation(); removePane(idx); }}
|
||||
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Close pane"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
@@ -283,7 +274,7 @@ export function Workspace({
|
||||
<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>
|
||||
@@ -354,9 +345,15 @@ export function Workspace({
|
||||
chatId={activePaneChatId(pane)}
|
||||
chatPending={isPaneChatPending(pane.id)}
|
||||
projectPath={project?.path}
|
||||
onConnectedChange={(connected) =>
|
||||
onConnectedChange={(connected) => {
|
||||
setCoderConnected((prev) =>
|
||||
prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected },
|
||||
);
|
||||
onCoderConnectedChange?.(pane.id, connected);
|
||||
}}
|
||||
onAgentLabelChange={(label) =>
|
||||
setCoderLabels((prev) =>
|
||||
prev[pane.id] === label ? prev : { ...prev, [pane.id]: label },
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -384,19 +381,12 @@ export function Workspace({
|
||||
/>
|
||||
) : (
|
||||
<SessionLandingPage
|
||||
sessionId={sessionId}
|
||||
projectId={projectId}
|
||||
chats={chats}
|
||||
sessionId={sessionId}
|
||||
agentId={agentId}
|
||||
onAgentChange={onAgentChange}
|
||||
createChat={() => api.chats.create(sessionId)}
|
||||
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
||||
onSend={(content) => void handleLandingSend(idx, content)}
|
||||
onReopenChat={async (chatId) => {
|
||||
await unarchiveChat(chatId);
|
||||
openChatInPane(idx, chatId);
|
||||
}}
|
||||
onArchiveChat={archiveChat}
|
||||
onRenameChat={renameChat}
|
||||
onDeleteChat={deleteChat}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
21
apps/web/src/components/icons/ProviderIcons.tsx
Normal file
21
apps/web/src/components/icons/ProviderIcons.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
interface IconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ClaudeIcon({ size = 14, className }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" className={className}>
|
||||
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenCodeIcon({ size = 14, className }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="96 64 288 384" fill="currentColor" className={className}>
|
||||
<path d="M320 224V352H192V224H320Z" opacity={0.4} />
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
|
||||
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
|
||||
import { MessageBubble, type MessageActions } from '@/components/MessageBubble';
|
||||
import { ToolCallGroup } from '@/components/ToolCallGroup';
|
||||
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
|
||||
import { AskUserInputCard } from '@/components/AskUserInputCard';
|
||||
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
|
||||
import type { Message } from '@/api/types';
|
||||
|
||||
export interface CoderMessageWire {
|
||||
id: string;
|
||||
@@ -141,54 +142,16 @@ function groupToolRuns(items: RenderItem[]): RenderItem[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function CoderTextBubble({ message }: { message: CoderMessageWire }) {
|
||||
const isUser = message.role === 'user';
|
||||
const isStreaming = message.status === 'streaming';
|
||||
const hasText = message.content.trim().length > 0;
|
||||
const hasReasoning = (message.reasoning_text?.trim().length ?? 0) > 0;
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{hasReasoning && (
|
||||
<details className="rounded border border-border/40 bg-muted/20 px-2 py-1">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground select-none">Reasoning</summary>
|
||||
<pre className="mt-1 max-h-48 overflow-y-auto whitespace-pre-wrap text-[11px] text-muted-foreground font-mono">
|
||||
{message.reasoning_text}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
{(hasText || (isStreaming && !hasReasoning)) && (
|
||||
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
|
||||
{hasText ? <MarkdownRenderer content={message.content} /> : null}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message.status === 'failed' && (
|
||||
<div className="text-xs text-destructive">message failed</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
messages: CoderTimelineWire[];
|
||||
chatId?: string;
|
||||
footer?: ReactNode;
|
||||
actions?: MessageActions;
|
||||
}
|
||||
|
||||
export function CoderMessageList({ messages, chatId, footer }: Props) {
|
||||
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete' | 'openInPane')[] = ['fork', 'openInPane'];
|
||||
|
||||
export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isNearBottomRef = useRef(true);
|
||||
@@ -220,7 +183,14 @@ export function CoderMessageList({ messages, chatId, footer }: Props) {
|
||||
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
|
||||
{renderItems.map((item) => {
|
||||
if (item.kind === 'message') {
|
||||
return <CoderTextBubble key={item.message.id} message={item.message} />;
|
||||
return (
|
||||
<MessageBubble
|
||||
key={item.message.id}
|
||||
message={item.message as unknown as Message}
|
||||
actions={actions}
|
||||
hideActions={CODER_HIDDEN_ACTIONS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === 'tool_run') {
|
||||
if (item.run.call.name === 'ask_user_input' && chatId) {
|
||||
@@ -230,6 +200,7 @@ export function CoderMessageList({ messages, chatId, footer }: Props) {
|
||||
toolCall={item.run.call}
|
||||
toolResult={item.run.result}
|
||||
chatId={chatId}
|
||||
apiPrefix="/api/coder"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Code, Send, Check, X, RefreshCw } from 'lucide-react';
|
||||
import { Code, Check, X, RefreshCw } from 'lucide-react';
|
||||
import { AgentComposerBar } from '@/components/AgentComposerBar';
|
||||
import { PermissionCard } from '@/components/PermissionCard';
|
||||
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
||||
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
|
||||
import { ChatInput } from '@/components/ChatInput';
|
||||
import { api } from '@/api/client';
|
||||
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
|
||||
import { useSkills } from '@/hooks/useSkills';
|
||||
@@ -32,6 +31,8 @@ interface CoderMessage {
|
||||
id: string;
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
ctx_used?: number | null;
|
||||
ctx_max?: number | null;
|
||||
}
|
||||
|
||||
interface CoderToolMessage {
|
||||
@@ -63,6 +64,7 @@ interface Props {
|
||||
chatPending?: boolean;
|
||||
projectPath?: string;
|
||||
onConnectedChange?: (connected: boolean) => void;
|
||||
onAgentLabelChange?: (label: string) => void;
|
||||
}
|
||||
|
||||
interface WsHandlers {
|
||||
@@ -91,6 +93,8 @@ type RawCoderMessage = {
|
||||
| { id: string; name: string; args?: Record<string, unknown> }
|
||||
| { id: string; function: { name: string; arguments: string } }
|
||||
> | null;
|
||||
ctx_used?: number | null;
|
||||
ctx_max?: number | null;
|
||||
};
|
||||
|
||||
function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null {
|
||||
@@ -126,6 +130,8 @@ function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null
|
||||
status: (raw.status ?? 'complete') as CoderMessage['status'],
|
||||
...(reasoning_text ? { reasoning_text } : {}),
|
||||
...(tool_calls?.length ? { tool_calls } : {}),
|
||||
ctx_used: raw.ctx_used ?? null,
|
||||
ctx_max: raw.ctx_max ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -228,7 +234,12 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
|
||||
);
|
||||
const next = prev.map((m) =>
|
||||
m.id === frame.message_id && m.role !== 'tool'
|
||||
? { ...m, status: 'complete' as const }
|
||||
? {
|
||||
...m,
|
||||
status: 'complete' as const,
|
||||
ctx_used: (frame as any).ctx_used ?? (m as any).ctx_used ?? null,
|
||||
ctx_max: (frame as any).ctx_max ?? (m as any).ctx_max ?? null,
|
||||
}
|
||||
: m,
|
||||
);
|
||||
if (completed) {
|
||||
@@ -290,7 +301,9 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
|
||||
} else if (frame.type === 'permission_requested') {
|
||||
handlersRef.current.onPermissionRequested?.({
|
||||
taskId: frame.task_id,
|
||||
kind: frame.kind,
|
||||
toolTitle: frame.tool_title,
|
||||
...(frame.input ? { input: frame.input as Record<string, unknown> } : {}),
|
||||
options: (frame.options ?? []).map((o: { option_id: string; label: string }) => ({
|
||||
optionId: o.option_id,
|
||||
label: o.label,
|
||||
@@ -341,7 +354,7 @@ function usePendingChanges(sessionId: string) {
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const approve = useCallback(async (changeId: string) => {
|
||||
const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/approve`, {
|
||||
const res = await fetch(`/api/coder/pending/${changeId}/apply`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -350,7 +363,7 @@ function usePendingChanges(sessionId: string) {
|
||||
}, [sessionId]);
|
||||
|
||||
const reject = useCallback(async (changeId: string) => {
|
||||
const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/reject`, {
|
||||
const res = await fetch(`/api/coder/pending/${changeId}/reject`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -461,6 +474,7 @@ export function CoderPane({
|
||||
chatPending = false,
|
||||
projectPath,
|
||||
onConnectedChange,
|
||||
onAgentLabelChange,
|
||||
}: Props) {
|
||||
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
|
||||
provider: 'boocode',
|
||||
@@ -468,6 +482,12 @@ export function CoderPane({
|
||||
modeId: null,
|
||||
thinkingOptionId: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const parts = [agentConfig.provider || 'boocode'];
|
||||
if (agentConfig.model) parts.push(agentConfig.model);
|
||||
onAgentLabelChange?.(parts.join(' · '));
|
||||
}, [agentConfig.provider, agentConfig.model, onAgentLabelChange]);
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
|
||||
const [permissionBusy, setPermissionBusy] = useState(false);
|
||||
@@ -513,6 +533,8 @@ export function CoderPane({
|
||||
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [queue, setQueue] = useState<string[]>([]);
|
||||
const queueProcessing = useRef(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Refresh pending changes when a message_complete arrives
|
||||
@@ -565,11 +587,11 @@ export function CoderPane({
|
||||
setProviderCommands(commands);
|
||||
}, []);
|
||||
|
||||
const handlePermissionRespond = useCallback(async (optionId: string | null) => {
|
||||
const handlePermissionRespond = useCallback(async (optionId: string | null, updatedInput?: Record<string, unknown>) => {
|
||||
if (!permissionPrompt) return;
|
||||
setPermissionBusy(true);
|
||||
try {
|
||||
await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId);
|
||||
await api.coder.respondTaskPermission(permissionPrompt.taskId, optionId, updatedInput);
|
||||
setPermissionPrompt(null);
|
||||
} finally {
|
||||
setPermissionBusy(false);
|
||||
@@ -656,43 +678,87 @@ export function CoderPane({
|
||||
setMessages,
|
||||
]);
|
||||
|
||||
const handleSlashSelect = useCallback((name: string) => {
|
||||
const next = `/${name} `;
|
||||
setInput(next);
|
||||
setSlashState(null);
|
||||
requestAnimationFrame(() => {
|
||||
const ta = inputRef.current;
|
||||
if (ta) {
|
||||
ta.selectionStart = ta.selectionEnd = next.length;
|
||||
ta.focus();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setInput(newValue);
|
||||
if (isSlashCommandToken(newValue)) {
|
||||
setSlashState({ query: slashQuery(newValue) });
|
||||
} else {
|
||||
setSlashState(null);
|
||||
const sendOneMessage = useCallback(async (text: string) => {
|
||||
if (!chatId) return;
|
||||
setSending(true);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]);
|
||||
|
||||
try {
|
||||
const data = await api.coder.sendMessage(sessionId, {
|
||||
content: text,
|
||||
pane_id: paneId,
|
||||
chat_id: chatId,
|
||||
provider: agentConfig.provider !== 'boocode' ? agentConfig.provider : undefined,
|
||||
model: agentConfig.model || undefined,
|
||||
mode_id: agentConfig.modeId ?? undefined,
|
||||
thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
|
||||
});
|
||||
if (data.user_message_id) {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
|
||||
);
|
||||
}
|
||||
if (data.task_id) {
|
||||
setActiveTaskId(data.task_id);
|
||||
} else {
|
||||
setActiveTaskId(null);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to send');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, []);
|
||||
}, [sessionId, paneId, chatId, agentConfig, setMessages]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (slashState) return;
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
// Drain queue when not busy
|
||||
useEffect(() => {
|
||||
if (sending || queue.length === 0 || queueProcessing.current) return;
|
||||
queueProcessing.current = true;
|
||||
const next = queue[0]!;
|
||||
setQueue((prev) => prev.slice(1));
|
||||
sendOneMessage(next).finally(() => { queueProcessing.current = false; });
|
||||
}, [sending, queue, sendOneMessage]);
|
||||
|
||||
const handleChatInputSend = useCallback(async (content: string) => {
|
||||
const text = content.trim();
|
||||
if (!text || !chatId) return;
|
||||
if (sending) {
|
||||
setQueue((prev) => [...prev, text]);
|
||||
return;
|
||||
}
|
||||
await sendOneMessage(text);
|
||||
}, [sending, chatId, sendOneMessage]);
|
||||
|
||||
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
||||
if (!chatId) return;
|
||||
if (agentConfig.provider === 'boocode' && skillsByName.has(skillName)) {
|
||||
setSending(true);
|
||||
setPermissionPrompt(null);
|
||||
setLiveTaskCommands([]);
|
||||
try {
|
||||
await api.coder.skillInvoke(sessionId, paneId, skillName, userMessage.length > 0 ? userMessage : null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[handleSend, slashState]
|
||||
);
|
||||
}
|
||||
}, [chatId, sessionId, paneId, agentConfig.provider, skillsByName]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<AgentComposerBar
|
||||
projectPath={projectPath}
|
||||
value={agentConfig}
|
||||
onChange={setAgentConfig}
|
||||
onProviderCommandsChange={handleProviderCommandsChange}
|
||||
connected={connected}
|
||||
/>
|
||||
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{messages.length === 0 ? (
|
||||
@@ -704,6 +770,9 @@ export function CoderPane({
|
||||
<CoderMessageList
|
||||
messages={messages as CoderTimelineWire[]}
|
||||
chatId={chatId}
|
||||
actions={{
|
||||
onResend: async (_chatId, content) => { await sendOneMessage(content); },
|
||||
}}
|
||||
footer={
|
||||
activeTaskId && !permissionPrompt && sending === false ? (
|
||||
<p className="text-xs text-muted-foreground animate-pulse">Agent running…</p>
|
||||
@@ -716,7 +785,7 @@ export function CoderPane({
|
||||
{permissionPrompt && (
|
||||
<PermissionCard
|
||||
prompt={permissionPrompt}
|
||||
onRespond={(id) => void handlePermissionRespond(id)}
|
||||
onRespond={(id, input) => void handlePermissionRespond(id, input)}
|
||||
busy={permissionBusy}
|
||||
/>
|
||||
)}
|
||||
@@ -736,44 +805,16 @@ export function CoderPane({
|
||||
|
||||
{/* Composer + input */}
|
||||
<div className="shrink-0 border-t border-border">
|
||||
{displayedCommands.length > 0 && <AgentCommandsHint commands={displayedCommands} />}
|
||||
<AgentComposerBar
|
||||
projectPath={projectPath}
|
||||
value={agentConfig}
|
||||
onChange={setAgentConfig}
|
||||
onProviderCommandsChange={handleProviderCommandsChange}
|
||||
<ChatInput
|
||||
disabled={sending || !chatId || chatPending}
|
||||
projectId={projectPath ?? ''}
|
||||
onSend={handleChatInputSend}
|
||||
onSlashCommand={handleChatInputSlash}
|
||||
chatId={chatId ?? undefined}
|
||||
chatLabel="BooCode"
|
||||
messages={messages as unknown as import('@/api/types').Message[]}
|
||||
modelContextLimit={null}
|
||||
/>
|
||||
<div className="p-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type / for commands…"
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring max-h-32 min-h-[36px] max-md:min-h-[44px]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!input.trim() || sending || !chatId || chatPending}
|
||||
className="inline-flex items-center justify-center size-9 max-md:min-h-[44px] max-md:min-w-[44px] rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{slashState && (
|
||||
<SlashCommandPicker
|
||||
query={slashState.query}
|
||||
items={displayedCommands}
|
||||
inputRef={inputRef}
|
||||
onSelect={handleSlashSelect}
|
||||
onClose={() => setSlashState(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -162,6 +162,10 @@ export interface ChatStatusEvent {
|
||||
reason?: ErrorReason;
|
||||
}
|
||||
|
||||
export interface RefetchMessagesEvent {
|
||||
type: 'refetch_messages';
|
||||
}
|
||||
|
||||
export type SessionEvent =
|
||||
| SessionRenamedEvent
|
||||
| ProjectCreatedEvent
|
||||
@@ -186,7 +190,8 @@ export type SessionEvent =
|
||||
| ProjectArchivedEvent
|
||||
| ProjectUnarchivedEvent
|
||||
| ProjectUpdatedEvent
|
||||
| ChatStatusEvent;
|
||||
| ChatStatusEvent
|
||||
| RefetchMessagesEvent;
|
||||
type Listener = (event: SessionEvent) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
@@ -294,5 +294,21 @@ export function useSessionStream(sessionId: string | undefined) {
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
return sessionEvents.subscribe((event) => {
|
||||
if (event.type === 'refetch_messages') {
|
||||
void api.messages
|
||||
.list(sessionId)
|
||||
.then((messages) => {
|
||||
setState((s) => applyFrame(s, { type: 'snapshot', messages }));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.warn('refetch_messages failed', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -184,6 +184,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
||||
case 'chat_unarchived':
|
||||
case 'chat_deleted':
|
||||
case 'chat_status':
|
||||
case 'refetch_messages':
|
||||
return prev;
|
||||
case 'project_archived': {
|
||||
const next = prev.projects.filter((p) => p.id !== event.project_id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useViewport } from './useViewport';
|
||||
|
||||
interface SidebarDrawerState {
|
||||
open: boolean;
|
||||
@@ -13,13 +14,17 @@ const Ctx = createContext<SidebarDrawerState | null>(null);
|
||||
export function SidebarDrawerProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
// Auto-close on navigation. Effect fires once on mount too (open default
|
||||
// is false, so no observable effect) and on every pathname change after.
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Close drawer on orientation change (landscape→portrait transition).
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [isMobile]);
|
||||
|
||||
const toggle = useCallback(() => setOpen((v) => !v), []);
|
||||
|
||||
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;
|
||||
|
||||
@@ -31,13 +31,48 @@ export function useViewport(): ViewportSnapshot {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mobileMq = window.matchMedia(`(max-width: ${MOBILE_MAX}px)`);
|
||||
const tabletMq = window.matchMedia(`(min-width: ${MOBILE_MAX + 1}px) and (max-width: ${TABLET_MAX}px)`);
|
||||
const update = () => setState(snapshot());
|
||||
const update = () =>
|
||||
setState((prev) => {
|
||||
const next = snapshot();
|
||||
// Bail if nothing changed — visualViewport 'resize' fires on every
|
||||
// URL-bar show/hide and scroll, and a fresh object would re-render
|
||||
// every consumer needlessly.
|
||||
if (
|
||||
prev.isMobile === next.isMobile &&
|
||||
prev.isTablet === next.isTablet &&
|
||||
prev.width === next.width
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
// matchMedia 'change' alone is not enough on iOS Safari/Vivaldi: when a
|
||||
// backgrounded tab is restored (bfcache) or refocused, no 'change' fires,
|
||||
// and the width captured at first paint can be a stale/oversized value
|
||||
// (iOS reports the wrong innerWidth for a beat before layout settles). That
|
||||
// leaves isMobile=false on a phone, so the sidebar renders as a permanent
|
||||
// desktop column with no way to close it. Re-snapshot on every signal that
|
||||
// accompanies a rejoin/viewport correction, not just breakpoint crossings.
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') update();
|
||||
};
|
||||
mobileMq.addEventListener('change', update);
|
||||
tabletMq.addEventListener('change', update);
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('orientationchange', update);
|
||||
window.addEventListener('pageshow', update);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
window.visualViewport?.addEventListener('resize', update);
|
||||
update();
|
||||
return () => {
|
||||
mobileMq.removeEventListener('change', update);
|
||||
tabletMq.removeEventListener('change', update);
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('orientationchange', update);
|
||||
window.removeEventListener('pageshow', update);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
window.visualViewport?.removeEventListener('resize', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -57,15 +57,17 @@ export function inferLanguage(filename: string): string | null {
|
||||
export function flattenToMessage(attachments: Attachment[], text: string): string {
|
||||
if (attachments.length === 0) return text;
|
||||
const blocks = attachments.map(a => {
|
||||
const fence = '```' + (a.language ?? '');
|
||||
let header: string;
|
||||
if (a.kind === 'lines') {
|
||||
header = `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`;
|
||||
} else if (a.kind === 'paste') {
|
||||
header = `// from: pasted text (${a.content.split('\n').length} lines)`;
|
||||
} else {
|
||||
header = `// from: ${a.filename}`;
|
||||
// Pasted text is raw context, not code from a file — insert it verbatim with
|
||||
// no ``` fence or provenance header. The chip only exists to keep the textarea
|
||||
// tidy while composing; on send it should be exactly what the user pasted.
|
||||
if (a.kind === 'paste') {
|
||||
return a.content;
|
||||
}
|
||||
const fence = '```' + (a.language ?? '');
|
||||
const header =
|
||||
a.kind === 'lines'
|
||||
? `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`
|
||||
: `// from: ${a.filename}`;
|
||||
return `${fence}\n${header}\n${a.content}\n\`\`\``;
|
||||
});
|
||||
return [...blocks, text].filter(Boolean).join('\n\n');
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
import { ChevronRight, FolderTree, Menu } from 'lucide-react';
|
||||
import { ChevronRight, FolderTree, Menu, X } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Project, Session as SessionType } from '@/api/types';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
@@ -61,6 +61,9 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
initializeFirstChatIfEmpty,
|
||||
validatePanes,
|
||||
} = panesHook;
|
||||
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
|
||||
const activePane = panes[activePaneIdx];
|
||||
const activeIsCoder = activePane?.kind === 'coder';
|
||||
|
||||
const openChatInActivePane = useCallback(
|
||||
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
|
||||
@@ -402,6 +405,16 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
onAddPane={addPaneAndSwitch}
|
||||
disabled={panes.length >= MAX_PANES}
|
||||
/>
|
||||
{activeIsCoder && activePane && panes.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePane(activePaneIdx)}
|
||||
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-full bg-muted/40 hover:bg-muted/70 text-foreground shrink-0"
|
||||
aria-label="Close pane"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -495,6 +508,11 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
session={session}
|
||||
project={project}
|
||||
onAddPane={addPaneAndSwitch}
|
||||
onCoderConnectedChange={(paneId, connected) =>
|
||||
setCoderConnected((prev) =>
|
||||
prev[paneId] === connected ? prev : { ...prev, [paneId]: connected },
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,9 +15,12 @@ WORKDIR /build
|
||||
|
||||
RUN apk add --no-cache git ca-certificates build-base
|
||||
|
||||
# Build codecontext from the v3.2.1 tag.
|
||||
# Build codecontext from the boocode-ts fork (has .codecontextignore support).
|
||||
# Source is staged into the build context by the pre-build step:
|
||||
# tar -czf codecontext/fork.tar.gz -C /opt/forks/codecontext .
|
||||
# CGO is required: codecontext binds tree-sitter via cgo.
|
||||
RUN git clone --depth=1 --branch v3.2.1 https://github.com/nmakod/codecontext.git /build/codecontext
|
||||
COPY fork.tar.gz /build/fork.tar.gz
|
||||
RUN mkdir -p /build/codecontext && tar -xzf /build/fork.tar.gz -C /build/codecontext
|
||||
WORKDIR /build/codecontext
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /build/codecontext-bin ./cmd/codecontext
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ func startChild() error {
|
||||
// initial scan target — codecontext rebuilds the graph against whatever
|
||||
// target_dir each call carries, so this is just a valid bootstrap path
|
||||
// (the default "." is the alpine root and trips on transient /proc fds).
|
||||
child = exec.Command("codecontext", "mcp", "--target=/opt/projects", "--watch=true")
|
||||
child = exec.Command("codecontext", "mcp", "--target=/opt/projects", "--watch=true", "--respect-gitignore")
|
||||
var err error
|
||||
childStdin, err = child.StdinPipe()
|
||||
if err != nil {
|
||||
|
||||
@@ -7,7 +7,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
description: Reviews code for bugs, security issues, and maintainability. Read-only.
|
||||
---
|
||||
You review code. Find real problems, not style nits.
|
||||
@@ -46,7 +46,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
description: Diagnoses bugs from error messages, logs, or described symptoms.
|
||||
---
|
||||
You diagnose bugs. Form a hypothesis, prove it with evidence from the code.
|
||||
@@ -72,7 +72,7 @@ top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
steps: 5
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
description: Proposes refactors for clarity, deduplication, or decoupling. Read-only — outputs plans, not edits.
|
||||
---
|
||||
You propose refactors. You do not apply them. The user applies via OpenCode or Claude Code.
|
||||
@@ -115,7 +115,7 @@ top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 1.5
|
||||
steps: 20
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
description: Designs new features, modules, or architectural changes. Outputs a build plan.
|
||||
---
|
||||
You design. You produce build plans, not code.
|
||||
@@ -157,7 +157,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
description: Audits code for security vulnerabilities. Read-only.
|
||||
---
|
||||
You audit for security issues. Concrete findings only, no generic warnings.
|
||||
@@ -240,7 +240,7 @@ top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes]
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
description: Discovers and maps unfamiliar codebases. Reads architecture, traces data flow, identifies key symbols.
|
||||
---
|
||||
You map codebases. Start broad, then drill into specifics.
|
||||
@@ -258,3 +258,67 @@ Output:
|
||||
- Data flow map (entry → transform → output)
|
||||
- Conventions observed
|
||||
- Areas that need deeper investigation
|
||||
|
||||
|
||||
## Planner
|
||||
---
|
||||
temperature: 0.6
|
||||
top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
steps: 10
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
|
||||
description: Produces actionable step plans from requirements. Read-only — never modifies files.
|
||||
---
|
||||
You produce actionable step plans. You do not modify files.
|
||||
|
||||
Process:
|
||||
1. Restate the goal. Confirm scope and constraints.
|
||||
2. Read the relevant code areas with view_file, list_dir, grep. Understand the current state before planning.
|
||||
3. Identify dependencies between steps. Order them so each step has its prerequisites met.
|
||||
4. Estimate complexity per step (small / medium / large).
|
||||
5. Call out risks and assumptions that could invalidate the plan.
|
||||
|
||||
Output:
|
||||
- Goal: one line
|
||||
- Prerequisites: what must be true before starting
|
||||
- Steps: numbered, each with file paths, what changes, and acceptance criteria
|
||||
- Risks: what could go wrong, how to detect it
|
||||
- Verification: how to confirm the plan succeeded (test commands, type checks, manual checks)
|
||||
|
||||
Rules:
|
||||
- Every step must be independently verifiable.
|
||||
- Do not produce code. Describe what to change, not the change itself.
|
||||
- If a step affects more than 3 files, break it into sub-steps.
|
||||
- Flag any step that requires a database migration or env var change.
|
||||
|
||||
|
||||
## Builder
|
||||
---
|
||||
temperature: 0.6
|
||||
top_p: 0.95
|
||||
top_k: 20
|
||||
min_p: 0.0
|
||||
presence_penalty: 0.0
|
||||
steps: 50
|
||||
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes, edit_file, create_file, delete_file, apply_pending, rewind]
|
||||
description: Implements changes using read and write tools. Routes all writes through pending changes.
|
||||
---
|
||||
You implement. Read the code, make the changes, verify they work.
|
||||
|
||||
Process:
|
||||
1. Read the target files and understand the current state.
|
||||
2. Use grep and get_dependencies to find all call sites and dependents.
|
||||
3. Make changes via edit_file / create_file. All writes queue in pending_changes.
|
||||
4. Review pending changes before calling apply_pending.
|
||||
5. After applying, verify: read the modified files, check that the change is correct.
|
||||
|
||||
Rules:
|
||||
- All file modifications go through edit_file / create_file / delete_file. Never bypass pending_changes.
|
||||
- Read before writing. Understand what exists before changing it.
|
||||
- Match existing code conventions: naming, imports, error handling patterns.
|
||||
- One logical change per edit. Do not bundle unrelated changes.
|
||||
- If a change breaks an import or type, fix it in the same batch before applying.
|
||||
- Use rewind if a batch of changes is wrong. Do not apply broken changes.
|
||||
- When done, state what changed and what the user should verify (type check, test, manual check)
|
||||
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||
BOOCODER_URL: http://100.114.205.53:9502
|
||||
LLAMA_SIDECAR_URL: http://100.101.41.16:8402
|
||||
volumes:
|
||||
- /opt:/opt
|
||||
- /opt/projects:/opt/projects:rw
|
||||
|
||||
283
openspec/changes/v2-6-persistent-agent-sessions/design.md
Normal file
283
openspec/changes/v2-6-persistent-agent-sessions/design.md
Normal file
@@ -0,0 +1,283 @@
|
||||
# v2.6 Design — Persistent agent sessions
|
||||
|
||||
Reference implementations: `/opt/forks/opencode` (server + SDK),
|
||||
`/opt/forks/paseo` (warm ACP + opencode server-manager + reasoning dedup).
|
||||
|
||||
## 1. Architecture overview
|
||||
|
||||
```
|
||||
BooCoder (systemd host service)
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ dispatcher (per-turn unit = tasks row) │
|
||||
│ │ resolve backend + worktree + agent-session for the chat │
|
||||
│ ▼ │
|
||||
│ agent-pool ──────────────────────────────────────────────────┐ │
|
||||
│ ├─ OpenCodeServerBackend (1 process, N sessions) │ │
|
||||
│ │ `opencode serve` ◄── @opencode-ai/sdk ──► /event SSE │ │
|
||||
│ └─ WarmAcpBackend[session] (1 stdio process per session) │ │
|
||||
│ `goose acp` / `qwen --acp` ◄── ClientSideConnection │ │
|
||||
└──────────────────────────────────────────────────────────────┘ │
|
||||
│ broker.publishFrame (delta / reasoning_delta / tool_call) │
|
||||
▼ │
|
||||
web (CoderPane) — unchanged │
|
||||
```
|
||||
|
||||
The **task row stays the per-turn unit**. What changes: instead of building a
|
||||
fresh world per task, the dispatcher resolves the chat's *persistent* backend,
|
||||
worktree, and agent-session, sends one prompt, streams events, diffs, and leaves
|
||||
everything warm.
|
||||
|
||||
## 2. Backends
|
||||
|
||||
Common interface (`AgentBackend`):
|
||||
|
||||
```
|
||||
interface AgentBackend {
|
||||
ensureSession(sessionId, opts): Promise<AgentSessionHandle> // create-or-reuse
|
||||
prompt(handle, input, { worktreePath, model, signal, onEvent }): Promise<TurnResult>
|
||||
closeSession(handle): Promise<void>
|
||||
dispose(): Promise<void> // backend teardown
|
||||
health(): 'up' | 'down'
|
||||
}
|
||||
```
|
||||
|
||||
`onEvent` emits the same normalized events the current `acp-dispatch.ts` produces
|
||||
(`text`, `reasoning`, `tool_call`, `tool_update`) so the broker-frame publishing and
|
||||
`persistExternalAgentTurn` paths are reused unchanged.
|
||||
|
||||
### 2a. OpenCodeServerBackend (shared HTTP server)
|
||||
|
||||
- **Spawn once per BooCoder process:** `opencode serve --hostname 127.0.0.1 --port <p>`
|
||||
with `OPENCODE_SERVER_PASSWORD=<random-at-boot>` (verified: `serve.ts`, `network.ts`;
|
||||
default port 4096, prints `opencode server listening on http://…`). Use the official
|
||||
`@opencode-ai/sdk` (`createOpencodeServer` / `createOpencodeClient`) rather than
|
||||
hand-rolling HTTP — it already parses the ready line and wraps routes.
|
||||
- **One SSE subscription** to `GET /event`, consumed in a single read loop; events
|
||||
demuxed by `properties.sessionID` → BooCode session. Reasoning arrives as
|
||||
`message.part.delta` (`field: "reasoning"`) and `message.part.updated`
|
||||
(`part.type: "reasoning"`); text as the `text` field; tool calls as tool parts.
|
||||
- **One opencode session per BooCode chat.** `client.session.create()` once, store the
|
||||
returned `id` in `agent_sessions.agent_session_id`. Per-turn: `client.session.prompt({
|
||||
path:{id}, body:{ parts:[{type:'text',text}], model:"provider/model" }})`. Worktree
|
||||
routing via the `x-opencode-directory` header (set to the session's persistent
|
||||
worktree) so the agent operates inside it.
|
||||
- **Reasoning dedup (port from Paseo `opencode-agent.ts`):** track
|
||||
`streamedPartKeys` of `reasoning:${partID}`; when a `message.part.updated` reasoning
|
||||
part arrives whose key was already streamed via delta, drop it. Prevents the
|
||||
double-thought bug (covered by Paseo's `opencode-reasoning-dedup` e2e test).
|
||||
|
||||
### 2b. WarmAcpBackend (goose, qwen — stdio)
|
||||
|
||||
- **One persistent process + ACP connection per (chat, agent)** (Paseo's
|
||||
`SpawnedACPProcess`): spawn `goose acp` / `qwen --acp` once, NDJSON over stdio,
|
||||
`initialize` → `session/new` once; store the ACP session id in the
|
||||
`agent_sessions` row. Each turn calls `session/prompt` on the same connection;
|
||||
switching away and back resumes this same connection/session. Reuses the existing `acp-dispatch.ts`
|
||||
`handleSessionUpdate` switch verbatim for `agent_message_chunk` /
|
||||
`agent_thought_chunk` / `tool_call*`.
|
||||
- **Child lifetime is the pool's, not a request's.** Spawn detached/managed; do not
|
||||
tie the process to a single dispatch's abort signal (only the in-flight `prompt`
|
||||
gets the per-turn signal). Mirrors the codecontext shim rule (CLAUDE.md): supervise
|
||||
the child and react to its exit, don't let a request scope kill it.
|
||||
|
||||
## 3. Data model
|
||||
|
||||
Agent switching is **free** within a chat (the picker is per-turn, not locked), so
|
||||
the worktree is shared across agents but each agent keeps its own backend session.
|
||||
That splits into two tables: one **shared worktree per chat**, and one **backend
|
||||
session per (chat, agent)** pair.
|
||||
|
||||
```sql
|
||||
-- One shared worktree per BooCode chat. All agents used in the chat operate in it.
|
||||
CREATE TABLE IF NOT EXISTS session_worktrees (
|
||||
session_id UUID PRIMARY KEY REFERENCES sessions(id),
|
||||
worktree_path TEXT NOT NULL,
|
||||
base_commit TEXT, -- project HEAD captured at create (diff baseline)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
-- One backend session per (chat, agent). Resumed when the user switches back to
|
||||
-- that agent, so each agent retains its own conversation memory across switches.
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
session_id UUID NOT NULL REFERENCES sessions(id),
|
||||
agent TEXT NOT NULL, -- opencode | goose | qwen (native boocode needs no row)
|
||||
backend TEXT NOT NULL, -- opencode_server | acp_warm
|
||||
agent_session_id TEXT, -- opencode/ACP native session id (the memory handle)
|
||||
server_port INTEGER, -- opencode server port (nullable)
|
||||
status TEXT NOT NULL DEFAULT 'idle', -- idle | active | crashed | closed
|
||||
last_active_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
PRIMARY KEY (session_id, agent),
|
||||
CONSTRAINT agent_sessions_backend_chk CHECK (backend IN ('opencode_server','acp_warm')),
|
||||
CONSTRAINT agent_sessions_status_chk CHECK (status IN ('idle','active','crashed','closed'))
|
||||
);
|
||||
```
|
||||
|
||||
Plus one column for attribution (drives the DiffPanel badges in §9):
|
||||
|
||||
```sql
|
||||
-- Which agent staged each pending change. Stamped at queue time:
|
||||
-- worktree-diff path → the task's agent; native boocode write tools → 'boocode';
|
||||
-- manual RightRail create (v2.5.x) → NULL (renders as "manual").
|
||||
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
|
||||
```
|
||||
|
||||
`tasks.worktree_path` already exists but was per-task; the persistent worktree now
|
||||
lives on `session_worktrees`. `tasks` stays the per-turn record (state machine
|
||||
unchanged) and gains nothing required. **Native boocode** keeps no `agent_sessions`
|
||||
row — it has no warm backend; it reconstructs conversation context from the chat's
|
||||
`messages` rows each turn (so it transparently sees every other agent's prior turns).
|
||||
DB is the source of truth for reconnect after a BooCoder restart (the in-memory pool
|
||||
rebuilds lazily from these tables on the next turn).
|
||||
|
||||
## 3a. Agent switching & continuity (the decided model)
|
||||
|
||||
Per the design review: **free switch, per-agent memory.** Concretely:
|
||||
|
||||
- **Picker is per-turn.** The message route already sends `provider`/`model` per
|
||||
message; nothing locks a chat to one agent. v2.6 keeps that.
|
||||
- **Worktree is shared.** All agents in a chat resolve the same `session_worktrees`
|
||||
row, so file state carries across switches — *once applied*. (See the staging
|
||||
boundary caveat below.)
|
||||
- **Each agent resumes its own session.** Switching opencode → boocode → opencode
|
||||
reuses opencode's stored `agent_session_id` (its memory intact), not a fresh one.
|
||||
Lazy-create on first use of an agent in the chat; resume thereafter.
|
||||
- **Native boocode is the universal reader.** It rebuilds from the `messages` table,
|
||||
so it always sees the full transcript including other agents' turns.
|
||||
- **Gap turns are NOT auto-replayed** into a resumed agent. When you return to
|
||||
opencode, it sees the shared worktree + your new prompt, but did not "hear" the
|
||||
boocode/goose turns in between. (A future refinement could inject a short
|
||||
"changes since you last ran" preamble; out of scope for v2.6.)
|
||||
- **Staging-boundary caveat (must be documented in the UI):** external agents edit
|
||||
*inside the worktree*; native boocode reads/writes the *project root* via
|
||||
`pending_changes`. So unapplied edits do **not** cross between a worktree agent and
|
||||
native boocode — file continuity between the two only exists after apply. This is
|
||||
an inherent consequence of v2.5's review-before-apply model, not a v2.6 bug.
|
||||
- **No mid-turn switch.** Per-chat turns are serialized (§5); the agent is fixed for
|
||||
the duration of an in-flight turn. The user can switch the picker for the *next*
|
||||
turn while one is running, but it won't retarget the running turn.
|
||||
|
||||
## 4. Persistent worktree + incremental diff
|
||||
|
||||
- **Create** on the first turn of a chat (`createWorktree(projectPath, sessionId)`
|
||||
— keyed by chat, not task), capturing project HEAD as `base_commit`. Persist the
|
||||
`session_worktrees` row; all agents in the chat share it.
|
||||
- **Reuse** every subsequent turn — no new worktree, no cleanup between turns.
|
||||
- **Diff strategy (per turn):** diff the worktree against the **project HEAD baseline**
|
||||
captured when the worktree was created. Each turn supersedes the prior
|
||||
`pending_changes` row for that session (one accumulating unified diff, latest wins) —
|
||||
mirrors how the anchored rolling summary supersedes itself. Avoids stacking N partial
|
||||
diffs the user must reason about; the pending change always reflects the full current
|
||||
delta of the worktree.
|
||||
- **Apply** merges the worktree delta back to the project (existing `apply_pending`
|
||||
path); after apply, re-baseline so the next turn's diff is relative to applied state.
|
||||
- **Cleanup** on chat close/archive (new hook) and on `dispose()`; removes the
|
||||
`session_worktrees` row + all `agent_sessions` rows for the chat. Orphan reaper
|
||||
sweeps worktrees with no live `session_worktrees` row (extends the periodic sweeper).
|
||||
|
||||
## 5. Concurrency
|
||||
|
||||
Current dispatcher: global `running` boolean → strictly one task at a time.
|
||||
Target: **per-session serialization, cross-session concurrency.**
|
||||
|
||||
- Replace the single `running` flag with a `Map<sessionId, Promise>` in-flight registry.
|
||||
- `poll()` selects the oldest pending task whose **session has no in-flight turn**, so
|
||||
two different chats run concurrently but a chat never has two turns at once (the agent
|
||||
holds conversational state — overlapping prompts would corrupt it).
|
||||
- The LISTEN/NOTIFY `tasks_new` fast path (v2.5.x) already triggers immediate polls;
|
||||
the registry replaces the boolean guard there too.
|
||||
|
||||
## 6. Lifecycle & failure
|
||||
|
||||
- **Lazy spawn:** backend/worktree/agent-session created on first turn for a session.
|
||||
- **Idle eviction:** pool evicts a backend/session after an idle TTL (e.g. 30 min);
|
||||
worktree persists (DB-backed); next turn re-spawns and reattaches via stored
|
||||
`agent_session_id` (opencode persists sessions on disk; ACP re-`session/new` if the
|
||||
native id is gone).
|
||||
- **Crash recovery:** supervise children; on exit mark `agent_sessions.status='crashed'`,
|
||||
publish `chat_status='error'`, and rebuild on the next turn. opencode server crash
|
||||
takes all opencode sessions down → restart server, recreate sessions.
|
||||
- **Shutdown drain:** `app.addHook('onClose')` disposes the pool (close opencode server,
|
||||
kill warm ACP children) after in-flight turns settle — extends the existing
|
||||
dispatcher `stop()`.
|
||||
- **systemd:** BooCoder already spawns agent children under `NoNewPrivileges`; long-lived
|
||||
pool children are fine. Use `context.Background`-equivalent detachment so children
|
||||
outlive the dispatch that created them.
|
||||
|
||||
## 7. Risks / open questions
|
||||
|
||||
- **opencode single-server blast radius:** one crash drops all opencode sessions. Mitigated
|
||||
by on-disk session persistence + lazy re-create. Could later shard one server per project
|
||||
if it bites.
|
||||
- **Worktree disk growth:** persistent worktrees per session accumulate; the close-hook +
|
||||
orphan reaper must be reliable or disk leaks. Add a max-live-worktrees cap with LRU evict.
|
||||
- **SDK version coupling:** `@opencode-ai/sdk` is a new workspace dep pinned to the installed
|
||||
opencode (1.15.x). Probe-time version check should warn on major drift.
|
||||
- **Incremental-diff baseline correctness:** re-baselining after apply must handle the user
|
||||
editing the project out-of-band; diff vs a stored base commit, not vs a moving target.
|
||||
- **Reconnect fidelity:** after BooCoder restart, reattaching to a stored opencode session id
|
||||
assumes the server (also restarted) still has it on disk — verify the SDK reattach path.
|
||||
- **Cross-agent staging gap:** worktree agents and native boocode don't see each other's
|
||||
*unapplied* edits (worktree vs project root). The UI must make this legible (e.g. show
|
||||
which agent staged a pending change) so a switch doesn't look like lost work. A resumed
|
||||
agent also won't have heard other agents' in-between turns — acceptable per the decided
|
||||
model, but worth a small "N turns by other agents since you last ran" hint later.
|
||||
- **Per-(chat,agent) session sprawl:** a chat that cycles through many agents accumulates
|
||||
warm backends/worktree co-tenants; idle eviction (§6) must key on (chat,agent), and the
|
||||
opencode server's session count is bounded by eviction, not per-chat.
|
||||
|
||||
## 8. File map (anticipated)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `apps/coder/src/services/agent-pool.ts` | NEW — pool + backend interface |
|
||||
| `apps/coder/src/services/backends/opencode-server.ts` | NEW — SDK + SSE demux + dedup |
|
||||
| `apps/coder/src/services/backends/warm-acp.ts` | NEW — persistent ACP connection |
|
||||
| `apps/coder/src/services/dispatcher.ts` | per-chat concurrency; resolve-or-create shared worktree + per-(chat,agent) backend session; no per-turn teardown |
|
||||
| `apps/coder/src/services/worktrees.ts` | chat-keyed create; baseline capture; re-baseline-on-apply |
|
||||
| `apps/coder/src/services/agent-turn-persist.ts` | reused as-is |
|
||||
| `apps/coder/src/schema.sql` | `session_worktrees` + `agent_sessions` (per (chat,agent)) + `pending_changes.agent` column |
|
||||
| `apps/coder/src/routes/sessions|tasks` | chat-close cleanup hook |
|
||||
| `apps/coder/src/routes/pending.ts` | `agent` on `listPending` response; stamp `agent` in queue paths |
|
||||
| `apps/coder/src/routes/agent-sessions.ts` | NEW — `GET /api/sessions/:id/agent-sessions` (§9b) |
|
||||
| `apps/coder/package.json` | add `@opencode-ai/sdk` dep |
|
||||
| `apps/web/src/components/panes/CoderPane.tsx` | `PendingChange.agent`; DiffPanel badges + staging hint; pass `sessionId` to composer |
|
||||
| `apps/web/src/components/AgentComposerBar.tsx` | optional `sessionId` prop; resumed/new chip; export `providerIcon` |
|
||||
| `apps/web/src/hooks/useAgentSessions.ts` | NEW — chat-scoped agent-session fetch |
|
||||
| `apps/web/src/api/client.ts` | `api.coder.agentSessions(sessionId)` |
|
||||
|
||||
## 9. Frontend UX — agent attribution & switch affordances
|
||||
|
||||
The switching model (§3a) is only good if it's **legible**: the user must see which
|
||||
agent did what, and whether switching back resumes or starts fresh. Pure read+display
|
||||
over the new `agent` column and `agent_sessions` — no dispatch-logic change.
|
||||
|
||||
### 9a. Per-change agent attribution (DiffPanel) — Phase 1
|
||||
- **Wire:** `listPending` returns the row; add `agent` to the response and to the
|
||||
frontend `PendingChange` type (`CoderPane.tsx`, today `{id, file_path, operation, diff?, status}`).
|
||||
- **UI:** each DiffPanel row gains a small agent badge before the file path — reuse the
|
||||
`providerIcon()` switch from `AgentComposerBar` (extract to a shared helper / the new
|
||||
`icons/ProviderIcons` module) + the provider label; `agent === null` → a neutral
|
||||
"manual" chip. When the pending set spans >1 distinct agent, a one-line header note
|
||||
("Changes from opencode, boocode") makes mixed provenance obvious.
|
||||
|
||||
### 9b. "Resumed" vs "new session" indicator (AgentComposerBar) — Phase 1
|
||||
- **API:** `GET /api/sessions/:id/agent-sessions` → `[{ agent, status, has_session, last_active_at }]`
|
||||
(reads `agent_sessions` for the chat). Chat-scoped, so it is NOT foldable into the
|
||||
project-level provider snapshot.
|
||||
- **Hook:** `useAgentSessions(sessionId)` — fetch on mount, refetch on `message_complete`
|
||||
(same trigger `usePendingChanges` already uses).
|
||||
- **UI:** a subtle chip right of the Provider picker:
|
||||
- current provider has a live row → muted **"resumed"** (title: "Resuming <agent> · last active <relative>").
|
||||
- native boocode (never has a row) → **"history"** (it reconstructs from the transcript).
|
||||
- otherwise → **"new session"**.
|
||||
- Render only when connected and the chat has ≥1 prior turn; hidden on a fresh chat.
|
||||
- `AgentComposerBar` gains an optional `sessionId?: string` prop (CoderPane has it);
|
||||
absent → render nothing, so BooChat and other callers are unaffected.
|
||||
|
||||
### 9c. Staging-boundary hint (DiffPanel) — Phase 3 polish
|
||||
- When the selected provider is **native boocode** and pending changes were staged by a
|
||||
**worktree agent** (or vice-versa), show a one-line muted caveat:
|
||||
"opencode's edits live in its worktree — boocode won't see them until applied."
|
||||
Derived purely from per-change `agent` + current `value.provider`; no new state.
|
||||
Keeps the §3a staging caveat from biting silently.
|
||||
114
openspec/changes/v2-6-persistent-agent-sessions/proposal.md
Normal file
114
openspec/changes/v2-6-persistent-agent-sessions/proposal.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# v2.6 Persistent agent sessions (warm processes + OpenCode server)
|
||||
|
||||
**Status:** Planned
|
||||
**Depends on:** v2.2 Paseo providers (ACP dispatch), v2.3 provider lifecycle (registry/snapshot)
|
||||
**Reference fork:** `/opt/forks/paseo`, `/opt/forks/opencode`
|
||||
**Pairs with:** the v2.5.x MessageBubble "Thinking" render fix — reasoning already flows; this batch is about persistence, not capability.
|
||||
|
||||
## Why
|
||||
|
||||
BooCode dispatches external agents (opencode, goose, qwen) **one-shot per task**:
|
||||
per task the dispatcher cuts a fresh worktree (`createWorktree(projectPath, taskId)`),
|
||||
spawns `opencode acp` / `goose acp` / `qwen --acp`, runs **one** turn, then tears
|
||||
down the process *and* the worktree (`dispatcher.ts:runExternalAgent`). Consequences:
|
||||
|
||||
- **No session continuity.** A follow-up message in the same chat creates a new
|
||||
task with a new worktree and a new agent process. The agent has no memory of
|
||||
the prior turn beyond what BooCode replays as chat history, and it cannot see
|
||||
the files it edited last turn (fresh worktree every time).
|
||||
- **Cold start every turn.** Each turn pays the process spawn + ACP `initialize`
|
||||
handshake (and, for some agents, model load) before any work happens.
|
||||
- **Diverges from Paseo.** Paseo runs **OpenCode as a long-lived HTTP server**
|
||||
(`opencode serve` + `@opencode-ai/sdk`, SSE `/event` stream) and keeps **goose /
|
||||
qwen as warm stdio-ACP processes** (`SpawnedACPProcess`: one ACP connection,
|
||||
`newSession()` once, many `prompt()`s). BooCode rebuilds the world per turn.
|
||||
|
||||
This batch makes a BooCode chat map to a **persistent agent backend + a persistent
|
||||
worktree** that live for the whole conversation, so turns are warm and the agent
|
||||
sees its own accumulating edits. Reasoning passthrough is **already solved** (ACP
|
||||
`agent_thought_chunk` → `reasoning_delta` → the new MessageBubble Thinking block);
|
||||
this batch does not touch it beyond porting OpenCode's reasoning-dedup.
|
||||
|
||||
## Decisions locked (from design review)
|
||||
|
||||
- **Worktree model:** *Persistent worktree per session.* A chat owns one worktree
|
||||
for the whole conversation; each turn the agent sees prior edits; pending_changes
|
||||
accumulate; worktree is cleaned on session close, not per turn.
|
||||
- **Agent switching:** *Free switch, per-agent memory.* The picker stays per-turn
|
||||
(not locked to a chat). The worktree is shared across agents; each agent keeps its
|
||||
own backend session, resumed when you switch back to it. Native boocode reconstructs
|
||||
from chat history (so it sees every agent's turns); a resumed agent does not auto-
|
||||
ingest the gap turns. Data model: one shared worktree per chat + one backend session
|
||||
per `(chat, agent)` pair. Caveat: unapplied edits don't cross the worktree↔project
|
||||
boundary between external agents and native boocode (a v2.5 review-model consequence).
|
||||
- **Transport per agent (matches Paseo exactly):**
|
||||
- **OpenCode** → one shared `opencode serve` HTTP server, driven via
|
||||
`@opencode-ai/sdk`; one opencode *session* per BooCode chat (multi-session,
|
||||
directory-routed via `x-opencode-directory`).
|
||||
- **Goose / Qwen** → warm **stdio** ACP process per live session. Their HTTP
|
||||
"server" modes are just ACP-over-HTTP wrappers (goose: undocumented/internal;
|
||||
qwen `serve`: an HTTP bridge around a single `qwen --acp` child) — no gain over
|
||||
stdio, so we keep stdio ACP like Paseo does.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
1. **Agent process pool** (`apps/coder/src/services/agent-pool.ts`) — owns long-lived
|
||||
backends, lazy spawn, idle eviction, crash restart, shutdown drain.
|
||||
2. **OpenCode server backend** — spawn `opencode serve`, hold SDK client + single
|
||||
SSE subscription demuxed by opencode `sessionID` → BooCode session; port +
|
||||
`OPENCODE_SERVER_PASSWORD` managed at boot.
|
||||
3. **Warm ACP backend** — persistent `SpawnedACPProcess`-style connection for
|
||||
goose/qwen reused across turns (one `newSession()`, many prompts).
|
||||
4. **Persistent worktree lifecycle** — worktree created on first turn of a session,
|
||||
reused, diffed incrementally into `pending_changes`, cleaned on session close.
|
||||
5. **Session ↔ backend ↔ worktree mapping** — new `agent_sessions` table.
|
||||
6. **Per-session concurrency** — replace the dispatcher's global single-flight
|
||||
`running` guard with per-session serialization (different sessions run
|
||||
concurrently; one turn at a time within a session).
|
||||
7. **OpenCode reasoning dedup** — port Paseo's `streamedPartKeys` partID dedup so
|
||||
reasoning isn't double-emitted (delta + final part).
|
||||
8. **Switch-aware UI** (design §9) — per-change agent attribution in the DiffPanel
|
||||
(`pending_changes.agent` column + badges), a resumed/new-session chip on the
|
||||
AgentComposerBar (chat-scoped `agent-sessions` endpoint), and a staging-boundary
|
||||
hint so the worktree↔project gap is legible.
|
||||
9. **Tests + smoke** — pool lifecycle unit tests; multi-turn opencode smoke; switch
|
||||
round-trip smoke; attribution/indicator smoke.
|
||||
|
||||
### Out of scope (this batch)
|
||||
|
||||
- Claude PTY→structured transport (separate deferred work — claude stays PTY here).
|
||||
- Goose/qwen HTTP server modes (intentionally not used).
|
||||
- Frontend redesign — existing CoderPane multi-turn chat UI already supports
|
||||
follow-ups; only backend continuity changes.
|
||||
- Replacing `acp-dispatch.ts` wholesale — warm backend reuses its event handlers.
|
||||
- Cross-host agent servers (opencode server stays local to the BooCoder host).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Multi-user session sharing (single-user homelab).
|
||||
- Multiple concurrent turns within one agent session (the agent holds conversational
|
||||
state; turns within a session are serialized).
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Send two messages in one external-agent chat → second turn reuses the same agent
|
||||
session **and** the same worktree (verified: no second `createWorktree`, agent
|
||||
references files it edited in turn 1).
|
||||
- Warm-start latency for turn 2 materially below turn 1 (no spawn/handshake).
|
||||
- opencode reasoning shows once per thought (no dupes) in the Thinking block.
|
||||
- Killing the opencode server mid-session → pool restarts it and the next turn
|
||||
recovers (opencode persists sessions on disk).
|
||||
- Switch opencode → boocode → opencode in one chat → opencode resumes its *same*
|
||||
session (its memory intact), boocode saw opencode's turns as history, and all three
|
||||
shared the one worktree. No agent is locked to the chat.
|
||||
- Closing/archiving a session removes its worktree; BooCoder restart drains cleanly.
|
||||
- Existing one-shot paths (arena, `new_task` tool, MCP create-task) still work.
|
||||
|
||||
## Deliverables
|
||||
|
||||
| Doc | Purpose |
|
||||
|-----|---------|
|
||||
| [`design.md`](./design.md) | Architecture, backends, data model, worktree/diff strategy, lifecycle, risks |
|
||||
| [`tasks.md`](./tasks.md) | Phased implementation checklist |
|
||||
94
openspec/changes/v2-6-persistent-agent-sessions/tasks.md
Normal file
94
openspec/changes/v2-6-persistent-agent-sessions/tasks.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# v2.6 Tasks — Persistent agent sessions
|
||||
|
||||
Phased so each phase is independently shippable and smoke-testable. Phase 1
|
||||
(OpenCode server) delivers the most value on the cleanest API; goose/qwen warm
|
||||
ACP follows; hardening last.
|
||||
|
||||
## Phase 0 — Foundations (no behavior change)
|
||||
|
||||
- [ ] 0.1 Add `session_worktrees` + `agent_sessions` tables (per `(session_id, agent)`)
|
||||
to `apps/coder/src/schema.sql` (idempotent; see design §3).
|
||||
- [ ] 0.2 Define `AgentBackend` / `AgentSessionHandle` interface + normalized `onEvent`
|
||||
event union (reuse shapes from `acp-dispatch.ts`).
|
||||
- [ ] 0.3 Scaffold `agent-pool.ts` with lazy get-or-create keyed by `(chat, agent)`,
|
||||
health, `dispose()`; wire `app.addHook('onClose')` to dispose alongside dispatcher `stop()`.
|
||||
|
||||
## Phase 1 — OpenCode server backend (multi-turn, warm)
|
||||
|
||||
- [ ] 1.1 Add `@opencode-ai/sdk` to `apps/coder/package.json`; pin to installed opencode major.
|
||||
- [ ] 1.2 `backends/opencode-server.ts`: spawn `opencode serve` once (random
|
||||
`OPENCODE_SERVER_PASSWORD`, allocated port), `createOpencodeClient`, wait for ready line.
|
||||
- [ ] 1.3 Single `/event` SSE read loop; demux by `properties.sessionID`; map
|
||||
`message.part.delta`/`updated` (text + reasoning) + tool parts to `onEvent`.
|
||||
- [ ] 1.4 Port Paseo `streamedPartKeys` reasoning dedup (delta vs final part).
|
||||
- [ ] 1.5 `ensureSession`: reuse the `(chat, opencode)` `agent_sessions` row if present
|
||||
(resume on switch-back), else `client.session.create()` → store `agent_session_id`.
|
||||
- [ ] 1.6 `prompt`: send via SDK with `x-opencode-directory` = session worktree + `model`.
|
||||
- [ ] 1.7 Dispatcher: when `agent==='opencode'`, route to pool backend instead of
|
||||
`dispatchViaAcp`; keep broker frames + `persistExternalAgentTurn` identical.
|
||||
- [ ] 1.8 Persistent worktree: chat-keyed `createWorktree` (shared across agents);
|
||||
capture base commit in `session_worktrees`; reuse across turns and agents.
|
||||
- [ ] 1.9 Per-session concurrency: replace global `running` with `Map<sessionId,Promise>`;
|
||||
`poll()` skips sessions with an in-flight turn.
|
||||
- [ ] 1.10 Per-turn diff → supersede prior `pending_changes` row for the session (latest-wins).
|
||||
- [ ] **Smoke 1:** two messages in one opencode chat → same `agent_session_id`, same worktree,
|
||||
no second `createWorktree`; agent references turn-1 edits; reasoning shows once; turn-2 faster.
|
||||
|
||||
## Phase 1 (UX) — Attribution & switch affordances (design §9)
|
||||
|
||||
- [ ] U.1 Stamp `pending_changes.agent` at queue time (worktree path → task agent;
|
||||
native write tools → `'boocode'`; manual RightRail create → NULL).
|
||||
- [ ] U.2 Add `agent` to `listPending` response + frontend `PendingChange` type.
|
||||
- [ ] U.3 Extract `providerIcon()` to a shared helper; DiffPanel renders an agent badge
|
||||
per row + a "Changes from X, Y" note when the pending set spans >1 agent (§9a).
|
||||
- [ ] U.4 `GET /api/sessions/:id/agent-sessions` route + `api.coder.agentSessions` +
|
||||
`useAgentSessions(sessionId)` (refetch on `message_complete`) (§9b).
|
||||
- [ ] U.5 `AgentComposerBar` optional `sessionId` prop → resumed / history / new-session
|
||||
chip beside the Provider picker; hidden on fresh chats and other callers (§9b).
|
||||
- [ ] **Smoke U:** stage edits with opencode then boocode → DiffPanel badges each row to the
|
||||
right agent; composer shows "resumed" when re-selecting opencode, "new session" for goose.
|
||||
|
||||
## Phase 2 — Warm ACP backend (goose, qwen)
|
||||
|
||||
- [ ] 2.1 `backends/warm-acp.ts`: persistent spawn + `ClientSideConnection`; `initialize` +
|
||||
`session/new` once; reuse `acp-dispatch.ts` `handleSessionUpdate`.
|
||||
- [ ] 2.2 `prompt`: `session/prompt` on the warm connection per turn; per-turn abort signal only.
|
||||
- [ ] 2.3 Child supervision: detached lifetime, exit handler marks `status='crashed'`.
|
||||
- [ ] 2.4 Dispatcher routes `goose`/`qwen` to warm backend; keep one-shot fallback for arena/MCP
|
||||
(or opt those into pool too — decide in review).
|
||||
- [ ] **Smoke 2:** two messages in a goose chat reuse the same process + ACP session + worktree;
|
||||
reasoning still renders; no per-turn respawn.
|
||||
- [ ] **Smoke 2b (switch round-trip):** opencode → boocode → opencode in one chat — opencode
|
||||
resumes the SAME `agent_session_id` (memory intact), boocode saw opencode's turns as
|
||||
history, all three shared the one worktree, and no agent was locked to the chat.
|
||||
|
||||
## Phase 3 — Lifecycle hardening
|
||||
|
||||
- [ ] 3.1 Idle TTL eviction keyed per `(chat, agent)`; reattach-on-next-turn from `agent_sessions`.
|
||||
- [ ] 3.2 Crash recovery: opencode server restart recreates sessions; ACP re-`session/new`.
|
||||
- [ ] 3.3 Chat close/archive hook → `closeSession` for every `(chat, agent)` + remove the
|
||||
shared `session_worktrees` row + worktree; mark agent rows `status='closed'`.
|
||||
- [ ] 3.4 Orphan worktree reaper (extend periodic sweeper) + max-live-worktrees LRU cap.
|
||||
- [ ] 3.5 Re-baseline worktree diff after `apply_pending`.
|
||||
- [ ] 3.6 Reconnect test: restart BooCoder mid-session → next turn reattaches/recreates cleanly.
|
||||
- [ ] 3.7 Staging-boundary hint in DiffPanel (§9c): muted one-liner when the selected
|
||||
provider can't see another agent's unapplied worktree edits (derived from per-change
|
||||
`agent` + current provider; no new state).
|
||||
|
||||
## Tests
|
||||
|
||||
- [ ] T.1 `agent-pool` unit: get-or-create, idle evict, dispose drains in-flight (DB-opt-in pattern).
|
||||
- [ ] T.2 opencode SSE demux + reasoning dedup unit (fixture event stream).
|
||||
- [ ] T.3 per-session concurrency: two sessions run concurrently, one session serializes.
|
||||
|
||||
## Docs
|
||||
|
||||
- [ ] D.1 Update `CLAUDE.md` (BooCoder dispatch section) + `BOOCODER.md` health/contract.
|
||||
- [ ] D.2 Note opencode `@opencode-ai/sdk` dep + `OPENCODE_SERVER_PASSWORD` env in env docs.
|
||||
- [ ] D.3 `CHANGELOG.md` entry on tag (`v2.6.0-persistent-agent-sessions`).
|
||||
|
||||
## Build / deploy gate
|
||||
|
||||
- [ ] B.1 `pnpm -C apps/server build && pnpm -C apps/coder build` clean.
|
||||
- [ ] B.2 `pnpm -C apps/server test` (+ DB-opt-in) green.
|
||||
- [ ] B.3 Deploy: `sudo systemctl restart boocoder`; `curl :9502/api/health` reports tool count.
|
||||
@@ -10,5 +10,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -155,6 +155,9 @@ importers:
|
||||
fastify:
|
||||
specifier: ^4.28.1
|
||||
version: 4.29.1
|
||||
parse5:
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
postgres:
|
||||
specifier: ^3.4.4
|
||||
version: 3.4.9
|
||||
@@ -2382,6 +2385,10 @@ packages:
|
||||
resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
entities@8.0.0:
|
||||
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
env-paths@2.2.1:
|
||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3274,6 +3281,9 @@ packages:
|
||||
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
parse5@8.0.1:
|
||||
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -6110,6 +6120,8 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.3
|
||||
|
||||
entities@8.0.0: {}
|
||||
|
||||
env-paths@2.2.1: {}
|
||||
|
||||
error-ex@1.3.4:
|
||||
@@ -7267,6 +7279,10 @@ snapshots:
|
||||
|
||||
parse-ms@4.0.0: {}
|
||||
|
||||
parse5@8.0.1:
|
||||
dependencies:
|
||||
entities: 8.0.0
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-browserify@1.0.1: {}
|
||||
|
||||
Reference in New Issue
Block a user