diff --git a/.gitignore b/.gitignore index 7a52b5af74..64f6cea075 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ tsconfig.tsbuildinfo # Rendered VHS demos — regenerable from .tape files, kept out of git docs/media/*.gif docs/reviews/AI-7520-code-pr1001-review-threads.md +docs/site/ diff --git a/README.md b/README.md index 7650ace3fd..45f5a5e9b2 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ no hallucinated SQL advice, no guessing at schema, no missed PII. **Works seamlessly with Claude Code and Codex.** Use `/configure-claude` or `/configure-codex` to set up integration in one step. altimate is the data engineering tool layer — use it standalone in your terminal, or mount it as the harness underneath whatever AI agent you already run. The two are complementary. -altimate-code is a fork of [OpenCode](https://github.com/anomalyco/opencode) rebuilt for data teams. Model-agnostic — bring your own LLM or run locally with Ollama. +altimate-code is a fork of [OpenCode](https://github.com/anomalyco/opencode) rebuilt for data teams. Model-agnostic — bring your own LLM, or run fully local with `altimate local`. ## Quick demo @@ -133,6 +133,9 @@ altimate-code is a fork of [OpenCode](https://github.com/anomalyco/opencode) reb # Generate dbt 1.8 unit tests for a model with CASE/WHEN and JOINs > /dbt-unit-tests for models/marts/fct_revenue.sql + +# Run fully local — no API key, no per-token cost, web tools ask before egress +altimate local ``` ## Key Features @@ -160,6 +163,9 @@ Manifest parsing, test generation, model scaffolding, incremental model detectio ### Data Visualization Interactive charts and dashboards from SQL results. The data-viz skill generates publication-ready visualizations with automatic chart type selection based on your data. +### Certified Local Mode +Run the whole agent on your own hardware with one command — `altimate local`. No account, no API key, no per-token cost: hardware detection picks a recipe, every artifact is SHA-256-verified, and the endpoint must pass certification probes (tool calls, reasoning, long-context prefill) before your config is touched. An egress guard makes web tools ask before anything leaves the machine. See the [Local Mode docs](https://docs.altimate.sh/usage/local/). + ### Local-First Tracing Built-in observability for AI interactions — trace tool calls, token usage, and session activity locally. No external services required. View session recordings with `altimate trace`. Features include loop detection, post-session summary, and shareable HTML exports. @@ -195,7 +201,7 @@ First-class support with schema indexing, query execution, and metadata introspe ## Works with Any LLM -Model-agnostic — bring your own provider or run locally. +Model-agnostic — bring your own provider, or run fully local with `altimate local` (managed setup, certified endpoint, no API key) or your own Ollama / LM Studio server. Altimate LLM Gateway · Anthropic · OpenAI · Google Gemini · Google Vertex AI · Amazon Bedrock · Azure OpenAI · Databricks AI Gateway · Snowflake Cortex · Mistral · Groq · DeepInfra · Cerebras · Cohere · Together AI · Perplexity · xAI · OpenRouter · LM Studio · Ollama · GitHub Copilot diff --git a/docs/docs/configure/index.md b/docs/docs/configure/index.md index ff089a421b..24584eda7a 100644 --- a/docs/docs/configure/index.md +++ b/docs/docs/configure/index.md @@ -33,7 +33,7 @@ Set up your warehouses, LLM providers, and preferences. For agents, tools, skill Connect to 35+ LLM providers — Anthropic, OpenAI, Bedrock, Ollama, and more. Configure API keys and model selection. - [:octicons-arrow-right-24: Providers](providers.md) · [:octicons-arrow-right-24: Models](models.md) + [:octicons-arrow-right-24: Providers](providers.md) · [:octicons-arrow-right-24: Models](models.md) · [:octicons-arrow-right-24: Local Mode](../usage/local.md) - :material-puzzle:{ .lg .middle } **MCPs & ACPs** diff --git a/docs/docs/configure/models.md b/docs/docs/configure/models.md index efd8ef0ae2..7ab0a6458e 100644 --- a/docs/docs/configure/models.md +++ b/docs/docs/configure/models.md @@ -18,6 +18,9 @@ altimate supports models from all configured providers. Use the `model` and `sma The model format is `provider/model-name`. +!!! note + `altimate local` pins `small_model` to the local provider automatically when you haven't set one — no background step (like compaction or title generation) silently calls a cloud model. See [Local Mode](../usage/local.md). + ## Browsing Models In the TUI: @@ -85,6 +88,7 @@ Models are referenced as `provider/model-name`: | OpenRouter | `openrouter/anthropic/claude-sonnet-4-6` | | Copilot | `copilot/gpt-4o` | | Snowflake Cortex | `snowflake-cortex/claude-sonnet-4-6` | +| Local ([`altimate local`](../usage/local.md)) | `local/qwen3.8-27b` | | Custom | `my-provider/my-model` | See [Providers](providers.md) for full provider configuration details. diff --git a/docs/docs/configure/permissions.md b/docs/docs/configure/permissions.md index f381029b8d..eaf5d96214 100644 --- a/docs/docs/configure/permissions.md +++ b/docs/docs/configure/permissions.md @@ -104,6 +104,9 @@ Override permissions for specific agents: | `todoread` | No | Read tasks | | `doom_loop` | No | Loop detection | +!!! note "Local Mode egress guard" + Running `altimate local` adds `ask` rules for `websearch`, `webfetch`, and `codesearch`, so a local-first session only reaches the internet with your approval. See [Local Mode](../usage/local.md#trust-model). + ## Environment Variable Set permissions via environment variable: diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index d07992a929..57f1bef988 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -233,6 +233,20 @@ Run local models through [LM Studio](https://lmstudio.ai)'s OpenAI-compatible se !!! note If you changed LM Studio's default port, update the `baseURL` accordingly. No real API key is needed — the `"lm-studio"` placeholder satisfies the SDK requirement. +## Altimate Local (Managed) + +Unlike Ollama or LM Studio above, `altimate local` does the setup for you: hardware detection, verified model download, certification, and config wiring, all in one command. + +```bash +altimate local +``` + +This detects your hardware, downloads a SHA-256-verified model and pinned `llama.cpp` runtime, certifies the endpoint (tool-call, reasoning, and long-context probes), and writes the `local` provider block into your config for you — endpoint, model entry, and context limits included. Your default model becomes `local/qwen3.8-27b` if you haven't set one. There is nothing to paste by hand: re-running `altimate local` refreshes the wiring, and `altimate local models` lists the registry. + +No API key needed, and the server binds `127.0.0.1` only. `altimate local` also adds `ask` permission rules for the web tools (`websearch`, `webfetch`, `codesearch`), so a local-first session reaches the internet only with your approval. + +See [Local Mode](../usage/local.md) for the full command reference, supported hardware, and trust model. + ## OpenRouter ```json diff --git a/docs/docs/getting-started/index.md b/docs/docs/getting-started/index.md index 2916b563aa..107a06f950 100644 --- a/docs/docs/getting-started/index.md +++ b/docs/docs/getting-started/index.md @@ -53,7 +53,7 @@ npm install -g altimate-code ### [Bring Your Own LLM](/configure/providers/) -Works with Anthropic, OpenAI, Google, AWS Bedrock, Azure, Ollama, and 10+ more providers. Swap models without swapping your harness. No vendor lock-in. +Works with Anthropic, OpenAI, Google, AWS Bedrock, Azure, Ollama, and 10+ more providers — or run fully local with `altimate local`, no API key. Swap models without swapping your harness. No vendor lock-in. diff --git a/docs/docs/getting-started/quickstart.md b/docs/docs/getting-started/quickstart.md index e7ec4bfd48..c81382bb7b 100644 --- a/docs/docs/getting-started/quickstart.md +++ b/docs/docs/getting-started/quickstart.md @@ -21,11 +21,12 @@ npm install -g altimate-code altimate # Launch the TUI ``` -On a fresh install, a welcome panel appears with a curated 6-provider picker: +On a fresh install, a welcome panel appears with a curated 7-provider picker: - **Altimate LLM Gateway** *(recommended)* — 10M tokens free, no API keys. Routes to the best model per task across Sonnet, Opus, GPT-5, and more. Sign-in opens a browser tab; complete Google or email signup and you're back in the TUI. If your terminal can't open a browser (SSH / tmux / WSL), the CLI prints the URL — paste it into a browser on your desktop. - **Anthropic** / **OpenAI** / **Google** — paste an API key or OAuth in. - **Big Pickle** — free tier, chats work but many data tasks fail; useful for kicking tires. +- **Local model** — no account, no API key; a certified open model runs on your machine. The picker explains the one-time setup: exit and run `altimate local`. - **Search all providers…** — full picker if you need Bedrock, Databricks AI Gateway, Cloudflare AI Gateway, Snowflake Cortex, DigitalOcean Inference, etc. Or set an environment variable and skip the picker: @@ -38,6 +39,9 @@ altimate !!! tip "Don't want to manage API keys?" The [Altimate LLM Gateway](https://help.altimate.ai/datamates/user-guide/components/llm-gateway/) is the top row of the picker — 10M free tokens, and altimate-code auto-selects the right model per task. First-run sign-in uses a loopback OAuth on `127.0.0.1:7317-7325` (falls back if the preferred port is taken). +!!! tip "Air-gapped or compliance-bound?" + Run `altimate local` — one command detects your hardware, downloads a certified model, and wires your config so nothing leaves your machine. See [Local Mode](../usage/local.md) for hardware requirements and the full command reference. + --- ## Step 2.5: First-run scan (optional) @@ -300,3 +304,4 @@ by route. - [Warehouses Reference](../configure/warehouses.md): All warehouse types, auth methods, SSH tunneling - [Config Reference](../configure/config.md): Full config file schema - [CI & Automation](../usage/ci-headless.md): Run altimate in automated pipelines +- [Local Mode](../usage/local.md): Run fully local — no API key, nothing leaves your machine diff --git a/docs/docs/reference/network.md b/docs/docs/reference/network.md index 5673f5d1b9..9ce096a8e8 100644 --- a/docs/docs/reference/network.md +++ b/docs/docs/reference/network.md @@ -46,6 +46,9 @@ altimate needs outbound HTTPS access to: | Your warehouse endpoints | Database connections | | `eastus-8.in.applicationinsights.azure.com` | Telemetry (Azure Application Insights) | +!!! note "Local Mode" + `altimate local` needs outbound access only for the one-time model and runtime download (Hugging Face and GitHub). After setup, LLM traffic is loopback-only (`127.0.0.1`) — no outbound calls for inference. Egress-guarded web tools (`websearch`, `webfetch`, `codesearch`) still reach the internet, but only with your per-step approval. See [Local Mode](../usage/local.md). + ### Disable Model Fetching If `models.dev` is unreachable: diff --git a/docs/docs/reference/security-faq.md b/docs/docs/reference/security-faq.md index 0c351aec7f..e286afda13 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -13,7 +13,7 @@ Answers to the most common security questions about running Altimate Code in you Altimate Code sends prompts and context to the LLM provider you configure (Anthropic, OpenAI, Azure OpenAI, AWS Bedrock, etc.). **You choose the provider.** No data is sent anywhere else except optional [telemetry](#what-telemetry-is-collected), which contains no code, queries, or credentials. -If you use a self-hosted or VPC-deployed model (e.g., AWS Bedrock, Azure OpenAI), your data never leaves your cloud account. +If you use a self-hosted or VPC-deployed model (e.g., AWS Bedrock, Azure OpenAI), your data never leaves your cloud account. With [Local Mode](../usage/local.md), inference runs on your own hardware entirely, and web tools ask before anything leaves the machine. ## Can the AI read my database credentials? @@ -99,7 +99,15 @@ No other outbound connections are made. See the [Network reference](network.md) ## Can I run Altimate Code without internet access? -Yes, with constraints. You need: +Yes. The fastest path is [Local Mode](../usage/local.md): + +```bash +altimate local +``` + +One command replaces the manual recipe below — it detects your hardware, downloads a SHA-256-verified model and runtime, certifies the endpoint, and wires your config. After the one-time model download, no outbound LLM traffic leaves your machine. + +If you're bringing your own runtime instead (a self-hosted model or a provider reachable only from your network), you need: 1. **A locally accessible LLM**, either a self-hosted model or a provider reachable from your network 2. **Model catalog disabled** by setting `ALTIMATE_CLI_DISABLE_MODELS_FETCH=true` or providing a local models file diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index 8af722f4fd..05f3b0148f 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -52,9 +52,10 @@ We collect the following categories of events: | `validator_check` | A completion-gate validator ran on session end — validator name, `ok` boolean, step, retry count, `enforced` flag (false in shadow mode), and structured `details` (model counts, elapsed time, concurrency limit — no SQL or model content). Only emitted when `ALTIMATE_VALIDATORS_ENABLED=1` or `ALTIMATE_VALIDATORS_SHADOW=1`. See [Validators](../data-engineering/validators.md). | | `validator_retries_exhausted` | A session terminated with unresolved validator failures after exhausting the synthetic-retry budget — names of the failing validators (no failure body content). | | `onboarding_started` | The first-run setup gate opened (fresh launch with no usable model). | -| `model_picker_shown` | The provider picker was displayed. `trigger` distinguishes the first run from `/connect`, from declining Big Pickle, and from the prompt gate. | +| `model_picker_shown` | The provider picker was displayed. `trigger` distinguishes the first run from `/connect`, from declining Big Pickle, from backing out of the local-model interstitial, and from the prompt gate. | | `provider_selected` | A provider row was chosen — `altimate_gateway`, `anthropic`, `openai`, `google`, `big_pickle`, `search_all`, or `other` for anything outside the curated five. `provider_id` carries the raw id only for publicly-known providers, so a provider you named yourself in config is reported as `other` with no name attached. `via_search` marks a pick made inside the full catalogue after choosing "Search all providers…". **Choosing search emits this event twice for one user** — once as `search_all`, then again with the provider actually chosen — so count distinct users or filter on `via_search`, not raw event count. Recorded at the moment of choice, so a sign-in that is then cancelled still counts. | | `big_pickle_confirm_shown` / `big_pickle_choice` | The Big Pickle interstitial was shown, and what the user decided (`accept`/`cancel`). | +| `local_model_info_shown` / `local_model_choice` | The "Local model" interstitial was shown from the picker, and what the user decided — `acknowledge` (will run `altimate local`), `back` (returned to the picker), or `cancel`. No hardware details are sent. | | `gateway_device_code_issued` | The Altimate Gateway authorize URL was built and the browser open attempted. **Name note:** the flow is a browser loopback OAuth — there is no device code. The name follows the original event spec. | | `gateway_auth_completed` / `gateway_auth_failed` | Gateway sign-in outcome. `reason` is `timeout`, `denied`, or `error` — never the underlying message, which can contain the instance name. An unrecognised callback state does not reject the pending attempt, so a CSRF mismatch surfaces as `timeout`. | | `instance_connected` | Credentials received and saved. `time_to_connect_ms` runs from the start of the authorize call, so it includes the browser launch. No instance or tenant name is sent. | diff --git a/docs/docs/reference/windows-wsl.md b/docs/docs/reference/windows-wsl.md index 83c63f44a8..cd536e7911 100644 --- a/docs/docs/reference/windows-wsl.md +++ b/docs/docs/reference/windows-wsl.md @@ -93,6 +93,21 @@ If you need to use Git Bash instead of WSL: export ALTIMATE_CLI_GIT_BASH_PATH="C:\\Program Files\\Git\\bin\\bash.exe" ``` +## Local Mode on Windows + +[Local Mode](../usage/local.md) (`altimate local` — run a local model with no +API key) has two paths on Windows: + +- **WSL 2 (recommended today):** uses the Linux Vulkan build; follow the + Linux instructions inside your WSL distro. +- **Native Windows (experimental):** the Vulkan runtime is pinned and + unpacks correctly, and memory detection works, but there is no GPU probe + yet and the flow has not been certified on physical Windows hardware. + `altimate local doctor` reports certification state honestly. + +See the [platform status table](../usage/local.md#platform-status-and-roadmap) +for what is auto-detected and certified per platform. + ## Known Limitations - The TUI works best in Windows Terminal or a modern terminal emulator diff --git a/docs/docs/usage/cli.md b/docs/docs/usage/cli.md index 320910c43d..d678517092 100644 --- a/docs/docs/usage/cli.md +++ b/docs/docs/usage/cli.md @@ -35,6 +35,7 @@ altimate --agent analyst | `mcp` | Model Context Protocol tools | | `acp` | Agent Communication Protocol | | `models` | List available models | +| `local` | Set up and manage the certified local model server ([Local Mode](local.md)) | | `stats` | Usage statistics | | `export` | Export session data | | `import` | Import session data | diff --git a/docs/docs/usage/local.md b/docs/docs/usage/local.md new file mode 100644 index 0000000000..0855046a46 --- /dev/null +++ b/docs/docs/usage/local.md @@ -0,0 +1,112 @@ +# Local mode (`altimate local`) + +Run Altimate Code against a local model — no API key, no data leaving your +machine. One command detects your hardware, downloads a verified model and +runtime, starts an OpenAI-compatible server, certifies it end-to-end, and wires +your config so the agent uses it. + +```bash +altimate local # detect, fetch, serve, certify, wire +altimate # then use the CLI as normal +``` + +## What you get + +- A pinned open 27B coding model in a quantization chosen for your hardware, + with every artifact SHA-256 verified against a signed recipe. +- A pinned `llama-server` runtime (Metal on Apple Silicon; Vulkan on + Linux/Windows, which runs on NVIDIA, AMD, and Intel GPUs — see the + [platform status table](#platform-status-and-roadmap) for what is + auto-detected and certified today). +- Speculative decoding (MTP) enabled where it measurably helps. +- A certification pass before the config is touched: the server must answer + health, completion, multi-turn, and tool-call probes — if any fail, nothing + is wired and the reason is printed. + +## Supported hardware + +| Tier | Hardware | Context | Notes | +|---|---|---|---| +| `laptop-24gb` | Apple Silicon / unified memory, 24GB+ | 65K | the default certified tier | +| `mac-64gb-unified` | Apple Silicon, 64GB+ | 131K | same recipe, more headroom | +| `gpu-24gb-discrete` | discrete NVIDIA 22GB+ VRAM (RTX 3090/4090-class); AMD/Intel run via Vulkan but are not auto-detected yet | 49K | certified end-to-end on NVIDIA L4 (Vulkan); context sized so weights + KV fit VRAM alone | +| `dgx-spark-128gb` | NVIDIA DGX Spark (GB10) | 131K | managed: runs the digest-pinned SGLang NVFP4+EAGLE container (~4× llama.cpp); needs Docker + nvidia-container-toolkit | +| `datacenter-80gb` | 80GB+ NVIDIA (A100/H100) | — | prints BF16/FP8 server deployment guidance | + +### Platform status and roadmap + +| Platform | Today | Next | +|---|---|---| +| macOS (Apple Silicon) | fully supported and certified | — | +| Linux + NVIDIA | fully supported; certified on L4 | — | +| Linux + AMD (RX 7900-class, 20GB+) | runtime works via Vulkan, but auto-detection probes NVIDIA only, so `altimate local` reports no match rather than guessing from system RAM (a CPU-only host can have just as much RAM as an AMD box, and RAM isn't a stand-in for VRAM on non-unified-memory hardware) | sysfs-based AMD VRAM detection (`amdgpu` exposes it without extra tools) | +| Windows (x64, native) | **experimental** — the Vulkan runtime is pinned and unpacked, memory detection works, but there is no GPU probe yet and it has not been certified on physical hardware. [WSL 2](../reference/windows-wsl.md) uses the Linux build and is the recommended path today. | native GPU probe + a hardware-certified pass to drop the experimental label | +| Intel Arc | runs under Vulkan, but current Arc cards (≤16GB) sit below the smallest discrete-GPU tier's VRAM floor, so detection would not unlock a tier | arrives together with a smaller-model registry entry whose tier fits 12–16GB cards | + +`altimate local doctor` reports certification state honestly on every platform. + +Before anything is downloaded, `altimate local` runs a preflight against the +matched tier and prints each check: accelerator memory floor, free disk, +Vulkan loader (Linux), and — for the DGX tier — Docker daemon, +`nvidia-container-toolkit`, and free memory. A fatal check aborts with the +fix spelled out; nothing is fetched or installed until preflight passes. + +## Commands + +- `altimate local` — full flow. Power flags: `--model`, `--ctx`, `--parallel`, + `--kv`, `--mtp/--no-mtp`, `--effort`, `--temperature`, `--port`, + `--no-egress-guard`. +- `altimate local models` — list the model registry and which entry matches + this machine. The registry is multi-model; more models will be added over + time, and `--model ` selects one explicitly. +- `altimate local status` — managed process + endpoint state, plus the + effective egress-guard rules. +- `altimate local stop` — graceful stop (SIGKILL only as a last resort). +- `altimate local doctor [--show]` — re-run all certification checks; + `--show` prints the certificate JSON. +- `altimate local update` — refresh the hash-pinned recipe snapshot + (falls back to the bundled copy on any error). + +## Performance expectations (measured) + +| Hardware | Config | Throughput | +|---|---|---| +| MacBook (M-series, 40-core class) | Q4 + MTP | ~19 tok/s | +| DGX Spark (GB10) | SGLang NVFP4 + EAGLE (guidance tier) | ~15–18 tok/s | +| 2× H100 (datacenter guidance) | SGLang FP8 + EAGLE | 270–290 tok/s | + +Numbers are single-stream, agent workloads, measured on the pinned revisions — +not vendor benchmarks. Your throughput scales with memory bandwidth. + +## Trust model + +- Recipes ship inside the CLI and are hash-pinned; `altimate local update` + only accepts a snapshot whose SHA-256 matches the published pin. +- Model and runtime downloads verify SHA-256 before use; partial downloads + resume and re-verify. +- The local server binds `127.0.0.1` only. Nothing is exposed to the network. +- **Egress guard** (on by default): wiring local mode adds `ask` rules for the + web tools (`websearch`, `webfetch`, `codesearch`), so a local-first session + escalates to the internet only with your per-step approval. Your own + permission settings are never overwritten, and `--no-egress-guard` removes + exactly the rules the guard added (nothing else). Scope note: this is a + *web-tool* guard — shell commands are governed separately by the bash + permission rules, which default to `ask` for anything not explicitly + allowlisted. +- Internal machinery stays local too: compaction follows the session model, + and `small_model` (title generation) is pinned to the local provider when + you haven't set it — no background step silently calls a cloud model. +- No telemetry is added by local mode; the agent's normal settings apply. + +## Troubleshooting + +- **"No Phase 1 recipe matches this machine"** — your usable memory is below + the smallest tier. The message states the floor. Discrete GPUs need ≥22GB + VRAM; unified-memory machines need ≥20GB free. +- **Server unhealthy after start** — run `altimate local doctor --show` and + check the failing probe; the most common cause is another process holding + the port (pass `--port`). +- **Slow first token on long prompts** — prefill is compute-bound on laptops; + the cache reuses your session prefix, so subsequent turns are much faster. +- **Linux: `llama-server` fails to start** — install your distro's Vulkan + loader (`libvulkan1`/`vulkan-loader`) and, for NVIDIA, a driver ≥ 535. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index e3f77f58a9..a89a4dc0c0 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -123,6 +123,7 @@ nav: - LLMs: - Providers: configure/providers.md - Models: configure/models.md + - Local Mode: usage/local.md - Bedrock Custom Endpoints: configure/bedrock-custom-endpoints.md - MCPs & ACPs: - MCP Servers: configure/mcp-servers.md diff --git a/packages/opencode/src/altimate/prompts/builder.txt b/packages/opencode/src/altimate/prompts/builder.txt index 5fab1f2e02..a7e0194487 100644 --- a/packages/opencode/src/altimate/prompts/builder.txt +++ b/packages/opencode/src/altimate/prompts/builder.txt @@ -210,3 +210,21 @@ When you detect a correction: - training_save — Save a learned pattern, rule, glossary term, or standard - training_list — List all learned training entries with budget usage - training_remove — Remove outdated training entries + +## Finish Protocol (mandatory before ending any build/fix task) + +Trace analysis of failed sessions shows two dominant, avoidable +failure modes: finishing without the final build, and shipping models/columns +under self-chosen names instead of the task's literal contract. Before you +declare a task complete, ALWAYS: + +1. **Re-read the task's literal requirements** — exact model names, exact + column names, exact file paths. Diff them against what you actually wrote. + Your naming preferences never override the stated contract, even when your + names are "better". +2. **Run the final build and tests** (e.g. `altimate-dbt build`) so the compiled + manifest reflects every model you created or changed. Work that exists only + as an un-built SQL file does not count as done. +3. **If you are running low on turns or context**, stop exploring and commit: + write the change, build, verify. A completed adequate solution beats an + unfinished perfect one. diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index e7f14db537..3b2093a7e6 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -207,6 +207,13 @@ export namespace Telemetry { trigger: "overflow_detection" | "error_recovery" attempt: number } + | { + type: "compaction_head_truncated" + timestamp: number + session_id: string + dropped_messages: number + kept_messages: number + } | { type: "tool_outputs_pruned" timestamp: number @@ -790,7 +797,7 @@ export namespace Telemetry { timestamp: number session_id: string /** the picker mounts from several paths — without this the event over-counts first runs */ - trigger: "first_run" | "connect_command" | "big_pickle_back" | "prompt_gate" + trigger: "first_run" | "connect_command" | "big_pickle_back" | "local_model_back" | "prompt_gate" } | { type: "provider_selected" @@ -799,7 +806,19 @@ export namespace Telemetry { /** `search_all` means the user opened the full catalogue; the provider they then chose * arrives as a second event with `via_search`. `other` is any provider outside the * curated five. */ - provider: "altimate_gateway" | "anthropic" | "openai" | "google" | "big_pickle" | "search_all" | "other" + // altimate_change start — upstream_fix: "local" is a real classifyProvider() output (see + // CURATED_PROVIDER_ENUM below), but was missing from this union so the TUI's `as` cast at + // the emit site let it through untyped, silently defeating consumers that switch on this type. + provider: + | "altimate_gateway" + | "anthropic" + | "openai" + | "google" + | "big_pickle" + | "local" + | "search_all" + | "other" + // altimate_change end /** Raw provider id, but ONLY for publicly-known providers (see KNOWN_PROVIDER_IDS). * A user-defined provider in opencode.json can be named after their company, so * anything unrecognised is reported as `other` with this omitted. */ @@ -820,6 +839,18 @@ export namespace Telemetry { session_id: string choice: "accept" | "cancel" } + | { + type: "local_model_info_shown" + timestamp: number + session_id: string + } + | { + type: "local_model_choice" + timestamp: number + session_id: string + /** acknowledge = user will run `altimate local`; back = returned to the picker. */ + choice: "acknowledge" | "back" | "cancel" + } | { type: "gateway_device_code_issued" timestamp: number @@ -1021,6 +1052,11 @@ export namespace Telemetry { anthropic: "anthropic", openai: "openai", google: "google", + // upstream_fix: the welcome picker's "Local model" row uses providerID + // "local" (see altimate-onboarding.tsx) — without this entry it fell through + // to `other` with the id stripped, indistinguishable from any unrecognized + // provider in the funnel. + local: "local", }) /** Classify a provider id for `provider_selected`. Returns the enum value plus the raw id when diff --git a/packages/opencode/src/altimate/telemetry/onboarding.ts b/packages/opencode/src/altimate/telemetry/onboarding.ts index 79a405e6ff..6db27b8d55 100644 --- a/packages/opencode/src/altimate/telemetry/onboarding.ts +++ b/packages/opencode/src/altimate/telemetry/onboarding.ts @@ -50,6 +50,8 @@ type OnboardingEventInput = Extract< | "provider_selected" | "big_pickle_confirm_shown" | "big_pickle_choice" + | "local_model_info_shown" + | "local_model_choice" | "gateway_device_code_issued" | "gateway_auth_completed" | "gateway_auth_failed" @@ -127,6 +129,14 @@ export async function emit(event: EmitInput, sessionID?: string): Promise try { const stage = STAGE_FOR_EVENT[event.type] if (stage) advance(stage) + // upstream_fix: the local-model interstitial has no gateway/auth follow-up — + // the picker cannot run the multi-minute `altimate local` setup itself, so + // "acknowledge" hands the user a command and the dialog closes. Without this, + // a user who acknowledges and quits to run it separately is reported as + // ABANDONED at `model_picker`, as if they never made a choice — the same + // false-abandonment `provider_selected` guards against above, applied to the + // one curated row that never reaches the gateway/auth stages. + if (event.type === "local_model_choice" && event.choice === "acknowledge") advance("provider_setup") if (event.type === "onboarding_completed") completed = true // Resolve the ambient session BEFORE the await: a setContext() landing during init() would diff --git a/packages/opencode/src/cli/cmd/run-accounting.ts b/packages/opencode/src/cli/cmd/run-accounting.ts new file mode 100644 index 0000000000..3584d04408 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run-accounting.ts @@ -0,0 +1,202 @@ +// Fork-only helpers for the `run` command (see FINAL harness-improvement plan): +// W1.10 — honest turn accounting: compaction-machinery steps must not consume the +// --max-turns budget. `step-start` parts carry only messageID/sessionID, so +// the owning message's agent is resolved via a lookup populated from +// `message.updated` events (the assistant message row is persisted — and its +// event published — before its first step-start part streams). +// W1.12 — E4 dual-attribution termination logging: every run records TWO independent +// fields instead of one rc: `why_model_stopped` and `why_harness_stopped`, +// so model-looping, tight budgets, and harness errors stop being conflated +// into a single exit code (SWE-agent #1262 vs OpenHands #9344 needed +// different fixes and were indistinguishable under rc-only accounting). +// W1.1 — real error serialization: never a bare name, "[object Object]", or a +// literal `{}` — automation needs the actual name/message/status. +export type WhyModelStopped = "stop" | "tool-call" | "explicit-done" | "length" | "content-filter" | "unknown" +export type WhyHarnessStopped = "budget-exhausted" | "timeout" | "error" | "idle-done" | "none" +export type Termination = { + why_model_stopped: WhyModelStopped + why_harness_stopped: WhyHarnessStopped +} + +// Recoverable by design: auto-compaction handles context overflow and the session +// continues, so an overflow error event alone must not flip the run's rc or its +// harness-stop attribution. +const RECOVERABLE_ERROR_NAMES = new Set(["ContextOverflowError"]) + +// Timeout classification for why_harness_stopped="timeout" and retry decisions. +const TIMEOUT_PATTERN = /\btimed?\s*out\b|\bETIMEDOUT\b|TimeoutError/i + +// W2.1 will make an explicit model DONE assertion the primary termination path; +// until it lands, a trailing DONE token in the final assistant text is the only +// signal available for the "explicit-done" attribution. +const DONE_PATTERN = /\bDONE\b[.!]?\s*$/ +// A trailing DONE token preceded by a negation ("not DONE", "isn't DONE", "not yet +// DONE") asserts the opposite of completion — must not classify as explicit-done. +// Anchored to the end of the text (matching DONE_PATTERN's own anchor): an +// earlier "not DONE" elsewhere in the message (a caveat, a quoted status, a +// negated sub-step) must not suppress a genuine trailing affirmative "DONE". +const NEGATED_DONE_PATTERN = /\b(?:not|isn'?t|not\s+yet)\s+DONE\b[.!]?\s*$/i + +export function create() { + const agents = new Map() + let turnCount = 0 + let lastFinishReason: string | undefined + let lastTextExplicitDone = false + let budgetExhausted = false + let fatalError: { name: string; timeout: boolean } | undefined + + function isCompactionStep(messageID: string) { + return agents.get(messageID) === "compaction" + } + + return { + /** Record an assistant message's agent so later part events can be attributed. */ + onAssistantMessage(info: { id: string; agent?: string }) { + agents.set(info.id, info.agent ?? "") + }, + isCompactionStep, + /** + * Count a step-start toward the turn budget unless it belongs to a + * compaction-machinery message. Returns true when the step was counted. + */ + onStepStart(messageID: string): boolean { + if (isCompactionStep(messageID)) return false + turnCount++ + return true + }, + get turnCount() { + return turnCount + }, + onStepFinish(messageID: string, reason: string | undefined) { + if (isCompactionStep(messageID)) return + lastFinishReason = reason + }, + onText(messageID: string, text: string) { + if (isCompactionStep(messageID)) return + const trimmed = text.trim() + lastTextExplicitDone = DONE_PATTERN.test(trimmed) && !NEGATED_DONE_PATTERN.test(trimmed) + }, + onSessionError(name: unknown, message?: string) { + const errorName = typeof name === "string" && name.length > 0 ? name : "UnknownError" + if (RECOVERABLE_ERROR_NAMES.has(errorName)) return + // upstream_fix: preserve the FIRST fatal error rather than overwriting it. A + // later cleanup/abort error (e.g. from tearing down after the original + // failure) must not clobber the root cause that actually stopped the run — + // onPromptResult below already uses this first-wins (`??=`) discipline; this + // path used unconditional assignment, which was the odd one out. + fatalError ??= { + name: errorName, + timeout: TIMEOUT_PATTERN.test(errorName) || TIMEOUT_PATTERN.test(message ?? ""), + } + }, + onBudgetExhausted() { + budgetExhausted = true + }, + /** + * Inspect the prompt call's returned terminal assistant message. Transport + * failures can be swallowed upstream into a clean-looking idle (observed: a + * mid-stream provider error surfaces ONLY as finish="other" with no error + * field and no session.error event), so the terminal message is the last + * honest signal available. finish="error"/"other" are the AI SDK's abnormal + * terminations; "stop"/"length"/"tool-calls"/"content-filter"/"unknown" are + * not treated as fatal. + */ + onPromptResult(info: { finish?: string; error?: { name?: unknown; data?: unknown } } | undefined) { + if (!info) return + if (info.error) { + // altimate_change start — upstream_fix: do NOT route through onSessionError's + // RECOVERABLE_ERROR_NAMES filter here. That filter exists for the mid-run streamed + // session.error events, where a ContextOverflowError is routinely recoverable — auto- + // compaction runs and the turn continues. `info` here is the prompt() call's FINAL + // returned message; a ContextOverflowError reaching this point means auto-compaction + // itself already exhausted its retries and gave up (SessionCompaction.process's + // `result === "compact"` terminal path returns "stop", ending the run) — there is no + // later step left to recover it, so it must always be fatal. + const data = (info.error.data ?? {}) as Record + const errorName = typeof info.error.name === "string" && info.error.name.length > 0 ? info.error.name : "UnknownError" + const message = typeof data.message === "string" ? data.message : undefined + fatalError ??= { name: errorName, timeout: TIMEOUT_PATTERN.test(errorName) || TIMEOUT_PATTERN.test(message ?? "") } + // altimate_change end + return + } + if (info.finish === "error" || info.finish === "other") { + fatalError ??= { name: `AbnormalFinish:${info.finish}`, timeout: false } + } + }, + /** True when the run ended by fatal abort — the process must exit nonzero (W1.1). */ + get fatal() { + return budgetExhausted || fatalError !== undefined + }, + /** E4 dual-attribution fields for the run record/output (W1.12). */ + termination(): Termination { + const model: WhyModelStopped = (() => { + // upstream_fix: a request that never reached a step-finish event (fatal + // abort before any model output, or a swallowed transport failure) left + // lastFinishReason undefined — this fell through to the "stop" default, + // claiming a clean model-side stop for a generation that never completed. + if (lastFinishReason === undefined) return "unknown" + if (lastFinishReason === "stop") return lastTextExplicitDone ? "explicit-done" : "stop" + if (lastFinishReason === "tool-calls" || lastFinishReason === "tool-call") return "tool-call" + // upstream_fix: AI-SDK "length" (truncation) and "content-filter" used to + // fall through to the same "stop" default, making a truncated or + // content-filtered run indistinguishable from a clean success — exactly + // what the dual-attribution split (W1.12) exists to prevent. + if (lastFinishReason === "length") return "length" + if (lastFinishReason === "content-filter") return "content-filter" + return "unknown" + })() + const harness: WhyHarnessStopped = (() => { + if (budgetExhausted) return "budget-exhausted" + if (fatalError?.timeout) return "timeout" + if (fatalError) return "error" + // "idle-done" is reserved for the run-mode idle-done heuristic (W2.1); + // a session that idles because the model finished is attributed to the + // model, so the harness reason is "none". + return "none" + })() + return { why_model_stopped: model, why_harness_stopped: harness } + }, + } +} +export type Info = ReturnType + +/** + * Serialize a session error event's payload to a real name/message/status string. + * Never returns a bare "[object Object]" or a literal "{}" (W1.1). + */ +export function serializeSessionError(error: unknown): string { + if (error === undefined || error === null) return "UnknownError" + if (typeof error !== "object") return String(error) + const obj = error as { name?: unknown; data?: unknown } + const name = typeof obj.name === "string" && obj.name.length > 0 ? obj.name : "UnknownError" + const data = (obj.data && typeof obj.data === "object" ? obj.data : {}) as Record + const status = + typeof data.status === "number" || (typeof data.status === "string" && data.status.length > 0) + ? data.status + : typeof data.statusCode === "number" + ? data.statusCode + : undefined + const message = + typeof data.message === "string" && data.message.length > 0 + ? data.message + : data.message !== undefined + ? JSON.stringify(data.message) + : undefined + const head = status !== undefined ? `${name} (status ${status})` : name + return message ? `${head}: ${message}` : head +} + +/** Provider 5xx responses are retryable at the enqueue boundary (W1.1). */ +export function isRetryableStatus(status: unknown): boolean { + return typeof status === "number" && status >= 500 && status <= 599 +} + +/** Thrown transport failures that warrant an enqueue retry: timeouts and dropped connections. */ +export function isRetryableThrown(error: unknown): boolean { + if (error === undefined || error === null) return false + const err = error as { name?: unknown; message?: unknown; code?: unknown } + const text = [err.name, err.message, err.code].filter((v) => typeof v === "string").join(" ") + return TIMEOUT_PATTERN.test(text) || /ECONNRESET|ECONNREFUSED|fetch failed|network error/i.test(text) +} + +export * as RunAccounting from "./run-accounting" diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 50638bcd14..a02c019990 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -28,6 +28,12 @@ import { BashTool } from "../../tool/bash" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "../../util/locale" import { Tracer, FileExporter, HttpExporter, type TraceExporter } from "../../altimate/observability/tracing" +// altimate_change start — W1.10/W1.12/W1.1 run accounting helpers (fork-only module) +import { RunAccounting } from "./run-accounting" +// altimate_change end +// altimate_change start — stable messageID for retry idempotency (see send() below) +import { Identifier } from "../../id/id" +// altimate_change end // altimate_change start — upstream_fix: type-only import for the tracing-config cast (see tracer setup below) import type { ConfigV1 } from "@opencode-ai/core/v1/config/config" // altimate_change end @@ -593,8 +599,21 @@ You are speaking to a non-technical business executive. Follow these rules stric return false } - const events = await sdk.event.subscribe() + // altimate_change start — W1.1: abortable event subscription. A fatal send + // failure (exhausted retries, a non-retryable enqueue error, or a non-retryable + // send() exception — see the retry loop below) throws before `await loopPromise`, + // and this open SSE connection would otherwise keep the process alive indefinitely + // (observed: `run` hung instead of exiting nonzero). All non-retryable throw sites + // in that loop abort this signal first so the connection closes and the process can + // reach its natural exit. + const eventsAbort = new AbortController() + const events = await sdk.event.subscribe(undefined, { signal: eventsAbort.signal }) + // altimate_change end let error: string | undefined + // altimate_change start — W1.10/W1.12: turn accounting + dual-attribution + // termination state for this run (see run-accounting.ts). + const accounting = RunAccounting.create() + // altimate_change end // Build tracer from config + CLI flags — must never crash the run command const tracer = await (async () => { @@ -630,12 +649,23 @@ You are speaking to a non-technical business executive. Follow these rules stric async function loop() { const toggles = new Map() - // altimate_change start — max-turns budget enforcement - let turnCount = 0 + // altimate_change start — max-turns budget enforcement (count kept in accounting) const maxTurns = args.maxTurns // altimate_change end for await (const event of events.stream) { + // altimate_change start — W1.10: record each assistant message's agent so + // step-start parts (which carry only messageID/sessionID) can be attributed. + // The assistant message row is persisted — and this event published — before + // its first step-start part streams, so the lookup is populated in time. + if ( + event.type === "message.updated" && + event.properties.info.role === "assistant" && + event.properties.info.sessionID === sessionID + ) { + accounting.onAssistantMessage(event.properties.info) + } + // altimate_change end if ( event.type === "message.updated" && event.properties.info.role === "assistant" && @@ -689,8 +719,12 @@ You are speaking to a non-technical business executive. Follow these rules stric if (part.type === "step-start") { tracer?.logStepStart(part) // altimate_change start — enforce max-turns budget - turnCount++ - if (maxTurns && turnCount > maxTurns) { + // W1.10: compaction-machinery steps are excluded from turn accounting — + // the owning message's agent is resolved via the message.updated lookup + // above, so compacting models are not differentially charged turns. + const counted = accounting.onStepStart(part.messageID) + if (counted && maxTurns && accounting.turnCount > maxTurns) { + accounting.onBudgetExhausted() error = `Budget exceeded: reached ${maxTurns} assistant turn${maxTurns !== 1 ? "s" : ""} limit` UI.println(UI.Style.TEXT_DANGER_BOLD + "!", UI.Style.TEXT_NORMAL + ` ${error}. Aborting session.`) await sdk.session.abort({ sessionID }) @@ -702,11 +736,17 @@ You are speaking to a non-technical business executive. Follow these rules stric if (part.type === "step-finish") { tracer?.logStepFinish(part) + // altimate_change start — W1.12: record the model-side finish reason + accounting.onStepFinish(part.messageID, (part as { reason?: string }).reason) + // altimate_change end if (emit("step_finish", { part })) continue } if (part.type === "text" && part.time?.end) { tracer?.logText(part) + // altimate_change start — W1.12: explicit-done attribution input + accounting.onText(part.messageID, part.text) + // altimate_change end if (emit("text", { part })) continue const text = part.text.trim() if (!text) continue @@ -738,10 +778,17 @@ You are speaking to a non-technical business executive. Follow these rules stric if (event.type === "session.error") { const props = event.properties if (props.sessionID !== sessionID || !props.error) continue - let err = String(props.error.name) - if ("data" in props.error && props.error.data && "message" in props.error.data) { - err = String(props.error.data.message) - } + // altimate_change start — W1.1: serialize the real error name/message/status + // (never a bare name, "[object Object]", or a literal {}); W1.12: feed the + // harness-stop attribution (recoverable overflow errors are excluded there). + const err = RunAccounting.serializeSessionError(props.error) + accounting.onSessionError( + props.error.name, + "data" in props.error && props.error.data && "message" in props.error.data + ? String(props.error.data.message) + : undefined, + ) + // altimate_change end error = error ? error + EOL + err : err if (emit("error", { error: props.error })) continue UI.error(err) @@ -869,6 +916,14 @@ You are speaking to a non-technical business executive. Follow these rules stric } const onBeforeExit = () => { tracer?.flushSync("Process exited") + // altimate_change start — W1.1: honest rc on fatal abort. beforeExit firing + // while this handler is still registered means the event loop drained before + // the run completed — the prompt/event stream was abandoned (observed: a + // mid-stream provider failure tears everything down and the process used to + // die here with rc 0). The handler is removed once the run loop drains + // normally, so completed runs are unaffected. + process.exitCode = 1 + // altimate_change end } process.on("SIGINT", onSigint) process.on("SIGTERM", onSigterm) @@ -880,19 +935,51 @@ You are speaking to a non-technical business executive. Follow these rules stric process.exit(1) }) - if (args.command) { - await sdk.session.command({ - sessionID, - agent, - model: args.model, - command: args.command, - arguments: message, - variant: args.variant, - }) - } else { + // altimate_change start — W1.1: bounded retry-with-backoff on provider 5xx/timeout + // at the enqueue boundary. Bounds are config-exposed via env (provenance: + // FINAL-PLAN W1.1 requires bounded retries with every retry logged so they can + // never mask a persistent provider failure; defaults mirror the in-stream + // SessionRetry posture — bounded and visible). On exhaustion the error is thrown + // so the process exits nonzero instead of hanging on an idle event that will + // never arrive. + const envBound = (name: string, fallback: number) => { + const raw = process.env[name]?.trim() + if (!raw) return fallback + const parsed = Number(raw) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback + } + const retryMax = envBound("ALTIMATE_RUN_RETRY_MAX", 3) + const retryBaseMs = envBound("ALTIMATE_RUN_RETRY_BASE_MS", 1000) + // altimate_change start — upstream_fix: stable messageID across retry attempts. + // Without this, every retry called send() with no messageID, so the server + // generated a fresh one each time (see createUserMessage in session/prompt.ts: + // `id: input.messageID ?? MessageID.ascending()`). On an AMBIGUOUS failure — + // the request was received and the agent loop ran (possibly executing tools) + // but the response never reached this process (timeout/ECONNRESET) — a retry + // with a new messageID creates a SECOND user turn and re-runs the loop, + // duplicating the prompt and any tool side effects. Reusing the same id lets + // the server's upsert-by-id (Session.updateMessage) and the loop's + // already-finished early exit (prompt.ts) recognize the retry as the same + // turn instead of a new one. This does not (and cannot, without a + // per-session lock the server doesn't have) rule out both attempts running + // concurrently if the retry fires WHILE the first is still mid-flight. + const messageID = Identifier.ascending("message") + // altimate_change end + const send = () => { + if (args.command) + return sdk.session.command({ + sessionID, + messageID, + agent, + model: args.model, + command: args.command, + arguments: message, + variant: args.variant, + }) const model = args.model ? Provider.parseModel(args.model) : undefined - await sdk.session.prompt({ + return sdk.session.prompt({ sessionID, + messageID, agent, model, variant: args.variant, @@ -900,6 +987,76 @@ You are speaking to a non-technical business executive. Follow these rules stric ...(audienceSystem ? { system: audienceSystem } : {}), }) } + type SendResult = { + error?: unknown + response?: Response + data?: { info?: { finish?: string; error?: { name?: unknown; data?: unknown } } } + } + // altimate_change start — upstream_fix: dedicated marker so a non-retryable send + // error can never be misclassified as retryable by isRetryableThrown() just + // because the provider's own error message happens to contain a word like + // "timeout" — see the throw site below. + class NonRetryableSendError extends Error {} + // altimate_change end + let sendResult: SendResult | undefined + for (let sendAttempt = 0; ; sendAttempt++) { + let reason: string + try { + const res = (await send()) as SendResult + const status = res?.response?.status + if (!res?.error) { + sendResult = res + break + } + // altimate_change start — upstream_fix: a non-retryable send error (4xx/429) + // used to share the same success break as `!res?.error`, so it was silently + // treated as a clean enqueue. `res.data` is undefined on error, so + // `accounting.onPromptResult` below no-ops and `accounting.fatal` stays + // false — the run then either hung on `await loopPromise` (no idle event + // ever arrives for a rejected enqueue) or exited 0 despite the failed + // prompt. Throw immediately instead, same as exhausted retries. + if (!RunAccounting.isRetryableStatus(status)) { + eventsAbort.abort() + throw new NonRetryableSendError(`prompt rejected: ${RunAccounting.serializeSessionError(res.error)}`) + } + // altimate_change end + reason = `provider returned status ${status}` + } catch (e) { + // altimate_change start — upstream_fix: propagate the non-retryable marker + // unconditionally, before the message-text retry classification below. + if (e instanceof NonRetryableSendError) throw e + // altimate_change end + // altimate_change start — upstream_fix: see the eventsAbort comment above. This throw + // site was missing the abort the other two non-retryable throw sites already have, so + // a non-retryable `send()` exception (not already a NonRetryableSendError, and not + // classified as retryable) left the SSE subscription open and could hang the process + // instead of exiting nonzero. + if (!RunAccounting.isRetryableThrown(e)) { + eventsAbort.abort() + throw e + } + // altimate_change end + reason = e instanceof Error ? e.message : String(e) + } + if (sendAttempt >= retryMax) { + // altimate_change start — W1.1: see the eventsAbort comment above. + eventsAbort.abort() + // altimate_change end + throw new Error(`prompt failed after ${retryMax} retries: ${reason}`) + } + const delay = retryBaseMs * 2 ** sendAttempt + if (!emit("retry", { attempt: sendAttempt + 1, max: retryMax, reason, delayMs: delay })) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + ` retrying prompt (${sendAttempt + 1}/${retryMax}) in ${delay}ms — ${reason}`, + ) + } + await new Promise((resolve) => setTimeout(resolve, delay)) + } + // W1.1/W1.12: the prompt response carries the TERMINAL assistant message — + // inspect it for swallowed abnormal endings (see RunAccounting.onPromptResult). + accounting.onPromptResult(sendResult?.data?.info) + // altimate_change end // Wait for the event loop to drain (breaks when session reaches idle) await loopPromise @@ -909,6 +1066,20 @@ You are speaking to a non-technical business executive. Follow these rules stric process.removeListener("SIGTERM", onSigterm) process.removeListener("beforeExit", onBeforeExit) + // altimate_change start — W1.12 E4: dual-attribution termination record. + // why_model_stopped and why_harness_stopped are independent fields so + // model-looping, tight budgets, and harness errors are distinguishable + // in the run output (rc alone conflates them). + const termination = accounting.termination() + if (!emit("termination", { ...termination }) && process.stdout.isTTY) { + UI.println( + UI.Style.TEXT_DIM + + `why_model_stopped=${termination.why_model_stopped} why_harness_stopped=${termination.why_harness_stopped}` + + UI.Style.TEXT_NORMAL, + ) + } + // altimate_change end + // Finalize trace and save to disk if (tracer) { Tracer.setActive(null) @@ -928,6 +1099,12 @@ You are speaking to a non-technical business executive. Follow these rules stric await Bun.write(outputPath, content) process.stderr.write(`\n✓ Output saved to: ${outputPath}\n`) } + + // altimate_change start — W1.1: honest rc — exit nonzero on fatal abort + // (budget exhaustion or an unrecovered session error). Uses process.exitCode + // (not process.exit) so pending stdout/trace writes still flush. + if (accounting.fatal) process.exitCode = 1 + // altimate_change end } if (args.attach) { diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index d2fe2b3506..25e0f8ac6d 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -10,7 +10,6 @@ import { UninstallCommand } from "./cli/cmd/uninstall" import { ModelsCommand } from "./cli/cmd/models" import { UI } from "./cli/ui" import { InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version" -import { Flag } from "@opencode-ai/core/flag/flag" import { FormatError } from "./cli/error" import { ServeCommand } from "./cli/cmd/serve" // altimate_change start — workspace-serve: dev-only workspace serve command @@ -45,12 +44,17 @@ import { SkillCommand } from "./cli/cmd/skill" // altimate_change start — check: deterministic SQL check command import { CheckCommand } from "./cli/cmd/check" // altimate_change end +import { Flag } from "@opencode-ai/core/flag/flag" // altimate_change start — link: workspace-binding subcommand import { LinkCommand } from "./cli/cmd/link" // altimate_change end import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" +// altimate_change start — local data agent command and persisted environment bootstrap +import { LocalCommand } from "./local/command" +import { applyLocalEnvironment } from "./local/environment" +// altimate_change end // altimate_change start - telemetry import import { Telemetry } from "./telemetry" // altimate_change end @@ -58,6 +62,10 @@ import { Telemetry } from "./telemetry" import { showWelcomeBannerIfNeeded } from "./cli/welcome" // altimate_change end +// altimate_change start — apply certified local agent defaults before any command runs +applyLocalEnvironment() +// altimate_change end + const args = hideBin(process.argv) function show(out: string) { @@ -174,6 +182,9 @@ let cli = yargs(args) // altimate_change start — check: register deterministic SQL check command .command(CheckCommand) // altimate_change end + // altimate_change start — register certified local data agent command group + .command(LocalCommand) +// altimate_change end // altimate_change start — link: gated on Flag.ALTIMATE_WORKSPACE (pilot) // so the command isn't registered — and doesn't show in --help — for users diff --git a/packages/opencode/src/local/README.md b/packages/opencode/src/local/README.md new file mode 100644 index 0000000000..563d7481ab --- /dev/null +++ b/packages/opencode/src/local/README.md @@ -0,0 +1,55 @@ +# Certified local data agent (`altimate local`) + +## Purpose + +Phase 1 manages one pinned, OpenAI-compatible `llama-server` for Altimate Code. It detects supported laptop hardware, downloads and verifies the selected GGUF, locates or installs the pinned llama.cpp runtime, health-gates startup, certifies the endpoint, and wires the user config. + +The module is intentionally single-model and single-user. It does not integrate Ollama or LM Studio, and the datacenter/vLLM tier only prints deployment guidance. + +## Commands + +- `altimate local` — select a recipe, fetch verified artifacts, start the server, certify it, and update the user config. `--model ` picks a registry entry; the registry is multi-model by design. +- `altimate local models` — list the model registry and the tier matching this machine. +- `altimate local status` — print managed process, endpoint state, and effective egress-guard rules. +- `altimate local stop` — send `SIGTERM`, wait, and use `SIGKILL` only if the managed process does not exit. +- `altimate local doctor [--show]` — force all certification checks to run again; `--show` prints the certificate JSON. +- `altimate local update` — refresh a hash-pinned recipe snapshot and fall back to the bundled copy on any error. + +Power options on the root command mirror the recipe fields: `--ctx`, `--parallel`, `--kv`, `--mtp/--no-mtp`, `--effort`, `--temperature`, and preferred `--port`. `--no-egress-guard` skips the default `ask` rules that `wire.ts` adds for `websearch`/`webfetch`/`codesearch`. + +## State and trust boundaries + +Runtime state lives under `~/.local/share/altimate-code/local/` (or `$XDG_DATA_HOME/altimate-code/local/`): + +- `state.json`, `server.pid`, and `server.log` describe the managed child. +- `models/` and `downloads/` contain GGUFs and runtime archives. +- `bin/b10516/` contains the extracted llama.cpp release. +- `certificates/.json` is keyed by model sha256, runtime version, and server-flags hash. +- `environment.json` enables the recipe's lexical tool retrieval on later CLI launches. + +Every downloaded model and runtime archive must have a 64-character sha256. The bundled snapshot deliberately uses clearly named `TODO_*` placeholders where the upstream checksum was unavailable during implementation; those values fail closed before a network request. A verified remote recipe can replace them. + +Set both variables to test the Phase 1 recipe refresh stub: + +```sh +export ALTIMATE_LOCAL_RECIPES_URL=https://example.com/recipes.json +export ALTIMATE_LOCAL_RECIPES_SHA256= +altimate local update +``` + +`ALTIMATE_LOCAL_HF_BASE_URL` changes the Hugging Face-compatible artifact base. `ALTIMATE_LOCAL_LLAMA_SERVER` selects an existing executable. `ALTIMATE_LOCAL_RUNTIME_URL` and `ALTIMATE_LOCAL_RUNTIME_SHA256` provide a pinned runtime mirror pair. + +## Module map + +- `recipes.ts` / `recipes.json` — schema-v1 validation, bundled snapshot, and pinned refresh. +- `hardware.ts` — macOS unified-memory and Linux NVIDIA/RAM probes plus tier matching. +- `fetch.ts` — resumable HTTP Range downloads and sha256 verification. +- `runtime.ts` — runtime discovery and b10516 archive installation. +- `server.ts` — port selection, detached process state, health polling, and safe shutdown. +- `certify.ts` — tool-call round trip, reasoning render, 8K prefill, and certificate cache. +- `wire.ts` / `environment.ts` — provider/agent JSONC updates and persistent tool-retrieval default. +- `command.ts` — CLI orchestration only. + +## Tests + +Focused tests live in `test/local/` and use temporary files, loopback sockets, and mocked HTTP responses. They never download model or runtime artifacts. diff --git a/packages/opencode/src/local/certify.ts b/packages/opencode/src/local/certify.ts new file mode 100644 index 0000000000..2896772a36 --- /dev/null +++ b/packages/opencode/src/local/certify.ts @@ -0,0 +1,293 @@ +import { createHash } from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" + +import { ensureLocalDirectories, getLocalPaths, type LocalPaths } from "./paths" + +type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise + +export interface CertificateCheck { + ok: boolean + duration_ms: number + detail: string +} + +export interface LocalCertificate { + schema: 1 + key: string + passed: boolean + cached: boolean + model_sha256: string + runtime_version: string + flags_sha256: string + endpoint: string + model: string + created_at: string + checks: { + tool_call_round_trip: CertificateCheck + reasoning_render: CertificateCheck + prompt_prefill_8k: CertificateCheck + } + certificate_sha256: string +} + +export function flagsHash(flags: readonly string[]) { + return createHash("sha256").update(JSON.stringify(flags)).digest("hex") +} + +export function certificateCacheKey(input: { + modelSha256: string + runtimeVersion: string + flags: readonly string[] + reasoningEffort: string + temperature: number +}) { + const hash = createHash("sha256") + hash.update(input.modelSha256) + hash.update("\0") + hash.update(input.runtimeVersion) + hash.update("\0") + hash.update(flagsHash(input.flags)) + hash.update("\0") + // The certification probes send these on every request (see chat() call + // sites below) but the Docker recipe's `flags` don't encode either — a + // refreshed recipe changing just reasoningEffort/temperature would + // otherwise silently reuse an old certificate that never actually ran + // under the new configuration. + hash.update(input.reasoningEffort) + hash.update("\0") + hash.update(String(input.temperature)) + return hash.digest("hex") +} + +function messageContent(value: unknown) { + if (!value || typeof value !== "object") return "" + const choices = (value as { choices?: unknown }).choices + if (!Array.isArray(choices) || !choices[0] || typeof choices[0] !== "object") return "" + const message = (choices[0] as { message?: unknown }).message + if (!message || typeof message !== "object") return "" + const content = (message as { content?: unknown }).content + return typeof content === "string" ? content.trim() : "" +} + +function firstMessage(value: unknown): Record | undefined { + if (!value || typeof value !== "object") return undefined + const choices = (value as { choices?: unknown }).choices + if (!Array.isArray(choices) || !choices[0] || typeof choices[0] !== "object") return undefined + const message = (choices[0] as { message?: unknown }).message + return message && typeof message === "object" ? (message as Record) : undefined +} + +async function chat(input: { baseURL: string; apiKey: string; fetchImpl: Fetch; body: Record }) { + const response = await input.fetchImpl(`${input.baseURL.replace(/\/+$/, "")}/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${input.apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify(input.body), + signal: AbortSignal.timeout(10 * 60_000), + }) + if (!response.ok) { + const detail = (await response.text()).slice(0, 500) + throw new Error(`HTTP ${response.status}${detail ? `: ${detail}` : ""}`) + } + return response.json() +} + +export async function check(run: () => Promise): Promise { + const started = Date.now() + try { + // Object-literal properties evaluate in source order, so awaiting + // `run()` inline in the `detail` field would compute `duration_ms` + // before the await — reporting near-zero time for every pass. + const detail = await run() + return { ok: true, duration_ms: Date.now() - started, detail } + } catch (error) { + return { + ok: false, + duration_ms: Date.now() - started, + detail: error instanceof Error ? error.message : String(error), + } + } +} + +async function toolCallRoundTrip(input: { + baseURL: string + apiKey: string + modelID: string + reasoningEffort: string + temperature: number + fetchImpl: Fetch +}) { + const user = { role: "user", content: "Use local_add to add 2 and 3. You must call the tool." } + const tools = [ + { + type: "function", + function: { + name: "local_add", + description: "Add two integers", + parameters: { + type: "object", + properties: { a: { type: "integer" }, b: { type: "integer" } }, + required: ["a", "b"], + additionalProperties: false, + }, + }, + }, + ] + const first = await chat({ + ...input, + body: { + model: input.modelID, + messages: [user], + tools, + tool_choice: "required", + max_tokens: 256, + temperature: input.temperature, + reasoning_effort: input.reasoningEffort, + }, + }) + const assistant = firstMessage(first) + const calls = assistant?.tool_calls + if (!Array.isArray(calls) || !calls[0] || typeof calls[0] !== "object") throw new Error("model returned no tool call") + const call = calls[0] as { id?: unknown; function?: { name?: unknown; arguments?: unknown } } + if (call.function?.name !== "local_add" || typeof call.id !== "string") + throw new Error("model returned an invalid tool call") + if (typeof call.function.arguments !== "string") throw new Error("tool arguments were not JSON") + const args = JSON.parse(call.function.arguments) as { a?: unknown; b?: unknown } + if (args.a !== 2 || args.b !== 3) throw new Error("tool arguments did not contain a=2 and b=3") + + const second = await chat({ + ...input, + body: { + model: input.modelID, + messages: [user, assistant, { role: "tool", tool_call_id: call.id, content: "5" }], + tools, + max_tokens: 128, + temperature: input.temperature, + reasoning_effort: input.reasoningEffort, + }, + }) + if (!messageContent(second)) throw new Error("model returned no final content after the tool result") + return "tool call and tool-result continuation succeeded" +} + +async function reasoningRender(input: { + baseURL: string + apiKey: string + modelID: string + reasoningEffort: string + temperature: number + fetchImpl: Fetch +}) { + const response = await chat({ + ...input, + body: { + model: input.modelID, + messages: [{ role: "user", content: "Compute 17 * 19 and explain the result in one sentence." }], + max_tokens: 512, + temperature: input.temperature, + reasoning_effort: input.reasoningEffort, + }, + }) + const content = messageContent(response) + if (!content) throw new Error("reasoning request returned empty assistant content") + // A raw block in content means the server's reasoning parser is not + // active — the agent would render chain-of-thought as the final answer. + if (//i.test(content)) throw new Error("assistant content contains an unparsed block") + return `${input.reasoningEffort} reasoning produced assistant content` +} + +async function promptPrefill(input: { + baseURL: string + apiKey: string + modelID: string + reasoningEffort: string + temperature: number + fetchImpl: Fetch +}) { + const prompt = `${" datum".repeat(8192)}\nReply with PREFILL_OK.` + const response = await chat({ + ...input, + body: { + model: input.modelID, + messages: [{ role: "user", content: prompt }], + max_tokens: 64, + temperature: input.temperature, + reasoning_effort: input.reasoningEffort, + }, + }) + if (!messageContent(response)) throw new Error("8K-token prefill returned empty assistant content") + return "8K-token prompt prefill succeeded" +} + +export async function certify(input: { + baseURL: string + apiKey?: string + modelID: string + modelSha256: string + runtimeVersion: string + flags: string[] + reasoningEffort: string + temperature: number + force?: boolean + paths?: LocalPaths + fetchImpl?: Fetch +}): Promise { + const paths = input.paths ?? getLocalPaths() + const fetchImpl = input.fetchImpl ?? fetch + const key = certificateCacheKey(input) + const file = path.join(paths.certificates, `${key}.json`) + if (!input.force) { + try { + const cached = JSON.parse(await fs.readFile(file, "utf8")) as LocalCertificate + if (cached.schema === 1 && cached.key === key && cached.passed) return { ...cached, cached: true } + } catch { + // A missing or malformed cache entry simply causes certification to run. + } + } + + const common = { + baseURL: input.baseURL, + apiKey: input.apiKey ?? "local", + modelID: input.modelID, + reasoningEffort: input.reasoningEffort, + temperature: input.temperature, + fetchImpl, + } + const checks = { + tool_call_round_trip: await check(() => toolCallRoundTrip(common)), + reasoning_render: await check(() => reasoningRender(common)), + prompt_prefill_8k: await check(() => promptPrefill(common)), + } + // `cached` is deliberately excluded from the signed payload: it is + // call-site metadata (whether THIS caller got a cache hit), not part of + // what the digest is meant to attest to. Including it would make the + // digest computed at write time (cached: false) mismatch the one implied + // by every later cache-hit read (cached: true) — a consumer validating + // the digest against the returned object would reject every cached + // certificate. + const unsigned = { + schema: 1 as const, + key, + passed: Object.values(checks).every((item) => item.ok), + model_sha256: input.modelSha256, + runtime_version: input.runtimeVersion, + flags_sha256: flagsHash(input.flags), + endpoint: input.baseURL, + model: input.modelID, + created_at: new Date().toISOString(), + checks, + } + const certificate: LocalCertificate = { + ...unsigned, + cached: false, + certificate_sha256: createHash("sha256").update(JSON.stringify(unsigned)).digest("hex"), + } + await ensureLocalDirectories(paths) + const temp = `${file}.${process.pid}.tmp` + await fs.writeFile(temp, JSON.stringify(certificate, null, 2) + "\n", { mode: 0o600 }) + await fs.rename(temp, file) + return certificate +} diff --git a/packages/opencode/src/local/command.ts b/packages/opencode/src/local/command.ts new file mode 100644 index 0000000000..b52bf1d434 --- /dev/null +++ b/packages/opencode/src/local/command.ts @@ -0,0 +1,410 @@ +import type { Argv } from "yargs" + +import { certify, certificateCacheKey, type LocalCertificate } from "./certify" +import { describeHardware, detectHardware, matchHardwareToTier } from "./hardware" +import { fetchModelArtifacts, type DownloadProgress } from "./fetch" +import { loadRecipes, refreshRecipes, selectModel, type DockerRecipeTier, type LlamaRecipeTier, type ModelRecipe } from "./recipes" +import { formatPreflight, runPreflight } from "./preflight" +import { withLifecycleLock } from "./lock" +import { runtimeAsset } from "./runtime" +import { startDockerServer } from "./docker" +import { pickPort, writeServerState } from "./server" +import { getLocalPaths, ensureLocalDirectories } from "./paths" +import { ensureLlamaServer } from "./runtime" +import { getServerStatus, startServer, stopServer, type ServerState } from "./server" +import { readEgressGuard, wireLocalProvider } from "./wire" + +export interface LocalArgs { + model?: string + egressGuard?: boolean + port?: number + ctx?: number + parallel?: number + kv?: string + mtp?: boolean + effort?: "low" | "medium" | "xhigh" + temperature?: number +} + +function message(error: unknown) { + return error instanceof Error ? error.message : String(error) +} + +async function task(run: () => Promise) { + try { + await run() + } catch (error) { + console.error(`Error: ${message(error)}`) + process.exitCode = 1 + } +} + +function replaceFlag(flags: string[], name: string, value: string) { + const index = flags.indexOf(name) + if (index >= 0 && index + 1 < flags.length) flags[index + 1] = value +} + +// Exported for unit testing — setup()'s full pipeline (recipe loading, +// hardware detection, preflight, download, server start) has no dedicated +// test seam, but the override-validation logic here is pure and worth +// testing directly. +export function withOverrides(tier: LlamaRecipeTier, args: LocalArgs): LlamaRecipeTier { + const result = structuredClone(tier) + if (args.ctx !== undefined) result.ctx = args.ctx + if (args.parallel !== undefined) result.parallel = args.parallel + if ( + !Number.isInteger(result.ctx) || + !Number.isInteger(result.parallel) || + result.ctx <= 0 || + result.parallel <= 0 || + result.ctx % result.parallel !== 0 + ) { + throw new Error("--ctx and --parallel must be positive integers, with --ctx dividing evenly across --parallel slots") + } + if (args.kv) result.kv = args.kv + if (args.effort) result.agent.reasoning_effort = args.effort + if (args.temperature !== undefined) { + if (!Number.isFinite(args.temperature) || args.temperature < 0) + throw new Error("--temperature must be non-negative") + result.agent.temperature = args.temperature + replaceFlag(result.flags, "--temp", String(args.temperature)) + } + if (args.mtp === false) result.mtp = undefined + return result +} + +function progressLogger() { + const last = new Map() + return (artifact: string, progress: DownloadProgress) => { + const percent = progress.total ? Math.floor((progress.received / progress.total) * 100) : undefined + const marker = percent ?? Math.floor(progress.received / (256 * 1024 * 1024)) + if (last.get(artifact) === marker) return + last.set(artifact, marker) + const received = (progress.received / 1024 ** 3).toFixed(1) + const total = progress.total ? ` / ${(progress.total / 1024 ** 3).toFixed(1)}GB` : "GB" + console.log(`Downloading ${artifact}: ${received}${total}${percent === undefined ? "" : ` (${percent}%)`}`) + } +} + +function printCertificate(certificate: LocalCertificate) { + for (const [name, result] of Object.entries(certificate.checks)) { + console.log(`${result.ok ? "✓" : "✗"} ${name.replaceAll("_", " ")}: ${result.detail}`) + } +} + +function certificationInput(state: ServerState) { + return { + baseURL: state.baseURL, + modelID: state.modelID, + modelSha256: state.modelSha256, + runtimeVersion: state.runtimeVersion, + flags: state.flags, + reasoningEffort: state.reasoningEffort, + temperature: state.temperature, + } +} + +function printReady(wired: { file: string; guarded: string[]; defaultModelIsLocal: boolean }, modelID: string) { + console.log(`✓ Ready. Configured local/${modelID} in ${wired.file}`) + if (!wired.defaultModelIsLocal) { + console.log(` ! Your default model is still set to something else — sessions keep using it, not local/${modelID}.`) + console.log(` Switch with: altimate --model local/${modelID}, or set "model" in ${wired.file}.`) + } + if (wired.guarded.length > 0) { + console.log(` Egress guard: ${wired.guarded.join(", ")} now ask before leaving this machine.`) + console.log(" Local runs have no per-token cost. Disable the guard with --no-egress-guard.") + } + console.log(" Note: the first turn of each session prefills the full context — on laptops") + console.log(" this can take a few minutes; later turns reuse the cache and stream normally.") + console.log(' Try: altimate "profile the orders table and suggest tests"') +} + +async function setupDocker(model: ModelRecipe, tier: DockerRecipeTier, args: LocalArgs) { + console.log(`◇ Recommended: ${model.name} ${tier.quant} · SGLang + EAGLE in the pinned container · ${tier.ctx} context`) + const port = await pickPort(args.port && args.port > 0 ? args.port : 8095) + console.log("◇ Starting SGLang container (first run downloads the weights — this can take a while)") + const started = await startDockerServer({ + tier, + modelID: model.id, + port, + onProgress: (line) => console.log(` ${line}`), + }) + const state: ServerState = { + schema: 1, + engine: "docker-sglang", + pid: started.pid, + host: "127.0.0.1", + port, + baseURL: `http://127.0.0.1:${port}/v1`, + modelID: model.id, + modelPath: tier.model_hf, + modelSha256: tier.image_digest.slice("sha256:".length), + runtimePath: `${tier.image}@${tier.image_digest}`, + runtimeVersion: `sglang ${tier.image}`, + tier: tier.name, + flags: [ + "--model-path", + `${tier.model_hf}@${tier.model_revision}`, + "--context-length", + String(tier.ctx), + ...tier.server_args, + ], + reasoningEffort: tier.agent.reasoning_effort, + temperature: tier.agent.temperature, + startedAt: new Date().toISOString(), + logPath: `docker logs ${started.container}`, + } + try { + await writeServerState(state) + } catch (error) { + // A running container without tracking state is an orphan: reap it. + const { removeDockerContainer } = await import("./docker") + await removeDockerContainer().catch(() => {}) + throw error + } + console.log(`◇ Local server healthy: ${state.baseURL}`) + + const certificate = await certify(certificationInput(state)) + printCertificate(certificate) + if (!certificate.passed) + throw new Error("Local certification failed. Run `altimate local doctor --show` for details.") + const wired = await wireLocalProvider({ + baseURL: state.baseURL, + modelID: model.id, + tier: { ctx: tier.ctx, parallel: 1, agent: tier.agent }, + egressGuard: args.egressGuard, + }) + printReady(wired, model.id) +} + +async function setup(args: LocalArgs) { + const loaded = await loadRecipes() + if (loaded.warning) console.warn(loaded.warning) + const model = selectModel(loaded.recipes, args.model) + const hardware = await detectHardware() + console.log(`◇ Detected: ${describeHardware(hardware)}`) + const match = matchHardwareToTier(hardware, model) + if (!match.tier) throw new Error(`${match.reason}. No Phase 1 recipe matches this machine.`) + const matched = match.tier + if (matched.engine !== "llama.cpp" && matched.engine !== "docker-sglang") { + console.log(`◇ Recommended: ${matched.name}`) + throw new Error(matched.guidance) + } + // Validate CLI overrides before anything else, including stopping a + // working existing server below — a bad --ctx/--parallel must fail before + // any destructive step, not after. + const tier = matched.engine === "llama.cpp" ? withOverrides(matched, args) : matched + + const paths = getLocalPaths() + await ensureLocalDirectories(paths) + const preflight = await runPreflight({ + tier, + model: { id: model.id, revision: model.revision }, + hardware, + availableGb: match.availableGb, + directory: paths.root, + }) + for (const line of formatPreflight(preflight)) console.log(`◇ ${line}`) + if (!preflight.passed) { + const fatal = preflight.checks.filter((check) => !check.ok && check.fatal) + throw new Error(`This machine cannot run the ${matched.name} recipe yet: ${fatal.map((check) => check.detail).join("; ")}`) + } + + // Only stop a working existing server once we're confident the + // replacement will actually be attempted (guidance-only engines and + // preflight failures are both handled above): a failed new setup must not + // leave the user with no working server when they had one before + // re-running this command. + const existing = await getServerStatus() + if (existing.state) { + console.log(`◇ Stopping existing managed server (${existing.state.tier}) before reconfiguring`) + await stopServer() + } + + if (tier.engine === "docker-sglang") { + await setupDocker(model, tier, args) + return + } + // No runtime build for this platform-arch (e.g. Intel macOS) must fail + // BEFORE the multi-GB model download, not after. + runtimeAsset({}) + console.log( + `◇ Recommended: ${model.name} ${tier.quant} · ${Math.floor(tier.ctx / tier.parallel)} context/slot · ` + + `${tier.agent.tool_retrieval ? "tool-slim" : "all tools"} · ${tier.mtp ? "MTP speculative" : "no MTP"}`, + ) + + const logProgress = progressLogger() + const artifacts = await fetchModelArtifacts({ + model, + tier, + mtp: args.mtp, + onProgress: logProgress, + }) + console.log(`◇ Model verified: ${artifacts.model.path}`) + const runtime = await ensureLlamaServer({ onProgress: (progress) => logProgress("runtime", progress) }) + console.log(`◇ Runtime: ${runtime.version} (${runtime.source})`) + const state = await startServer({ + runtime, + modelID: model.id, + modelPath: artifacts.model.path, + modelSha256: artifacts.model.sha256, + mtpPath: artifacts.mtp?.path, + mtpSha256: artifacts.mtp?.sha256, + tier, + port: args.port, + }) + console.log(`◇ Local server healthy: ${state.baseURL}`) + + const certificate = await certify(certificationInput(state)) + printCertificate(certificate) + if (!certificate.passed) + throw new Error("Local certification failed. Run `altimate local doctor --show` for details.") + const wired = await wireLocalProvider({ + baseURL: state.baseURL, + modelID: model.id, + tier, + egressGuard: args.egressGuard, + }) + printReady(wired, model.id) +} + +const LocalStatusCommand = { + command: "status", + describe: "show the managed local model server status", + async handler() { + await task(async () => { + const status = await getServerStatus() + if (!status.state) { + console.log("Local model server: stopped") + return + } + console.log( + `Local model server: ${status.healthy ? "healthy" : status.processAlive ? "unhealthy" : "stopped (stale state)"}`, + ) + console.log(`PID: ${status.state.pid}`) + console.log(`Endpoint: ${status.state.baseURL}`) + console.log(`Model: local/${status.state.modelID} (${status.state.tier})`) + console.log(`Runtime: ${status.state.runtimeVersion}`) + console.log(`Started: ${status.state.startedAt}`) + const key = certificateCacheKey({ + modelSha256: status.state.modelSha256, + runtimeVersion: status.state.runtimeVersion, + flags: status.state.flags, + reasoningEffort: status.state.reasoningEffort, + temperature: status.state.temperature, + }) + console.log(`Certificate: ${key}`) + const guard = await readEgressGuard() + console.log("Egress guard (network tools):") + for (const [permission, action] of Object.entries(guard)) { + console.log(` ${permission}: ${action}`) + } + console.log("Local runs: no per-token cost") + }) + }, +} + +const LocalStopCommand = { + command: "stop", + describe: "stop the managed local model server", + async handler() { + await task(async () => { + const result = await withLifecycleLock(() => stopServer()) + if (result.stopped) console.log(`Stopped local model server (pid ${result.pid}).`) + else if (result.reason === "stale") console.log("Removed stale local server state; no process was running.") + else console.log("Local model server is not running.") + }) + }, +} + +const LocalDoctorCommand = { + command: "doctor", + describe: "re-run local model certification", + builder: (yargs: Argv) => + yargs.option("show", { + type: "boolean", + default: false, + describe: "print the certificate JSON", + }), + async handler(args: { show?: boolean }) { + await task(async () => { + const status = await getServerStatus() + if (!status.state || !status.healthy) + throw new Error("Local model server is not healthy. Run `altimate local` first.") + const certificate = await certify({ ...certificationInput(status.state), force: true }) + if (args.show) console.log(JSON.stringify(certificate, null, 2)) + else printCertificate(certificate) + if (!certificate.passed) { + process.exitCode = 1 + return + } + console.log("✓ Local certification passed.") + }) + }, +} + +const LocalModelsCommand = { + command: "models", + describe: "list the local model registry", + async handler() { + await task(async () => { + const loaded = await loadRecipes() + if (loaded.warning) console.warn(loaded.warning) + const hardware = await detectHardware() + for (const model of loaded.recipes.models) { + const match = matchHardwareToTier(hardware, model) + const fit = match.tier ? `matches this machine (${match.tier.name})` : "no matching tier here" + const isDefault = model === loaded.recipes.models[0] ? " · default" : "" + console.log(`${model.id} — ${model.name}${isDefault}`) + console.log(` tiers: ${model.tiers.map((tier) => `${tier.name} (${tier.quant})`).join(", ")}`) + console.log(` ${fit}`) + } + console.log("Run `altimate local --model ` to set one up.") + }) + }, +} + +const LocalUpdateCommand = { + command: "update", + describe: "refresh the pinned local model recipes", + async handler() { + await task(async () => { + const result = await refreshRecipes() + if (result.warning) console.warn(result.warning) + console.log(`Local recipes: ${result.source} (schema ${result.recipes.schema})`) + console.log("Run `altimate local` to apply the selected recipe and model artifact.") + }) + }, +} + +export const LocalCommand = { + command: "local", + describe: "set up and run the certified local data agent", + builder: (yargs: Argv) => + yargs + .command(LocalStatusCommand) + .command(LocalStopCommand) + .command(LocalDoctorCommand) + .command(LocalModelsCommand) + .command(LocalUpdateCommand) + .option("model", { type: "string", describe: "registry model id (see `altimate local models`)" }) + .option("egress-guard", { + type: "boolean", + default: true, + describe: "make network tools (websearch/webfetch/codesearch) ask before leaving this machine", + }) + .option("port", { type: "number", describe: "preferred llama-server port (auto-picks if unavailable)" }) + .option("ctx", { type: "number", describe: "aggregate llama.cpp context size" }) + .option("parallel", { type: "number", describe: "llama.cpp parallel slot count" }) + .option("kv", { type: "string", describe: "K/V cache type" }) + .option("mtp", { type: "boolean", default: true, describe: "enable MTP speculative decoding" }) + .option("effort", { + type: "string", + choices: ["low", "medium", "xhigh"] as const, + describe: "reasoning effort", + }) + .option("temperature", { type: "number", describe: "client and server sampling temperature" }), + async handler(args: LocalArgs) { + await task(() => withLifecycleLock(() => setup(args))) + }, +} diff --git a/packages/opencode/src/local/docker.ts b/packages/opencode/src/local/docker.ts new file mode 100644 index 0000000000..8481706183 --- /dev/null +++ b/packages/opencode/src/local/docker.ts @@ -0,0 +1,305 @@ +import os from "node:os" +import path from "node:path" +import { execFile } from "node:child_process" +import { promisify } from "node:util" + +import type { DockerRecipeTier } from "./recipes" + +const execFileAsync = promisify(execFile) + +export const LOCAL_CONTAINER_NAME = "altimate-local-model" +// Stamped on every container `altimate local` creates so removeDockerContainer +// can verify ownership before force-removing whatever currently holds this +// fixed, globally-visible name — without it, an unrelated workload that +// happens to use the same container name would be destroyed on the next +// `altimate local` / `stop` run. +export const LOCAL_MANAGEMENT_LABEL_KEY = "ai.altimate.local-model" +export const LOCAL_MANAGEMENT_LABEL_VALUE = "managed" + +export type DockerExec = (file: string, args: string[], timeoutMs?: number) => Promise<{ stdout: string; stderr: string }> + +const defaultExec: DockerExec = async (file, args, timeoutMs = 60_000) => { + const result = await execFileAsync(file, args, { maxBuffer: 8 * 1024 * 1024, timeout: timeoutMs }) + return { stdout: String(result.stdout), stderr: String(result.stderr) } +} + +type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise + +export function buildDockerRunArgs(input: { tier: DockerRecipeTier; modelID: string; port: number; hfCache: string }) { + const tier = input.tier + return [ + "run", + "-d", + "--name", + LOCAL_CONTAINER_NAME, + "--label", + `${LOCAL_MANAGEMENT_LABEL_KEY}=${LOCAL_MANAGEMENT_LABEL_VALUE}`, + "--gpus", + "all", + "--ipc=host", + "-p", + `127.0.0.1:${input.port}:${tier.container_port}`, + "-v", + `${input.hfCache}:/root/.cache/huggingface`, + `${tier.image}@${tier.image_digest}`, + "python3", + "-m", + "sglang.launch_server", + "--model-path", + tier.model_hf, + "--revision", + tier.model_revision, + "--served-model-name", + input.modelID, + "--tp", + "1", + "--context-length", + String(tier.ctx), + ...tier.server_args, + "--host", + "0.0.0.0", + "--port", + String(tier.container_port), + ] +} + +// SGLang's /health returns 200 with an empty body, unlike llama-server's +// {"status":"ok"} — any 2xx counts as healthy here. +export async function dockerHealthy(port: number, fetchImpl: Fetch = fetch) { + try { + const response = await fetchImpl(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(3000) }) + return response.ok + } catch { + return false + } +} + +// Docker's own "not found" errors are the only ones that legitimately mean +// "container absent" — any other exec failure (daemon down, permission +// denied, timeout) must propagate instead of being read as absence, or a +// caller can conclude the container is gone/stopped while it is still +// running. +function isContainerNotFoundError(error: unknown): boolean { + const stderr = typeof (error as { stderr?: unknown })?.stderr === "string" ? (error as { stderr: string }).stderr : "" + const message = error instanceof Error ? error.message : String(error) + return /no such (object|container|image)/i.test(stderr) || /no such (object|container|image)/i.test(message) +} + +export async function dockerContainerRunning(exec: DockerExec = defaultExec) { + try { + const result = await exec("docker", ["inspect", "-f", "{{.State.Running}}", LOCAL_CONTAINER_NAME]) + return result.stdout.trim() === "true" + } catch (error) { + if (isContainerNotFoundError(error)) return false + throw error + } +} + +export async function removeDockerContainer(exec: DockerExec = defaultExec) { + let exists: boolean + try { + await exec("docker", ["inspect", "-f", "{{.Id}}", LOCAL_CONTAINER_NAME]) + exists = true + } catch (error) { + if (!isContainerNotFoundError(error)) throw error + exists = false + } + if (!exists) return { existed: false, removed: false } + + // Force-removing by this fixed, globally-visible name is only safe if we + // created it: verify the management label every `altimate local` docker + // run stamps (see buildDockerRunArgs) before touching a container that + // some unrelated workload might happen to also be using under this name. + // Left unguarded (unlike the existence check above): a docker/exec failure + // here is a real error to propagate, not evidence of "not ours" — only an + // empty/mismatched label value means that. + const label = await exec("docker", [ + "inspect", + "-f", + `{{index .Config.Labels "${LOCAL_MANAGEMENT_LABEL_KEY}"}}`, + LOCAL_CONTAINER_NAME, + ]) + if (label.stdout.trim() !== LOCAL_MANAGEMENT_LABEL_VALUE) { + throw new Error( + `A container named "${LOCAL_CONTAINER_NAME}" already exists but was not created by \`altimate local\` — refusing to force-remove a container this tool does not own. Remove it manually if that is safe.`, + ) + } + + // rm failure must NOT look like success: callers keep state so the + // container is never orphaned silently. + await exec("docker", ["rm", "-f", LOCAL_CONTAINER_NAME], 120_000) + return { existed: true, removed: true } +} + +export interface ContainerReaper { + // Aborted the instant a signal arrives (before the async removeDockerContainer + // call below even starts) — callers in the untracked window can check this to + // avoid returning a "success" that races the reaper's own container removal. + readonly signal: AbortSignal + uninstall(): void +} + +// The container is created (and can spend up to 45 minutes downloading weights +// or occupying the GPU) before setupDocker ever writes state.json — that only +// happens once startDockerServer returns. An interrupt (Ctrl-C) during that +// window kills the CLI but leaves the labeled container running, invisible to +// `altimate local stop`/`status` because they only act on tracked state. +// Reaping it here — via the same ownership-checked removeDockerContainer used +// everywhere else — closes that window without needing state to exist yet. +export function installContainerReaper( + exec: DockerExec, + onExit: (code: number) => void = (code) => process.exit(code), + signalSource: Pick = process, +): ContainerReaper { + const controller = new AbortController() + let cleaningUp = false + let exited = false + const finishOnce = (code: number) => { + if (exited) return + exited = true + onExit(code) + } + const handler = (signal: NodeJS.Signals) => { + const code = signal === "SIGINT" ? 130 : 143 + controller.abort() + if (cleaningUp) { + // A second signal while `docker rm` is still in flight (up to its 120s + // exec timeout, longer if the daemon is wedged) must not be swallowed — + // force immediate exit instead of making the user wait it out. + finishOnce(code) + return + } + cleaningUp = true + removeDockerContainer(exec) + .catch((error) => { + console.error( + `Failed to remove the local model container during shutdown: ${error instanceof Error ? error.message : String(error)}`, + ) + }) + .finally(() => finishOnce(code)) + } + signalSource.on("SIGINT", handler) + signalSource.on("SIGTERM", handler) + return { + signal: controller.signal, + uninstall: () => { + signalSource.off("SIGINT", handler) + signalSource.off("SIGTERM", handler) + }, + } +} + +async function containerLogTail(exec: DockerExec) { + return exec("docker", ["logs", "--tail", "1", LOCAL_CONTAINER_NAME]) + .then((result) => (result.stderr || result.stdout).trim().split("\n").at(-1) ?? "") + .catch(() => "") +} + +export async function startDockerServer(input: { + tier: DockerRecipeTier + modelID: string + port: number + exec?: DockerExec + fetchImpl?: Fetch + timeoutMs?: number + pollIntervalMs?: number + onProgress?: (line: string) => void + // Test-only seams (default to `process`/`process.exit`): let tests inject + // a fake signal source and a non-terminating exit callback instead of + // emitting real SIGINT/SIGTERM on — and calling process.exit() in — the + // shared test process. + signalSource?: Pick + onSignalExit?: (code: number) => void +}) { + const exec = input.exec ?? defaultExec + const pollIntervalMs = input.pollIntervalMs ?? 3000 + await removeDockerContainer(exec) + const hfCache = path.join(os.homedir(), ".cache", "huggingface") + await exec("docker", buildDockerRunArgs({ tier: input.tier, modelID: input.modelID, port: input.port, hfCache }), 30 * 60_000) + // The container now exists but is untracked (state.json isn't written until + // setupDocker records this function's return value) for as long as the + // pid-inspect and health-wait steps below take — up to 45 minutes on a slow + // first-run weight download. Reap the (ownership-checked) labeled container + // on Ctrl-C/SIGTERM for that whole window, not just the explicit failure + // paths already handled by the try/catches below. + const reaper = installContainerReaper(exec, input.onSignalExit, input.signalSource) + try { + let pidRaw: { stdout: string; stderr: string } + try { + pidRaw = await exec("docker", ["inspect", "-f", "{{.State.Pid}}", LOCAL_CONTAINER_NAME]) + } catch (error) { + // The container is already running (docker run succeeded); an inspect + // failure here must not leave it orphaned and untracked. + await removeDockerContainer(exec) + throw error + } + const pid = Number(pidRaw.stdout.trim()) + if (!Number.isInteger(pid) || pid <= 0) { + await removeDockerContainer(exec) + throw new Error("SGLang container started but did not report a pid") + } + + // First run downloads the weights inside the container; allow a long window + // and surface container log lines so the wait is legible. + try { + const deadline = Date.now() + (input.timeoutMs ?? 45 * 60_000) + let lastLine = "" + while (Date.now() < deadline) { + if (await dockerHealthy(input.port, input.fetchImpl)) { + // A signal can arrive while the dockerHealthy call above is + // in-flight: the reaper aborts synchronously the instant it fires, + // before its own removeDockerContainer starts, so this check right + // after resuming from the await catches it before we hand back a + // "success" that races the reaper's in-flight removal — returning + // here would let setupDocker write state.json and wire the config + // for a container the reaper is concurrently deleting. + if (reaper.signal.aborted) { + throw new Error( + "Startup was interrupted by a shutdown signal just as the server became healthy; the container is being removed.", + ) + } + return { pid, container: LOCAL_CONTAINER_NAME } + } + if (!(await dockerContainerRunning(exec))) { + const logs = await exec("docker", ["logs", "--tail", "25", LOCAL_CONTAINER_NAME]) + .then((result) => result.stderr + result.stdout) + .catch(() => "") + throw new Error(`SGLang container exited before becoming healthy.\n${logs.slice(-2000)}`) + } + const line = await containerLogTail(exec) + if (line && line !== lastLine) { + lastLine = line + input.onProgress?.(line) + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)) + } + throw new Error("SGLang container did not become healthy in time") + } catch (error) { + // Any failure while polling — including dockerContainerRunning itself + // throwing on a transient daemon error, not just the two explicit + // failure messages above — must not leave an untracked container + // running: setupDocker only records state once this function succeeds, + // so anything left behind here is invisible to `status`/`stop`. + let cleanupError: unknown + try { + await removeDockerContainer(exec) + } catch (removeError) { + cleanupError = removeError + } + if (cleanupError !== undefined) { + // Swallowing this used to hide it entirely behind the original + // polling error: the container can still be running, untracked, and + // the failure message gave no hint that cleanup itself also failed. + const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + const original = error instanceof Error ? error : new Error(String(error)) + throw new Error( + `${original.message}\n\nAdditionally, removing the container during cleanup failed and it may still be running untracked: ${cleanupMessage}`, + { cause: original }, + ) + } + throw error + } + } finally { + reaper.uninstall() + } +} diff --git a/packages/opencode/src/local/environment.ts b/packages/opencode/src/local/environment.ts new file mode 100644 index 0000000000..0b18f094d2 --- /dev/null +++ b/packages/opencode/src/local/environment.ts @@ -0,0 +1,79 @@ +import fs from "node:fs" +import fsPromises from "node:fs/promises" + +import { ensureLocalDirectories, getLocalPaths, type LocalPaths } from "./paths" + +interface LocalEnvironment { + schema: 1 + tool_retrieval: boolean + // Whether the last `altimate local` setup wired the web-tool egress guard. + // Absent on files written before the guard existed. + egress_guard?: boolean + // Exact permission keys THIS wiring added under the egress guard (not + // merely "guard was on"). Lets --no-egress-guard remove only what it + // actually added instead of guessing from the boolean alone, which would + // otherwise delete a user-set "ask" rule the guard skipped adding (see + // wireLocalProvider in wire.ts). Absent on files written before this field + // existed — callers fall back to the coarser boolean heuristic for those. + guarded_permissions?: string[] +} + +// Basic shape validation for a file this subsystem is the only writer of, +// but which could still be corrupted, hand-edited, or (in principle) shared +// with an unrelated tool that happens to produce JSON with an +// `egress_guard: true` field. Trusting an unvalidated object here would let +// that field silently grant guard-removal ownership it never earned. +function isLocalEnvironment(value: unknown): value is LocalEnvironment { + if (!value || typeof value !== "object") return false + const v = value as Record + if (v.schema !== 1) return false + if (typeof v.tool_retrieval !== "boolean") return false + if (v.egress_guard !== undefined && typeof v.egress_guard !== "boolean") return false + if (v.guarded_permissions !== undefined) { + if (!Array.isArray(v.guarded_permissions) || !v.guarded_permissions.every((k) => typeof k === "string")) + return false + } + return true +} + +export function applyLocalEnvironment(env: NodeJS.ProcessEnv = process.env, paths = getLocalPaths(env)) { + try { + const settings = JSON.parse(fs.readFileSync(paths.environment, "utf8")) as unknown + if (isLocalEnvironment(settings) && settings.tool_retrieval === true && env.ALTIMATE_TOOL_RETRIEVAL === undefined) { + env.ALTIMATE_TOOL_RETRIEVAL = "1" + } + } catch { + // A missing or malformed optional local environment file must not affect + // unrelated CLI commands. `altimate local` rewrites it during setup. + } +} + +// Read-only view of the environment file written by the LAST `altimate local` +// setup, used to tell whether a prior wiring actually applied the egress guard +// (as opposed to a value the user set some other way) before removing it. +export async function readLocalEnvironment(paths: LocalPaths): Promise { + try { + const parsed = JSON.parse(await fsPromises.readFile(paths.environment, "utf8")) as unknown + return isLocalEnvironment(parsed) ? parsed : undefined + } catch { + return undefined + } +} + +export async function writeLocalEnvironment( + toolRetrieval: boolean, + paths: LocalPaths, + egressGuard?: boolean, + guardedPermissions?: string[], +) { + await ensureLocalDirectories(paths) + const temp = `${paths.environment}.${process.pid}.tmp` + const settings: LocalEnvironment = { + schema: 1, + tool_retrieval: toolRetrieval, + egress_guard: egressGuard, + guarded_permissions: guardedPermissions, + } + await fsPromises.writeFile(temp, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 }) + await fsPromises.rename(temp, paths.environment) +} diff --git a/packages/opencode/src/local/fetch.ts b/packages/opencode/src/local/fetch.ts new file mode 100644 index 0000000000..25a2822587 --- /dev/null +++ b/packages/opencode/src/local/fetch.ts @@ -0,0 +1,209 @@ +import { createHash, timingSafeEqual } from "node:crypto" +import { createReadStream, createWriteStream } from "node:fs" +import fs from "node:fs/promises" +import path from "node:path" +import { Readable, Transform } from "node:stream" +import { pipeline } from "node:stream/promises" + +import type { LlamaRecipeTier, ModelRecipe } from "./recipes" +import { ensureLocalDirectories, getLocalPaths, type LocalPaths } from "./paths" + +type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise + +export class MissingChecksumError extends Error { + constructor(readonly checksum: string) { + super(`Artifact checksum is not pinned (${checksum}). Replace the TODO_* recipe value before downloading.`) + this.name = "MissingChecksumError" + } +} + +export class ChecksumMismatchError extends Error { + constructor( + readonly expected: string, + readonly actual: string, + ) { + super(`sha256 mismatch: expected ${expected}, got ${actual}`) + this.name = "ChecksumMismatchError" + } +} + +export function requirePinnedSha256(value: string) { + if (!/^[a-f0-9]{64}$/i.test(value)) throw new MissingChecksumError(value) + return value.toLowerCase() +} + +export async function sha256File(file: string) { + const hash = createHash("sha256") + await pipeline(createReadStream(file), hash) + return hash.digest("hex") +} + +export async function verifySha256(file: string, expectedInput: string) { + const expected = requirePinnedSha256(expectedInput) + const actual = await sha256File(file) + const left = Buffer.from(actual, "hex") + const right = Buffer.from(expected, "hex") + if (left.length !== right.length || !timingSafeEqual(left, right)) throw new ChecksumMismatchError(expected, actual) + return actual +} + +export interface DownloadProgress { + received: number + total?: number + resumed: boolean +} + +export async function downloadWithResume(input: { + url: string + destination: string + sha256: string + fetchImpl?: Fetch + onProgress?: (progress: DownloadProgress) => void +}) { + const expected = requirePinnedSha256(input.sha256) + const fetchImpl = input.fetchImpl ?? fetch + await fs.mkdir(path.dirname(input.destination), { recursive: true }) + + try { + const stat = await fs.stat(input.destination) + const actual = await verifySha256(input.destination, expected) + input.onProgress?.({ received: stat.size, total: stat.size, resumed: false }) + return { path: input.destination, sha256: actual, bytes: stat.size, resumed: false } + } catch (error) { + // altimate_change start — upstream_fix: a completed file that fails its pinned checksum + // (disk corruption, an older interrupted downloader that skipped verification, ...) must not + // be left in place — every subsequent `altimate local` run would hit this same stat+verify + // and fail identically forever. Delete it and fall through to the normal download path below, + // which re-verifies the fresh download and cleans up + throws on a repeat mismatch (no loop). + if (error instanceof ChecksumMismatchError) await fs.unlink(input.destination).catch(() => {}) + // altimate_change end + } + + const partial = `${input.destination}.partial` + const offset = await fs + .stat(partial) + .then((stat) => stat.size) + .catch(() => 0) + const headers = new Headers() + if (offset > 0) headers.set("range", `bytes=${offset}-`) + const response = await fetchImpl(input.url, { headers }) + + if (response.status === 416 && offset > 0) { + try { + const actual = await verifySha256(partial, expected) + await fs.rename(partial, input.destination) + return { path: input.destination, sha256: actual, bytes: offset, resumed: true } + } catch (error) { + // A stale/oversized .partial that fails the pinned checksum must not + // be left in place: every subsequent run would resume from the same + // offset, get 416 again, and fail identically forever. Same cleanup + // as the post-download mismatch path below. + if (error instanceof ChecksumMismatchError) await fs.unlink(partial).catch(() => {}) + throw error + } + } + if (!response.ok) throw new Error(`Download failed with HTTP ${response.status}: ${input.url}`) + if (!response.body) throw new Error(`Download returned no response body: ${input.url}`) + + let append = offset > 0 && response.status === 206 + if (append) { + const range = response.headers.get("content-range")?.match(/^bytes\s+(\d+)-/i) + if (!range || Number(range[1]) !== offset) { + // A proxy/server that ignores our Range header but still answers 206 with + // an absent or mismatched Content-Range leaves us unable to trust the + // partial's offset. Deleting it here (rather than leaving it in place) is + // what makes this recoverable: every subsequent run would otherwise resend + // the same Range request against the same stale offset and hit this exact + // failure forever. The next run starts a fresh, unresumed download instead. + await fs.unlink(partial).catch(() => {}) + throw new Error("Download server returned an invalid Content-Range") + } + } + const receivedAtStart = append ? offset : 0 + // `?? NaN` (not a bare `Number(null)`, which is 0): a response without a + // Content-Length header (chunked/gzip transfers) must report an unknown + // total, not a total that equals whatever's already been received — + // which would make `received` immediately exceed `total` as new bytes + // arrive, corrupting progress percentages downstream. + const length = Number(response.headers.get("content-length") ?? NaN) + const total = Number.isFinite(length) && length >= 0 ? receivedAtStart + length : undefined + let received = receivedAtStart + const progress = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + received += chunk.length + input.onProgress?.({ received, total, resumed: append }) + callback(null, chunk) + }, + }) + input.onProgress?.({ received, total, resumed: append }) + await pipeline( + Readable.fromWeb(response.body as never), + progress, + createWriteStream(partial, { flags: append ? "a" : "w" }), + ) + + try { + const actual = await verifySha256(partial, expected) + const stat = await fs.stat(partial) + await fs.rename(partial, input.destination) + return { path: input.destination, sha256: actual, bytes: stat.size, resumed: append } + } catch (error) { + if (error instanceof ChecksumMismatchError) await fs.unlink(partial).catch(() => {}) + throw error + } +} + +function encodePath(value: string) { + return value.split("/").map(encodeURIComponent).join("/") +} + +export function huggingFaceArtifactUrl(input: { + repo: string + revision: string + file: string + env?: NodeJS.ProcessEnv +}) { + const env = input.env ?? process.env + const base = (env.ALTIMATE_LOCAL_HF_BASE_URL || "https://huggingface.co").replace(/\/+$/, "") + return `${base}/${encodePath(input.repo)}/resolve/${encodeURIComponent(input.revision)}/${encodePath(input.file)}?download=true` +} + +export async function fetchModelArtifacts(input: { + model: ModelRecipe + tier: LlamaRecipeTier + mtp?: boolean + paths?: LocalPaths + env?: NodeJS.ProcessEnv + fetchImpl?: Fetch + onProgress?: (artifact: "model" | "mtp", progress: DownloadProgress) => void +}) { + const env = input.env ?? process.env + const paths = input.paths ?? getLocalPaths(env) + await ensureLocalDirectories(paths) + const directory = path.join(paths.models, input.model.id, input.model.revision) + const modelPath = path.join(directory, path.basename(input.tier.file)) + const model = await downloadWithResume({ + url: huggingFaceArtifactUrl({ + repo: input.model.hf_repo, + revision: input.model.revision, + file: input.tier.file, + env, + }), + destination: modelPath, + sha256: input.tier.sha256, + fetchImpl: input.fetchImpl, + onProgress: (progress) => input.onProgress?.("model", progress), + }) + + const useMtp = input.mtp !== false && input.tier.mtp + if (!useMtp) return { model, mtp: undefined } + const mtpPath = path.join(directory, path.basename(useMtp.file)) + const mtp = await downloadWithResume({ + url: huggingFaceArtifactUrl({ repo: input.model.hf_repo, revision: input.model.revision, file: useMtp.file, env }), + destination: mtpPath, + sha256: useMtp.sha256, + fetchImpl: input.fetchImpl, + onProgress: (progress) => input.onProgress?.("mtp", progress), + }) + return { model, mtp } +} diff --git a/packages/opencode/src/local/hardware.ts b/packages/opencode/src/local/hardware.ts new file mode 100644 index 0000000000..fdb30f5cd7 --- /dev/null +++ b/packages/opencode/src/local/hardware.ts @@ -0,0 +1,226 @@ +import fs from "node:fs/promises" +import os from "node:os" + +import type { ModelRecipe, RecipeTier } from "./recipes" +import { RUNTIME_ASSETS } from "./runtime" + +const GIB = 1024 ** 3 + +export interface HardwareInfo { + platform: NodeJS.Platform + arch: string + name: string + memoryGb: number + accelerator: "metal" | "nvidia" | "cpu" | "unknown" + acceleratorMemoryGb?: number + unifiedMemory: boolean +} + +export interface TierMatch { + tier?: RecipeTier + availableGb: number + reason: string +} + +type Run = (command: string[]) => Promise<{ exitCode: number; stdout: string }> + +async function run(command: string[]) { + const child = Bun.spawn(command, { stdout: "pipe", stderr: "ignore" }) + const [exitCode, stdout] = await Promise.all([child.exited, new Response(child.stdout).text()]) + return { exitCode, stdout } +} + +function gb(bytes: number) { + return Math.round((bytes / GIB) * 10) / 10 +} + +function parseNvidia(output: string) { + const rows = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const split = line.lastIndexOf(",") + if (split < 0) return undefined + const name = line.slice(0, split).trim() + if (!name) return undefined + // DGX Spark (GB10) reports memory.total as [N/A]; keep the GPU with + // unknown memory instead of discarding it (tier match falls back to + // system memory, and the GB10 tier matches on name alone). + const memoryMiB = Number(line.slice(split + 1).trim()) + return { name, memoryMiB: Number.isFinite(memoryMiB) && memoryMiB > 0 ? memoryMiB : undefined } + }) + .filter((row): row is { name: string; memoryMiB: number | undefined } => Boolean(row)) + if (rows.length === 0) return undefined + const known = rows.filter((row): row is { name: string; memoryMiB: number } => typeof row.memoryMiB === "number") + return { + name: rows.map((row) => row.name).join(" + "), + memoryGb: + known.length > 0 + ? Math.round((known.reduce((sum, row) => sum + row.memoryMiB, 0) / 1024) * 10) / 10 + : undefined, + } +} + +function parseMeminfo(input: string) { + const match = input.match(/^MemTotal:\s+(\d+)\s+kB$/m) + return match ? gb(Number(match[1]) * 1024) : 0 +} + +export async function detectHardware( + options: { + platform?: NodeJS.Platform + arch?: string + run?: Run + readFile?: (path: string, encoding: BufferEncoding) => Promise + } = {}, +): Promise { + const platform = options.platform ?? process.platform + const arch = options.arch ?? process.arch + const execute = options.run ?? run + const readFile = options.readFile ?? ((file, encoding) => fs.readFile(file, encoding)) + + if (platform === "darwin") { + const [memory, brand] = await Promise.all([ + execute(["sysctl", "-n", "hw.memsize"]), + execute(["sysctl", "-n", "machdep.cpu.brand_string"]), + ]) + const bytes = Number(memory.stdout.trim()) + if (memory.exitCode !== 0 || !Number.isFinite(bytes) || bytes <= 0) { + throw new Error("Could not detect macOS memory with sysctl hw.memsize") + } + const appleSilicon = arch === "arm64" + return { + platform, + arch, + name: brand.stdout.trim() || `macOS ${arch}`, + memoryGb: gb(bytes), + accelerator: appleSilicon ? "metal" : "unknown", + acceleratorMemoryGb: appleSilicon ? gb(bytes) : undefined, + unifiedMemory: appleSilicon, + } + } + + if (platform === "linux") { + const nvidia = await execute([ + "nvidia-smi", + "--query-gpu=name,memory.total", + "--format=csv,noheader,nounits", + ]).catch(() => ({ exitCode: 1, stdout: "" })) + const gpu = nvidia.exitCode === 0 ? parseNvidia(nvidia.stdout) : undefined + const meminfo = await readFile("/proc/meminfo", "utf8").catch(() => "") + const memoryGb = parseMeminfo(meminfo) + if (gpu) { + return { + platform, + arch, + name: gpu.name, + memoryGb, + accelerator: "nvidia", + acceleratorMemoryGb: gpu.memoryGb, + unifiedMemory: false, + } + } + return { + platform, + arch, + name: `Linux ${arch}`, + memoryGb, + accelerator: "cpu", + unifiedMemory: false, + } + } + + // No dedicated probe below (e.g. win32): os.totalmem() is a plain Node API + // that works cross-platform without shelling out, and reports real system + // RAM instead of the 0 that made every advertised tier unreachable here. + return { + platform, + arch, + name: `${platform} ${arch}`, + memoryGb: gb(os.totalmem()), + accelerator: "unknown", + unifiedMemory: false, + } +} + +function named(model: ModelRecipe, name: string) { + return model.tiers.find((tier) => tier.name === name) +} + +export function matchHardwareToTier(hardware: HardwareInfo, model: ModelRecipe): TierMatch { + const availableGb = hardware.unifiedMemory ? hardware.memoryGb : (hardware.acceleratorMemoryGb ?? hardware.memoryGb) + + if (hardware.accelerator === "nvidia" && /\bGB10\b/i.test(hardware.name)) { + const spark = named(model, "dgx-spark-128gb") + if (spark) return { tier: spark, availableGb, reason: "DGX Spark (GB10) detected" } + } + + if (hardware.accelerator === "nvidia" && (hardware.acceleratorMemoryGb ?? 0) >= 80) { + const tier = named(model, "datacenter-80gb") + if (tier) return { tier, availableGb, reason: "80GB+ NVIDIA accelerator detected" } + } + + if (hardware.platform === "darwin" && hardware.arch === "arm64" && hardware.memoryGb >= 64) { + const tier = named(model, "mac-64gb-unified") + if (tier) return { tier, availableGb, reason: "64GB+ Apple unified memory detected" } + } + + if (hardware.accelerator === "nvidia" && !hardware.unifiedMemory) { + const gpu = named(model, "gpu-24gb-discrete") + if (gpu && (hardware.acceleratorMemoryGb ?? 0) >= gpu.min_vram_gb) { + return { tier: gpu, availableGb, reason: "discrete NVIDIA GPU: context sized to fit VRAM alone" } + } + } + + const laptop = named(model, "laptop-24gb") + // The laptop tier's 131K context assumes unified memory; discrete cards that + // reached here are below the discrete tier's floor and must not inherit it. + const discreteNvidia = hardware.accelerator === "nvidia" && !hardware.unifiedMemory + // This is a hardware-only fallback with no platform gate, so it can match + // platforms llama.cpp has no published runtime for (e.g. Intel macOS): + // require a RUNTIME_ASSETS entry before letting it proceed to a download. + const runtimeAvailable = + laptop?.engine !== "llama.cpp" || Boolean(RUNTIME_ASSETS[`${hardware.platform}-${hardware.arch}`]) + // Treating system RAM as usable accelerator memory only holds when RAM + // actually IS the accelerator's memory, i.e. Apple Silicon's unified + // memory (darwin). Without this gate, a Linux host where nvidia-smi found + // no GPU falls through here reporting accelerator "cpu" — and would still + // pass on system RAM alone, downloading ~16GB for effectively-unusable CPU + // inference. AMD/Intel GPUs on Linux aren't detected yet (nvidia-smi is + // the only probe run today), so they're also excluded here for now; that's + // a real gap tracked as a roadmap item, not something this fallback should + // paper over with an untrustworthy RAM guess. + const unifiedMemoryFallback = hardware.platform === "darwin" + if (laptop && !discreteNvidia && unifiedMemoryFallback && runtimeAvailable && availableGb >= laptop.min_vram_gb) { + return { tier: laptop, availableGb, reason: `${availableGb}GB available memory meets the laptop tier` } + } + + if (laptop && !discreteNvidia && unifiedMemoryFallback && !runtimeAvailable && availableGb >= laptop.min_vram_gb) { + return { + availableGb, + reason: `No Phase 1 llama.cpp runtime is available for ${hardware.platform}-${hardware.arch}`, + } + } + + if (laptop && !discreteNvidia && !unifiedMemoryFallback && availableGb >= laptop.min_vram_gb) { + return { + availableGb, + reason: `No confirmed GPU accelerator was detected on ${hardware.platform} (reported "${hardware.accelerator}"); AMD/Intel GPU detection is not implemented yet, so a RAM-only fallback is not offered here to avoid downloading a recipe this host cannot usefully run`, + } + } + + return { + availableGb, + reason: `The smallest bundled recipe needs ${laptop?.min_vram_gb ?? 20}GB of usable accelerator or unified memory`, + } +} + +export function describeHardware(hardware: HardwareInfo) { + const memory = hardware.unifiedMemory + ? `${hardware.memoryGb}GB unified memory` + : hardware.acceleratorMemoryGb + ? `${hardware.acceleratorMemoryGb}GB VRAM, ${hardware.memoryGb}GB RAM` + : `${hardware.memoryGb}GB RAM` + return `${hardware.name} (${memory})` +} diff --git a/packages/opencode/src/local/lock.ts b/packages/opencode/src/local/lock.ts new file mode 100644 index 0000000000..495d1681cb --- /dev/null +++ b/packages/opencode/src/local/lock.ts @@ -0,0 +1,145 @@ +import fs from "node:fs/promises" +import path from "node:path" + +import { getLocalPaths, type LocalPaths } from "./paths" + +// PID liveness is the primary staleness signal (checked in isOwnerStale +// below). This age is only a last-resort fallback for the rare case where +// the OS has recycled the recorded pid onto an unrelated live process — it +// must stay far longer than any legitimate setup step (model downloads can +// run well past ten minutes) or it forcibly evicts a live, working lock. +const PID_REUSE_FALLBACK_MS = 24 * 60 * 60_000 + +// Acquisition is two steps — mkdir(dir) publishes exclusivity, then +// owner.json is written into it — so a waiter can observe the dir existing +// with owner.json still missing. That's not proof the holder crashed: it +// may just be mid-way through those two steps. Only treat a missing +// owner.json as stale once the dir itself has existed longer than any +// legitimate mkdir-then-write gap could take; otherwise a waiter would +// delete the lock out from under a live holder and steal it while the +// holder still believes it holds it exclusively. +const OWNER_PUBLISH_GRACE_MS = 2_000 + +export function isOwnerStale(owner: { pid?: number; at?: number } | undefined, now: number): boolean { + const dead = (() => { + if (!owner?.pid) return true + try { + process.kill(owner.pid, 0) + return false + } catch { + return true + } + })() + if (dead) return true + return Boolean(owner?.at && now - owner.at > PID_REUSE_FALLBACK_MS) +} + +type Owner = { pid?: number; at?: number } + +async function readOwner(meta: string): Promise { + return fs + .readFile(meta, "utf8") + .then((raw) => JSON.parse(raw) as Owner) + .catch(() => undefined) +} + +function sameOwner(a: Owner | undefined, b: Owner | undefined): boolean { + return a?.pid === b?.pid && a?.at === b?.at +} + +async function isLockStale(dir: string, owner: Owner | undefined, now: number): Promise { + if (owner) return isOwnerStale(owner, now) + const dirAge = await fs + .stat(dir) + .then((s) => now - s.mtimeMs) + .catch(() => Infinity) + return dirAge > OWNER_PUBLISH_GRACE_MS +} + +// Reclaim a lock directory judged stale, guarding against a delayed +// reclaimer racing a fresh acquirer. The rename itself is atomic (only one +// renamer can succeed on a given path — see withLifecycleLock below), but +// that alone is pathname-based: it says nothing about WHICH lock ended up at +// `dir` when the rename ran. If the original (stale) holder's lock was +// itself reclaimed and re-acquired by someone else between this caller's +// staleness read (`staleOwner`, read before calling this) and the rename +// below, this caller's rename would move that FRESH, live lock aside instead +// of the dead one it actually observed — so verify the owner.json inside the +// renamed-aside directory still matches what was read as stale, and restore +// it if not. +export async function reclaimStaleLock(dir: string, staleOwner: Owner | undefined): Promise<"reclaimed" | "restored" | "retry"> { + const stale = `${dir}.stale-${process.pid}-${Date.now()}` + try { + await fs.rename(dir, stale) + } catch { + return "retry" + } + const renamedOwner = await readOwner(path.join(stale, "owner.json")) + if (!sameOwner(staleOwner, renamedOwner)) { + // We moved aside a different, live lock — put it back rather than + // deleting it out from under its holder. + try { + await fs.rename(stale, dir) + } catch { + // `dir` was recreated again in the meantime; nothing to restore. + } + return "restored" + } + await fs.rm(stale, { recursive: true, force: true }).catch(() => {}) + return "reclaimed" +} + +// Cross-process mutex for the local-server lifecycle: concurrent +// `altimate local` / `local stop` invocations otherwise race on state.json +// (last-writer-wins) and can orphan a server the other command just started. +export async function withLifecycleLock(run: () => Promise, paths: LocalPaths = getLocalPaths()): Promise { + const dir = path.join(paths.root, ".lifecycle-lock") + const meta = path.join(dir, "owner.json") + const deadline = Date.now() + 30_000 + // Ensure the lock's parent directory exists once, up front: on a truly + // fresh install paths.root does not exist yet, so a plain fs.mkdir(dir) + // below fails with ENOENT (not EEXIST) and the catch branch below treats + // that the same as "no owner file", loops back to mkdir, and ENOENTs + // forever without ever reaching the deadline check. + await fs.mkdir(paths.root, { recursive: true }) + for (;;) { + // Checked unconditionally at the top of every attempt (not only in the + // "live owner, keep waiting" branch below): a persistent filesystem + // error (ENOSPC, EACCES) makes `mkdir` fail for a reason that is neither + // EEXIST nor a stale lock, so `meta` never exists either — the old + // deadline check, reachable only from the non-stale branch, was never + // hit and the loop spun forever. + if (Date.now() > deadline) throw new Error(`Timed out acquiring the local lifecycle lock at ${dir}.`) + try { + await fs.mkdir(dir) + await fs.writeFile(meta, JSON.stringify({ pid: process.pid, at: Date.now() }), { mode: 0o600 }) + break + } catch { + // Captured BEFORE the reclaim rename below, and passed through to it: + // reclaimStaleLock compares this against what the rename actually + // moved aside, so a lock that got reclaimed and re-acquired by someone + // else in between is restored instead of destroyed. See its own + // comment for the full race this closes. + const owner = await readOwner(meta) + if (await isLockStale(dir, owner, Date.now())) { + // Reclaim via an atomic rename, not a blind rm: two waiters can both + // observe the same stale lock and both decide to reclaim it. If both + // simply `rm`'d `dir`, the loser's rm could delete the WINNER's freshly + // mkdir'd + owner.json'd lock directory (the loser's stale-check ran + // against the old, dead owner before the winner ever acquired) — both + // processes then believe they hold the lock. Renaming `dir` aside is + // atomic: only one renamer can succeed on a given path; the other's + // rename fails (ENOENT, because the path is already gone) and it falls + // through to retry from the top instead of destroying a live lock. + await reclaimStaleLock(dir, owner) + continue + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + } + try { + return await run() + } finally { + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}) + } +} diff --git a/packages/opencode/src/local/paths.ts b/packages/opencode/src/local/paths.ts new file mode 100644 index 0000000000..98d47ee145 --- /dev/null +++ b/packages/opencode/src/local/paths.ts @@ -0,0 +1,48 @@ +import os from "node:os" +import path from "node:path" +import fs from "node:fs/promises" + +export interface LocalPaths { + root: string + bin: string + models: string + downloads: string + certificates: string + state: string + pid: string + log: string + environment: string + recipes: string + recipesMeta: string +} + +export function getLocalPaths( + env: NodeJS.ProcessEnv = process.env, + home = env.OPENCODE_TEST_HOME || os.homedir(), +): LocalPaths { + const data = env.XDG_DATA_HOME || path.join(home, ".local", "share") + const root = path.join(data, "altimate-code", "local") + return { + root, + bin: path.join(root, "bin"), + models: path.join(root, "models"), + downloads: path.join(root, "downloads"), + certificates: path.join(root, "certificates"), + state: path.join(root, "state.json"), + pid: path.join(root, "server.pid"), + log: path.join(root, "server.log"), + environment: path.join(root, "environment.json"), + recipes: path.join(root, "recipes.json"), + recipesMeta: path.join(root, "recipes.meta.json"), + } +} + +export async function ensureLocalDirectories(paths = getLocalPaths()) { + await Promise.all([ + fs.mkdir(paths.root, { recursive: true }), + fs.mkdir(paths.bin, { recursive: true }), + fs.mkdir(paths.models, { recursive: true }), + fs.mkdir(paths.downloads, { recursive: true }), + fs.mkdir(paths.certificates, { recursive: true }), + ]) +} diff --git a/packages/opencode/src/local/preflight.ts b/packages/opencode/src/local/preflight.ts new file mode 100644 index 0000000000..90e9800e0d --- /dev/null +++ b/packages/opencode/src/local/preflight.ts @@ -0,0 +1,224 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { execFile } from "node:child_process" +import { promisify } from "node:util" + +import type { HardwareInfo } from "./hardware" +import type { ModelRecipe, RecipeTier } from "./recipes" + +const execFileAsync = promisify(execFile) + +export type PreflightExec = (file: string, args: string[]) => Promise<{ stdout: string; stderr: string }> + +const defaultExec: PreflightExec = async (file, args) => { + const result = await execFileAsync(file, args, { maxBuffer: 4 * 1024 * 1024 }) + return { stdout: String(result.stdout), stderr: String(result.stderr) } +} + +export interface PreflightCheck { + name: string + ok: boolean + fatal: boolean + detail: string +} + +export interface PreflightResult { + checks: PreflightCheck[] + passed: boolean +} + +// Disk estimates include the artifact plus extraction/runtime headroom. +// When the artifacts are already cached locally only working headroom is needed. +const LLAMA_DISK_GB = 24 +const DOCKER_DISK_GB = 45 +const CACHED_DISK_GB = 4 + +// A model artifact truncated well below this floor cannot plausibly be a real +// multi-GB gguf — checking sha256 here would be correct but means hashing a +// file that can be 16GB+ just to decide a disk estimate, which defeats the +// point of a fast preflight check. This floor catches the cheap, common case +// (zero-byte or partial download) without paying that cost; downloadWithResume +// still re-verifies the pinned sha256 before ever reusing this file. +const CACHED_ARTIFACT_MIN_BYTES = 1024 * 1024 + +async function artifactsCached( + tier: RecipeTier, + model: Pick, + directory: string, + exec: PreflightExec, + home: string, +) { + if (tier.engine === "docker-sglang") { + const repo = tier.model_hf.replace("/", "--") + const snapshot = path.join(home, ".cache", "huggingface", "hub", `models--${repo}`, "snapshots", tier.model_revision) + const snapshotCached = await fs + .stat(snapshot) + .then((entry) => entry.isDirectory()) + .catch(() => false) + if (!snapshotCached) return false + // The HF snapshot alone is not "fully cached": `docker run` still needs + // the pinned SGLang image, which is multi-GB and not implied by the + // weights being present. Discounting the disk estimate on the snapshot + // alone let preflight pass right before an image pull failed for lack + // of space. + return exec("docker", ["image", "inspect", `${tier.image}@${tier.image_digest}`]) + .then(() => true) + .catch(() => false) + } + if (tier.engine === "llama.cpp") { + // Must key on this tier's exact target file (matching fetchModelArtifacts' + // models/// layout), not + // "any .gguf anywhere under models/" — otherwise a cached file from a + // different model or revision falsely discounts the disk-space estimate + // for the multi-GB download that is about to happen. + const target = path.join(directory, "models", model.id, model.revision, path.basename(tier.file)) + return fs + .stat(target) + .then((entry) => entry.isFile() && entry.size >= CACHED_ARTIFACT_MIN_BYTES) + .catch(() => false) + } + return false +} + +async function probeFreeDiskGb(target: string, exec: PreflightExec) { + const result = await exec("df", ["-k", target]) + const line = result.stdout.trim().split("\n").at(-1) + const fields = line?.split(/\s+/) ?? [] + // POSIX df -k: Filesystem 1K-blocks Used Available ... + const availableKb = Number(fields[3]) + if (!Number.isFinite(availableKb)) throw new Error(`unparseable df output for ${target}`) + return availableKb / 1024 ** 2 +} + +async function freeDiskGb(directory: string, exec: PreflightExec) { + return probeFreeDiskGb(directory, exec).catch(() => probeFreeDiskGb(os.homedir(), exec)) +} + +async function dockerDataRoot(exec: PreflightExec) { + return exec("docker", ["info", "--format", "{{.DockerRootDir}}"]) + .then((result) => result.stdout.trim() || undefined) + .catch(() => undefined) +} + +async function memAvailableGb(readFile: typeof fs.readFile) { + const raw = await readFile("/proc/meminfo", "utf8").catch(() => "") + const match = /MemAvailable:\s+(\d+)\s+kB/.exec(String(raw)) + if (!match) return undefined + return Number(match[1]) / 1024 ** 2 +} + +async function vulkanLoaderPresent(exec: PreflightExec) { + for (const ldconfig of ["ldconfig", "/sbin/ldconfig"]) { + try { + const result = await exec(ldconfig, ["-p"]) + return { known: true, present: result.stdout.includes("libvulkan.so.1") } + } catch { + // try the next ldconfig location + } + } + return { known: false, present: false } +} + +export async function runPreflight(input: { + tier: RecipeTier + model: Pick + hardware: HardwareInfo + availableGb: number + directory: string + exec?: PreflightExec + readFile?: typeof fs.readFile + platform?: NodeJS.Platform + home?: string +}): Promise { + const exec = input.exec ?? defaultExec + const readFile = input.readFile ?? fs.readFile + const platform = input.platform ?? process.platform + const home = input.home ?? os.homedir() + const checks: PreflightCheck[] = [] + + checks.push({ + name: "accelerator_memory", + ok: input.availableGb >= input.tier.min_vram_gb, + fatal: true, + detail: `${input.availableGb.toFixed(1)}GB usable vs ${input.tier.min_vram_gb}GB required by ${input.tier.name}`, + }) + + const cached = await artifactsCached(input.tier, input.model, input.directory, exec, home) + const diskNeed = cached ? CACHED_DISK_GB : input.tier.engine === "docker-sglang" ? DOCKER_DISK_GB : LLAMA_DISK_GB + // For the docker engine, weights land in the HF cache (bind-mounted into + // the container) and the image lands in Docker's own storage root — + // neither is necessarily on `input.directory`'s filesystem. Measuring only + // that directory could approve setup right before either real destination + // turns out to be short on space. + const diskTargets = + input.tier.engine === "docker-sglang" + ? [path.join(home, ".cache", "huggingface"), ...((await dockerDataRoot(exec).then((root) => (root ? [root] : []))) as string[])] + : [input.directory] + const diskFrees = await Promise.all(diskTargets.map((target) => freeDiskGb(target, exec).catch(() => undefined))) + const diskFree = diskFrees.every((value) => value !== undefined) ? Math.min(...(diskFrees as number[])) : undefined + checks.push({ + name: "disk_space", + ok: diskFree === undefined ? true : diskFree >= diskNeed, + fatal: diskFree !== undefined, + detail: + diskFree === undefined + ? "could not measure free disk space; continuing" + : `${diskFree.toFixed(0)}GB free vs ~${diskNeed}GB needed${cached ? " (artifacts already cached)" : " for artifacts"}`, + }) + + if (input.tier.engine === "llama.cpp" && platform === "linux") { + const vulkan = await vulkanLoaderPresent(exec) + checks.push({ + name: "vulkan_loader", + ok: !vulkan.known || vulkan.present, + fatal: vulkan.known, + detail: vulkan.present + ? "libvulkan.so.1 found" + : vulkan.known + ? "libvulkan.so.1 missing — install your distro's Vulkan loader (e.g. `apt install libvulkan1`) and a GPU driver with a Vulkan ICD" + : "ldconfig unavailable; skipping Vulkan loader check", + }) + } + + if (input.tier.engine === "docker-sglang") { + const daemon = await exec("docker", ["version", "--format", "{{.Server.Version}}"]).catch(() => undefined) + checks.push({ + name: "docker_daemon", + ok: daemon !== undefined, + fatal: true, + detail: daemon ? `docker server ${daemon.stdout.trim()}` : "docker daemon unreachable — install and start Docker", + }) + + let nvidiaRuntime = false + if (daemon) { + const info = await exec("docker", ["info", "--format", "{{json .Runtimes}}"]).catch(() => undefined) + nvidiaRuntime = info !== undefined && info.stdout.includes("nvidia") + } + checks.push({ + name: "nvidia_container_runtime", + ok: nvidiaRuntime, + fatal: true, + detail: nvidiaRuntime + ? "nvidia runtime registered with docker" + : "nvidia container runtime missing — install nvidia-container-toolkit and restart docker", + }) + + const available = await memAvailableGb(readFile) + checks.push({ + name: "free_memory", + ok: available === undefined || available >= 40, + fatal: false, + detail: + available === undefined + ? "could not read MemAvailable; continuing" + : `${available.toFixed(0)}GB available now — model load needs ~40GB; stop other workloads if the server fails to start`, + }) + } + + return { checks, passed: checks.every((check) => check.ok || !check.fatal) } +} + +export function formatPreflight(result: PreflightResult) { + return result.checks.map((check) => `${check.ok ? "✓" : check.fatal ? "✗" : "!"} ${check.name.replaceAll("_", " ")}: ${check.detail}`) +} diff --git a/packages/opencode/src/local/recipes.json b/packages/opencode/src/local/recipes.json new file mode 100644 index 0000000000..a2aae7673c --- /dev/null +++ b/packages/opencode/src/local/recipes.json @@ -0,0 +1,162 @@ +{ + "schema": 1, + "models": [ + { + "id": "qwen3.8-27b", + "name": "Qwen3.8-27B", + "hf_repo": "unsloth/Qwen3.8-27B-GGUF", + "revision": "4ca720788d1e01f1bff70c033e0d0028fd02e502", + "llama_cpp_ref": "b10516", + "tiers": [ + { + "name": "laptop-24gb", + "min_vram_gb": 20, + "engine": "llama.cpp", + "quant": "UD-Q4_K_M", + "file": "Qwen3.8-27B-UD-Q4_K_M.gguf", + "sha256": "322e194ff79741c7baa497c240f677f54b201b0efab44ca8e50f122b39123482", + "ctx": 65536, + "parallel": 1, + "kv": "f16", + "mtp": { + "file": "MTP/mtp-Qwen3.8-27B-Q4_0.gguf", + "sha256": "50d9ce5a6da381bbcfb31061cf73df94a90e6faf8efeddee379a9cb8f1501c6e", + "draft_max": 2 + }, + "flags": [ + "--jinja", + "-fa", + "on", + "--cache-reuse", + "256", + "--temp", + "1.0", + "--top-p", + "0.95", + "--top-k", + "20", + "--min-p", + "0.0" + ], + "agent": { + "tool_retrieval": true, + "reasoning_effort": "medium", + "temperature": 1.0 + } + }, + { + "name": "mac-64gb-unified", + "min_vram_gb": 64, + "engine": "llama.cpp", + "quant": "UD-Q4_K_M", + "file": "Qwen3.8-27B-UD-Q4_K_M.gguf", + "sha256": "322e194ff79741c7baa497c240f677f54b201b0efab44ca8e50f122b39123482", + "ctx": 131072, + "parallel": 1, + "kv": "f16", + "mtp": { + "file": "MTP/mtp-Qwen3.8-27B-Q4_0.gguf", + "sha256": "50d9ce5a6da381bbcfb31061cf73df94a90e6faf8efeddee379a9cb8f1501c6e", + "draft_max": 2 + }, + "flags": [ + "--jinja", + "-fa", + "on", + "--cache-reuse", + "256", + "--temp", + "1.0", + "--top-p", + "0.95", + "--top-k", + "20", + "--min-p", + "0.0" + ], + "agent": { + "tool_retrieval": true, + "reasoning_effort": "medium", + "temperature": 1.0 + } + }, + { + "name": "datacenter-80gb", + "min_vram_gb": 80, + "engine": "vllm", + "quant": "BF16-or-FP8", + "guidance": "The datacenter vLLM tier is not installed by Phase 1. Use the published FP8/vLLM deployment guide." + }, + { + "name": "gpu-24gb-discrete", + "min_vram_gb": 22, + "engine": "llama.cpp", + "quant": "UD-Q4_K_M", + "file": "Qwen3.8-27B-UD-Q4_K_M.gguf", + "sha256": "322e194ff79741c7baa497c240f677f54b201b0efab44ca8e50f122b39123482", + "ctx": 49152, + "parallel": 1, + "kv": "f16", + "mtp": { + "file": "MTP/mtp-Qwen3.8-27B-Q4_0.gguf", + "sha256": "50d9ce5a6da381bbcfb31061cf73df94a90e6faf8efeddee379a9cb8f1501c6e", + "draft_max": 2 + }, + "flags": [ + "--jinja", + "-fa", + "on", + "--cache-reuse", + "256", + "--temp", + "1.0", + "--top-p", + "0.95", + "--top-k", + "20", + "--min-p", + "0.0" + ], + "agent": { + "tool_retrieval": true, + "reasoning_effort": "medium", + "temperature": 1.0 + }, + "notes": "Discrete 24GB GPUs (RTX 3090/4090-class, Vulkan build): weights+KV must fit VRAM alone, so context is capped at 49K. Unified-memory laptops use laptop-24gb instead." + }, + { + "name": "dgx-spark-128gb", + "min_vram_gb": 100, + "engine": "docker-sglang", + "quant": "NVFP4", + "image": "lmsysorg/sglang:qwen38-27b", + "image_digest": "sha256:febfb971c7352570fc445c466ebd6ffc9d896024958e544a60f2137fd85856b1", + "model_hf": "Inferact/Qwen3.8-27B-NVFP4", + "ctx": 131072, + "container_port": 30000, + "server_args": [ + "--reasoning-parser", + "qwen3", + "--tool-call-parser", + "qwen3_coder", + "--speculative-algorithm", + "EAGLE", + "--speculative-num-steps", + "3", + "--speculative-eagle-topk", + "1", + "--speculative-num-draft-tokens", + "4" + ], + "agent": { + "tool_retrieval": false, + "reasoning_effort": "medium", + "temperature": 1.0 + }, + "guidance": "DGX Spark (GB10) managed setup runs SGLang NVFP4 + EAGLE in the pinned official container (~4x llama.cpp on this hardware). Requires Docker with nvidia-container-toolkit. Manual fallback: docker run --gpus all --ipc=host -p 8095:30000 -v $HOME/.cache/huggingface:/root/.cache/huggingface lmsysorg/sglang:qwen38-27b python3 -m sglang.launch_server --model-path Inferact/Qwen3.8-27B-NVFP4 --served-model-name qwen3.8-27b --tp 1 --context-length 131072 --reasoning-parser qwen3 --tool-call-parser qwen3_coder --speculative-algorithm EAGLE --speculative-num-steps 3 --speculative-eagle-topk 1 --speculative-num-draft-tokens 4 --host 0.0.0.0 --port 30000", + "model_revision": "6128240ebaf4eaa7bad2b3d1c72c37d677c5f462" + } + ] + } + ] +} diff --git a/packages/opencode/src/local/recipes.ts b/packages/opencode/src/local/recipes.ts new file mode 100644 index 0000000000..acc86cf8a9 --- /dev/null +++ b/packages/opencode/src/local/recipes.ts @@ -0,0 +1,352 @@ +import { createHash } from "node:crypto" +import fs from "node:fs/promises" + +import snapshot from "./recipes.json" +import { ensureLocalDirectories, getLocalPaths, type LocalPaths } from "./paths" +import { LLAMA_CPP_REF } from "./runtime" + +export type ReasoningEffort = "low" | "medium" | "xhigh" + +export interface RecipeAgent { + tool_retrieval: boolean + reasoning_effort: ReasoningEffort + temperature: number +} + +export interface RecipeMtp { + file: string + sha256: string + draft_max: number +} + +export interface LlamaRecipeTier { + name: string + min_vram_gb: number + engine: "llama.cpp" + quant: string + file: string + sha256: string + ctx: number + parallel: number + kv: string + mtp?: RecipeMtp + flags: string[] + agent: RecipeAgent +} + +export interface GuidanceRecipeTier { + name: string + min_vram_gb: number + engine: "vllm" | "guidance" + quant: string + guidance: string +} + +export interface DockerRecipeTier { + name: string + min_vram_gb: number + engine: "docker-sglang" + quant: string + image: string + image_digest: string + model_hf: string + model_revision: string + ctx: number + container_port: number + server_args: string[] + agent: RecipeAgent + guidance: string +} + +export type RecipeTier = LlamaRecipeTier | GuidanceRecipeTier | DockerRecipeTier + +export interface ModelRecipe { + id: string + name: string + hf_repo: string + revision: string + llama_cpp_ref: string + tiers: RecipeTier[] +} + +export interface Recipes { + schema: 1 + models: ModelRecipe[] +} + +export interface RecipeLoadResult { + recipes: Recipes + source: "bundled" | "cache" | "remote" + warning?: string +} + +type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`) + return value as Record +} + +function string(value: unknown, label: string) { + if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`) + return value +} + +// fetchModelArtifacts joins this value directly into a filesystem path +// (paths.models///...) without further sanitization. A remote +// recipe (loaded via ALTIMATE_LOCAL_RECIPES_URL, pinned by sha256 but not +// otherwise trusted) containing "../" here could escape the managed model +// cache directory. +function pathSegment(value: unknown, label: string) { + const result = string(value, label) + if (result === "." || result === ".." || /[\\/]/.test(result)) { + throw new Error(`${label} must not contain path separators or be "." or ".."`) + } + return result +} + +function finite(value: unknown, label: string) { + if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${label} must be a finite number`) + return value +} + +function positiveInteger(value: unknown, label: string) { + const result = finite(value, label) + if (!Number.isInteger(result) || result <= 0) throw new Error(`${label} must be a positive integer`) + return result +} + +function port(value: unknown, label: string) { + const result = positiveInteger(value, label) + if (result > 65535) throw new Error(`${label} must be between 1 and 65535`) + return result +} + +function sha(value: unknown, label: string) { + const result = string(value, label) + if (!/^[a-f0-9]{64}$/i.test(result) && !/^TODO_[A-Z0-9_]+$/.test(result)) { + throw new Error(`${label} must be a sha256 or a TODO_* placeholder`) + } + return result +} + +function validateAgent(value: unknown, label: string): RecipeAgent { + const input = record(value, label) + if (typeof input.tool_retrieval !== "boolean") throw new Error(`${label}.tool_retrieval must be a boolean`) + const reasoning = string(input.reasoning_effort, `${label}.reasoning_effort`) + if (!(["low", "medium", "xhigh"] as string[]).includes(reasoning)) { + throw new Error(`${label}.reasoning_effort is unsupported`) + } + return { + tool_retrieval: input.tool_retrieval, + reasoning_effort: reasoning as ReasoningEffort, + temperature: finite(input.temperature, `${label}.temperature`), + } +} + +function validateMtp(value: unknown, label: string): RecipeMtp { + const input = record(value, label) + return { + file: string(input.file, `${label}.file`), + sha256: sha(input.sha256, `${label}.sha256`), + draft_max: positiveInteger(input.draft_max, `${label}.draft_max`), + } +} + +function validateTier(value: unknown, label: string): RecipeTier { + const input = record(value, label) + const engine = string(input.engine, `${label}.engine`) + const common = { + name: string(input.name, `${label}.name`), + min_vram_gb: finite(input.min_vram_gb, `${label}.min_vram_gb`), + quant: string(input.quant, `${label}.quant`), + } + if (common.min_vram_gb <= 0) throw new Error(`${label}.min_vram_gb must be positive`) + + if (engine === "vllm" || engine === "guidance") { + return { ...common, engine, guidance: string(input.guidance, `${label}.guidance`) } + } + if (engine === "docker-sglang") { + const imageDigest = string(input.image_digest, `${label}.image_digest`) + if (!/^sha256:[a-f0-9]{64}$/.test(imageDigest)) throw new Error(`${label}.image_digest must be a sha256: digest`) + if (!Array.isArray(input.server_args) || input.server_args.some((flag) => typeof flag !== "string")) { + throw new Error(`${label}.server_args must be an array of strings`) + } + return { + ...common, + engine, + image: string(input.image, `${label}.image`), + image_digest: imageDigest, + model_hf: string(input.model_hf, `${label}.model_hf`), + model_revision: (() => { + const revision = string(input.model_revision, `${label}.model_revision`) + if (!/^[a-f0-9]{40}$/i.test(revision)) throw new Error(`${label}.model_revision must be a pinned 40-character commit`) + return revision + })(), + ctx: positiveInteger(input.ctx, `${label}.ctx`), + container_port: port(input.container_port, `${label}.container_port`), + server_args: [...input.server_args] as string[], + agent: validateAgent(input.agent, `${label}.agent`), + guidance: string(input.guidance, `${label}.guidance`), + } + } + if (engine !== "llama.cpp") throw new Error(`${label}.engine is unsupported`) + if (!Array.isArray(input.flags) || input.flags.some((flag) => typeof flag !== "string")) { + throw new Error(`${label}.flags must be an array of strings`) + } + const ctx = positiveInteger(input.ctx, `${label}.ctx`) + const parallel = positiveInteger(input.parallel, `${label}.parallel`) + if (ctx % parallel !== 0) throw new Error(`${label}.ctx must divide evenly across parallel slots`) + return { + ...common, + engine, + file: string(input.file, `${label}.file`), + sha256: sha(input.sha256, `${label}.sha256`), + ctx, + parallel, + kv: string(input.kv, `${label}.kv`), + mtp: input.mtp === undefined ? undefined : validateMtp(input.mtp, `${label}.mtp`), + flags: [...input.flags] as string[], + agent: validateAgent(input.agent, `${label}.agent`), + } +} + +function validateModel(value: unknown, label: string): ModelRecipe { + const input = record(value, label) + if (!Array.isArray(input.tiers) || input.tiers.length === 0) throw new Error(`${label}.tiers must be non-empty`) + const tiers = input.tiers.map((tier, index) => validateTier(tier, `${label}.tiers[${index}]`)) + if (new Set(tiers.map((tier) => tier.name)).size !== tiers.length) + throw new Error(`${label} has duplicate tier names`) + const revision = string(input.revision, `${label}.revision`) + if (!/^[a-f0-9]{40}$/i.test(revision)) throw new Error(`${label}.revision must be a pinned 40-character commit`) + const llama_cpp_ref = string(input.llama_cpp_ref, `${label}.llama_cpp_ref`) + // Runtime discovery/download in runtime.ts always installs the hard-coded + // LLAMA_CPP_REF build, ignoring this field entirely — a remote recipe (loaded + // via ALTIMATE_LOCAL_RECIPES_URL) that advances llama_cpp_ref past what the + // installer supports must fail loudly here rather than silently running an + // incompatible llama.cpp build against its updated flags/model. + if (tiers.some((tier) => tier.engine === "llama.cpp") && llama_cpp_ref !== LLAMA_CPP_REF) { + throw new Error( + `${label}.llama_cpp_ref (${llama_cpp_ref}) does not match the installer's supported build (${LLAMA_CPP_REF}); upgrade altimate-code before using this recipe`, + ) + } + return { + id: pathSegment(input.id, `${label}.id`), + name: string(input.name, `${label}.name`), + hf_repo: string(input.hf_repo, `${label}.hf_repo`), + revision, + llama_cpp_ref, + tiers, + } +} + +export function validateRecipes(value: unknown): Recipes { + const input = record(value, "recipes") + if (input.schema !== 1) throw new Error("recipes.schema must be 1") + if (!Array.isArray(input.models) || input.models.length === 0) throw new Error("recipes.models must be non-empty") + const models = input.models.map((model, index) => validateModel(model, `recipes.models[${index}]`)) + if (new Set(models.map((model) => model.id)).size !== models.length) + throw new Error("recipes has duplicate model ids") + return { schema: 1, models } +} + +export const BUNDLED_RECIPES = validateRecipes(snapshot) + +function digest(bytes: Uint8Array) { + return createHash("sha256").update(bytes).digest("hex") +} + +function pinnedSha(value: string | undefined) { + return value && /^[a-f0-9]{64}$/i.test(value) ? value.toLowerCase() : undefined +} + +export async function loadRecipes( + options: { + env?: NodeJS.ProcessEnv + paths?: LocalPaths + } = {}, +): Promise { + const env = options.env ?? process.env + const paths = options.paths ?? getLocalPaths(env) + const url = env.ALTIMATE_LOCAL_RECIPES_URL + const expected = pinnedSha(env.ALTIMATE_LOCAL_RECIPES_SHA256) + if (!url || !expected) return { recipes: BUNDLED_RECIPES, source: "bundled" } + + try { + const [raw, metaRaw] = await Promise.all([fs.readFile(paths.recipes), fs.readFile(paths.recipesMeta, "utf8")]) + const meta = JSON.parse(metaRaw) as { url?: string; sha256?: string } + if (meta.url !== url || meta.sha256 !== expected || digest(raw) !== expected) { + throw new Error("cached recipe pin does not match") + } + return { recipes: validateRecipes(JSON.parse(raw.toString("utf8"))), source: "cache" } + } catch { + return { recipes: BUNDLED_RECIPES, source: "bundled" } + } +} + +export async function refreshRecipes( + options: { + env?: NodeJS.ProcessEnv + paths?: LocalPaths + fetchImpl?: Fetch + } = {}, +): Promise { + const env = options.env ?? process.env + const paths = options.paths ?? getLocalPaths(env) + const fetchImpl = options.fetchImpl ?? fetch + const url = env.ALTIMATE_LOCAL_RECIPES_URL + const expected = pinnedSha(env.ALTIMATE_LOCAL_RECIPES_SHA256) + if (!url) { + return { + recipes: BUNDLED_RECIPES, + source: "bundled", + warning: "ALTIMATE_LOCAL_RECIPES_URL is not set; using the bundled recipe snapshot.", + } + } + if (!expected) { + return { + recipes: BUNDLED_RECIPES, + source: "bundled", + warning: "ALTIMATE_LOCAL_RECIPES_SHA256 must be a 64-character sha256; refusing an unpinned recipe update.", + } + } + + try { + const response = await fetchImpl(url, { headers: { accept: "application/json" } }) + if (!response.ok) throw new Error(`recipe server returned HTTP ${response.status}`) + const raw = new Uint8Array(await response.arrayBuffer()) + const actual = digest(raw) + if (actual !== expected) throw new Error(`recipe sha256 mismatch: expected ${expected}, got ${actual}`) + const recipes = validateRecipes(JSON.parse(new TextDecoder().decode(raw))) + await ensureLocalDirectories(paths) + const temp = `${paths.recipes}.${process.pid}.tmp` + await fs.writeFile(temp, raw) + await fs.rename(temp, paths.recipes) + await fs.writeFile(paths.recipesMeta, JSON.stringify({ url, sha256: expected }, null, 2) + "\n", { mode: 0o600 }) + return { recipes, source: "remote" } + } catch (error) { + return { + recipes: BUNDLED_RECIPES, + source: "bundled", + warning: `Recipe update failed; using bundled snapshot: ${error instanceof Error ? error.message : String(error)}`, + } + } +} + +export function firstModel(recipes: Recipes) { + const model = recipes.models[0] + if (!model) throw new Error("No local model recipe is available") + return model +} + +// The registry is multi-model; the default is its first entry. `--model` selects +// any other entry by id. +export function selectModel(recipes: Recipes, id?: string) { + if (id === undefined) return firstModel(recipes) + const model = recipes.models.find((candidate) => candidate.id === id) + if (!model) { + const available = recipes.models.map((candidate) => candidate.id).join(", ") + throw new Error(`Unknown local model "${id}". Available: ${available}`) + } + return model +} diff --git a/packages/opencode/src/local/runtime.ts b/packages/opencode/src/local/runtime.ts new file mode 100644 index 0000000000..0875fd6175 --- /dev/null +++ b/packages/opencode/src/local/runtime.ts @@ -0,0 +1,228 @@ +import { constants } from "node:fs" +import fs from "node:fs/promises" +import path from "node:path" +import { execFile } from "node:child_process" +import { promisify } from "node:util" + +import { downloadWithResume, type DownloadProgress } from "./fetch" +import { ensureLocalDirectories, getLocalPaths, type LocalPaths } from "./paths" + +const execFileAsync = promisify(execFile) + +export const LLAMA_CPP_REF = "b10516" + +interface RuntimeAsset { + file: string + sha256: string +} + +// sha256 digests from the ggml-org/llama.cpp b10516 GitHub release assets. +// Linux uses the Vulkan builds: the release publishes no CUDA binaries, and +// Vulkan runs on NVIDIA/AMD/Intel GPUs alike. +export const RUNTIME_ASSETS: Record = { + "darwin-arm64": { + file: `llama-${LLAMA_CPP_REF}-bin-macos-arm64.tar.gz`, + sha256: "ee3324327d621026ae80c24031670e65fa62a0b23a3a027dbe2f65f240affd30", + }, + "linux-x64": { + file: `llama-${LLAMA_CPP_REF}-bin-ubuntu-vulkan-x64.tar.gz`, + sha256: "5ce186720f43c415465869b0cd93973b828b219cbf6fbcc22aa899531973c505", + }, + "linux-arm64": { + file: `llama-${LLAMA_CPP_REF}-bin-ubuntu-vulkan-arm64.tar.gz`, + sha256: "760c434827e76342f1d2cf9d1e48b318e7805e7965587ffdbc324b516d955510", + }, + // Windows support is EXPERIMENTAL: asset pinned and unpack path wired, but + // not yet smoke-tested on real hardware; doctor labels it accordingly. + "win32-x64": { + file: `llama-${LLAMA_CPP_REF}-bin-win-vulkan-x64.zip`, + sha256: "530f57d2a874ce017827c1e5a926812b9d5de4667248575d1372b1c0acf94d83", + }, +} + +const BIN_NAME = process.platform === "win32" ? "llama-server.exe" : "llama-server" + +export interface RuntimeInfo { + path: string + version: string + source: "override" | "installed" | "path" | "download" +} + +async function executable(file: string) { + return fs + .access(file, constants.X_OK) + .then(() => true) + .catch(() => false) +} + +function pathCandidates(env: NodeJS.ProcessEnv) { + const delimiter = process.platform === "win32" ? ";" : ":" + return (env.PATH ?? "") + .split(delimiter) + .filter(Boolean) + .map((directory) => path.join(directory, BIN_NAME)) +} + +export async function runtimeVersion(file: string) { + // The first exec right after archive extraction can fail while the dylibs + // beside the binary are still being flushed; one delayed retry covers it. + let lastError: unknown + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 1000)) + try { + const result = await execFileAsync(file, ["--version"], { + timeout: 5000, + maxBuffer: 1024 * 1024, + // Linux release tarballs ship libllama-common.so.* beside the binary; + // unlike macOS @rpath, the Linux loader does not search that directory. + env: { ...process.env, LD_LIBRARY_PATH: [path.dirname(file), process.env.LD_LIBRARY_PATH].filter(Boolean).join(":") }, + }) + const output = `${result.stdout}\n${result.stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) + return output || "unknown" + } catch (error) { + lastError = error + } + } + throw new Error( + `Could not read llama-server version from ${file}: ${lastError instanceof Error ? lastError.message : String(lastError)}`, + ) +} + +export async function locateLlamaServer( + options: { + env?: NodeJS.ProcessEnv + paths?: LocalPaths + } = {}, +): Promise { + const env = options.env ?? process.env + const paths = options.paths ?? getLocalPaths(env) + const override = env.ALTIMATE_LOCAL_LLAMA_SERVER + if (override) { + if (!(await executable(override))) throw new Error(`ALTIMATE_LOCAL_LLAMA_SERVER is not executable: ${override}`) + return { path: override, version: await runtimeVersion(override), source: "override" } + } + + const installed = [path.join(paths.bin, LLAMA_CPP_REF, BIN_NAME), path.join(paths.bin, BIN_NAME)] + for (const candidate of installed) { + if (await executable(candidate)) { + // A present-but-broken install (crashes on --version, e.g. missing + // shared libs) must count as "not found" so the reinstall fallback + // actually runs; a thrown rejection here escapes the ?? fallback. + const version = await runtimeVersion(candidate).catch(() => undefined) + if (version) return { path: candidate, version, source: "installed" } + } + } + for (const candidate of pathCandidates(env)) { + if (await executable(candidate)) { + const version = await runtimeVersion(candidate).catch(() => undefined) + if (version) return { path: candidate, version, source: "path" } + } + } + return undefined +} + +export function runtimeAsset(options: { platform?: NodeJS.Platform; arch?: string; env?: NodeJS.ProcessEnv }) { + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const arch = options.arch ?? process.arch + const asset = RUNTIME_ASSETS[`${platform}-${arch}`] + if (!asset) throw new Error(`No Phase 1 llama.cpp runtime is available for ${platform}-${arch}`) + const file = env.ALTIMATE_LOCAL_RUNTIME_URL + ? path.basename(new URL(env.ALTIMATE_LOCAL_RUNTIME_URL).pathname) + : asset.file + const url = + env.ALTIMATE_LOCAL_RUNTIME_URL || + `https://github.com/ggml-org/llama.cpp/releases/download/${LLAMA_CPP_REF}/${asset.file}` + const sha256 = env.ALTIMATE_LOCAL_RUNTIME_SHA256 || asset.sha256 + return { file, url, sha256 } +} + +// A file with the execute bit set is not necessarily a working runtime (see +// the "present-but-broken install" case in locateLlamaServer above): a bad +// unpack can leave an executable binary that crashes on --version because a +// shared lib beside it is missing. Only treat an existing target as good +// enough to keep — discarding the freshly downloaded replacement — if it +// actually runs. +export async function isWorkingRuntime(file: string): Promise { + return runtimeVersion(file) + .then(() => true) + .catch(() => false) +} + +async function findFile(directory: string, name: string): Promise { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name) + if (entry.isFile() && entry.name === name) return target + if (entry.isDirectory()) { + const nested = await findFile(target, name) + if (nested) return nested + } + } + return undefined +} + +export async function installLlamaServer( + options: { + env?: NodeJS.ProcessEnv + paths?: LocalPaths + platform?: NodeJS.Platform + arch?: string + fetchImpl?: typeof fetch + onProgress?: (progress: DownloadProgress) => void + } = {}, +): Promise { + const env = options.env ?? process.env + const paths = options.paths ?? getLocalPaths(env) + await ensureLocalDirectories(paths) + const asset = runtimeAsset({ platform: options.platform, arch: options.arch, env }) + const archive = path.join(paths.downloads, asset.file) + await downloadWithResume({ + url: asset.url, + destination: archive, + sha256: asset.sha256, + fetchImpl: options.fetchImpl, + onProgress: options.onProgress, + }) + + const extraction = path.join(paths.root, `.runtime-extract-${process.pid}-${Date.now()}`) + const staging = path.join(paths.bin, `.${LLAMA_CPP_REF}-${process.pid}-${Date.now()}`) + const target = path.join(paths.bin, LLAMA_CPP_REF) + await fs.mkdir(extraction, { recursive: true }) + try { + if (archive.endsWith(".zip")) { + // Windows release assets are zips; bsdtar (present on win10+/macOS/linux) + // handles them with the same CLI surface. + await execFileAsync("tar", ["-xf", archive, "-C", extraction], { timeout: 120_000 }) + } else { + await execFileAsync("tar", ["-xzf", archive, "-C", extraction], { timeout: 120_000 }) + } + const binary = await findFile(extraction, BIN_NAME) + if (!binary) throw new Error(`Runtime archive ${asset.file} does not contain llama-server`) + // verbatimSymlinks: without it fs.cp resolves the archive's RELATIVE + // lib symlinks (libllama-common.so.0 -> .so.0.1.2) into absolute paths + // under the temporary extraction dir, which is deleted below — leaving + // every versioned .so dangling on Linux. + await fs.cp(path.dirname(binary), staging, { recursive: true, verbatimSymlinks: true }) + await fs.chmod(path.join(staging, BIN_NAME), 0o755) + if (await isWorkingRuntime(path.join(target, BIN_NAME))) { + await fs.rm(staging, { recursive: true, force: true }) + } else { + await fs.rm(target, { recursive: true, force: true }) + await fs.rename(staging, target) + } + } finally { + await fs.rm(extraction, { recursive: true, force: true }) + await fs.rm(staging, { recursive: true, force: true }).catch(() => {}) + } + + const binary = path.join(target, BIN_NAME) + if (!(await executable(binary))) throw new Error(`Installed runtime is not executable: ${binary}`) + return { path: binary, version: await runtimeVersion(binary), source: "download" } +} + +export async function ensureLlamaServer(options: Parameters[0] = {}) { + return (await locateLlamaServer(options)) ?? installLlamaServer(options) +} diff --git a/packages/opencode/src/local/server.ts b/packages/opencode/src/local/server.ts new file mode 100644 index 0000000000..56461d3921 --- /dev/null +++ b/packages/opencode/src/local/server.ts @@ -0,0 +1,427 @@ +import { closeSync, openSync } from "node:fs" +import fs from "node:fs/promises" +import net from "node:net" +import { spawn } from "node:child_process" +import path from "node:path" +import { once } from "node:events" +import { execFile } from "node:child_process" +import { promisify } from "node:util" + +import type { LlamaRecipeTier } from "./recipes" +import { dockerContainerRunning, dockerHealthy, removeDockerContainer, type DockerExec } from "./docker" +import type { RuntimeInfo } from "./runtime" +import { ensureLocalDirectories, getLocalPaths, type LocalPaths } from "./paths" + +const execFileAsync = promisify(execFile) + +export interface ServerState { + schema: 1 + engine?: "llama.cpp" | "docker-sglang" + pid: number + host: "127.0.0.1" + port: number + baseURL: string + modelID: string + modelPath: string + modelSha256: string + mtpSha256?: string + runtimePath: string + runtimeVersion: string + tier: string + flags: string[] + reasoningEffort: string + temperature: number + startedAt: string + logPath: string +} + +export interface ServerStatus { + state?: ServerState + processAlive: boolean + healthy: boolean + stale: boolean +} + +type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise + +async function bind(port: number) { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.once("error", reject) + server.listen(port, "127.0.0.1", () => { + const address = server.address() + const selected = typeof address === "object" && address ? address.port : 0 + server.close((error) => (error ? reject(error) : resolve(selected))) + }) + }) +} + +// Bind success is NOT proof a port is free: Bun-based dev servers listen with +// SO_REUSEPORT, so a second bind on the same 127.0.0.1 port silently succeeds +// and the two processes then share incoming connections (observed: health +// polls landing on an unrelated admin app instead of llama-server). A port +// only counts as free when binding succeeds AND nothing answers HTTP on it. +async function respondsToHttp(port: number, fetchImpl: Fetch = fetch) { + try { + await fetchImpl(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(750) }) + return true + } catch { + return false + } +} + +export async function pickPort( + preferred = 42625, + bindPort: (port: number) => Promise = bind, + probe: (port: number) => Promise = respondsToHttp, +) { + const candidates = preferred > 0 ? [preferred, preferred + 1, preferred + 2, preferred + 3] : [] + for (const candidate of candidates) { + const selected = await bindPort(candidate).catch(() => 0) + if (selected && !(await probe(selected))) return selected + } + for (let attempt = 0; attempt < 4; attempt++) { + const selected = await bindPort(0) + if (!selected) break + if (!(await probe(selected))) return selected + } + throw new Error("Could not find a local port that is both bindable and silent") +} + +export async function readServerState(paths = getLocalPaths()): Promise { + try { + const parsed = JSON.parse(await fs.readFile(paths.state, "utf8")) as ServerState + if (parsed.schema !== 1 || !Number.isInteger(parsed.pid) || !Number.isInteger(parsed.port)) return undefined + return parsed + } catch { + return undefined + } +} + +export async function writeServerState(state: ServerState, paths = getLocalPaths()) { + await ensureLocalDirectories(paths) + const temp = `${paths.state}.${process.pid}.tmp` + await fs.writeFile(temp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }) + await fs.rename(temp, paths.state) + await fs.writeFile(paths.pid, `${state.pid}\n`, { mode: 0o600 }) +} + +async function clearServerState(paths: LocalPaths) { + await Promise.all([fs.unlink(paths.state).catch(() => {}), fs.unlink(paths.pid).catch(() => {})]) +} + +function processAlive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +// process.kill throws ESRCH if the process already exited between our last +// liveness check and this call — a real race under a fast-crashing or +// already-terminating llama-server. Swallowing it here (rather than at each +// call site) keeps that TOCTOU window from turning into an uncaught +// exception that skips the state cleanup that must always follow. +function tryKill(pid: number, signal: NodeJS.Signals) { + try { + process.kill(pid, signal) + } catch { + // Already exited — nothing to signal. + } +} + +// altimate_change start — upstream_fix: split out so the win32 PowerShell invocation can be +// unit-tested (args array only, no PowerShell execution) from CI that doesn't run on Windows. +export function windowsProcessCommandArgs(pid: number): string[] { + return [ + "-NoProfile", + "-NonInteractive", + "-Command", + // PowerShell's default host wraps string output at the (fake, redirected-console) buffer + // width — commonly 80 or 120 columns — inserting a newline mid-string. A command line longer + // than that would get split, breaking the `.includes(runtimePath)` / `.includes(modelPath)` + // substring checks in managedProcess() below with a false negative. 32767 matches Windows' + // own max command-line length, so no real command line can exceed it. + `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine | Out-String -Width 32767`, + ] +} +// altimate_change end + +async function processCommand(pid: number) { + if (process.platform === "linux") { + return fs + .readFile(`/proc/${pid}/cmdline`, "utf8") + .then((value) => value.replaceAll("\0", " ")) + .catch(() => "") + } + if (process.platform === "win32") { + // Native Windows has no `ps`. PowerShell's CIM cmdlets are the standard + // way to read another process's full command line (tasklist only + // exposes the image name, and `wmic` is deprecated/removed on newer + // Windows). Without this, the command lookup always returned "", so + // managedProcess() below always reported false and `altimate local + // stop` refused to signal a live, managed llama-server.exe. + return execFileAsync("powershell", windowsProcessCommandArgs(pid)) + .then((result) => result.stdout) + .catch(() => "") + } + return execFileAsync("ps", ["-p", String(pid), "-o", "command="]) + .then((result) => result.stdout) + .catch(() => "") +} + +async function managedProcess(state: ServerState) { + if (!processAlive(state.pid)) return false + const command = await processCommand(state.pid) + // Match the exact recorded runtime binary path (works with a custom + // ALTIMATE_LOCAL_LLAMA_SERVER binary name) plus the model path, instead of + // a hard-coded "llama-server" substring: that both false-negatives on a + // renamed binary and false-positives on any unrelated process whose + // command line happens to mention both strings. + return command.includes(state.runtimePath) && command.includes(state.modelPath) +} + +export async function checkHealth(port: number, fetchImpl: Fetch = fetch) { + try { + const response = await fetchImpl(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(3000) }) + if (!response.ok) return false + const body = (await response.json()) as { status?: unknown } + return body.status === "ok" + } catch { + return false + } +} + +export async function getServerStatus( + options: { paths?: LocalPaths; fetchImpl?: Fetch; dockerExec?: DockerExec } = {}, +): Promise { + const paths = options.paths ?? getLocalPaths() + const state = await readServerState(paths) + if (!state) return { processAlive: false, healthy: false, stale: false } + if (state.engine === "docker-sglang") { + const running = await dockerContainerRunning(options.dockerExec) + const healthy = running && (await dockerHealthy(state.port, options.fetchImpl)) + return { state, processAlive: running, healthy, stale: !running } + } + // Raw PID liveness alone is not identity: if the recorded pid gets + // recycled by the OS onto an unrelated process, a bare `processAlive` + // check reports "alive" and a subsequent health probe on the recorded + // port could hit an unrelated loopback service, marking a dead server + // healthy. managedProcess() also checks the command line against the + // recorded runtime + model path. + const alive = await managedProcess(state) + const healthy = alive && (await checkHealth(state.port, options.fetchImpl)) + return { state, processAlive: alive, healthy, stale: !alive } +} + +function recipeFlags(input: { tier: LlamaRecipeTier; mtpPath?: string }) { + const flags = [ + "--n-gpu-layers", + "99", + "--ctx-size", + String(input.tier.ctx), + "--parallel", + String(input.tier.parallel), + "--reasoning-budget", + "-1", + "--chat-template-kwargs", + JSON.stringify({ reasoning_effort: input.tier.agent.reasoning_effort }), + "-ctk", + input.tier.kv, + "-ctv", + input.tier.kv, + ...input.tier.flags, + ] + if (input.mtpPath && input.tier.mtp) { + flags.push( + "--model-draft", + input.mtpPath, + "--spec-type", + "draft-mtp", + "--spec-draft-n-max", + String(input.tier.mtp.draft_max), + ) + } + return flags +} + +export function buildServerArguments(input: { + modelID: string + modelPath: string + port: number + tier: LlamaRecipeTier + mtpPath?: string +}) { + const flags = recipeFlags(input) + return { + flags, + args: [ + "--model", + input.modelPath, + "--alias", + input.modelID, + "--host", + "127.0.0.1", + "--port", + String(input.port), + ...flags, + ], + } +} + +async function waitForHealth(input: { port: number; pid: number; timeoutMs: number; fetchImpl?: Fetch }) { + const deadline = Date.now() + input.timeoutMs + while (Date.now() < deadline) { + if (!processAlive(input.pid)) throw new Error("llama-server exited before becoming healthy") + if (await checkHealth(input.port, input.fetchImpl)) return + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + throw new Error(`llama-server did not become healthy within ${Math.round(input.timeoutMs / 1000)} seconds`) +} + +export async function startServer(input: { + runtime: RuntimeInfo + modelID: string + modelPath: string + modelSha256: string + mtpPath?: string + mtpSha256?: string + tier: LlamaRecipeTier + port?: number + timeoutMs?: number + paths?: LocalPaths + fetchImpl?: Fetch +}) { + const paths = input.paths ?? getLocalPaths() + const desiredFlags = [ + ...recipeFlags({ tier: input.tier, mtpPath: input.mtpPath }), + ...(input.mtpSha256 ? [`mtp-sha256=${input.mtpSha256}`] : []), + ] + const current = await getServerStatus({ paths, fetchImpl: input.fetchImpl }) + if (current.healthy && current.state) { + const same = + current.state.modelID === input.modelID && + current.state.modelSha256 === input.modelSha256 && + current.state.runtimeVersion === input.runtime.version && + current.state.tier === input.tier.name && + JSON.stringify(current.state.flags) === JSON.stringify(desiredFlags) && + (input.port === undefined || input.port === current.state.port) + if (same) return current.state + throw new Error( + "A local server with different model, runtime, port, or flags is already running. Run `altimate local stop` first.", + ) + } + if (current.processAlive && current.state) { + throw new Error("The managed llama-server process is running but unhealthy. Run `altimate local stop` first.") + } + if (current.stale) await clearServerState(paths) + + await ensureLocalDirectories(paths) + const port = input.port && input.port > 0 ? await pickPort(input.port) : await pickPort() + const built = buildServerArguments({ + modelID: input.modelID, + modelPath: input.modelPath, + port, + tier: input.tier, + mtpPath: input.mtpPath, + }) + const log = openSync(paths.log, "a", 0o600) + const child = spawn(input.runtime.path, built.args, { + detached: true, + stdio: ["ignore", log, log], + env: { + ...process.env, + LD_LIBRARY_PATH: [path.dirname(input.runtime.path), process.env.LD_LIBRARY_PATH].filter(Boolean).join(":"), + }, + }) + try { + await Promise.race([once(child, "spawn"), once(child, "error").then(([error]) => Promise.reject(error))]) + } finally { + closeSync(log) + } + child.unref() + if (!child.pid) throw new Error("llama-server did not report a pid") + + const state: ServerState = { + schema: 1, + pid: child.pid, + host: "127.0.0.1", + port, + baseURL: `http://127.0.0.1:${port}/v1`, + modelID: input.modelID, + modelPath: input.modelPath, + modelSha256: input.modelSha256, + mtpSha256: input.mtpSha256, + runtimePath: input.runtime.path, + runtimeVersion: input.runtime.version, + tier: input.tier.name, + flags: desiredFlags, + reasoningEffort: input.tier.agent.reasoning_effort, + temperature: input.tier.agent.temperature, + startedAt: new Date().toISOString(), + logPath: paths.log, + } + try { + await writeServerState(state, paths) + await waitForHealth({ port, pid: child.pid, timeoutMs: input.timeoutMs ?? 180_000, fetchImpl: input.fetchImpl }) + return state + } catch (error) { + // Covers state-write failures too (e.g. ENOSPC right after the model + // download): a spawned server must never outlive its tracking state. + // Same escalation as stopServer — a child that ignores SIGTERM would + // otherwise be orphaned with its state already cleared. tryKill absorbs + // the child exiting on its own between the liveness check and the + // signal (ESRCH), so that race can't skip clearServerState below and + // replace `error` with a kill-time exception. + try { + if (processAlive(child.pid)) { + tryKill(child.pid, "SIGTERM") + const deadline = Date.now() + 10_000 + while (processAlive(child.pid) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)) + } + if (processAlive(child.pid)) tryKill(child.pid, "SIGKILL") + } + } finally { + await clearServerState(paths) + } + throw error + } +} + +export async function stopServer(options: { paths?: LocalPaths; graceMs?: number; dockerExec?: DockerExec } = {}) { + const paths = options.paths ?? getLocalPaths() + const state = await readServerState(paths) + if (!state) return { stopped: false, reason: "not-running" as const } + if (state.engine === "docker-sglang") { + const running = await dockerContainerRunning(options.dockerExec) + // Throws on rm failure — state stays so the container is not orphaned. + await removeDockerContainer(options.dockerExec) + await clearServerState(paths) + if (!running) return { stopped: false, reason: "stale" as const } + return { stopped: true, reason: "stopped" as const, pid: state.pid } + } + if (!processAlive(state.pid)) { + await clearServerState(paths) + return { stopped: false, reason: "stale" as const } + } + if (!(await managedProcess(state))) { + throw new Error(`Refusing to signal pid ${state.pid}: it is not the managed llama-server process`) + } + + // tryKill absorbs the process exiting on its own between the managedProcess + // check above (or the liveness poll below) and the signal call — without + // it, that race throws ESRCH and skips clearServerState, leaving stale + // state.json behind for an already-dead server. + tryKill(state.pid, "SIGTERM") + const deadline = Date.now() + (options.graceMs ?? 10_000) + while (processAlive(state.pid) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)) + } + if (processAlive(state.pid)) tryKill(state.pid, "SIGKILL") + await clearServerState(paths) + return { stopped: true, reason: "stopped" as const, pid: state.pid } +} diff --git a/packages/opencode/src/local/wire.ts b/packages/opencode/src/local/wire.ts new file mode 100644 index 0000000000..944393cc51 --- /dev/null +++ b/packages/opencode/src/local/wire.ts @@ -0,0 +1,279 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { applyEdits, modify, parse, type ParseError } from "jsonc-parser" +import { mergeDeep } from "remeda" + +import type { LlamaRecipeTier } from "./recipes" +import { getLocalPaths, type LocalPaths } from "./paths" +import { writeLocalEnvironment, readLocalEnvironment } from "./environment" +import { Wildcard } from "@/util/wildcard" + +// Precedence Config actually applies when merging (lowest to highest — +// see config/config.ts's load order; each later file overrides the earlier +// ones). If more than one of these exists, the LAST one here is the file +// that actually takes effect. +const CONFIG_PRECEDENCE = ["config.json", "opencode.json", "opencode.jsonc", "altimate-code.json", "altimate-code.jsonc"] +// Default target when nothing exists yet, so a fresh install still lands in +// the expected file. +const DEFAULT_CONFIG_FILE = "altimate-code.json" + +function configFile(env: NodeJS.ProcessEnv, home: string) { + const root = path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "altimate-code") + return { + root, + defaultFile: path.join(root, DEFAULT_CONFIG_FILE), + // Highest precedence first: the file that wins if it exists. + precedence: [...CONFIG_PRECEDENCE].reverse().map((name) => path.join(root, name)), + } +} + +// Which existing config file actually wins under Config's merge order. +// Writing to (or reading from) any lower-precedence file that also exists +// would be silently shadowed by this one — see config/config.ts's load order. +async function winningConfigFile(config: ReturnType) { + for (const file of config.precedence) { + if ( + await fs + .stat(file) + .then(() => true) + .catch(() => false) + ) + return file + } + return config.defaultFile +} + +// The config schema accepts a bare `"permission": "deny"` shorthand string, which +// ConfigPermissionV1's own decoder normalizes to `{ "*": "deny" }` — but that normalization lives +// in the Config schema pipeline, not in raw JSON. Every reader of the raw parsed permission value +// in this file goes through this so a shorthand string can't reach `Object.keys()` (returns +// character indices, not pattern keys) or a bare `key in permission` (throws on a primitive). +function normalizePermission(raw: unknown): Record { + return typeof raw === "string" ? { "*": raw } : ((raw ?? {}) as Record) +} + +function asRecord(raw: unknown): Record { + return raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : {} +} + +// Effective permission across every config file that actually exists, merged +// in the SAME low-to-high precedence order Config itself applies (see +// CONFIG_PRECEDENCE above and config/config.ts's mergeDeep-based loader). +// The winning file alone can look empty while a lower-precedence file still +// carries a rule (e.g. a global `{"*":"deny"}`) that the real loader would +// still apply — reading only the winning file would miss it and let a +// guard-owned "ask" key silently widen the user's effective permissions. +async function readEffectivePermission(config: ReturnType): Promise> { + let merged: Record = {} + for (const name of CONFIG_PRECEDENCE) { + const file = path.join(config.root, name) + const text = await fs.readFile(file, "utf8").catch(() => undefined) + if (text === undefined) continue + const parsed = parse(text, [], { allowTrailingComma: true, disallowComments: false }) as Record | undefined + merged = mergeDeep(merged, normalizePermission(parsed?.permission)) as Record + } + return merged +} + +function patch(input: string, keys: (string | number)[], value: unknown) { + return applyEdits( + input, + modify(input, keys, value, { + formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, + }), + ) +} + +// Tool permission keys that send content off the machine. With the egress guard +// on, each gets an "ask" rule so a local-first session escalates to the network +// only with per-step approval. User config merges after agent rulesets +// (last-match-wins), so these override the agents' built-in "allow". +export const EGRESS_PERMISSIONS = ["websearch", "webfetch", "codesearch"] as const + +export async function wireLocalProvider(input: { + baseURL: string + modelID: string + tier: Pick + egressGuard?: boolean + env?: NodeJS.ProcessEnv + home?: string + paths?: LocalPaths +}) { + const env = input.env ?? process.env + const home = input.home ?? env.OPENCODE_TEST_HOME ?? os.homedir() + const paths = input.paths ?? getLocalPaths(env, home) + const config = configFile(env, home) + await fs.mkdir(config.root, { recursive: true }) + const file = await winningConfigFile(config) + const before = await fs.readFile(file, "utf8").catch(() => "{}") + const errors: ParseError[] = [] + const parsed = parse(before, errors, { allowTrailingComma: true, disallowComments: false }) as Record + if (errors.length > 0 || !parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Refusing to update invalid JSON/JSONC config: ${file}`) + } + + const advertisedContext = Math.floor(input.tier.ctx / input.tier.parallel) + // Deep-merge onto any pre-existing `provider.local` the user already + // defined instead of replacing it wholesale — a prior custom provider can + // carry its own extra models, options, or top-level keys, and those must + // survive re-wiring. We only own baseURL/apiKey (under options) and our + // own model entry (under models); everything else the user set is spread + // in first and kept as-is. + const existingProvider = asRecord(asRecord(parsed.provider).local) + const existingOptions = asRecord(existingProvider.options) + const existingModels = asRecord(existingProvider.models) + const provider = { + ...existingProvider, + name: "Local OpenAI-compatible", + npm: "@ai-sdk/openai-compatible", + options: { + ...existingOptions, + apiKey: "local", + baseURL: input.baseURL, + }, + models: { + ...existingModels, + [input.modelID]: { + name: input.modelID, + tool_call: true, + reasoning: true, + temperature: true, + interleaved: { field: "reasoning_content" }, + limit: { context: advertisedContext, output: 16384 }, + }, + }, + } + + let updated = before + if (!("$schema" in parsed)) updated = patch(updated, ["$schema"], "https://altimate.ai/config.json") + updated = patch(updated, ["provider", "local"], provider) + if (!("model" in parsed)) updated = patch(updated, ["model"], `local/${input.modelID}`) + // Whether the config's default `model` actually resolves to this local model: + // the patch above never overwrites an existing value (see comment above), + // so a user with a cloud default keeps using it silently after setup + // reports "Ready" — callers use this to warn instead of implying the + // switch happened. Computed here (before the agent-tuning patch below) + // because that patch is itself gated on this. + const defaultModelIsLocal = !("model" in parsed) || parsed.model === `local/${input.modelID}` + if (defaultModelIsLocal) { + // "builder" is the real built-in agent; "build" is only a config-lookup + // alias that fires when no `agent.build` entry exists (agent/agent.ts). + // Writing to "build" here would materialize a phantom non-native agent + // that shadows the alias, so these settings would never reach the actual + // builder agent. Only tune when the default model is actually local: + // "builder"/"general" are shared agents also used for cloud sessions, so + // clobbering their tuning when the user's cloud default is left in place + // (never-clobbered above) would silently change cloud behavior too. + for (const agent of ["builder", "general"] as const) { + updated = patch(updated, ["agent", agent, "temperature"], input.tier.agent.temperature) + updated = patch(updated, ["agent", agent, "options", "reasoningEffort"], input.tier.agent.reasoning_effort) + } + } + + // Keep internal machinery (compaction, title generation) on the local model: + // getSmallModel falls back to the session model today, but an explicit value + // survives future default changes without silently leaving the machine. + if (!("small_model" in parsed)) updated = patch(updated, ["small_model"], `local/${input.modelID}`) + + const guarded: string[] = [] + // Winning-file-only view: still correct for the "did WE previously set this + // to ask" ownership check below, since we only ever write guard keys into + // the winning file. The "does the user already have coverage" check further + // down needs the full merged view instead — see readEffectivePermission. + const permission = normalizePermission(parsed.permission) + if (input.egressGuard !== false) { + // Carry forward ownership from a prior guard-on wiring. Without this, a key already set to + // "ask" (because a previous guard-on run added it) matches itself in the "already covered" + // check below and gets skipped — so `guarded` would only ever contain keys added on THIS + // run, and a second guard-on run in a row would report `guarded: []`. That empty list then + // overwrites `guarded_permissions` in environment.json, and a later --no-egress-guard reads + // it back as "the guard owns nothing", removing none of the "ask" rules it actually added. + // Only keys still set to exactly "ask" are carried forward — if the user has since changed + // the value, we no longer own it. + const priorEnvironment = await readLocalEnvironment(paths) + const priorOwned = priorEnvironment?.egress_guard === true ? (priorEnvironment.guarded_permissions ?? EGRESS_PERMISSIONS) : [] + for (const key of priorOwned) { + if ((EGRESS_PERMISSIONS as readonly string[]).includes(key) && permission[key] === "ask") guarded.push(key) + } + // Checking only the winning file's own keys missed rules that live in a + // LOWER-precedence file Config still merges in — e.g. a global + // `{"*":"deny"}` with nothing in the winning file. Writing an exact "ask" + // rule into the winning file in that case would win under last-match-wins + // permission evaluation and silently widen the user's effective policy, + // so check every existing config file's merged effective permission. + const effectivePermissionKeys = Object.keys(await readEffectivePermission(config)) + for (const key of EGRESS_PERMISSIONS) { + if (guarded.includes(key)) continue + // Skip if the user already has ANY rule that resolves for this tool — + // an exact key or a wildcard/pattern key (e.g. "*": "deny") that would + // already cover it. Checking only exact-key presence missed wildcard + // rules: adding "ask" here would widen a user's broader top-level rule + // the moment this key happens to sort after it in the permission + // engine's evaluation order. Never clobber their config. + if (effectivePermissionKeys.some((existing) => Wildcard.match(key, existing))) continue + updated = patch(updated, ["permission", key], "ask") + guarded.push(key) + } + } else { + // Reversible: --no-egress-guard removes only permission keys THIS wiring + // actually added under the guard, recorded in environment.json's + // guarded_permissions (see writeLocalEnvironment below) — never a value + // the user configured independently, and never rules from a run that + // had the guard off in the first place. Older environment files (written + // before guarded_permissions existed) only recorded the boolean; fall + // back to the previous coarse heuristic (treat every egress permission + // as possibly guard-owned) for those so upgrading doesn't stop honoring + // --no-egress-guard on state from an older setup. + const priorEnvironment = await readLocalEnvironment(paths) + if (priorEnvironment?.egress_guard === true) { + const ownedKeys = priorEnvironment.guarded_permissions ?? EGRESS_PERMISSIONS + for (const key of ownedKeys) { + if ((EGRESS_PERMISSIONS as readonly string[]).includes(key) && permission[key] === "ask") { + updated = patch(updated, ["permission", key], undefined) + } + } + } + } + if (!updated.endsWith("\n")) updated += "\n" + + if (updated !== before) { + const temp = `${file}.${process.pid}.tmp` + await fs.writeFile(temp, updated, { mode: 0o600 }) + await fs.rename(temp, file) + } + await fs.chmod(file, 0o600) + await writeLocalEnvironment(input.tier.agent.tool_retrieval, paths, input.egressGuard !== false, guarded) + return { file, changed: updated !== before, advertisedContext, guarded, defaultModelIsLocal } +} + +// Resolve the effective action for `key` the way the permission engine does: +// the LAST rule (in config key order) whose pattern matches wins, including +// wildcard/pattern keys like "*". A non-string value at the exact key is a +// nested per-pattern ruleset, not a simple top-level decision — report it as +// "custom" rather than trying to resolve a single winner from it. +function resolveEgressAction(permission: Record, key: string): string { + if (key in permission && typeof permission[key] !== "string") return "custom" + let resolved: string | undefined + for (const [pattern, value] of Object.entries(permission)) { + if (typeof value !== "string") continue + if (Wildcard.match(key, pattern)) resolved = value + } + return resolved ?? "allow (no rule)" +} + +// Effective egress-guard state for `altimate local status`: what each +// network-egress permission resolves to in the user config file. +export async function readEgressGuard(env?: NodeJS.ProcessEnv, home?: string) { + const resolvedEnv = env ?? process.env + const resolvedHome = home ?? resolvedEnv.OPENCODE_TEST_HOME ?? os.homedir() + const config = configFile(resolvedEnv, resolvedHome) + const file = await winningConfigFile(config) + const text = await fs.readFile(file, "utf8").catch(() => undefined) + if (text === undefined) return Object.fromEntries(EGRESS_PERMISSIONS.map((key) => [key, "allow (no rule)"])) + const parsed = parse(text, [], { allowTrailingComma: true, disallowComments: false }) as + | Record + | undefined + const permission = normalizePermission(parsed?.permission) + return Object.fromEntries(EGRESS_PERMISSIONS.map((key) => [key, resolveEgressAction(permission, key)])) +} diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index d1c2b9e171..c3b5300266 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -40,6 +40,11 @@ export namespace ProviderError { /greater than the context length/i, // LM Studio /context window exceeds limit/i, // MiniMax /exceeded model token limit/i, // Kimi For Coding, Moonshot + // altimate_change start — upstream_fix: SGLang phrasing is unmatched by + // every pattern above, so its 400 bypassed overflow recovery and killed + // the session instead of triggering auto-compaction. + /exceeds the model's maximum context/i, // SGLang + // altimate_change end /context[_ ]length[_ ]exceeded/i, // Generic fallback /request entity too large/i, // HTTP 413 /the request was too long/i, // Azure OpenAI diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 8dbc383bdf..778687a95f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -16,6 +16,10 @@ import { Config } from "@/config/config" import { ProviderTransform } from "@/provider/transform" import { Telemetry } from "@/telemetry" // altimate_change — telemetry for compaction events import { ModelID, ProviderID } from "@/provider/schema" +// altimate_change start — summarizer-integrity error (harness plan W1.6 / item 3) +import { NamedError } from "@opencode-ai/util/error" +import type { LLM } from "./llm" +// altimate_change end // altimate_change start — Effect Context.Service facade for the upstream runtime import { Context, Effect, Layer } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -97,6 +101,45 @@ export namespace SessionCompaction { } // altimate_change end + // altimate_change start — proactive overflow tail estimator: the usage recorded + // on lastFinished is from the LAST assistant turn; tool results appended since + // then are not counted, and one oversized output can jump the session past the + // window between checks — a common failure mode on small-context models. Exported (not + // an inline IIFE in the prompt loop) so it's unit-testable on its own. + export function uncountedTailTokens(input: { messages: MessageV2.WithParts[]; lastFinishedId?: MessageID }) { + if (!input.lastFinishedId) return 0 + const index = input.messages.findIndex((m) => m.info.id === input.lastFinishedId) + if (index < 0) return 0 + let tokens = 0 + // altimate_change start — count completed tool output living ON lastFinished itself. + // The usage snapshot on lastFinished is taken when its LLM call's finish-step fires, + // which happens once that step's own tool calls have already been executed and their + // results written onto this SAME message (see processor.ts "tool-result" case, which + // runs before "finish-step" within a step). That usage reflects only the model's own + // input/output tokens — a tool's own output size is never sent back to the provider + // within that step, so it's never part of the recorded count. Slicing strictly AFTER + // lastFinishedId (the pre-existing behavior below) misses this entirely: a large tool + // result can sit uncounted on lastFinished until the NEXT overflow check, one full + // turn late. Text/reasoning parts on lastFinished are excluded — those WERE generated + // by this step and are already inside its recorded output tokens. + const lastFinishedMessage = input.messages[index]! + for (const part of lastFinishedMessage.parts) { + if (part.type === "tool" && part.state?.status === "completed") tokens += Token.estimate(part.state.output ?? "") + } + // altimate_change end + for (const m of input.messages.slice(index + 1)) { + for (const part of m.parts) { + if (part.type === "text") tokens += Token.estimate(part.text ?? "") + if (part.type === "tool" && part.state?.status === "completed") tokens += Token.estimate(part.state.output ?? "") + } + } + // 0.8: same safety margin fitHead applies to its budget — Token.estimate can + // undercount dense code/JSON tool output, so inflate the tail estimate before + // it feeds the overflow threshold check. + return Math.ceil(tokens / 0.8) + } + // altimate_change end + export const PRUNE_MINIMUM = 20_000 export const PRUNE_PROTECT = 40_000 @@ -203,6 +246,57 @@ export namespace SessionCompaction { return undefined } + // altimate_change start — head-truncation fallback for un-compactable sessions + // A session can overflow so far past the window (huge tool result landing in + // one turn) that the summarization request itself no longer fits, which used + // to terminate the session with "too large to compact". Summarizing a + // truncated head is lossy; killing the session loses everything. + export async function fitHead(input: { head: MessageV2.WithParts[]; model: Provider.Model }) { + const context = input.model.limit.context + if (context === 0) return { head: input.head, dropped: 0 } + const maxOutput = ProviderTransform.maxOutputTokens(input.model) + const base = input.model.limit.input ?? context + // 0.8: Token.estimate undercounts dense code/tool output on some tokenizers. + const budget = Math.floor(Math.max(0, base - maxOutput - 2_000) * 0.8) + // altimate_change start — upstream_fix: a non-positive budget means the model's + // context can't fit any head alongside its own output reservation — returning + // `input.head` unchanged here (the original guard) reproduces the exact overflow + // fitHead exists to recover from. An empty head is safe for the same reason the + // shrink loop below treats one as safe: the caller always appends its own + // trailing user prompt after `head`. + if (budget <= 0) return { head: [], dropped: input.head.length } + // altimate_change end + let head = input.head + let dropped = 0 + // altimate_change start — upstream_fix: keep shrinking down to (and including) + // an empty head. The original guard was `head.length > 1`, which stops the loop + // the moment one message remains WITHOUT re-checking whether that single message + // still exceeds budget (a lone assistant message with an oversized tool result + // can itself blow the window) — the fallback then silently returns an oversized + // head, defeating its own purpose. Dropping to an empty head is safe: the caller + // always appends its own trailing user prompt after `head` (see the summarizer + // call site), so an empty head still produces a valid, non-empty request. + while (head.length > 0 && (await estimate({ messages: head, model: input.model })) > budget) { + const step = Math.max(1, Math.floor(head.length / 8)) + // Round the cut forward to the next turn boundary: a head that starts + // mid-turn (assistant/tool messages with no leading user turn) is + // rejected by providers with a 400, defeating the fallback entirely. + let cut = step + while (cut < head.length && head[cut]!.info.role !== "user") cut++ + // No user boundary exists anywhere in the remainder — reverting to the + // raw `step` offset would still start the head mid-turn, the exact 400 + // this rounding exists to prevent. Drop the whole remaining head + // instead: safe for the same reason the empty-head case above is safe + // (the caller always appends its own trailing user prompt). + if (cut >= head.length) cut = head.length + head = head.slice(cut) + dropped += cut + } + // altimate_change end + return { head, dropped } + } + // altimate_change end + async function select(input: { messages: MessageV2.WithParts[]; cfg: ConfigInfo; model: Provider.Model }) { const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS if (limit <= 0) return { head: input.messages, tail_start_id: undefined } @@ -492,16 +586,43 @@ When constructing the summary, try to stick to this template: ---` const promptText = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n") - const result = await processor.process({ + // altimate_change start — summarizer integrity (harness plan W1.6 / item 3): + // hoist the summarizer input so a failed attempt can be retried with identical + // input, and pass an explicit toolChoice "none". Previously toolChoice was + // undefined, which the AI SDK defaults to "auto" — models could spend the + // summary step on a tool call and commit a summary with no text. + const summarizerInput: LLM.StreamInput = { user: userMessage, agent, abort: input.abort, sessionID: input.sessionID, tools: {}, system: [], + toolChoice: "none" as const, messages: [ - // altimate_change start — upstream_fix: summarize only the selected head when preserving recent tail - ...(await MessageV2.toModelMessages(selected.head, model, { stripMedia: true })), + // altimate_change start — upstream_fix: summarize only the selected head when preserving recent tail; + // trim the head from the front when even the summarization request cannot fit the window + ...(await MessageV2.toModelMessages( + await (async () => { + const fitted = await fitHead({ head: selected.head, model }) + if (fitted.dropped > 0) { + log.warn("compaction head truncated to fit window", { + dropped: fitted.dropped, + kept: fitted.head.length, + }) + Telemetry.track({ + type: "compaction_head_truncated", + timestamp: Date.now(), + session_id: input.sessionID, + dropped_messages: fitted.dropped, + kept_messages: fitted.head.length, + }) + } + return fitted.head + })(), + model, + { stripMedia: true }, + )), // altimate_change end { role: "user", @@ -514,7 +635,29 @@ When constructing the summary, try to stick to this template: }, ], model, - }) + } + // A "continue" result was previously committed regardless of whether the + // summary step produced any text — an empty summary erases history (the + // post-compaction amnesia signature). Guard the commit: retry ONCE with + // identical input, then mark the summary message as errored and stop. + const summaryHasText = () => + MessageV2.get({ sessionID: input.sessionID, messageID: msg.id }).parts.some( + (part) => part.type === "text" && part.text.trim().length > 0, + ) + let result = await processor.process(summarizerInput) + if (result === "continue" && !summaryHasText()) { + log.warn("compaction summary empty, retrying once", { sessionID: input.sessionID }) + result = await processor.process(summarizerInput) + if (result === "continue" && !summaryHasText()) { + processor.message.error = new NamedError.Unknown({ + message: "Compaction summarizer produced no summary text after retry", + }).toObject() + processor.message.finish = "error" + await Session.updateMessage(processor.message) + result = "stop" + } + } + // altimate_change end if (result === "compact") { processor.message.error = new MessageV2.ContextOverflowError({ @@ -565,6 +708,16 @@ When constructing the summary, try to stick to this template: }) } } else { + // altimate_change start — harness plan W1.5 / item 12: the continue message + // carries the original format/tools/system/variant, exactly as the replay + // branch above copies them from the original user message. Dropping them made + // the first auto-compaction silently reset the session's tool allowlist, + // custom system prompt, output format, and variant. The compaction marker + // (this branch's userMessage) never carries these fields, so source them from + // the most recent real (non-compaction) user message; no-op when never set. + const original = messages.findLast( + (m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"), + )?.info as MessageV2.User | undefined const continueMsg = await Session.updateMessage({ id: MessageID.ascending(), role: "user", @@ -572,7 +725,12 @@ When constructing the summary, try to stick to this template: time: { created: Date.now() }, agent: userMessage.agent, model: userMessage.model, + format: original?.format ?? userMessage.format, + tools: original?.tools ?? userMessage.tools, + system: original?.system ?? userMessage.system, + variant: original?.variant ?? userMessage.variant, }) + // altimate_change end const text = (input.overflow ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 893f4dda4d..fb5a998f77 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -173,19 +173,7 @@ export namespace LLM { // tools absent from the current set. Add stub definitions for any missing tools. // Fixes: https://github.com/AltimateAI/altimate-code/issues/678 const referencedTools = toolNamesFromMessages(input.messages) - for (const name of referencedTools) { - if (!Object.hasOwn(tools, name)) { - tools[name] = tool({ - description: `[Historical] Tool no longer available in this session`, - inputSchema: jsonSchema({ type: "object", properties: {} }), - execute: async () => ({ - output: "This tool is no longer available. Please use an alternative approach.", - title: "", - metadata: {}, - }), - }) - } - } + addHistoricalToolStubs(tools, referencedTools) // altimate_change end // altimate_change start — tool retrieval @@ -340,6 +328,40 @@ export namespace LLM { } return names } + + // Mutates `tools`, adding a stub definition for every referenced historical tool + // name that has no real definition (see toolNamesFromMessages above / issue #678). + // + // Harness plan W1.6 / item 3: when the call exposes ZERO real tools (e.g. the + // compaction summarizer, which passes tools: {} and toolChoice "none"), skip stub + // injection entirely. With an empty tool set the AI SDK omits both `tools` and + // `tool_choice` from the request, which every provider accepts — this is the + // compat fallback for providers whose OpenAI-compat layer rejects toolChoice + // "none". Injecting stubs here would instead advertise callable tools on a call + // that must produce text only. + export function addHistoricalToolStubs(tools: Record, referenced: Iterable) { + // upstream_fix: "invalid" is the AI-SDK fallback tool for malformed tool calls + // (see the retrieval exemption above), not a real user-facing tool. A resolved + // set containing ONLY "invalid" is functionally the zero-real-tools case this + // guard exists for — treating it as non-empty injected historical stubs that + // then became active/callable on a turn that was meant to have no real tools. + const realToolCount = Object.keys(tools).filter((name) => name !== "invalid").length + if (realToolCount === 0) return tools + for (const name of referenced) { + if (!Object.hasOwn(tools, name)) { + tools[name] = tool({ + description: `[Historical] Tool no longer available in this session`, + inputSchema: jsonSchema({ type: "object", properties: {} }), + execute: async () => ({ + output: "This tool is no longer available. Please use an alternative approach.", + title: "", + metadata: {}, + }), + }) + } + } + return tools + } // altimate_change end // altimate_change start — Effect Context.Service facade so the new upstream consumers diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index d89899520e..5b926e2e7f 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -31,6 +31,37 @@ export namespace MessageV2 { return mime.startsWith("image/") || mime === "application/pdf" } + // altimate_change start — W1.8: deterministic tool-call id sanitation. Some + // OpenAI-compatible servers emit non-string (numeric/object) tool-call ids; + // providers reject any request whose tool_use/tool_result pair carries a + // malformed or mismatched id. Valid non-empty strings pass through untouched. + // Anything else is regenerated deterministically (FNV-1a over the JSON form), + // so the SAME raw value always maps to the SAME id — the property that keeps + // the call half and the result half of a pair consistent whether coerced at + // ingestion (processor.ts) or defensively at replay (toModelMessagesEffect). + export function sanitizeToolCallID(id: unknown): string { + if (typeof id === "string" && id.length > 0) return id + const raw = typeof id === "string" ? id : (JSON.stringify(id) ?? String(id)) + // upstream_fix: two independent FNV-1a passes (different seeds) concatenated + // into a 64-bit digest instead of one 32-bit pass. A single 32-bit digest has + // only ~4B buckets, so two DISTINCT malformed ids can collide and get the same + // toolCallId, breaking replay/provider pairing; widening cuts that collision + // probability from ~1e-9 to ~1e-19 for the same volume of malformed ids, with + // no loss of determinism — both ingestion (processor.ts) and replay call this + // same function, so they still agree on the mapping. + let hashA = 0x811c9dc5 + let hashB = 0x9e3779b9 + for (let i = 0; i < raw.length; i++) { + const c = raw.charCodeAt(i) + hashA ^= c + hashA = Math.imul(hashA, 0x01000193) + hashB ^= c + hashB = Math.imul(hashB, 0x85ebca6b) + } + return "call_" + (hashA >>> 0).toString(16).padStart(8, "0") + (hashB >>> 0).toString(16).padStart(8, "0") + } + // altimate_change end + // altimate_change start — shared synthetic-attachment prompt text. Used both when // injecting tool-result media as a user message (below) and by the GitHub Copilot // plugin's imgMsg() heuristic so the two stay in sync. @@ -781,6 +812,14 @@ export namespace MessageV2 { }) if (part.type === "tool") { toolNames.add(part.tool) + // altimate_change start — W1.8: defensive replay-side id coercion. Parts + // persisted after the ingestion fix already carry sanitized string ids; + // transcripts written before it may hold malformed (non-string) callIDs. + // Computing the sanitized id ONCE per tool part and using it for every + // rendered half guarantees the tool-call and its paired tool-result emit + // identical toolCallId values, so provider pairing validation cannot 400. + const replayCallID = sanitizeToolCallID(part.callID) + // altimate_change end if (part.state.status === "completed") { // altimate_change start — toolOutputMaxChars truncates long tool output for compaction const rawOutputText = part.state.time.compacted @@ -816,7 +855,9 @@ export namespace MessageV2 { assistantMessage.parts.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-available", - toolCallId: part.callID, + // altimate_change start — W1.8 replay-side id coercion + toolCallId: replayCallID, + // altimate_change end input: part.state.input, output, ...(differentModel ? {} : { callProviderMetadata: part.metadata }), @@ -829,7 +870,7 @@ export namespace MessageV2 { assistantMessage.parts.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-available", - toolCallId: part.callID, + toolCallId: replayCallID, input: part.state.input, output, ...(differentModel ? {} : { callProviderMetadata: part.metadata }), @@ -838,7 +879,7 @@ export namespace MessageV2 { assistantMessage.parts.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-error", - toolCallId: part.callID, + toolCallId: replayCallID, input: part.state.input, errorText: part.state.error, ...(differentModel ? {} : { callProviderMetadata: part.metadata }), @@ -852,7 +893,9 @@ export namespace MessageV2 { assistantMessage.parts.push({ type: ("tool-" + part.tool) as `tool-${string}`, state: "output-error", - toolCallId: part.callID, + // altimate_change start — W1.8 replay-side id coercion + toolCallId: replayCallID, + // altimate_change end input: part.state.input, errorText: "[Tool execution was interrupted]", ...(differentModel ? {} : { callProviderMetadata: part.metadata }), diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index acc3235c61..53c58f2f0a 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -39,6 +39,34 @@ export namespace SessionProcessor { export type Info = Awaited> export type Result = Awaited> + // altimate_change start — W1.8: per-processor tool-call id coercer. Malformed + // (non-string) ids from OpenAI-compatible servers are regenerated deterministically + // via MessageV2.sanitizeToolCallID; the raw→sanitized alias map (keyed on the JSON + // form) makes the propagation to paired tool-result/tool-error events atomic — even + // when the provider flips the value's type mid-pair (numeric call id, string result + // id), both halves resolve to the SAME sanitized id. A regenerated call id with an + // un-regenerated result id would 400 every subsequent provider request. + // Exported as a factory so the ingestion half is unit-testable against the replay + // half in message-v2.ts (they must produce identical output for a pair). + export function createToolCallIDCoercer() { + // upstream_fix: null-prototype map. A plain `{}` resolves reserved keys like + // "toString"/"constructor"/"__proto__" to inherited Object.prototype values — + // a provider emitting one of those as a raw tool-call id would read back a + // FUNCTION where a sanitized string id is expected, and writing "__proto__" + // reassigns the object's prototype instead of storing an alias. Mirrors the + // same guard on CURATED_PROVIDER_ENUM in altimate/telemetry/index.ts. + const aliases: Record = Object.create(null) + return (raw: unknown): string => { + const key = typeof raw === "string" ? raw : (JSON.stringify(raw) ?? String(raw)) + const existing = aliases[key] + if (existing !== undefined) return existing + const sanitized = MessageV2.sanitizeToolCallID(raw) + aliases[key] = sanitized + return sanitized + } + } + // altimate_change end + export function create(input: { assistantMessage: MessageV2.Assistant sessionID: SessionID @@ -46,6 +74,10 @@ export namespace SessionProcessor { abort: AbortSignal }) { const toolcalls: Record = {} + // altimate_change start — W1.8: coerce malformed tool-call ids at ingestion; + // sanitized ids are used as BOTH the persisted callID and the pairing key. + const coerceToolCallID = createToolCallIDCoercer() + // altimate_change end // altimate_change start — per-tool call counter for varied-input loop detection const toolCallCounts: Record = {} // altimate_change end @@ -70,7 +102,9 @@ export namespace SessionProcessor { return input.assistantMessage }, partFromToolCall(toolCallID: string) { - return toolcalls[toolCallID] + // altimate_change start — W1.8: tool-execution lookups use the same coercion + return toolcalls[coerceToolCallID(toolCallID)] + // altimate_change end }, async process(streamInput: LLM.StreamInput) { log.info("process") @@ -146,20 +180,24 @@ export namespace SessionProcessor { break case "tool-input-start": + // altimate_change start — W1.8: sanitize the incoming id before it + // becomes the persisted callID and the pairing key. + const inputStartCallID = coerceToolCallID(value.id) const part = await Session.updatePart({ - id: toolcalls[value.id]?.id ?? PartID.ascending(), + id: toolcalls[inputStartCallID]?.id ?? PartID.ascending(), messageID: input.assistantMessage.id, sessionID: input.assistantMessage.sessionID, type: "tool", tool: value.toolName, - callID: value.id, + callID: inputStartCallID, state: { status: "pending", input: {}, raw: "", }, }) - toolcalls[value.id] = part as MessageV2.ToolPart + toolcalls[inputStartCallID] = part as MessageV2.ToolPart + // altimate_change end break case "tool-input-delta": @@ -169,7 +207,10 @@ export namespace SessionProcessor { break case "tool-call": { - const match = toolcalls[value.toolCallId] + // altimate_change start — W1.8: resolve the pair via the coerced id + const toolCallCallID = coerceToolCallID(value.toolCallId) + const match = toolcalls[toolCallCallID] + // altimate_change end if (match) { const part = await Session.updatePart({ ...match, @@ -190,7 +231,9 @@ export namespace SessionProcessor { : value.providerMetadata, // altimate_change end }) - toolcalls[value.toolCallId] = part as MessageV2.ToolPart + // altimate_change start — W1.8: key by the coerced id + toolcalls[toolCallCallID] = part as MessageV2.ToolPart + // altimate_change end // altimate_change start — session has now tool-called; suppresses plan refusal warning sessionToolCallsMade++ // altimate_change end @@ -256,7 +299,10 @@ export namespace SessionProcessor { break } case "tool-result": { - const match = toolcalls[value.toolCallId] + // altimate_change start — W1.8: resolve the pair via the coerced id + const toolResultCallID = coerceToolCallID(value.toolCallId) + const match = toolcalls[toolResultCallID] + // altimate_change end if (match && match.state.status === "running") { await Session.updatePart({ ...match, @@ -274,13 +320,18 @@ export namespace SessionProcessor { }, }) - delete toolcalls[value.toolCallId] + // altimate_change start — W1.8: delete by the coerced id + delete toolcalls[toolResultCallID] + // altimate_change end } break } case "tool-error": { - const match = toolcalls[value.toolCallId] + // altimate_change start — W1.8: resolve the pair via the coerced id + const toolErrorCallID = coerceToolCallID(value.toolCallId) + const match = toolcalls[toolErrorCallID] + // altimate_change end if (match && match.state.status === "running") { await Session.updatePart({ ...match, @@ -301,7 +352,9 @@ export namespace SessionProcessor { ) { blocked = shouldBreak } - delete toolcalls[value.toolCallId] + // altimate_change start — W1.8: delete by the coerced id + delete toolcalls[toolErrorCallID] + // altimate_change end } break } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 268babfc66..80a2bdb055 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -817,11 +817,21 @@ export namespace SessionPrompt { } // context overflow, needs compaction + // altimate_change start — proactive overflow check (see SessionCompaction.uncountedTailTokens) + const uncountedTail = SessionCompaction.uncountedTailTokens({ messages: msgs, lastFinishedId: lastFinished?.id }) if ( lastFinished && lastFinished.summary !== true && - (await SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model })) + (await SessionCompaction.isOverflow({ + tokens: { + ...lastFinished.tokens, + input: (lastFinished.tokens.input ?? 0) + uncountedTail, + total: lastFinished.tokens.total ? lastFinished.tokens.total + uncountedTail : lastFinished.tokens.total, + }, + model, + })) ) { + // altimate_change end await SessionCompaction.create({ sessionID, agent: lastUser.agent, @@ -1944,6 +1954,23 @@ export namespace SessionPrompt { } async function createUserMessage(input: PromptInput) { + // altimate_change start — upstream_fix: idempotent re-delivery for a client-supplied + // messageID. The run command retries with a stable messageID after an ambiguous + // network failure; the message row upserts by id, but parts always get fresh + // ascending ids, so a re-send appended a duplicate copy of every user part to the + // same message. If the message already landed with parts, return it untouched — + // no duplicate parts, no double plugin trigger. + if (input.messageID) { + try { + const existing = MessageV2.get({ sessionID: input.sessionID, messageID: input.messageID }) + if (existing.info.role === "user" && existing.parts.length > 0) { + return { info: existing.info, parts: existing.parts } + } + } catch { + // Not found — first delivery of this message, proceed normally. + } + } + // altimate_change end const agentName = input.agent ?? (await Agent.defaultAgent()) const agent = await Agent.get(agentName) if (!agent) { diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 8c55fb6c99..1c49a69707 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -24,6 +24,7 @@ import { Skill } from "@/skill" import { Fingerprint } from "../altimate/fingerprint" import { Config } from "../config/config" import { selectSkillsWithLLM } from "../altimate/skill-selector" +import { Retrieval } from "@/tool/retrieval" // altimate_change start — Effect Service facade for SystemPrompt.skills (see bottom of namespace) import { Context, Effect, Layer } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -159,7 +160,18 @@ export namespace SystemPrompt { "Use the skill tool to load a skill when a task matches its description.", // the agents seem to ingest the information about skills a bit better if we present a more verbose // version of them here and a less verbose version in tool description, rather than vice versa. - Skill.fmt(filtered, { verbose: true }), + // + // Token diet (local models): under ALTIMATE_TOOL_RETRIEVAL the listing keeps + // every skill name but compacts each description to its first sentence — + // multi-paragraph descriptions across dozens of installed skills otherwise + // dominate the first-turn prefill on small-context local models. Invoking a + // skill still loads its full body. + Retrieval.enabled() + ? Skill.fmt( + filtered.map((skill) => ({ ...skill, description: Retrieval.compactDescription(skill.description) })), + { verbose: false }, + ) + : Skill.fmt(filtered, { verbose: true }), ) return parts.join("\n") } diff --git a/packages/opencode/src/tool/retrieval.ts b/packages/opencode/src/tool/retrieval.ts index f81161d215..702bcc762a 100644 --- a/packages/opencode/src/tool/retrieval.ts +++ b/packages/opencode/src/tool/retrieval.ts @@ -49,6 +49,24 @@ export namespace Retrieval { return process.env["ALTIMATE_TOOL_RETRIEVAL"] === "1" } + /** + * Compact a multi-sentence description to its first sentence (capped), for + * token-diet listings (e.g. the skills block on small-context local models). + * Items stay discoverable by name; the full description loads on invocation. + */ + export function compactDescription(text: string | undefined, max = 160): string { + if (!text) return "" + const normalized = text.replace(/\s+/g, " ").trim() + // A terminator only ends the first sentence when followed by whitespace-then- + // uppercase (a new sentence starting) or the end of the string. This avoids + // mis-cutting on abbreviations ("e.g. run the linter") and decimals ("v2.0 + // models"), which a bare "terminator followed by whitespace" test would stop + // at prematurely. + const sentence = normalized.match(/^.*?[.!?](?=\s+[A-Z]|\s*$)/)?.[0] ?? normalized + if (sentence.length <= max) return sentence + return sentence.slice(0, max - 1).trimEnd() + "…" + } + function score(query: string, t: Tool): number { // Tokenize on alphanumerics + underscore so digits survive (e.g. "v2", "s3") // and hyphenated names split into matchable parts (e.g. "dbt-schema-verify"). diff --git a/packages/opencode/src/tool/truncate-core.ts b/packages/opencode/src/tool/truncate-core.ts new file mode 100644 index 0000000000..b4b994d530 --- /dev/null +++ b/packages/opencode/src/tool/truncate-core.ts @@ -0,0 +1,169 @@ +// Pure truncation-selection algorithm shared by `tool/truncate.ts` (the Effect +// Service every `Tool.define()` output is routed through via `tool.ts:wrap()` — +// this is what the bash tool actually uses in production) and +// `tool/truncation.ts` (the plain-async twin used directly by `bash.ts`'s +// description-text constants, `bootstrap.ts`'s cleanup scheduler, and +// `prompt.ts`'s MCP tool-output truncation). Both call this ONE algorithm so a +// future change to truncation behavior cannot silently apply on one call path +// and not the other, the way the pre-existing hand-duplicated implementations +// could. +export * as TruncateCore from "./truncate-core" + +export const MAX_LINES = 2000 +export const MAX_BYTES = 50 * 1024 + +// Head:tail split for "middle" (head+tail) truncation. First-principles, not +// fitted to any specific corpus: root-cause errors print FIRST for the +// common compiler/build/test tool families (tsc, pytest, gcc, dbt-compile, +// ...) while verdict/success lines print LAST — weighting toward the tail +// keeps the higher-density trailing content while still guaranteeing the +// command's first error line(s) survive at the head. Callers may override +// per call via `Options.headRatio`. +export const DEFAULT_HEAD_RATIO = 1 / 3 + +// Promoted default (was "head"): pure head truncation silently drops +// trailing content — including the success/verdict line most command +// families print last. "middle" is family-neutral by construction. +export const DEFAULT_DIRECTION: Direction = "middle" + +export type Direction = "head" | "tail" | "middle" + +export interface Options { + maxLines?: number + maxBytes?: number + direction?: Direction + headRatio?: number +} + +export interface ResolvedOptions { + maxLines: number + maxBytes: number + direction: Direction + headRatio: number +} + +export interface Preview { + head: string + tail: string + removed: number + unit: "bytes" | "lines" +} + +export function fits(lines: string[], totalBytes: number, maxLines: number, maxBytes: number): boolean { + return lines.length <= maxLines && totalBytes <= maxBytes +} + +interface Selection { + lines: string[] + bytes: number + hitBytes: boolean +} + +function selectFromHead(lines: string[], maxLines: number, maxBytes: number): Selection { + const out: string[] = [] + let bytes = 0 + let hitBytes = false + for (let i = 0; i < lines.length && out.length < maxLines; i++) { + const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.push(lines[i]) + bytes += size + } + return { lines: out, bytes, hitBytes } +} + +// `notBefore`: lowest index the tail selection may consume, so a "middle" +// selection can never re-select a line already claimed by the head half. +function selectFromTail(lines: string[], maxLines: number, maxBytes: number, notBefore: number): Selection { + const out: string[] = [] + let bytes = 0 + let hitBytes = false + for (let i = lines.length - 1; i >= notBefore && out.length < maxLines; i--) { + const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.unshift(lines[i]) + bytes += size + } + return { lines: out, bytes, hitBytes } +} + +/** + * Selects the preview lines to keep for `lines`/`totalBytes` under the given + * direction and budget. Callers must first confirm `fits()` is false — + * `preview()` always assumes at least one line/byte is being removed. + */ +export function preview(lines: string[], totalBytes: number, opts: ResolvedOptions): Preview { + const { maxLines, maxBytes, direction, headRatio } = opts + + if (direction === "tail") { + const sel = selectFromTail(lines, maxLines, maxBytes, 0) + const removed = sel.hitBytes ? totalBytes - sel.bytes : lines.length - sel.lines.length + return { head: "", tail: sel.lines.join("\n"), removed, unit: sel.hitBytes ? "bytes" : "lines" } + } + + if (direction === "middle") { + // A non-finite or out-of-[0,1] headRatio would make one sub-budget larger + // than the total maxBytes, letting the assembled preview exceed it. Clamp + // finite values and fall back to the default for invalid ones. + const safeHeadRatio = Number.isFinite(headRatio) ? Math.min(1, Math.max(0, headRatio)) : DEFAULT_HEAD_RATIO + const headBudgetLines = Math.max(1, Math.floor(maxLines * safeHeadRatio)) + // Not `Math.max(1, ...)`: flooring the tail budget to 1 would let the + // two halves together exceed maxLines (e.g. maxLines=1 -> head claims + // the only line, but tail would still floor up to 1 and add a second). + const tailBudgetLines = Math.max(0, maxLines - headBudgetLines) + const headBudgetBytes = Math.max(1, Math.floor(maxBytes * safeHeadRatio)) + const tailBudgetBytes = Math.max(0, maxBytes - headBudgetBytes) + + const headSel = selectFromHead(lines, headBudgetLines, headBudgetBytes) + // notBefore = headSel.lines.length: the tail walk stops at the boundary + // of what the head half already claimed, so the two halves never overlap. + const tailSel = selectFromTail(lines, tailBudgetLines, tailBudgetBytes, headSel.lines.length) + + // A boundary line bigger than its own head/tail share of the split budget + // used to be dropped by BOTH halves even when it fits the overall maxBytes, + // returning only the marker/hint with no content. Fall back to a plain head + // selection against the full (undivided) budget so it survives. + if (headSel.lines.length === 0 && tailSel.lines.length === 0 && lines.length > 0) { + const fallback = selectFromHead(lines, maxLines, maxBytes) + const removed = fallback.hitBytes ? totalBytes - fallback.bytes : lines.length - fallback.lines.length + return { + head: fallback.lines.join("\n"), + tail: "", + removed, + unit: fallback.hitBytes ? "bytes" : "lines", + } + } + + const keptLines = headSel.lines.length + tailSel.lines.length + const keptBytes = headSel.bytes + tailSel.bytes + const linesRemoved = Math.max(0, lines.length - keptLines) + const bytesRemoved = Math.max(0, totalBytes - keptBytes) + const hitBytes = headSel.hitBytes || tailSel.hitBytes + + return { + head: headSel.lines.join("\n"), + tail: tailSel.lines.join("\n"), + removed: hitBytes ? bytesRemoved : linesRemoved, + unit: hitBytes ? "bytes" : "lines", + } + } + + // direction === "head" + const sel = selectFromHead(lines, maxLines, maxBytes) + const removed = sel.hitBytes ? totalBytes - sel.bytes : lines.length - sel.lines.length + return { head: sel.lines.join("\n"), tail: "", removed, unit: sel.hitBytes ? "bytes" : "lines" } +} + +/** Assembles the final tool-output content from a preview, the retrieval hint, and direction. */ +export function assemble(p: Preview, hint: string, direction: Direction): string { + const marker = `...${p.removed} ${p.unit} truncated...` + if (direction === "tail") return `${marker}\n\n${hint}\n\n${p.tail}` + if (direction === "middle") return `${p.head}\n\n${marker}\n\n${hint}\n\n${p.tail}` + return `${p.head}\n\n${marker}\n\n${hint}` +} diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 81fdfa2b3e..6911853e87 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -8,21 +8,25 @@ import { evaluate } from "@/permission/evaluate" import { Config } from "@/config/config" import { ToolID } from "./schema" import { TRUNCATION_DIR } from "./truncation-dir" +// altimate_change start — W1.7: shared truncation algorithm (see truncate-core.ts +// header) so this Service and the tool/truncation.ts twin can't drift. +import { TruncateCore } from "./truncate-core" +// altimate_change end const RETENTION = Duration.days(7) -export const MAX_LINES = 2000 -export const MAX_BYTES = 50 * 1024 +// altimate_change start — W1.7: constants sourced from the shared truncate-core +export const MAX_LINES = TruncateCore.MAX_LINES +export const MAX_BYTES = TruncateCore.MAX_BYTES +// altimate_change end export const DIR = TRUNCATION_DIR export const GLOB = path.join(TRUNCATION_DIR, "*") export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } -export interface Options { - maxLines?: number - maxBytes?: number - direction?: "head" | "tail" -} +// altimate_change start — W1.7: options type sourced from the shared truncate-core +export type Options = TruncateCore.Options +// altimate_change end function hasTaskTool(agent?: Agent.Info) { if (!agent?.permission) return false @@ -92,48 +96,22 @@ export const layer = Layer.effect( } }) + // altimate_change start — W1.7: default direction "middle" (head+tail, + // tail-weighted elision) via the shared truncate-core.ts algorithm. const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) { const resolved = yield* limits() const maxLines = options.maxLines ?? resolved.maxLines const maxBytes = options.maxBytes ?? resolved.maxBytes - const direction = options.direction ?? "head" + const direction = options.direction ?? TruncateCore.DEFAULT_DIRECTION + const headRatio = options.headRatio ?? TruncateCore.DEFAULT_HEAD_RATIO const lines = text.split("\n") const totalBytes = Buffer.byteLength(text, "utf-8") - if (lines.length <= maxLines && totalBytes <= maxBytes) { + if (TruncateCore.fits(lines, totalBytes, maxLines, maxBytes)) { return { content: text, truncated: false } as const } - const out: string[] = [] - let i = 0 - let bytes = 0 - let hitBytes = false - - if (direction === "head") { - for (i = 0; i < lines.length && i < maxLines; i++) { - const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.push(lines[i]) - bytes += size - } - } else { - for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { - const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.unshift(lines[i]) - bytes += size - } - } - - const removed = hitBytes ? totalBytes - bytes : lines.length - out.length - const unit = hitBytes ? "bytes" : "lines" - const preview = out.join("\n") + const preview = TruncateCore.preview(lines, totalBytes, { maxLines, maxBytes, direction, headRatio }) const file = yield* write(text) const hint = hasTaskTool(agent) @@ -141,14 +119,12 @@ export const layer = Layer.effect( : `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` return { - content: - direction === "head" - ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` - : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`, + content: TruncateCore.assemble(preview, hint, direction), truncated: true, outputPath: file, } as const }) + // altimate_change end yield* cleanup().pipe( Effect.catchCause((cause) => Effect.logError("truncation cleanup failed", { cause: Cause.pretty(cause) })), diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index fbd92b1d7c..6bddf67c9c 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -7,10 +7,14 @@ import { Scheduler } from "../scheduler" import { Filesystem } from "../util/filesystem" import { Glob } from "../util/glob" import { ToolID } from "./schema" +// altimate_change start — W1.7: shared truncation algorithm (see truncate-core.ts +// header) so this twin and tool/truncate.ts's Effect Service can't drift. +import { TruncateCore } from "./truncate-core" +// altimate_change end export namespace Truncate { - export const MAX_LINES = 2000 - export const MAX_BYTES = 50 * 1024 + export const MAX_LINES = TruncateCore.MAX_LINES + export const MAX_BYTES = TruncateCore.MAX_BYTES export const DIR = path.join(Global.Path.data, "tool-output") export const GLOB = path.join(DIR, "*") const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days @@ -18,11 +22,7 @@ export namespace Truncate { export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } - export interface Options { - maxLines?: number - maxBytes?: number - direction?: "head" | "tail" - } + export type Options = TruncateCore.Options export function init() { Scheduler.register({ @@ -60,47 +60,21 @@ export namespace Truncate { return rule.action !== "deny" } + // altimate_change start — W1.7: default direction "middle" (head+tail, + // tail-weighted elision) via the shared truncate-core.ts algorithm. export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise { const maxLines = options.maxLines ?? MAX_LINES const maxBytes = options.maxBytes ?? MAX_BYTES - const direction = options.direction ?? "head" + const direction = options.direction ?? TruncateCore.DEFAULT_DIRECTION + const headRatio = options.headRatio ?? TruncateCore.DEFAULT_HEAD_RATIO const lines = text.split("\n") const totalBytes = Buffer.byteLength(text, "utf-8") - if (lines.length <= maxLines && totalBytes <= maxBytes) { + if (TruncateCore.fits(lines, totalBytes, maxLines, maxBytes)) { return { content: text, truncated: false } } - const out: string[] = [] - let i = 0 - let bytes = 0 - let hitBytes = false - - if (direction === "head") { - for (i = 0; i < lines.length && i < maxLines; i++) { - const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.push(lines[i]) - bytes += size - } - } else { - for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { - const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break - } - out.unshift(lines[i]) - bytes += size - } - } - - const removed = hitBytes ? totalBytes - bytes : lines.length - out.length - const unit = hitBytes ? "bytes" : "lines" - const preview = out.join("\n") + const preview = TruncateCore.preview(lines, totalBytes, { maxLines, maxBytes, direction, headRatio }) const id = ToolID.ascending() const filepath = path.join(DIR, id) @@ -109,11 +83,8 @@ export namespace Truncate { const hint = hasTaskTool(agent) ? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` : `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` - const message = - direction === "head" - ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` - : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}` - return { content: message, truncated: true, outputPath: filepath } + return { content: TruncateCore.assemble(preview, hint, direction), truncated: true, outputPath: filepath } } + // altimate_change end } diff --git a/packages/opencode/test/altimate/prompts-builder-dbt-guard.test.ts b/packages/opencode/test/altimate/prompts-builder-dbt-guard.test.ts new file mode 100644 index 0000000000..5832b3778e --- /dev/null +++ b/packages/opencode/test/altimate/prompts-builder-dbt-guard.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { readFileSync } from "fs" + +// builder.txt itself says "Never call raw `dbt` directly (except `dbt deps`)" — +// a prior revision of the Finish Protocol section told agents to run `dbt build` +// as its own example, directly contradicting that rule and risking the agent +// literally running the prohibited raw command. Guard against that regression. +const BUILDER_PROMPT_PATH = path.join(import.meta.dir, "../../src/altimate/prompts/builder.txt") + +describe("altimate/prompts/builder.txt", () => { + test("never instructs a raw `dbt` subcommand other than `dbt deps`", () => { + const text = readFileSync(BUILDER_PROMPT_PATH, "utf-8") + const rawDbtCommand = /`dbt (?!deps\b)[a-z-]+/g + const matches = text.match(rawDbtCommand) ?? [] + expect(matches).toEqual([]) + }) +}) diff --git a/packages/opencode/test/altimate/telemetry/onboarding.test.ts b/packages/opencode/test/altimate/telemetry/onboarding.test.ts index 563af6c467..e297ffe3d4 100644 --- a/packages/opencode/test/altimate/telemetry/onboarding.test.ts +++ b/packages/opencode/test/altimate/telemetry/onboarding.test.ts @@ -71,6 +71,41 @@ describe("onboarding abandonment", () => { expect((abandoned[0] as any).last_stage).toBe("provider_setup") }) + test("acknowledging the local-model interstitial and quitting is not reported as a stall at model_picker", async () => { + const events = captureEvents() + + // The picker cannot run the multi-minute `altimate local` setup itself — it + // hands the user a command and closes. There is no gateway/auth follow-up, + // so `opts.connected` never suppresses this the way it does for gateway + // providers. A real, completed choice must still advance the funnel past + // model_picker, the same way picking a gateway provider does. + await Onboarding.emit({ type: "onboarding_started" }) + await Onboarding.emit({ type: "model_picker_shown", trigger: "first_run" }) + await Onboarding.emit({ type: "local_model_info_shown" }) + await Onboarding.emit({ type: "local_model_choice", choice: "acknowledge" }) + await Onboarding.emitAbandonedIfIncomplete() + await settle() + + const abandoned = events.filter((e) => e.type === "onboarding_abandoned") + expect(abandoned).toHaveLength(1) + expect((abandoned[0] as any).last_stage).toBe("provider_setup") + }) + + test("cancelling or backing out of the local-model interstitial does not advance the funnel", async () => { + const events = captureEvents() + + await Onboarding.emit({ type: "onboarding_started" }) + await Onboarding.emit({ type: "model_picker_shown", trigger: "first_run" }) + await Onboarding.emit({ type: "local_model_info_shown" }) + await Onboarding.emit({ type: "local_model_choice", choice: "cancel" }) + await Onboarding.emitAbandonedIfIncomplete() + await settle() + + const abandoned = events.filter((e) => e.type === "onboarding_abandoned") + expect(abandoned).toHaveLength(1) + expect((abandoned[0] as any).last_stage).toBe("model_picker") + }) + test("a completed onboarding is never reported as abandoned", async () => { const events = captureEvents() diff --git a/packages/opencode/test/cli/run-accounting.test.ts b/packages/opencode/test/cli/run-accounting.test.ts new file mode 100644 index 0000000000..b6843f4de4 --- /dev/null +++ b/packages/opencode/test/cli/run-accounting.test.ts @@ -0,0 +1,260 @@ +// W1.10 — honest turn accounting: compaction-machinery steps must not consume the +// --max-turns budget. W1.12 (E4) — dual-attribution termination logging: every run +// records why_model_stopped AND why_harness_stopped as independent fields. +// W1.1 — real error serialization (never a bare name, "[object Object]", or "{}"). +import { describe, expect, test } from "bun:test" +import { RunAccounting } from "../../src/cli/cmd/run-accounting" + +describe("RunAccounting turn accounting (W1.10)", () => { + test("counts ordinary assistant steps", () => { + const acc = RunAccounting.create() + acc.onAssistantMessage({ id: "msg_1", agent: "build" }) + expect(acc.onStepStart("msg_1")).toBe(true) + expect(acc.onStepStart("msg_1")).toBe(true) + expect(acc.turnCount).toBe(2) + }) + + test("excludes compaction-machinery steps from turnCount", () => { + const acc = RunAccounting.create() + acc.onAssistantMessage({ id: "msg_work", agent: "build" }) + acc.onAssistantMessage({ id: "msg_compact", agent: "compaction" }) + expect(acc.onStepStart("msg_work")).toBe(true) + expect(acc.onStepStart("msg_compact")).toBe(false) + expect(acc.onStepStart("msg_compact")).toBe(false) + expect(acc.onStepStart("msg_work")).toBe(true) + expect(acc.turnCount).toBe(2) + }) + + test("a step whose owning message is unknown is counted (conservative default)", () => { + const acc = RunAccounting.create() + expect(acc.onStepStart("msg_unknown")).toBe(true) + expect(acc.turnCount).toBe(1) + }) + + test("compaction steps do not perturb termination attribution", () => { + const acc = RunAccounting.create() + acc.onAssistantMessage({ id: "msg_work", agent: "build" }) + acc.onAssistantMessage({ id: "msg_compact", agent: "compaction" }) + acc.onStepFinish("msg_work", "stop") + // compaction machinery finishing later must not overwrite the model's reason + acc.onStepFinish("msg_compact", "tool-calls") + acc.onText("msg_compact", "summary text DONE") + expect(acc.termination().why_model_stopped).toBe("stop") + }) +}) + +describe("RunAccounting termination attribution (W1.12 E4)", () => { + test("both fields are always present with valid enum values", () => { + const acc = RunAccounting.create() + const t = acc.termination() + expect(["stop", "tool-call", "explicit-done", "length", "content-filter", "unknown"]).toContain( + t.why_model_stopped, + ) + expect(["budget-exhausted", "timeout", "error", "idle-done", "none"]).toContain(t.why_harness_stopped) + }) + + test("a run with no step-finish at all attributes model=unknown, not a false 'stop'", () => { + // No onStepFinish call means lastFinishReason is undefined (e.g. a fatal abort + // before any model output). Previously this fell through to a default "stop", + // falsely claiming a clean model-side finish for a generation that never + // completed. + const acc = RunAccounting.create() + expect(acc.termination().why_model_stopped).toBe("unknown") + }) + + test("length/content-filter finish reasons are attributed distinctly, not collapsed into stop", () => { + for (const [reason, expected] of [ + ["length", "length"], + ["content-filter", "content-filter"], + ] as const) { + const acc = RunAccounting.create() + acc.onAssistantMessage({ id: "m1", agent: "build" }) + acc.onStepStart("m1") + acc.onStepFinish("m1", reason) + expect(acc.termination().why_model_stopped).toBe(expected) + } + }) + + test("natural finish: model=stop, harness=none", () => { + const acc = RunAccounting.create() + acc.onAssistantMessage({ id: "m1", agent: "build" }) + acc.onStepStart("m1") + acc.onStepFinish("m1", "stop") + expect(acc.termination()).toEqual({ why_model_stopped: "stop", why_harness_stopped: "none" }) + expect(acc.fatal).toBe(false) + }) + + test("model still tool-calling when harness exhausts the budget", () => { + const acc = RunAccounting.create() + acc.onAssistantMessage({ id: "m1", agent: "build" }) + acc.onStepStart("m1") + acc.onStepFinish("m1", "tool-calls") + acc.onBudgetExhausted() + expect(acc.termination()).toEqual({ why_model_stopped: "tool-call", why_harness_stopped: "budget-exhausted" }) + expect(acc.fatal).toBe(true) + }) + + test("explicit DONE assertion in the final text classifies as explicit-done", () => { + const acc = RunAccounting.create() + acc.onAssistantMessage({ id: "m1", agent: "build" }) + acc.onText("m1", "All checks pass. DONE") + acc.onStepFinish("m1", "stop") + expect(acc.termination().why_model_stopped).toBe("explicit-done") + }) + + test("a negated DONE assertion is not classified as explicit-done", () => { + for (const text of ["Still working on this. NOT DONE.", "I am not DONE", "not yet DONE"]) { + const acc = RunAccounting.create() + acc.onAssistantMessage({ id: "m1", agent: "build" }) + acc.onText("m1", text) + acc.onStepFinish("m1", "stop") + expect(acc.termination().why_model_stopped).toBe("stop") + } + }) + + test("a trailing DONE after an earlier negated DONE still classifies as explicit-done", () => { + const acc = RunAccounting.create() + acc.onAssistantMessage({ id: "m1", agent: "build" }) + acc.onText("m1", "The previous state was not DONE. DONE") + acc.onStepFinish("m1", "stop") + expect(acc.termination().why_model_stopped).toBe("explicit-done") + }) + + test("a trailing negated DONE after an earlier bare DONE is still not classified as explicit-done", () => { + const acc = RunAccounting.create() + acc.onAssistantMessage({ id: "m1", agent: "build" }) + acc.onText("m1", "DONE with step one, but overall not DONE") + acc.onStepFinish("m1", "stop") + expect(acc.termination().why_model_stopped).toBe("stop") + }) + + test("a later non-DONE text clears the explicit-done classification", () => { + const acc = RunAccounting.create() + acc.onAssistantMessage({ id: "m1", agent: "build" }) + acc.onText("m1", "DONE") + acc.onText("m1", "actually, one more thing") + acc.onStepFinish("m1", "stop") + expect(acc.termination().why_model_stopped).toBe("stop") + }) + + test("fatal session error attributes harness=error and flips fatal", () => { + const acc = RunAccounting.create() + acc.onSessionError("APIError", "boom") + expect(acc.termination().why_harness_stopped).toBe("error") + expect(acc.fatal).toBe(true) + }) + + test("timeout-shaped session error attributes harness=timeout", () => { + const acc = RunAccounting.create() + acc.onSessionError("UnknownError", "request timed out waiting for provider") + expect(acc.termination().why_harness_stopped).toBe("timeout") + }) + + test("recoverable ContextOverflowError does not flip fatal or the attribution", () => { + // Auto-compaction recovers overflow; the error event alone must not change rc. + const acc = RunAccounting.create() + acc.onSessionError("ContextOverflowError", "context window exceeded") + expect(acc.fatal).toBe(false) + expect(acc.termination().why_harness_stopped).toBe("none") + }) + + // Regression: onPromptResult used to route a terminal ContextOverflowError through + // onSessionError's recoverable-name filter — the same filter that (correctly) suppresses the + // MID-RUN streamed overflow event, since auto-compaction usually recovers from that one. But + // `info` here is the prompt() call's FINAL returned message: a ContextOverflowError reaching + // this point means auto-compaction itself already gave up (SessionCompaction.process's + // `result === "compact"` terminal path), so it must always be fatal, unlike the streamed case. + test("a terminal ContextOverflowError (compaction itself gave up) is fatal, unlike the recoverable mid-run event", () => { + const acc = RunAccounting.create() + acc.onPromptResult({ finish: "error", error: { name: "ContextOverflowError", data: { message: "too large" } } }) + expect(acc.fatal).toBe(true) + expect(acc.termination().why_harness_stopped).toBe("error") + }) + + test("terminal message with abnormal finish (error/other) is fatal (swallowed transport failure)", () => { + for (const finish of ["error", "other"]) { + const acc = RunAccounting.create() + acc.onPromptResult({ finish }) + expect(acc.fatal).toBe(true) + expect(acc.termination().why_harness_stopped).toBe("error") + } + }) + + test("terminal message with a normal finish is not fatal", () => { + for (const finish of ["stop", "length", "tool-calls", "content-filter", "unknown", undefined]) { + const acc = RunAccounting.create() + acc.onPromptResult({ finish }) + expect(acc.fatal).toBe(false) + } + }) + + test("terminal message carrying an error field is fatal via the session-error path", () => { + const acc = RunAccounting.create() + acc.onPromptResult({ finish: "stop", error: { name: "APIError", data: { message: "boom" } } }) + expect(acc.fatal).toBe(true) + expect(acc.termination().why_harness_stopped).toBe("error") + }) + + test("budget exhaustion takes precedence over a subsequent abort error", () => { + const acc = RunAccounting.create() + acc.onBudgetExhausted() + acc.onSessionError("MessageAbortedError", "aborted") + expect(acc.termination().why_harness_stopped).toBe("budget-exhausted") + }) + + test("the FIRST fatal error is preserved when a second, unrelated error follows", () => { + // A later cleanup/abort error (e.g. torn down after the original failure) + // must not overwrite the root cause that actually stopped the run. + const acc = RunAccounting.create() + acc.onSessionError("UnknownError", "request timed out waiting for provider") + acc.onSessionError("MessageAbortedError", "aborted during cleanup") + expect(acc.termination().why_harness_stopped).toBe("timeout") + }) +}) + +describe("RunAccounting.serializeSessionError (W1.1)", () => { + test("composes name, status, and message", () => { + expect( + RunAccounting.serializeSessionError({ name: "APIError", data: { message: "upstream broke", status: 502 } }), + ).toBe("APIError (status 502): upstream broke") + }) + + test("statusCode variant is picked up", () => { + expect(RunAccounting.serializeSessionError({ name: "APIError", data: { message: "x", statusCode: 500 } })).toBe( + "APIError (status 500): x", + ) + }) + + test("never returns a literal {} or [object Object]", () => { + for (const input of [{}, { name: "", data: {} }, { name: "E", data: { message: { nested: true } } }, null, 7]) { + const out = RunAccounting.serializeSessionError(input) + expect(out).not.toBe("{}") + expect(out).not.toContain("[object Object]") + expect(out.length).toBeGreaterThan(0) + } + }) + + test("name-only errors serialize to the name", () => { + expect(RunAccounting.serializeSessionError({ name: "MessageOutputLengthError", data: {} })).toBe( + "MessageOutputLengthError", + ) + }) +}) + +describe("RunAccounting retry classification (W1.1)", () => { + test("5xx statuses are retryable; 4xx and non-numbers are not", () => { + expect(RunAccounting.isRetryableStatus(500)).toBe(true) + expect(RunAccounting.isRetryableStatus(503)).toBe(true) + expect(RunAccounting.isRetryableStatus(400)).toBe(false) + expect(RunAccounting.isRetryableStatus(404)).toBe(false) + expect(RunAccounting.isRetryableStatus(undefined)).toBe(false) + }) + + test("timeouts and dropped connections are retryable thrown errors", () => { + expect(RunAccounting.isRetryableThrown(new Error("request timed out"))).toBe(true) + expect(RunAccounting.isRetryableThrown(Object.assign(new Error("io"), { code: "ECONNRESET" }))).toBe(true) + expect(RunAccounting.isRetryableThrown(new Error("fetch failed"))).toBe(true) + expect(RunAccounting.isRetryableThrown(new Error("model not found"))).toBe(false) + expect(RunAccounting.isRetryableThrown(undefined)).toBe(false) + }) +}) diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index bfb21aae2d..0d5d2b8cd0 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -73,21 +73,21 @@ describe("opencode run (non-interactive subprocess)", () => { 30_000, ) - // Locks in the current behavior: when the LLM stream errors mid-response - // (the prompt was accepted, then the upstream provider failed), opencode - // emits a session.error event and the process exits 0 today. - // - // This is debatable — a future cleanup might flip it to exit 1. If you're - // changing this expectation, do it deliberately and say so in the PR. + // W1.1 (harness-improvement plan): a run that ends with an unrecovered session + // error is a fatal abort and must exit nonzero — an honest rc is the contract + // automation needs. This deliberately flips the previous "exits 0 today" + // contract lock-in (its comment asked for exactly this kind of deliberate + // change). Recoverable errors (context overflow handled by auto-compaction) + // still exit 0; see RunAccounting.onSessionError. cliIt.concurrent( - "mid-stream LLM error still exits 0 today (contract lock-in)", + "mid-stream LLM error exits nonzero (W1.1 honest rc on fatal abort)", ({ llm, opencode }) => Effect.gen(function* () { yield* llm.fail("upstream provider exploded mid-stream") // bunRun: the compiled binary hangs handling a mid-stream stream error in the isolated test env - // (never exits); `bun run src` exits 0 in ~1s. See cliCommand in test/lib/cli-process.ts. + // (never exits); `bun run src` exits promptly. See cliCommand in test/lib/cli-process.ts. const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000, bunRun: true }) - expect(result.exitCode).toBe(0) + expect(result.exitCode).toBe(1) }), 60_000, ) diff --git a/packages/opencode/test/local/certify.test.ts b/packages/opencode/test/local/certify.test.ts new file mode 100644 index 0000000000..5c1a998c19 --- /dev/null +++ b/packages/opencode/test/local/certify.test.ts @@ -0,0 +1,139 @@ +import path from "node:path" +import { describe, expect, test } from "bun:test" + +import { tmpdir } from "../fixture/fixture" +import { certificateCacheKey, certify, check, flagsHash } from "../../src/local/certify" +import type { LocalPaths } from "../../src/local/paths" + +function testPaths(root: string): LocalPaths { + return { + root, + bin: path.join(root, "bin"), + models: path.join(root, "models"), + downloads: path.join(root, "downloads"), + certificates: path.join(root, "certificates"), + state: path.join(root, "state.json"), + pid: path.join(root, "server.pid"), + log: path.join(root, "server.log"), + environment: path.join(root, "environment.json"), + recipes: path.join(root, "recipes.json"), + recipesMeta: path.join(root, "recipes.meta.json"), + } +} + +// Answers the certify() probe sequence in order: tool_call_round_trip sends +// two requests (the tool call, then the tool-result continuation), followed +// by one request each for reasoning_render and prompt_prefill_8k. +function passingFetchImpl() { + let call = 0 + return async () => { + call++ + if (call === 1) { + return new Response( + JSON.stringify({ + choices: [ + { + message: { + tool_calls: [{ id: "call_1", function: { name: "local_add", arguments: '{"a":2,"b":3}' } }], + }, + }, + ], + }), + { status: 200 }, + ) + } + return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { status: 200 }) + } +} + +const base = { + modelSha256: "a".repeat(64), + runtimeVersion: "llama.cpp b10516", + flags: ["--ctx-size", "131072", "--parallel", "1"], + reasoningEffort: "medium", + temperature: 1, +} + +describe("local certificate cache keying", () => { + test("is deterministic and sha256-shaped", () => { + const key = certificateCacheKey(base) + expect(key).toMatch(/^[a-f0-9]{64}$/) + expect(certificateCacheKey({ ...base, flags: [...base.flags] })).toBe(key) + }) + + test("changes with model bytes, runtime, flags, reasoning effort, or temperature", () => { + const key = certificateCacheKey(base) + expect(certificateCacheKey({ ...base, modelSha256: "b".repeat(64) })).not.toBe(key) + expect(certificateCacheKey({ ...base, runtimeVersion: "llama.cpp b10517" })).not.toBe(key) + expect(certificateCacheKey({ ...base, flags: [...base.flags, "--jinja"] })).not.toBe(key) + // The Docker recipe's `flags` don't encode either of these — without + // them in the key, a refreshed recipe changing just reasoning effort or + // temperature would silently reuse a certificate that never ran under + // the new configuration. + expect(certificateCacheKey({ ...base, reasoningEffort: "xhigh" })).not.toBe(key) + expect(certificateCacheKey({ ...base, temperature: 0.5 })).not.toBe(key) + }) + + test("flags hash preserves argument order", () => { + expect(flagsHash(["--ctx-size", "131072"])).not.toBe(flagsHash(["131072", "--ctx-size"])) + }) +}) + +describe("certify caching", () => { + test("certificate_sha256 is unchanged between a fresh run and a cache hit", async () => { + await using tmp = await tmpdir() + const paths = testPaths(tmp.path) + const request = { + baseURL: "http://127.0.0.1:42625/v1", + modelID: "test-model", + modelSha256: "a".repeat(64), + runtimeVersion: "llama.cpp b10516", + flags: ["--ctx-size", "131072"], + reasoningEffort: "medium", + temperature: 1, + paths, + } + + const fresh = await certify({ ...request, fetchImpl: passingFetchImpl() }) + expect(fresh.passed).toBe(true) + expect(fresh.cached).toBe(false) + + // No fetchImpl calls should happen on the cache-hit path — if this throws, + // the cache was not honored. + const cached = await certify({ + ...request, + fetchImpl: async () => { + throw new Error("must not probe again on a cache hit") + }, + }) + expect(cached.cached).toBe(true) + // The whole point: a consumer that validates the digest against the + // returned object must accept both a fresh result and a cached one. If + // `cached` were part of the signed payload, these would differ. + expect(cached.certificate_sha256).toBe(fresh.certificate_sha256) + }) +}) + +describe("check", () => { + test("measures duration across the full awaited run, not just up to the call", async () => { + const result = await check(async () => { + await new Promise((resolve) => setTimeout(resolve, 40)) + return "done" + }) + expect(result.ok).toBe(true) + expect(result.detail).toBe("done") + // A regression that computes duration_ms before awaiting `run()` reports + // near-zero here regardless of the 40ms delay above. + expect(result.duration_ms).toBeGreaterThanOrEqual(20) + }) + + test("still reports duration on failure", async () => { + const result = await check(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + throw new Error("boom") + }) + expect(result.ok).toBe(false) + expect(result.detail).toBe("boom") + expect(result.duration_ms).toBeGreaterThanOrEqual(0) + }) +}) diff --git a/packages/opencode/test/local/command.test.ts b/packages/opencode/test/local/command.test.ts new file mode 100644 index 0000000000..f7013e1d14 --- /dev/null +++ b/packages/opencode/test/local/command.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" + +import { withOverrides, type LocalArgs } from "../../src/local/command" +import { BUNDLED_RECIPES, type LlamaRecipeTier } from "../../src/local/recipes" + +const llamaTier = BUNDLED_RECIPES.models[0]!.tiers.find((tier) => tier.name === "gpu-24gb-discrete")! as LlamaRecipeTier +if (llamaTier.engine !== "llama.cpp") throw new Error("expected a llama.cpp tier fixture") + +function args(overrides: Partial = {}): LocalArgs { + return { ...overrides } +} + +describe("withOverrides", () => { + test("passes through the tier unchanged when no overrides are given", () => { + const result = withOverrides(llamaTier, args()) + expect(result.ctx).toBe(llamaTier.ctx) + expect(result.parallel).toBe(llamaTier.parallel) + }) + + test("accepts integer --ctx/--parallel that divide evenly", () => { + const result = withOverrides(llamaTier, args({ ctx: 65536, parallel: 2 })) + expect(result.ctx).toBe(65536) + expect(result.parallel).toBe(2) + }) + + test("rejects --ctx that does not divide evenly across --parallel", () => { + expect(() => withOverrides(llamaTier, args({ ctx: 65536, parallel: 3 }))).toThrow(/positive integers/) + }) + + test("rejects non-positive --ctx or --parallel", () => { + expect(() => withOverrides(llamaTier, args({ ctx: 0, parallel: 1 }))).toThrow(/positive integers/) + expect(() => withOverrides(llamaTier, args({ ctx: 65536, parallel: 0 }))).toThrow(/positive integers/) + }) + + // A non-integer can pass the divisibility check by coincidence (or even + // fail it in a confusing way) while still being an invalid value to hand + // llama-server as a slot/context count. + test("rejects a non-integer --ctx even when it happens to divide evenly", () => { + expect(() => withOverrides(llamaTier, args({ ctx: 1000.5, parallel: 1 }))).toThrow(/positive integers/) + }) + + test("rejects a non-integer --parallel", () => { + expect(() => withOverrides(llamaTier, args({ ctx: 65536, parallel: 2.5 }))).toThrow(/positive integers/) + }) + + test("applies --effort and --temperature overrides", () => { + const result = withOverrides(llamaTier, args({ effort: "xhigh", temperature: 0.3 })) + expect(result.agent.reasoning_effort).toBe("xhigh") + expect(result.agent.temperature).toBe(0.3) + }) + + test("rejects a negative --temperature", () => { + expect(() => withOverrides(llamaTier, args({ temperature: -0.1 }))).toThrow(/non-negative/) + }) + + test("--mtp false drops the tier's MTP config", () => { + const result = withOverrides(llamaTier, args({ mtp: false })) + expect(result.mtp).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/local/docker.test.ts b/packages/opencode/test/local/docker.test.ts new file mode 100644 index 0000000000..deea5a95d0 --- /dev/null +++ b/packages/opencode/test/local/docker.test.ts @@ -0,0 +1,470 @@ +import { describe, expect, test } from "bun:test" +import { EventEmitter } from "node:events" + +import { + buildDockerRunArgs, + dockerContainerRunning, + installContainerReaper, + LOCAL_CONTAINER_NAME, + LOCAL_MANAGEMENT_LABEL_KEY, + LOCAL_MANAGEMENT_LABEL_VALUE, + removeDockerContainer, + startDockerServer, + type DockerExec, +} from "../../src/local/docker" +import { BUNDLED_RECIPES } from "../../src/local/recipes" + +const model = BUNDLED_RECIPES.models[0]! +const tier = model.tiers.find((entry) => entry.name === "dgx-spark-128gb")! +if (tier.engine !== "docker-sglang") throw new Error("dgx tier must be docker-sglang") + +describe("buildDockerRunArgs", () => { + test("pins the image by digest and binds only to loopback", () => { + if (tier.engine !== "docker-sglang") throw new Error("dgx tier must be docker-sglang") + const args = buildDockerRunArgs({ tier, modelID: model.id, port: 8095, hfCache: "/home/user/.cache/huggingface" }) + expect(args).toContain(`${tier.image}@${tier.image_digest}`) + expect(args).toContain(`127.0.0.1:8095:${tier.container_port}`) + expect(args).toContain(LOCAL_CONTAINER_NAME) + // Ownership label: lets removeDockerContainer refuse to force-remove a + // container this tool did not create. + expect(args.join(" ")).toContain(`--label ${LOCAL_MANAGEMENT_LABEL_KEY}=${LOCAL_MANAGEMENT_LABEL_VALUE}`) + expect(args.join(" ")).toContain(`--model-path ${tier.model_hf}`) + expect(args.join(" ")).toContain(`--served-model-name ${model.id}`) + expect(args.join(" ")).toContain(`--context-length ${tier.ctx}`) + // EAGLE speculative args come from the recipe, not hardcoded + expect(args).toContain("--speculative-algorithm") + }) + + test("bundled dgx tier is a valid docker recipe", () => { + expect(tier.engine).toBe("docker-sglang") + if (tier.engine !== "docker-sglang") return + expect(tier.image_digest).toMatch(/^sha256:[a-f0-9]{64}$/) + expect(tier.ctx).toBe(131072) + expect(tier.agent.reasoning_effort).toBe("medium") + }) +}) + +type ExecResult = { stdout: string; stderr: string } + +// Default: a container that exists and passes the label-ownership check. +// Tests that need to simulate a foreign (unmanaged) container override +// inspectLabel to return something else. +const managedLabel = async (): Promise => ({ stdout: `${LOCAL_MANAGEMENT_LABEL_VALUE}\n`, stderr: "" }) + +function execRouter(handlers: { + inspectId?: () => Promise + run?: () => Promise + inspectPid?: () => Promise + inspectRunning?: () => Promise + inspectLabel?: () => Promise + logsTail1?: () => Promise + logsTail25?: () => Promise + rm?: () => Promise +}): DockerExec { + const inspectLabel = handlers.inspectLabel ?? managedLabel + return async (file, args) => { + if (args[0] === "inspect" && args[2] === "{{.Id}}" && handlers.inspectId) return handlers.inspectId() + if (args[0] === "inspect" && args[2] === "{{.State.Pid}}" && handlers.inspectPid) return handlers.inspectPid() + if (args[0] === "inspect" && args[2] === "{{.State.Running}}" && handlers.inspectRunning) return handlers.inspectRunning() + if (args[0] === "inspect" && args[2] === `{{index .Config.Labels "${LOCAL_MANAGEMENT_LABEL_KEY}"}}`) return inspectLabel() + if (args[0] === "run" && handlers.run) return handlers.run() + if (args[0] === "logs" && args[2] === "1" && handlers.logsTail1) return handlers.logsTail1() + if (args[0] === "logs" && args[2] === "25" && handlers.logsTail25) return handlers.logsTail25() + if (args[0] === "rm" && handlers.rm) return handlers.rm() + throw new Error(`unexpected exec call: ${file} ${args.join(" ")}`) + } +} + +const containerNotFound = async (): Promise => { + throw new Error("no such container") +} + +const daemonError = async (): Promise => { + const error = new Error("Cannot connect to the Docker daemon") as Error & { stderr: string } + error.stderr = "Cannot connect to the Docker daemon at unix:///var/run/docker.sock: is the docker daemon running?" + throw error +} + +describe("dockerContainerRunning", () => { + test("reports not running when docker inspect says the container is absent", async () => { + const exec = execRouter({ inspectRunning: containerNotFound }) + expect(await dockerContainerRunning(exec)).toBe(false) + }) + + test("propagates a docker daemon error instead of reporting not running", async () => { + const exec = execRouter({ inspectRunning: daemonError }) + await expect(dockerContainerRunning(exec)).rejects.toThrow(/Docker daemon/) + }) +}) + +describe("removeDockerContainer", () => { + test("skips rm when docker inspect says the container is absent", async () => { + let rmCalls = 0 + const exec = execRouter({ + inspectId: containerNotFound, + rm: async () => { + rmCalls++ + return { stdout: "", stderr: "" } + }, + }) + expect(await removeDockerContainer(exec)).toEqual({ existed: false, removed: false }) + expect(rmCalls).toBe(0) + }) + + test("propagates a docker daemon error instead of treating it as container absence", async () => { + let rmCalls = 0 + const exec = execRouter({ + inspectId: daemonError, + rm: async () => { + rmCalls++ + return { stdout: "", stderr: "" } + }, + }) + await expect(removeDockerContainer(exec)).rejects.toThrow(/Docker daemon/) + expect(rmCalls).toBe(0) + }) + + // The container name is fixed and globally visible; force-removing + // whatever currently holds it is only safe if `altimate local` created it. + test("refuses to force-remove a container that exists but was not created by altimate local", async () => { + let rmCalls = 0 + const exec = execRouter({ + inspectId: async () => ({ stdout: "someOtherContainerId\n", stderr: "" }), + inspectLabel: async () => ({ stdout: "\n", stderr: "" }), // label absent: not ours + rm: async () => { + rmCalls++ + return { stdout: "", stderr: "" } + }, + }) + await expect(removeDockerContainer(exec)).rejects.toThrow(/not created by `altimate local`/) + expect(rmCalls).toBe(0) + }) +}) + +describe("startDockerServer", () => { + test("resolves with the pid once the health check reports healthy", async () => { + const exec = execRouter({ + inspectId: containerNotFound, + run: async () => ({ stdout: "container123\n", stderr: "" }), + inspectPid: async () => ({ stdout: "4242\n", stderr: "" }), + }) + const fetchImpl = async () => new Response(null, { status: 200 }) + + const result = await startDockerServer({ tier, modelID: model.id, port: 8095, exec, fetchImpl }) + expect(result).toEqual({ pid: 4242, container: LOCAL_CONTAINER_NAME }) + }) + + test("throws with recent logs when the container exits before becoming healthy", async () => { + const exec = execRouter({ + inspectId: containerNotFound, + run: async () => ({ stdout: "container123\n", stderr: "" }), + inspectPid: async () => ({ stdout: "4242\n", stderr: "" }), + inspectRunning: async () => ({ stdout: "false\n", stderr: "" }), + logsTail25: async () => ({ stdout: "", stderr: "CUDA error: out of memory\n" }), + }) + const fetchImpl = async () => new Response(null, { status: 503 }) + + await expect( + startDockerServer({ tier, modelID: model.id, port: 8095, exec, fetchImpl, pollIntervalMs: 1 }), + ).rejects.toThrow(/CUDA error: out of memory/) + }) + + test("throws and removes the container after the health timeout elapses", async () => { + let removeCalls = 0 + const exec = execRouter({ + inspectId: async () => ({ stdout: "container123\n", stderr: "" }), + run: async () => ({ stdout: "container123\n", stderr: "" }), + inspectPid: async () => ({ stdout: "4242\n", stderr: "" }), + inspectRunning: async () => ({ stdout: "true\n", stderr: "" }), + logsTail1: async () => ({ stdout: "loading weights...\n", stderr: "" }), + rm: async () => { + removeCalls++ + return { stdout: "", stderr: "" } + }, + }) + const fetchImpl = async () => new Response(null, { status: 503 }) + + await expect( + startDockerServer({ + tier, + modelID: model.id, + port: 8095, + exec, + fetchImpl, + pollIntervalMs: 2, + timeoutMs: 25, + }), + ).rejects.toThrow(/did not become healthy in time/) + expect(removeCalls).toBeGreaterThan(0) + }) + + test("removes the container when PID inspection fails after `docker run`, instead of leaving it untracked", async () => { + let rmCalls = 0 + let inspectIdCalls = 0 + const exec = execRouter({ + // First call is startDockerServer's pre-run cleanup (nothing to remove + // yet); second call is the cleanup triggered by the pid-inspect + // failure below, after `docker run` has already created the container. + inspectId: async () => { + inspectIdCalls++ + if (inspectIdCalls === 1) throw new Error("no such container") + return { stdout: "container123\n", stderr: "" } + }, + run: async () => ({ stdout: "container123\n", stderr: "" }), + inspectPid: async () => { + throw new Error("Cannot connect to the Docker daemon") + }, + rm: async () => { + rmCalls++ + return { stdout: "", stderr: "" } + }, + }) + const fetchImpl = async () => new Response(null, { status: 503 }) + + await expect( + startDockerServer({ tier, modelID: model.id, port: 8095, exec, fetchImpl }), + ).rejects.toThrow(/Cannot connect to the Docker daemon/) + expect(rmCalls).toBe(1) + }) + + test("removes the container when the docker daemon errors mid-poll, instead of leaving it untracked", async () => { + // dockerContainerRunning throwing (not just returning false) inside the + // health-polling loop used to propagate straight out of startDockerServer, + // skipping cleanup entirely — setupDocker only records state once this + // function succeeds, so the container would be invisible to status/stop. + let rmCalls = 0 + let inspectIdCalls = 0 + const exec = execRouter({ + inspectId: async () => { + inspectIdCalls++ + if (inspectIdCalls === 1) throw new Error("no such container") // pre-run cleanup: nothing yet + return { stdout: "container123\n", stderr: "" } + }, + run: async () => ({ stdout: "container123\n", stderr: "" }), + inspectPid: async () => ({ stdout: "4242\n", stderr: "" }), + inspectRunning: async () => { + const error = new Error("Cannot connect to the Docker daemon") as Error & { stderr: string } + error.stderr = "Cannot connect to the Docker daemon at unix:///var/run/docker.sock" + throw error + }, + rm: async () => { + rmCalls++ + return { stdout: "", stderr: "" } + }, + }) + const fetchImpl = async () => new Response(null, { status: 503 }) // never healthy, forces the running-check + + await expect( + startDockerServer({ tier, modelID: model.id, port: 8095, exec, fetchImpl, pollIntervalMs: 1 }), + ).rejects.toThrow(/Cannot connect to the Docker daemon/) + expect(rmCalls).toBe(1) + }) + + test("preserves the cleanup error instead of hiding it behind the original polling failure", async () => { + // If `docker rm` itself fails during cleanup after a polling failure, the + // caller previously only ever saw the original error — with no hint that + // the container might still be running, untracked, because cleanup also + // failed. + let inspectIdCalls = 0 + const exec = execRouter({ + inspectId: async () => { + inspectIdCalls++ + if (inspectIdCalls === 1) throw new Error("no such container") // pre-run cleanup: nothing yet + const error = new Error("Cannot connect to the Docker daemon") as Error & { stderr: string } + error.stderr = "Cannot connect to the Docker daemon at unix:///var/run/docker.sock" + throw error // the cleanup attempt inside the catch block also fails + }, + run: async () => ({ stdout: "container123\n", stderr: "" }), + inspectPid: async () => ({ stdout: "4242\n", stderr: "" }), + inspectRunning: async () => ({ stdout: "true\n", stderr: "" }), + logsTail1: async () => ({ stdout: "loading weights...\n", stderr: "" }), + }) + const fetchImpl = async () => new Response(null, { status: 503 }) + + const failure = await startDockerServer({ + tier, + modelID: model.id, + port: 8095, + exec, + fetchImpl, + pollIntervalMs: 2, + timeoutMs: 5, + }).catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(Error) + const message = (failure as Error).message + expect(message).toMatch(/did not become healthy in time/) + expect(message).toMatch(/cleanup failed/i) + expect(message).toMatch(/Cannot connect to the Docker daemon/) + }) + + test("does not return success when a shutdown signal arrives right as the health check passes", async () => { + // The reaper aborts synchronously the instant a signal fires, before its + // own removeDockerContainer call even starts. If the health check races + // that abort, startDockerServer must not hand back a "success" that the + // caller (setupDocker) would use to write state.json and wire the config + // for a container the reaper is concurrently deleting. + const signalSource = new EventEmitter() as unknown as Pick + let rmCalls = 0 + let inspectIdCalls = 0 + const exec = execRouter({ + inspectId: async () => { + inspectIdCalls++ + if (inspectIdCalls === 1) throw new Error("no such container") // pre-run cleanup: nothing yet + return { stdout: "container123\n", stderr: "" } + }, + run: async () => ({ stdout: "container123\n", stderr: "" }), + inspectPid: async () => ({ stdout: "4242\n", stderr: "" }), + rm: async () => { + rmCalls++ + return { stdout: "", stderr: "" } + }, + }) + // "Healthy" only fires the signal first, simulating the interrupt landing + // in the same tick the health probe resolves true. + const fetchImpl = async () => { + ;(signalSource as EventEmitter).emit("SIGINT", "SIGINT") + return new Response(null, { status: 200 }) + } + + await expect( + startDockerServer({ tier, modelID: model.id, port: 8095, exec, fetchImpl, signalSource, onSignalExit: () => {} }), + ).rejects.toThrow(/interrupted by a shutdown signal/) + expect(rmCalls).toBeGreaterThan(0) + }) +}) + +describe("installContainerReaper", () => { + // Signals are injected through a fresh EventEmitter rather than emitted on + // `process` itself: `process.emit("SIGINT", ...)` would invoke every other + // SIGINT listener in this test process (parallel test setup, the real CLI's + // own handlers), not just the one this test installed. + function fakeSignalSource() { + return new EventEmitter() as unknown as Pick & { + emit(event: "SIGINT" | "SIGTERM", signal: "SIGINT" | "SIGTERM"): boolean + } + } + + // The container is created before setupDocker ever writes state.json; an + // interrupt during the (up to 45-minute) health wait must not leave it + // orphaned and invisible to `altimate local stop`/`status`. + test("removes the labeled container and reports a SIGINT-shaped exit code", async () => { + let rmCalls = 0 + const exec = execRouter({ + inspectId: async () => ({ stdout: "container123\n", stderr: "" }), + rm: async () => { + rmCalls++ + return { stdout: "", stderr: "" } + }, + }) + const signalSource = fakeSignalSource() + let exitCode: number | undefined + const reaper = installContainerReaper( + exec, + (code) => { + exitCode = code + }, + signalSource, + ) + try { + signalSource.emit("SIGINT", "SIGINT") + expect(reaper.signal.aborted).toBe(true) + // removeDockerContainer's exec calls are async; let them settle. + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(rmCalls).toBe(1) + expect(exitCode).toBe(130) + } finally { + reaper.uninstall() + } + }) + + test("uninstalling removes the signal listeners so a later signal does nothing", async () => { + let rmCalls = 0 + const exec = execRouter({ + inspectId: async () => ({ stdout: "container123\n", stderr: "" }), + rm: async () => { + rmCalls++ + return { stdout: "", stderr: "" } + }, + }) + const signalSource = fakeSignalSource() + const reaper = installContainerReaper(exec, () => {}, signalSource) + reaper.uninstall() + signalSource.emit("SIGINT", "SIGINT") + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(rmCalls).toBe(0) + }) + + test("only removes the container once even if both SIGINT and SIGTERM arrive back-to-back", async () => { + let rmCalls = 0 + const exec = execRouter({ + inspectId: async () => ({ stdout: "container123\n", stderr: "" }), + rm: async () => { + rmCalls++ + return { stdout: "", stderr: "" } + }, + }) + const signalSource = fakeSignalSource() + let exitCalls = 0 + const reaper = installContainerReaper( + exec, + () => { + exitCalls++ + }, + signalSource, + ) + try { + signalSource.emit("SIGINT", "SIGINT") + signalSource.emit("SIGTERM", "SIGTERM") + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(rmCalls).toBe(1) + // Exactly one exit call: the second signal exits immediately (see next + // test) rather than waiting for the first's cleanup, but the first + // cleanup's own exit call is suppressed once we've already exited. + expect(exitCalls).toBe(1) + } finally { + reaper.uninstall() + } + }) + + test("a second signal forces immediate exit without waiting for a slow docker rm", async () => { + let rmCalls = 0 + let resolveRm: (() => void) | undefined + const exec = execRouter({ + inspectId: async () => ({ stdout: "container123\n", stderr: "" }), + rm: () => + new Promise((resolve) => { + rmCalls++ + resolveRm = () => resolve({ stdout: "", stderr: "" }) + }), + }) + const signalSource = fakeSignalSource() + const exitCalls: number[] = [] + const reaper = installContainerReaper(exec, (code) => exitCalls.push(code), signalSource) + try { + signalSource.emit("SIGINT", "SIGINT") + // Let the pending inspect calls ahead of `docker rm` in + // removeDockerContainer settle so `rm` (which we hold open) is reached. + await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(rmCalls).toBe(1) + expect(exitCalls).toEqual([]) + + // A second Ctrl-C while cleanup is still pending must exit right away — + // not wait out a potentially wedged `docker rm`. + signalSource.emit("SIGINT", "SIGINT") + expect(exitCalls).toEqual([130]) + + // Once the slow rm eventually resolves, its own exit call is a no-op — + // we already exited once. + resolveRm?.() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(exitCalls).toEqual([130]) + expect(rmCalls).toBe(1) + } finally { + reaper.uninstall() + } + }) +}) diff --git a/packages/opencode/test/local/fetch.test.ts b/packages/opencode/test/local/fetch.test.ts new file mode 100644 index 0000000000..c5a1ee5981 --- /dev/null +++ b/packages/opencode/test/local/fetch.test.ts @@ -0,0 +1,168 @@ +import { createHash } from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" +import { describe, expect, test } from "bun:test" + +import { tmpdir } from "../fixture/fixture" +import { + ChecksumMismatchError, + MissingChecksumError, + downloadWithResume, + sha256File, + verifySha256, +} from "../../src/local/fetch" + +const sha = (value: string) => createHash("sha256").update(value).digest("hex") + +describe("local artifact sha256 verification", () => { + test("hashes and verifies a temporary file", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "artifact.gguf") + await fs.writeFile(file, "verified bytes") + expect(await sha256File(file)).toBe(sha("verified bytes")) + await expect(verifySha256(file, sha("verified bytes"))).resolves.toBe(sha("verified bytes")) + }) + + test("refuses a checksum mismatch", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "artifact.gguf") + await fs.writeFile(file, "wrong bytes") + await expect(verifySha256(file, sha("expected bytes"))).rejects.toBeInstanceOf(ChecksumMismatchError) + }) + + test("fails closed on a TODO checksum", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "artifact.gguf") + await fs.writeFile(file, "bytes") + await expect(verifySha256(file, "TODO_MODEL_SHA256")).rejects.toBeInstanceOf(MissingChecksumError) + }) + + test("resumes with HTTP Range using a mocked response", async () => { + await using tmp = await tmpdir() + const destination = path.join(tmp.path, "artifact.gguf") + await fs.writeFile(`${destination}.partial`, "hello ") + let calls = 0 + const fetchImpl = async (_input: string | URL | Request, init?: RequestInit) => { + calls++ + expect(new Headers(init?.headers).get("range")).toBe("bytes=6-") + return new Response("world", { + status: 206, + headers: { "content-range": "bytes 6-10/11", "content-length": "5" }, + }) + } + const result = await downloadWithResume({ + url: "https://example.invalid/artifact.gguf", + destination, + sha256: sha("hello world"), + fetchImpl, + }) + expect(calls).toBe(1) + expect(result.resumed).toBe(true) + expect(await fs.readFile(destination, "utf8")).toBe("hello world") + }) + + test("a checksum mismatch after HTTP 416 deletes the stale partial instead of getting stuck forever", async () => { + await using tmp = await tmpdir() + const destination = path.join(tmp.path, "artifact.gguf") + const partial = `${destination}.partial` + await fs.writeFile(partial, "stale wrong bytes") + const fetchImpl = async () => new Response(null, { status: 416 }) + + await expect( + downloadWithResume({ + url: "https://example.invalid/artifact.gguf", + destination, + sha256: sha("expected different bytes"), + fetchImpl, + }), + ).rejects.toBeInstanceOf(ChecksumMismatchError) + // Without cleanup, a retry resumes from the same offset, gets 416 again, + // and fails identically forever. + await expect(fs.stat(partial)).rejects.toThrow() + }) + + // Regression: a completed destination file that fails its pinned checksum (disk corruption, an + // older interrupted downloader that skipped verification, ...) used to be left in place, so a + // retry hit the same stat+verify and failed identically forever instead of redownloading. + test("a corrupt completed artifact is deleted and redownloaded instead of getting stuck forever", async () => { + await using tmp = await tmpdir() + const destination = path.join(tmp.path, "artifact.gguf") + await fs.writeFile(destination, "corrupt bytes") + let calls = 0 + const fetchImpl = async () => { + calls++ + return new Response("good bytes", { status: 200, headers: { "content-length": "10" } }) + } + + const result = await downloadWithResume({ + url: "https://example.invalid/artifact.gguf", + destination, + sha256: sha("good bytes"), + fetchImpl, + }) + expect(calls).toBe(1) // redownloaded exactly once, not retried in a loop + expect(result.resumed).toBe(false) + expect(await fs.readFile(destination, "utf8")).toBe("good bytes") + }) + + // A repeat mismatch on the redownload must still fail closed (not loop forever) and clean up + // after itself — same guarantee the existing download path already provides. + test("a corrupt completed artifact that fails checksum again after redownload still errors and cleans up", async () => { + await using tmp = await tmpdir() + const destination = path.join(tmp.path, "artifact.gguf") + await fs.writeFile(destination, "corrupt bytes") + const fetchImpl = async () => new Response("still wrong bytes", { status: 200 }) + + await expect( + downloadWithResume({ + url: "https://example.invalid/artifact.gguf", + destination, + sha256: sha("expected bytes"), + fetchImpl, + }), + ).rejects.toBeInstanceOf(ChecksumMismatchError) + await expect(fs.stat(destination)).rejects.toThrow() + await expect(fs.stat(`${destination}.partial`)).rejects.toThrow() + }) + + // A proxy that ignores our Range header but still answers 206 with an + // absent/mismatched Content-Range must not leave the stale partial in place: + // every retry would resend the same Range request against the same offset + // and hit this same failure forever. + test("an invalid Content-Range on a resumed request clears the partial instead of getting stuck forever", async () => { + await using tmp = await tmpdir() + const destination = path.join(tmp.path, "artifact.gguf") + const partial = `${destination}.partial` + await fs.writeFile(partial, "hello ") + const fetchImpl = async () => + new Response("ignored", { status: 206, headers: { "content-range": "bytes 0-4/999" } }) // mismatched offset + + await expect( + downloadWithResume({ + url: "https://example.invalid/artifact.gguf", + destination, + sha256: sha("hello world"), + fetchImpl, + }), + ).rejects.toThrow(/invalid Content-Range/) + await expect(fs.stat(partial)).rejects.toThrow() + }) + + test("a response with no Content-Length reports an unknown total instead of coercing it to 0", async () => { + await using tmp = await tmpdir() + const destination = path.join(tmp.path, "artifact.gguf") + const progress: Array<{ received: number; total?: number }> = [] + const fetchImpl = async () => new Response("hello world", { status: 200 }) // deliberately no content-length + + const result = await downloadWithResume({ + url: "https://example.invalid/artifact.gguf", + destination, + sha256: sha("hello world"), + fetchImpl, + onProgress: (progressUpdate) => progress.push(progressUpdate), + }) + expect(result.bytes).toBe("hello world".length) + expect(progress.length).toBeGreaterThan(0) + expect(progress.every((p) => p.total === undefined)).toBe(true) + }) +}) diff --git a/packages/opencode/test/local/hardware.test.ts b/packages/opencode/test/local/hardware.test.ts new file mode 100644 index 0000000000..2a738e8f04 --- /dev/null +++ b/packages/opencode/test/local/hardware.test.ts @@ -0,0 +1,126 @@ +import os from "node:os" +import { describe, expect, test } from "bun:test" + +import { detectHardware, matchHardwareToTier, type HardwareInfo } from "../../src/local/hardware" +import { BUNDLED_RECIPES, firstModel } from "../../src/local/recipes" + +const model = firstModel(BUNDLED_RECIPES) + +function hardware(input: Partial): HardwareInfo { + return { + platform: "linux", + arch: "x64", + name: "test machine", + memoryGb: 32, + accelerator: "cpu", + unifiedMemory: false, + ...input, + } +} + +describe("local hardware tier matching", () => { + test("routes a discrete 24GB NVIDIA card to the VRAM-fitted tier", () => { + const match = matchHardwareToTier(hardware({ accelerator: "nvidia", acceleratorMemoryGb: 24 }), model) + expect(match.tier?.name).toBe("gpu-24gb-discrete") + }) + + test("matches a 24GB unified-memory laptop to the laptop tier", () => { + const match = matchHardwareToTier( + hardware({ platform: "darwin", arch: "arm64", memoryGb: 24, unifiedMemory: true, accelerator: "metal" }), + model, + ) + expect(match.tier?.name).toBe("laptop-24gb") + }) + + test("discrete 16GB NVIDIA card gets no tier (below discrete floor)", () => { + const match = matchHardwareToTier(hardware({ accelerator: "nvidia", acceleratorMemoryGb: 16, memoryGb: 16 }), model) + expect(match.tier).toBeUndefined() + }) + + test("detects GB10 with [N/A] memory as an NVIDIA accelerator and matches the DGX tier", async () => { + const detected = await detectHardware({ + platform: "linux", + arch: "arm64", + run: async (argv: string[]) => + argv[0] === "nvidia-smi" + ? { exitCode: 0, stdout: "NVIDIA GB10, [N/A]\n" } + : { exitCode: 1, stdout: "" }, + readFile: async () => "MemTotal: 125829120 kB\n", + }) + expect(detected.accelerator).toBe("nvidia") + expect(detected.name).toBe("NVIDIA GB10") + expect(detected.acceleratorMemoryGb).toBeUndefined() + const match = matchHardwareToTier(detected, model) + expect(match.tier?.name).toBe("dgx-spark-128gb") + }) + + test("routes DGX Spark (GB10) to its guidance tier", () => { + const match = matchHardwareToTier( + hardware({ accelerator: "nvidia", name: "NVIDIA GB10", acceleratorMemoryGb: 119, memoryGb: 119 }), + model, + ) + expect(match.tier?.name).toBe("dgx-spark-128gb") + }) + + test("prefers the unified-memory recipe on a 64GB Apple Silicon Mac", () => { + const match = matchHardwareToTier( + hardware({ + platform: "darwin", + arch: "arm64", + memoryGb: 64, + accelerator: "metal", + acceleratorMemoryGb: 64, + unifiedMemory: true, + }), + model, + ) + expect(match.tier?.name).toBe("mac-64gb-unified") + }) + + test("returns the datacenter guidance stub for an 80GB NVIDIA GPU", () => { + const match = matchHardwareToTier(hardware({ accelerator: "nvidia", acceleratorMemoryGb: 80 }), model) + expect(match.tier?.name).toBe("datacenter-80gb") + expect(match.tier?.engine).toBe("vllm") + }) + + test("falls back to system memory on darwin when no accelerator memory is reported", () => { + // Apple Silicon's RAM genuinely IS the accelerator's memory (unified + // memory), so treating it as usable here is correct on darwin. + const match = matchHardwareToTier(hardware({ platform: "darwin", arch: "arm64", memoryGb: 32 }), model) + expect(match.tier?.name).toBe("laptop-24gb") + }) + + test("does NOT fall back to system memory on a Linux host with no detected GPU (CPU-only)", () => { + // nvidia-smi found nothing, so accelerator is "cpu" — system RAM is not + // the accelerator's memory here, unlike on unified-memory Apple Silicon. + // Matching the laptop tier anyway would download ~16GB of weights for a + // host that can't usefully run GPU-oriented inference. + const match = matchHardwareToTier(hardware({ platform: "linux", memoryGb: 32, accelerator: "cpu" }), model) + expect(match.tier).toBeUndefined() + expect(match.reason).toContain("No confirmed GPU accelerator") + expect(match.reason).toContain("AMD/Intel GPU detection is not implemented yet") + }) + + test("returns no match below the minimum", () => { + const match = matchHardwareToTier(hardware({ memoryGb: 16 }), model) + expect(match.tier).toBeUndefined() + expect(match.reason).toContain("20GB") + }) + + test("reports real system RAM on platforms with no dedicated probe (e.g. native Windows)", async () => { + // Previously this branch hardcoded memoryGb: 0, which made every + // advertised tier unreachable regardless of how much RAM the machine has. + const detected = await detectHardware({ platform: "win32", arch: "x64" }) + expect(detected.memoryGb).toBeGreaterThan(0) + expect(detected.memoryGb).toBeCloseTo(Math.round((os.totalmem() / 1024 ** 3) * 10) / 10, 1) + }) + + test("refuses the laptop fallback on a platform-arch with no published llama.cpp runtime", () => { + // Intel macOS has enough memory to clear the laptop tier's floor but + // RUNTIME_ASSETS only ships darwin-arm64 — must not match a tier whose + // runtime can never be installed. + const match = matchHardwareToTier(hardware({ platform: "darwin", arch: "x64", memoryGb: 32 }), model) + expect(match.tier).toBeUndefined() + expect(match.reason).toContain("darwin-x64") + }) +}) diff --git a/packages/opencode/test/local/lock.test.ts b/packages/opencode/test/local/lock.test.ts new file mode 100644 index 0000000000..3c0d8d26b1 --- /dev/null +++ b/packages/opencode/test/local/lock.test.ts @@ -0,0 +1,194 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { describe, expect, test } from "bun:test" + +import { tmpdir } from "../fixture/fixture" +import { isOwnerStale, reclaimStaleLock, withLifecycleLock } from "../../src/local/lock" +import type { LocalPaths } from "../../src/local/paths" + +function paths(root: string): LocalPaths { + return { + root, + bin: path.join(root, "bin"), + models: path.join(root, "models"), + downloads: path.join(root, "downloads"), + certificates: path.join(root, "certificates"), + state: path.join(root, "state.json"), + pid: path.join(root, "server.pid"), + log: path.join(root, "server.log"), + environment: path.join(root, "environment.json"), + recipes: path.join(root, "recipes.json"), + recipesMeta: path.join(root, "recipes.meta.json"), + } +} + +async function deadPid() { + const child = Bun.spawn(["true"], { stdout: "ignore", stderr: "ignore" }) + await child.exited + return child.pid +} + +describe("withLifecycleLock", () => { + test("acquires the lock on a truly fresh install where the root directory does not exist yet", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "fresh", "nested", "root") + await expect(fs.stat(root)).rejects.toThrow() + + const result = await withLifecycleLock(async () => "done", paths(root)) + expect(result).toBe("done") + // The lock directory is released after the run. + await expect(fs.stat(path.join(root, ".lifecycle-lock"))).rejects.toThrow() + }, 10_000) + + // Acquisition is two steps (mkdir, then write owner.json). A waiter that + // observes the dir but not yet owner.json must not assume the holder + // crashed and steal the lock out from under it — it should wait out a + // short grace window instead. + test("a waiter does not steal the lock while the holder is still publishing owner.json", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "race") + const testPaths = paths(root) + const lockDir = path.join(root, ".lifecycle-lock") + await fs.mkdir(lockDir, { recursive: true }) // holder has mkdir'd but not yet written owner.json + + let waiterRan = false + const waiter = withLifecycleLock(async () => { + waiterRan = true + return "waiter" + }, testPaths) + + // Still inside the grace window: the waiter must not have proceeded. + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(waiterRan).toBe(false) + + // Holder finishes publishing and releases normally. + await fs.writeFile(path.join(lockDir, "owner.json"), JSON.stringify({ pid: process.pid, at: Date.now() }), { + mode: 0o600, + }) + await fs.rm(lockDir, { recursive: true, force: true }) + + expect(await waiter).toBe("waiter") + expect(waiterRan).toBe(true) + }, 10_000) + + test("reclaims a lock whose owner.json never appears (holder crashed right after mkdir)", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "crashed") + const testPaths = paths(root) + const lockDir = path.join(root, ".lifecycle-lock") + await fs.mkdir(lockDir, { recursive: true }) // dir exists, owner.json never written — simulates a crash + + const result = await withLifecycleLock(async () => "reclaimed", testPaths) + expect(result).toBe("reclaimed") + }, 10_000) + + // Two waiters can both observe the same stale lock (dead-pid owner) and both + // decide to reclaim it. Reclaiming via a blind `rm` let the loser delete the + // WINNER's freshly mkdir'd + owner.json'd lock directory, so both processes + // believed they held the lock simultaneously. The atomic-rename reclaim must + // keep this mutually exclusive no matter how the two reclaim attempts interleave. + test("two concurrent reclaimers of the same stale lock never both hold it", async () => { + await using tmp = await tmpdir() + const root = path.join(tmp.path, "concurrent-stale") + const testPaths = paths(root) + const lockDir = path.join(root, ".lifecycle-lock") + await fs.mkdir(lockDir, { recursive: true }) + const deadOwnerPid = await deadPid() + await fs.writeFile(path.join(lockDir, "owner.json"), JSON.stringify({ pid: deadOwnerPid, at: Date.now() }), { + mode: 0o600, + }) + + let active = 0 + let maxActive = 0 + function run(label: string) { + return withLifecycleLock(async () => { + active++ + maxActive = Math.max(maxActive, active) + await new Promise((resolve) => setTimeout(resolve, 50)) + active-- + return label + }, testPaths) + } + + const results = await Promise.all([run("a"), run("b")]) + expect(results.sort()).toEqual(["a", "b"]) + expect(maxActive).toBe(1) + }, 10_000) +}) + +describe("reclaimStaleLock", () => { + // A delayed reclaimer can read a stale owner, then lose the CPU before its + // rename runs. If a different waiter fully reclaims and re-acquires `dir` + // in that gap, the delayed reclaimer's pathname-only rename would move + // that FRESH, live lock aside — reclaimStaleLock must detect the owner + // mismatch and restore it instead. + test("restores a fresh lock that was reclaimed by someone else between the stale read and the rename", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, ".lifecycle-lock") + const deadOwnerPid = await deadPid() + // What the delayed reclaimer observed as stale, before losing the race. + const staleOwnerSeenByLoser = { pid: deadOwnerPid, at: Date.now() - 1000 } + + // Simulate the winner having already fully reclaimed and re-acquired + // `dir` with a fresh, live owner by the time the loser's rename runs. + await fs.mkdir(dir, { recursive: true }) + const freshOwner = { pid: process.pid, at: Date.now() } + await fs.writeFile(path.join(dir, "owner.json"), JSON.stringify(freshOwner), { mode: 0o600 }) + + const result = await reclaimStaleLock(dir, staleOwnerSeenByLoser) + expect(result).toBe("restored") + + // The winner's live lock must be intact afterward, not stolen. + const owner = JSON.parse(await fs.readFile(path.join(dir, "owner.json"), "utf8")) + expect(owner).toEqual(freshOwner) + }) + + test("reclaims cleanly when the renamed-aside owner matches what was observed stale", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, ".lifecycle-lock") + const deadOwnerPid = await deadPid() + const staleOwner = { pid: deadOwnerPid, at: Date.now() - 1000 } + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, "owner.json"), JSON.stringify(staleOwner), { mode: 0o600 }) + + const result = await reclaimStaleLock(dir, staleOwner) + expect(result).toBe("reclaimed") + await expect(fs.stat(dir)).rejects.toThrow() + }) + + test("retries when the directory is already gone by the time the rename runs", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "never-existed") + const result = await reclaimStaleLock(dir, { pid: 999999999, at: Date.now() }) + expect(result).toBe("retry") + }) +}) + +describe("isOwnerStale", () => { + test("treats a missing owner as stale", () => { + expect(isOwnerStale(undefined, Date.now())).toBe(true) + }) + + test("treats a dead pid as stale regardless of age", () => { + expect(isOwnerStale({ pid: 999999999, at: Date.now() }, Date.now())).toBe(true) + }) + + test("does not evict a live owner just because it is older than ten minutes", async () => { + // A model download can legitimately run well past ten minutes; a live + // lock holder must not be forcibly evicted on age alone. + const elevenMinutesAgo = Date.now() - 11 * 60_000 + expect(isOwnerStale({ pid: process.pid, at: elevenMinutesAgo }, Date.now())).toBe(false) + }) + + test("still evicts a genuinely dead process's lock even if it exited moments ago", async () => { + const pid = await deadPid() + expect(isOwnerStale({ pid, at: Date.now() }, Date.now())).toBe(true) + }) + + test("falls back to evicting an implausibly old lock even if the pid reads as alive", () => { + // Guards against the OS recycling the recorded pid onto an unrelated + // live process, which would otherwise wedge the lock forever. + const wayInThePast = Date.now() - 25 * 60 * 60_000 + expect(isOwnerStale({ pid: process.pid, at: wayInThePast }, Date.now())).toBe(true) + }) +}) diff --git a/packages/opencode/test/local/preflight.test.ts b/packages/opencode/test/local/preflight.test.ts new file mode 100644 index 0000000000..3ae0e3eb99 --- /dev/null +++ b/packages/opencode/test/local/preflight.test.ts @@ -0,0 +1,279 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { describe, expect, test } from "bun:test" + +import { tmpdir } from "../fixture/fixture" +import { runPreflight } from "../../src/local/preflight" +import { BUNDLED_RECIPES, type LlamaRecipeTier } from "../../src/local/recipes" +import type { HardwareInfo } from "../../src/local/hardware" + +const model = BUNDLED_RECIPES.models[0]! +const llamaTier = model.tiers.find((tier) => tier.name === "gpu-24gb-discrete")! as LlamaRecipeTier +const dockerTier = model.tiers.find((tier) => tier.name === "dgx-spark-128gb")! + +const nvidia: HardwareInfo = { + platform: "linux", + arch: "x64", + name: "NVIDIA L4", + memoryGb: 31, + accelerator: "nvidia", + acceleratorMemoryGb: 22.5, + unifiedMemory: false, +} + +const spark: HardwareInfo = { + platform: "linux", + arch: "arm64", + name: "NVIDIA GB10", + memoryGb: 119, + accelerator: "nvidia", + unifiedMemory: false, +} + +const DF_OK = { stdout: "Filesystem 1K-blocks Used Available Use% Mounted\n/dev/sda1 999999999 1 524288000 1% /\n", stderr: "" } +const DF_FULL = { stdout: "Filesystem 1K-blocks Used Available Use% Mounted\n/dev/sda1 999999999 1 1048576 1% /\n", stderr: "" } +// ~10GB free: enough for the "already cached" 4GB estimate but not the ~24GB +// fresh-download estimate, so these two outcomes are distinguishable. +const DF_10GB = { stdout: "Filesystem 1K-blocks Used Available Use% Mounted\n/dev/sda1 999999999 1 10485760 1% /\n", stderr: "" } +const VULKAN_OK = { stdout: "libvulkan.so.1 (libc6,x86-64) => /lib/libvulkan.so.1\n", stderr: "" } + +function exec(table: Record) { + return async (file: string, args: string[]) => { + const key = [file, ...args].join(" ") + for (const [prefix, result] of Object.entries(table)) { + if (key.startsWith(prefix)) { + if (result instanceof Error) throw result + return result + } + } + throw new Error(`unexpected exec: ${key}`) + } +} + +describe("runPreflight", () => { + test("llama tier on linux fails fatally without the Vulkan loader", async () => { + const result = await runPreflight({ + tier: llamaTier, + model: { id: model.id, revision: model.revision }, + hardware: nvidia, + availableGb: 22.5, + directory: "/tmp", + platform: "linux", + exec: exec({ df: DF_OK, ldconfig: { stdout: "libc.so.6 => /lib/libc.so.6\n", stderr: "" } }), + }) + expect(result.passed).toBe(false) + const vulkan = result.checks.find((check) => check.name === "vulkan_loader")! + expect(vulkan.ok).toBe(false) + expect(vulkan.fatal).toBe(true) + expect(vulkan.detail).toContain("libvulkan1") + }) + + test("llama tier passes with loader present and enough disk", async () => { + const result = await runPreflight({ + tier: llamaTier, + model: { id: model.id, revision: model.revision }, + hardware: nvidia, + availableGb: 22.5, + directory: "/tmp", + platform: "linux", + exec: exec({ df: DF_OK, ldconfig: { stdout: "libvulkan.so.1 (libc6,x86-64) => /lib/libvulkan.so.1\n", stderr: "" } }), + }) + expect(result.passed).toBe(true) + }) + + test("insufficient disk is fatal before any download", async () => { + const result = await runPreflight({ + tier: llamaTier, + model: { id: model.id, revision: model.revision }, + hardware: nvidia, + availableGb: 22.5, + directory: "/tmp", + platform: "darwin", + exec: exec({ df: DF_FULL }), + }) + expect(result.passed).toBe(false) + expect(result.checks.find((check) => check.name === "disk_space")!.ok).toBe(false) + }) + + test("docker tier fails fatally when the daemon is unreachable", async () => { + const result = await runPreflight({ + tier: dockerTier, + model: { id: model.id, revision: model.revision }, + hardware: spark, + availableGb: 119, + directory: "/tmp", + platform: "linux", + exec: exec({ df: DF_OK, docker: new Error("no daemon") }), + readFile: (async () => "MemAvailable: 104857600 kB\n") as never, + }) + expect(result.passed).toBe(false) + expect(result.checks.find((check) => check.name === "docker_daemon")!.ok).toBe(false) + expect(result.checks.find((check) => check.name === "nvidia_container_runtime")!.ok).toBe(false) + }) + + test("docker tier passes with nvidia runtime; low free memory is a warning only", async () => { + const result = await runPreflight({ + tier: dockerTier, + model: { id: model.id, revision: model.revision }, + hardware: spark, + availableGb: 119, + directory: "/tmp", + platform: "linux", + exec: exec({ + df: DF_OK, + "docker version": { stdout: "27.1.1\n", stderr: "" }, + "docker info": { stdout: '{"nvidia":{"path":"nvidia-container-runtime"}}\n', stderr: "" }, + }), + readFile: (async () => "MemAvailable: 12582912 kB\n") as never, + }) + expect(result.passed).toBe(true) + const memory = result.checks.find((check) => check.name === "free_memory")! + expect(memory.ok).toBe(false) + expect(memory.fatal).toBe(false) + }) + + test("capacity below the tier floor is fatal", async () => { + const result = await runPreflight({ + tier: dockerTier, + model: { id: model.id, revision: model.revision }, + hardware: { ...spark, memoryGb: 64 }, + availableGb: 64, + directory: "/tmp", + platform: "linux", + exec: exec({ + df: DF_OK, + "docker version": { stdout: "27.1.1\n", stderr: "" }, + "docker info": { stdout: '{"nvidia":{}}\n', stderr: "" }, + }), + readFile: (async () => "MemAvailable: 41943040 kB\n") as never, + }) + expect(result.passed).toBe(false) + expect(result.checks.find((check) => check.name === "accelerator_memory")!.ok).toBe(false) + }) + + test("a cached gguf from a different model/revision does not discount the disk estimate", async () => { + await using tmp = await tmpdir() + // A .gguf exists on disk, but for an unrelated model/revision — not the + // one this tier is about to download. + const other = path.join(tmp.path, "models", "some-other-model", "deadbeef", "other.gguf") + await fs.mkdir(path.dirname(other), { recursive: true }) + await fs.writeFile(other, "not the target artifact") + + const result = await runPreflight({ + tier: llamaTier, + model: { id: model.id, revision: model.revision }, + hardware: nvidia, + availableGb: 22.5, + directory: tmp.path, + platform: "linux", + exec: exec({ df: DF_10GB, ldconfig: VULKAN_OK }), + }) + const disk = result.checks.find((check) => check.name === "disk_space")! + expect(disk.detail).not.toContain("already cached") + expect(disk.ok).toBe(false) + }) + + test("the exact target gguf being cached discounts the disk estimate", async () => { + await using tmp = await tmpdir() + const target = path.join(tmp.path, "models", model.id, model.revision, path.basename(llamaTier.file)) + await fs.mkdir(path.dirname(target), { recursive: true }) + // Plausibly-sized (well above the truncation floor below) — a real gguf is + // multi-GB; this only needs to clear the cheap sanity floor. + await fs.writeFile(target, Buffer.alloc(2 * 1024 * 1024)) + + const result = await runPreflight({ + tier: llamaTier, + model: { id: model.id, revision: model.revision }, + hardware: nvidia, + availableGb: 22.5, + directory: tmp.path, + platform: "linux", + exec: exec({ df: DF_10GB, ldconfig: VULKAN_OK }), + }) + const disk = result.checks.find((check) => check.name === "disk_space")! + expect(disk.detail).toContain("already cached") + expect(disk.ok).toBe(true) + }) + + // A truncated/corrupt cached file must not get the "already cached" 4GB + // discount: downloadWithResume would detect the checksum mismatch, delete + // it, and start a full download — potentially on a filesystem preflight + // just approved for only 4GB of headroom. + test("a truncated cached gguf below the plausibility floor does not discount the disk estimate", async () => { + await using tmp = await tmpdir() + const target = path.join(tmp.path, "models", model.id, model.revision, path.basename(llamaTier.file)) + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, "truncated") // a handful of bytes, not a real gguf + + const result = await runPreflight({ + tier: llamaTier, + model: { id: model.id, revision: model.revision }, + hardware: nvidia, + availableGb: 22.5, + directory: tmp.path, + platform: "linux", + exec: exec({ df: DF_10GB, ldconfig: VULKAN_OK }), + }) + const disk = result.checks.find((check) => check.name === "disk_space")! + expect(disk.detail).not.toContain("already cached") + expect(disk.ok).toBe(false) + }) + + test("docker tier: cached HF weights alone do not discount the estimate when the SGLang image is missing", async () => { + await using tmp = await tmpdir() + if (dockerTier.engine !== "docker-sglang") throw new Error("expected docker tier fixture") + const home = tmp.path + const repo = dockerTier.model_hf.replace("/", "--") + const snapshot = path.join(home, ".cache", "huggingface", "hub", `models--${repo}`, "snapshots", dockerTier.model_revision) + await fs.mkdir(snapshot, { recursive: true }) + + const result = await runPreflight({ + tier: dockerTier, + model: { id: model.id, revision: model.revision }, + hardware: spark, + availableGb: 119, + directory: tmp.path, + platform: "linux", + home, + exec: exec({ + df: DF_10GB, // enough for the "cached" 4GB floor but not the ~45GB fresh-download estimate + "docker version": { stdout: "27.1.1\n", stderr: "" }, + "docker info": { stdout: '{"nvidia":{"path":"nvidia-container-runtime"}}\n', stderr: "" }, + "docker image inspect": new Error("no such image"), // weights cached, but the image was never pulled + }), + readFile: (async () => "MemAvailable: 41943040 kB\n") as never, + }) + const disk = result.checks.find((check) => check.name === "disk_space")! + expect(disk.detail).not.toContain("already cached") + expect(disk.ok).toBe(false) + }) + + test("docker tier: HF weights and the SGLang image both cached discounts the estimate", async () => { + await using tmp = await tmpdir() + if (dockerTier.engine !== "docker-sglang") throw new Error("expected docker tier fixture") + const home = tmp.path + const repo = dockerTier.model_hf.replace("/", "--") + const snapshot = path.join(home, ".cache", "huggingface", "hub", `models--${repo}`, "snapshots", dockerTier.model_revision) + await fs.mkdir(snapshot, { recursive: true }) + + const result = await runPreflight({ + tier: dockerTier, + model: { id: model.id, revision: model.revision }, + hardware: spark, + availableGb: 119, + directory: tmp.path, + platform: "linux", + home, + exec: exec({ + df: DF_10GB, + "docker version": { stdout: "27.1.1\n", stderr: "" }, + "docker info": { stdout: '{"nvidia":{"path":"nvidia-container-runtime"}}\n', stderr: "" }, + "docker image inspect": { stdout: "sha256:deadbeef\n", stderr: "" }, + }), + readFile: (async () => "MemAvailable: 41943040 kB\n") as never, + }) + const disk = result.checks.find((check) => check.name === "disk_space")! + expect(disk.detail).toContain("already cached") + expect(disk.ok).toBe(true) + }) +}) diff --git a/packages/opencode/test/local/recipes.test.ts b/packages/opencode/test/local/recipes.test.ts new file mode 100644 index 0000000000..3ca8fd5b04 --- /dev/null +++ b/packages/opencode/test/local/recipes.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test" + +import { BUNDLED_RECIPES, selectModel, validateRecipes } from "../../src/local/recipes" + +describe("local recipe schema", () => { + test("accepts the bundled schema-v1 snapshot", () => { + const recipes = validateRecipes(structuredClone(BUNDLED_RECIPES)) + expect(recipes.schema).toBe(1) + expect(recipes.models[0]?.revision).toBe("4ca720788d1e01f1bff70c033e0d0028fd02e502") + expect(recipes.models[0]?.tiers.map((tier) => tier.name)).toContain("mac-64gb-unified") + }) + + test("rejects an unknown schema version", () => { + const input = structuredClone(BUNDLED_RECIPES) as any + input.schema = 2 + expect(() => validateRecipes(input)).toThrow("recipes.schema must be 1") + }) + + test("rejects an unpinned model revision", () => { + const input = structuredClone(BUNDLED_RECIPES) as any + input.models[0].revision = "main" + expect(() => validateRecipes(input)).toThrow("must be a pinned 40-character commit") + }) + + test("accepts explicit TODO checksum placeholders but rejects arbitrary values", () => { + const accepted = structuredClone(BUNDLED_RECIPES) as any + accepted.models[0].tiers[0].sha256 = "TODO_MODEL_SHA256" + expect(validateRecipes(accepted).models[0]?.tiers[0]?.name).toBe("laptop-24gb") + + const rejected = structuredClone(BUNDLED_RECIPES) as any + rejected.models[0].tiers[0].sha256 = "unknown" + expect(() => validateRecipes(rejected)).toThrow("must be a sha256 or a TODO_* placeholder") + }) + + // runtime.ts always installs the hard-coded LLAMA_CPP_REF build regardless of + // this field — a remote recipe advancing llama_cpp_ref past what the + // installer supports must fail loudly here, not silently run an + // incompatible llama.cpp build against its updated flags/model. + test("rejects a llama_cpp_ref that does not match the installer's supported build", () => { + const input = structuredClone(BUNDLED_RECIPES) as any + input.models[0].llama_cpp_ref = "b99999" + expect(() => validateRecipes(input)).toThrow(/llama_cpp_ref.*does not match/) + }) + + test("rejects context that cannot divide across parallel slots", () => { + const input = structuredClone(BUNDLED_RECIPES) as any + input.models[0].tiers[0].parallel = 3 + expect(() => validateRecipes(input)).toThrow("ctx must divide evenly") + }) + + // fetchModelArtifacts joins model.id directly into a filesystem path with no + // further sanitization — a remote (pinned-by-sha256, but not otherwise + // trusted) recipe containing path separators here could write outside the + // managed model cache directory. + test("rejects a model id containing path separators", () => { + const traversal = structuredClone(BUNDLED_RECIPES) as any + traversal.models[0].id = "../../etc" + expect(() => validateRecipes(traversal)).toThrow(/must not contain path separators/) + + const slash = structuredClone(BUNDLED_RECIPES) as any + slash.models[0].id = "foo/bar" + expect(() => validateRecipes(slash)).toThrow(/must not contain path separators/) + + const dotdot = structuredClone(BUNDLED_RECIPES) as any + dotdot.models[0].id = ".." + expect(() => validateRecipes(dotdot)).toThrow(/must not contain path separators/) + }) + + test("rejects a docker tier container_port outside the valid port range", () => { + const dockerModel = structuredClone(BUNDLED_RECIPES) as any + const dockerTier = dockerModel.models[0].tiers.find((tier: any) => tier.engine === "docker-sglang") + expect(dockerTier).toBeDefined() + dockerTier.container_port = 70000 + expect(() => validateRecipes(dockerModel)).toThrow(/container_port must be between 1 and 65535/) + }) +}) + +describe("selectModel", () => { + test("defaults to the first registry entry", () => { + const recipes = validateRecipes(structuredClone(BUNDLED_RECIPES)) + expect(selectModel(recipes).id).toBe(recipes.models[0]!.id) + }) + + test("selects a model by id", () => { + const recipes = validateRecipes(structuredClone(BUNDLED_RECIPES)) + const id = recipes.models[0]!.id + expect(selectModel(recipes, id).id).toBe(id) + }) + + test("rejects an unknown id and lists what is available", () => { + const recipes = validateRecipes(structuredClone(BUNDLED_RECIPES)) + expect(() => selectModel(recipes, "no-such-model")).toThrow(/Unknown local model.*Available/) + }) +}) diff --git a/packages/opencode/test/local/runtime.test.ts b/packages/opencode/test/local/runtime.test.ts new file mode 100644 index 0000000000..23a4145c43 --- /dev/null +++ b/packages/opencode/test/local/runtime.test.ts @@ -0,0 +1,93 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { describe, expect, test } from "bun:test" + +import { tmpdir } from "../fixture/fixture" +import { isWorkingRuntime, locateLlamaServer, RUNTIME_ASSETS } from "../../src/local/runtime" +import type { LocalPaths } from "../../src/local/paths" + +const BIN_NAME = process.platform === "win32" ? "llama-server.exe" : "llama-server" + +function paths(bin: string): LocalPaths { + return { + root: bin, + bin, + models: path.join(bin, "models"), + downloads: path.join(bin, "downloads"), + certificates: path.join(bin, "certificates"), + state: path.join(bin, "state.json"), + pid: path.join(bin, "server.pid"), + log: path.join(bin, "server.log"), + environment: path.join(bin, "environment.json"), + recipes: path.join(bin, "recipes.json"), + recipesMeta: path.join(bin, "recipes.meta.json"), + } +} + +describe("locateLlamaServer", () => { + test("treats a present-but-broken install as not found instead of throwing", async () => { + await using tmp = await tmpdir() + const binary = path.join(tmp.path, BIN_NAME) + // A script that is executable but always fails `--version` reproduces an + // install broken in place (e.g. missing shared libs after a bad unpack). + await fs.writeFile(binary, "#!/bin/sh\nexit 1\n") + await fs.chmod(binary, 0o755) + + const result = await locateLlamaServer({ env: {}, paths: paths(tmp.path) }) + expect(result).toBeUndefined() + }, 10_000) + + // Skipped on win32: this fixture is a POSIX #!/bin/sh script written to a + // path ending in .exe. Windows dispatches execution by file extension, not + // shebang, so a real win32 run can't launch it at all — that's a genuine + // difference from every other platform here, not something this fixture + // can paper over without a real compiled Windows binary. + test.skipIf(process.platform === "win32")( + "returns the installed runtime once it is executable and reports a version", + async () => { + await using tmp = await tmpdir() + const binary = path.join(tmp.path, BIN_NAME) + await fs.writeFile(binary, '#!/bin/sh\necho "llama-server build 1"\nexit 0\n') + await fs.chmod(binary, 0o755) + + const result = await locateLlamaServer({ env: {}, paths: paths(tmp.path) }) + expect(result?.source).toBe("installed") + expect(result?.version).toBe("llama-server build 1") + }, + ) +}) + +describe("isWorkingRuntime", () => { + // installLlamaServer uses this to decide whether an already-installed + // target binary is good enough to keep (discarding the freshly downloaded + // replacement). It must check that the binary actually runs, not just + // that its execute bit is set — otherwise a broken-but-chmod'd install + // (e.g. missing shared libs after a bad unpack) can never be repaired. + test("is false for a file that has the execute bit but fails to run", async () => { + await using tmp = await tmpdir() + const binary = path.join(tmp.path, BIN_NAME) + await fs.writeFile(binary, "#!/bin/sh\nexit 1\n") + await fs.chmod(binary, 0o755) + + expect(await isWorkingRuntime(binary)).toBe(false) + }) + + // Same reason as the skip above: a POSIX shebang script named *.exe cannot + // actually run on native Windows. + test.skipIf(process.platform === "win32")("is true for a binary that runs and reports a version", async () => { + await using tmp = await tmpdir() + const binary = path.join(tmp.path, BIN_NAME) + await fs.writeFile(binary, '#!/bin/sh\necho "llama-server build 1"\nexit 0\n') + await fs.chmod(binary, 0o755) + + expect(await isWorkingRuntime(binary)).toBe(true) + }) +}) + +describe("RUNTIME_ASSETS coverage", () => { + test("only lists platform-arch pairs llama.cpp actually publishes builds for", () => { + expect(Object.keys(RUNTIME_ASSETS).sort()).toEqual( + ["darwin-arm64", "linux-arm64", "linux-x64", "win32-x64"].sort(), + ) + }) +}) diff --git a/packages/opencode/test/local/server.test.ts b/packages/opencode/test/local/server.test.ts new file mode 100644 index 0000000000..1ba7ee9b29 --- /dev/null +++ b/packages/opencode/test/local/server.test.ts @@ -0,0 +1,379 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { describe, expect, test } from "bun:test" + +import { tmpdir } from "../fixture/fixture" +import { + getServerStatus, + pickPort, + readServerState, + startServer, + stopServer, + windowsProcessCommandArgs, + writeServerState, +} from "../../src/local/server" +import type { ServerState } from "../../src/local/server" +import { LOCAL_CONTAINER_NAME, LOCAL_MANAGEMENT_LABEL_KEY, LOCAL_MANAGEMENT_LABEL_VALUE } from "../../src/local/docker" +import type { DockerExec } from "../../src/local/docker" +import type { LocalPaths } from "../../src/local/paths" +import type { RuntimeInfo } from "../../src/local/runtime" +import { BUNDLED_RECIPES } from "../../src/local/recipes" + +const llamaTier = BUNDLED_RECIPES.models[0]!.tiers.find((tier) => tier.name === "gpu-24gb-discrete")! +if (llamaTier.engine !== "llama.cpp") throw new Error("expected a llama.cpp tier fixture") + +function testPaths(root: string): LocalPaths { + return { + root, + bin: path.join(root, "bin"), + models: path.join(root, "models"), + downloads: path.join(root, "downloads"), + certificates: path.join(root, "certificates"), + state: path.join(root, "state.json"), + pid: path.join(root, "server.pid"), + log: path.join(root, "server.log"), + environment: path.join(root, "environment.json"), + recipes: path.join(root, "recipes.json"), + recipesMeta: path.join(root, "recipes.meta.json"), + } +} + +function dockerState(overrides: Partial = {}): ServerState { + return { + schema: 1, + engine: "docker-sglang", + pid: 4242, + host: "127.0.0.1", + port: 8095, + baseURL: "http://127.0.0.1:8095/v1", + modelID: "test-model", + modelPath: "org/model@rev", + modelSha256: "sha256:deadbeef", + runtimePath: "image@sha256:deadbeef", + runtimeVersion: "sglang test", + tier: "dgx-spark-128gb", + flags: [], + reasoningEffort: "medium", + temperature: 0, + startedAt: new Date().toISOString(), + logPath: `docker logs ${LOCAL_CONTAINER_NAME}`, + ...overrides, + } +} + +function fakeDockerExec(table: Record): DockerExec { + return async (file, args) => { + const key = [file, ...args].join(" ") + for (const [prefix, result] of Object.entries(table)) { + if (key.startsWith(prefix)) { + if (result instanceof Error) throw result + return result + } + } + throw new Error(`unexpected docker exec: ${key}`) + } +} + +// A shebang script that sleeps briefly. Its own path stands in for the real +// llama-server binary so `managedProcess()`'s `ps`/`/proc` substring check +// (which requires "llama-server" and the tracked modelPath in the live +// process's command line) passes without spawning a real model server. +async function fakeLlamaServerScript(dir: string) { + const scriptPath = path.join(dir, "llama-server") + await fs.writeFile(scriptPath, "#!/bin/sh\nsleep 5\n") + await fs.chmod(scriptPath, 0o755) + return scriptPath +} + +// Node's "spawn" event fires when the child is created (post-fork), not when +// execve has completed — on a loaded runner, /proc//cmdline read +// immediately after startServer can still show the parent's command line, so +// the managedProcess() identity match transiently fails. Real flows never +// race this (health polling precedes any status check by seconds); tests +// assert immediately, so they poll until the identity settles. +async function statusOnceSettled(options: Parameters[0]) { + let status = await getServerStatus(options) + for (let attempt = 0; attempt < 40 && !status.processAlive; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 25)) + status = await getServerStatus(options) + } + return status +} + +describe("local server port selection", () => { + test("asks the OS for a free loopback port", async () => { + const requested: number[] = [] + const port = await pickPort(0, async (candidate) => { + requested.push(candidate) + return 43123 + }) + expect(requested).toEqual([0]) + expect(port).toBe(43123) + }) + + test("auto-picks the next candidate when the preferred port is occupied", async () => { + const requested: number[] = [] + const selected = await pickPort( + 8080, + async (candidate) => { + requested.push(candidate) + if (candidate === 8080) throw Object.assign(new Error("occupied"), { code: "EADDRINUSE" }) + return candidate === 0 ? 43124 : candidate + }, + // Explicit always-false probe: without this, pickPort defaults to the + // real respondsToHttp and makes an actual HTTP request to + // 127.0.0.1:8081, which flakes whenever anything else on the machine + // happens to be listening on that port during the test run. + async () => false, + ) + expect(requested[0]).toBe(8080) + expect(selected).toBe(8081) + }) + + test("skips a port that binds but already answers HTTP (SO_REUSEPORT shadow)", async () => { + const probed: number[] = [] + const selected = await pickPort( + 9000, + async (candidate) => (candidate === 0 ? 43125 : candidate), + async (port) => { + probed.push(port) + return port === 9000 + }, + ) + expect(probed[0]).toBe(9000) + expect(selected).toBe(9001) + }) +}) + +describe("getServerStatus / stopServer — docker-sglang engine", () => { + test("reports healthy when the container is running and /health responds", async () => { + await using tmp = await tmpdir() + const paths = testPaths(tmp.path) + await writeServerState(dockerState(), paths) + + const status = await getServerStatus({ + paths, + fetchImpl: async () => new Response(null, { status: 200 }), + dockerExec: fakeDockerExec({ + [`docker inspect -f {{.State.Running}} ${LOCAL_CONTAINER_NAME}`]: { stdout: "true\n", stderr: "" }, + }), + }) + + expect(status.processAlive).toBe(true) + expect(status.healthy).toBe(true) + expect(status.stale).toBe(false) + }) + + test("reports stale when the tracked container is gone", async () => { + await using tmp = await tmpdir() + const paths = testPaths(tmp.path) + await writeServerState(dockerState(), paths) + + const status = await getServerStatus({ + paths, + dockerExec: fakeDockerExec({ + [`docker inspect -f {{.State.Running}} ${LOCAL_CONTAINER_NAME}`]: { stdout: "false\n", stderr: "" }, + }), + }) + + expect(status.processAlive).toBe(false) + expect(status.healthy).toBe(false) + expect(status.stale).toBe(true) + }) + + test("stopServer removes a running container and clears state", async () => { + await using tmp = await tmpdir() + const paths = testPaths(tmp.path) + await writeServerState(dockerState(), paths) + + const dockerExec = fakeDockerExec({ + [`docker inspect -f {{.State.Running}} ${LOCAL_CONTAINER_NAME}`]: { stdout: "true\n", stderr: "" }, + [`docker inspect -f {{.Id}} ${LOCAL_CONTAINER_NAME}`]: { stdout: "abc123\n", stderr: "" }, + [`docker inspect -f {{index .Config.Labels "${LOCAL_MANAGEMENT_LABEL_KEY}"}} ${LOCAL_CONTAINER_NAME}`]: { + stdout: `${LOCAL_MANAGEMENT_LABEL_VALUE}\n`, + stderr: "", + }, + [`docker rm -f ${LOCAL_CONTAINER_NAME}`]: { stdout: `${LOCAL_CONTAINER_NAME}\n`, stderr: "" }, + }) + + const result = await stopServer({ paths, dockerExec }) + expect(result).toEqual({ stopped: true, reason: "stopped", pid: 4242 }) + expect(await readServerState(paths)).toBeUndefined() + }) + + test("a docker rm failure must not look like success: state is preserved so the container is not orphaned", async () => { + await using tmp = await tmpdir() + const paths = testPaths(tmp.path) + await writeServerState(dockerState(), paths) + + const dockerExec = fakeDockerExec({ + [`docker inspect -f {{.State.Running}} ${LOCAL_CONTAINER_NAME}`]: { stdout: "true\n", stderr: "" }, + [`docker inspect -f {{.Id}} ${LOCAL_CONTAINER_NAME}`]: { stdout: "abc123\n", stderr: "" }, + [`docker inspect -f {{index .Config.Labels "${LOCAL_MANAGEMENT_LABEL_KEY}"}} ${LOCAL_CONTAINER_NAME}`]: { + stdout: `${LOCAL_MANAGEMENT_LABEL_VALUE}\n`, + stderr: "", + }, + [`docker rm -f ${LOCAL_CONTAINER_NAME}`]: new Error("docker daemon busy"), + }) + + await expect(stopServer({ paths, dockerExec })).rejects.toThrow("docker daemon busy") + const state = await readServerState(paths) + expect(state?.pid).toBe(4242) + }) +}) + +describe("startServer / getServerStatus / stopServer — llama.cpp engine", () => { + test("waitForHealth + state write/read/clear round trip", async () => { + await using tmp = await tmpdir() + const paths = testPaths(tmp.path) + const scriptPath = await fakeLlamaServerScript(tmp.path) + const modelPath = path.join(tmp.path, "model.gguf") + const runtime: RuntimeInfo = { path: scriptPath, version: "test-runtime", source: "path" } + + const state = await startServer({ + runtime, + modelID: "test-model", + modelPath, + modelSha256: "sha256:deadbeef", + tier: llamaTier, + paths, + timeoutMs: 5_000, + fetchImpl: async () => new Response(JSON.stringify({ status: "ok" }), { status: 200 }), + }) + + // writeServerState happened before waitForHealth returned; confirm the + // round trip reads back exactly what was written. + const persisted = await readServerState(paths) + expect(persisted).toEqual(state) + expect(state.modelPath).toBe(modelPath) + + const status = await statusOnceSettled({ paths, fetchImpl: async () => new Response(JSON.stringify({ status: "ok" }), { status: 200 }) }) + expect(status.processAlive).toBe(true) + expect(status.healthy).toBe(true) + + const result = await stopServer({ paths, graceMs: 2_000 }) + expect(result.stopped).toBe(true) + expect(await readServerState(paths)).toBeUndefined() + }) + + test("stopServer refuses to signal a live pid that is not the tracked llama-server process", async () => { + await using tmp = await tmpdir() + const paths = testPaths(tmp.path) + // A real, currently-alive process whose command line does NOT contain + // "llama-server" or the tracked modelPath — state.json pointed at it is + // either stale or corrupted, and stopServer must never signal it blind. + await writeServerState( + { + schema: 1, + pid: process.pid, + host: "127.0.0.1", + port: 42625, + baseURL: "http://127.0.0.1:42625/v1", + modelID: "test-model", + modelPath: "/models/does-not-match.gguf", + modelSha256: "sha256:deadbeef", + runtimePath: "/usr/local/bin/llama-server", + runtimeVersion: "test-runtime", + tier: llamaTier.name, + flags: [], + reasoningEffort: "medium", + temperature: 0, + startedAt: new Date().toISOString(), + logPath: path.join(tmp.path, "server.log"), + }, + paths, + ) + + await expect(stopServer({ paths })).rejects.toThrow(/Refusing to signal pid/) + // Untouched: state stays exactly as written, and the real process is unharmed. + expect((await readServerState(paths))?.pid).toBe(process.pid) + }) + + test("getServerStatus treats a recorded pid resolving to an unrelated process as not alive (PID-recycle guard)", async () => { + await using tmp = await tmpdir() + const paths = testPaths(tmp.path) + // process.pid is genuinely alive, but its command line matches neither + // the tracked runtime nor model path — simulating the OS having + // recycled the recorded pid onto an unrelated process. A raw + // processAlive() check alone would (wrongly) call this "alive" and go + // on to health-probe the recorded port. + await writeServerState( + { + schema: 1, + pid: process.pid, + host: "127.0.0.1", + port: 42625, + baseURL: "http://127.0.0.1:42625/v1", + modelID: "test-model", + modelPath: "/models/does-not-match.gguf", + modelSha256: "sha256:deadbeef", + runtimePath: "/usr/local/bin/llama-server", + runtimeVersion: "test-runtime", + tier: llamaTier.name, + flags: [], + reasoningEffort: "medium", + temperature: 0, + startedAt: new Date().toISOString(), + logPath: path.join(tmp.path, "server.log"), + }, + paths, + ) + + // Even a health check that would report "ok" must not be trusted unless + // identity checks out first. + const status = await getServerStatus({ + paths, + fetchImpl: async () => new Response(JSON.stringify({ status: "ok" }), { status: 200 }), + }) + expect(status.processAlive).toBe(false) + expect(status.healthy).toBe(false) + expect(status.stale).toBe(true) + }) + + test("stop/status work with a custom-named runtime binary, matched by its exact recorded path", async () => { + await using tmp = await tmpdir() + const paths = testPaths(tmp.path) + // Deliberately not named "llama-server": a hard-coded name substring + // check would false-negative here (ALTIMATE_LOCAL_LLAMA_SERVER lets + // users point at any binary name). + const scriptPath = path.join(tmp.path, "custom-runtime-binary") + await fs.writeFile(scriptPath, "#!/bin/sh\nsleep 5\n") + await fs.chmod(scriptPath, 0o755) + const modelPath = path.join(tmp.path, "model.gguf") + const runtime: RuntimeInfo = { path: scriptPath, version: "test-runtime", source: "path" } + + await startServer({ + runtime, + modelID: "test-model", + modelPath, + modelSha256: "sha256:deadbeef", + tier: llamaTier, + paths, + timeoutMs: 5_000, + fetchImpl: async () => new Response(JSON.stringify({ status: "ok" }), { status: 200 }), + }) + + const status = await statusOnceSettled({ + paths, + fetchImpl: async () => new Response(JSON.stringify({ status: "ok" }), { status: 200 }), + }) + expect(status.processAlive).toBe(true) + expect(status.healthy).toBe(true) + + const result = await stopServer({ paths, graceMs: 2_000 }) + expect(result.stopped).toBe(true) + expect(await readServerState(paths)).toBeUndefined() + }) +}) + +describe("windowsProcessCommandArgs", () => { + // Regression: PowerShell's default host wraps string output at the (fake, redirected-console) + // buffer width, which can split a long command line mid-string and break the .includes() + // substring checks in managedProcess(). `Out-String -Width 32767` disables that wrapping. + test("pipes the command line through Out-String with a width wide enough for any real command line", () => { + const args = windowsProcessCommandArgs(4242) + const command = args[args.length - 1]! + expect(command).toContain("ProcessId=4242") + expect(command).toContain("| Out-String -Width 32767") + }) +}) diff --git a/packages/opencode/test/local/wire.test.ts b/packages/opencode/test/local/wire.test.ts new file mode 100644 index 0000000000..d061a94883 --- /dev/null +++ b/packages/opencode/test/local/wire.test.ts @@ -0,0 +1,382 @@ +import { afterEach, describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { parse } from "jsonc-parser" + +import { readEgressGuard, wireLocalProvider, EGRESS_PERMISSIONS } from "../../src/local/wire" + +const TIER = { + ctx: 131072, + parallel: 2, + agent: { tool_retrieval: true, reasoning_effort: "medium" as const, temperature: 1 }, +} + +async function makeHome() { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "altimate-wire-")) + cleanup.push(home) + return home +} + +const cleanup: string[] = [] +afterEach(async () => { + while (cleanup.length) await fs.rm(cleanup.pop()!, { recursive: true, force: true }) +}) + +function wire(home: string, overrides?: Partial[0]>) { + return wireLocalProvider({ + baseURL: "http://127.0.0.1:42625/v1", + modelID: "qwen3.8-27b", + tier: TIER, + env: {} as NodeJS.ProcessEnv, + home, + ...overrides, + }) +} + +async function readConfig(file: string) { + return parse(await fs.readFile(file, "utf8"), [], { allowTrailingComma: true }) as Record +} + +describe("wireLocalProvider egress guard", () => { + test("adds ask rules for every network permission by default", async () => { + const home = await makeHome() + const wired = await wire(home) + expect(wired.guarded).toEqual([...EGRESS_PERMISSIONS]) + const config = await readConfig(wired.file) + for (const key of EGRESS_PERMISSIONS) expect(config.permission[key]).toBe("ask") + }) + + test("pins small_model to the local provider when unset", async () => { + const home = await makeHome() + const wired = await wire(home) + const config = await readConfig(wired.file) + expect(config.small_model).toBe("local/qwen3.8-27b") + }) + + test("never clobbers an existing user decision", async () => { + const home = await makeHome() + const dir = path.join(home, ".config", "altimate-code") + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile( + path.join(dir, "altimate-code.json"), + JSON.stringify({ permission: { websearch: "allow" }, small_model: "anthropic/claude-haiku-4-5" }), + ) + const wired = await wire(home) + expect(wired.guarded).toEqual(["webfetch", "codesearch"]) + const config = await readConfig(wired.file) + expect(config.permission.websearch).toBe("allow") + expect(config.permission.webfetch).toBe("ask") + expect(config.small_model).toBe("anthropic/claude-haiku-4-5") + }) + + test("egressGuard: false skips permission patches entirely", async () => { + const home = await makeHome() + const wired = await wire(home, { egressGuard: false }) + expect(wired.guarded).toEqual([]) + const config = await readConfig(wired.file) + expect(config.permission).toBeUndefined() + }) + + test("readEgressGuard reports effective actions and no-rule fallback", async () => { + const home = await makeHome() + await wire(home) + const guard = await readEgressGuard({} as NodeJS.ProcessEnv, home) + for (const key of EGRESS_PERMISSIONS) expect(guard[key]).toBe("ask") + + const empty = await makeHome() + const none = await readEgressGuard({} as NodeJS.ProcessEnv, empty) + for (const key of EGRESS_PERMISSIONS) expect(none[key]).toBe("allow (no rule)") + }) + + test("guard is reversible: on → off → on", async () => { + const home = await makeHome() + const first = await wire(home) + expect(first.guarded).toEqual([...EGRESS_PERMISSIONS]) + + await wire(home, { egressGuard: false }) + const off = await readConfig(first.file) + for (const key of EGRESS_PERMISSIONS) expect(off.permission?.[key]).toBeUndefined() + + const again = await wire(home) + expect(again.guarded).toEqual([...EGRESS_PERMISSIONS]) + const on = await readConfig(first.file) + for (const key of EGRESS_PERMISSIONS) expect(on.permission[key]).toBe("ask") + }) + + test("disabling the guard keeps custom user values", async () => { + const home = await makeHome() + const dir = path.join(home, ".config", "altimate-code") + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, "altimate-code.json"), JSON.stringify({ permission: { websearch: "deny" } })) + const wired = await wire(home, { egressGuard: false }) + const config = await readConfig(wired.file) + expect(config.permission.websearch).toBe("deny") + expect(config.permission.webfetch).toBeUndefined() + }) + + test("wiring twice is idempotent", async () => { + const home = await makeHome() + const first = await wire(home) + const before = await fs.readFile(first.file, "utf8") + const second = await wire(home) + expect(second.changed).toBe(false) + expect(await fs.readFile(second.file, "utf8")).toBe(before) + }) + + // --no-egress-guard must only remove "ask" rules a prior `altimate local` + // wiring actually set — never a value the user configured independently, and + // never rules from a run that had the guard off in the first place. + test("--no-egress-guard removes nothing when the guard was never applied", async () => { + const home = await makeHome() + const dir = path.join(home, ".config", "altimate-code") + await fs.mkdir(dir, { recursive: true }) + // User (or some other tool) wrote "ask" rules directly, with no prior `altimate local` run. + await fs.writeFile( + path.join(dir, "altimate-code.json"), + JSON.stringify({ permission: { websearch: "ask", webfetch: "ask" } }), + ) + const wired = await wire(home, { egressGuard: false }) + const config = await readConfig(wired.file) + expect(config.permission.websearch).toBe("ask") + expect(config.permission.webfetch).toBe("ask") + }) + + test("--no-egress-guard removes nothing when the last wiring already had the guard off", async () => { + const home = await makeHome() + await wire(home, { egressGuard: false }) + const dir = path.join(home, ".config", "altimate-code") + // Guard was never turned on, so nothing it owns exists — but simulate a + // user-set "ask" value that must survive the (still off) --no-egress-guard run. + const file = path.join(dir, "altimate-code.json") + const contents = JSON.parse(await fs.readFile(file, "utf8")) + contents.permission = { websearch: "ask" } + await fs.writeFile(file, JSON.stringify(contents)) + const wired = await wire(home, { egressGuard: false }) + const config = await readConfig(wired.file) + expect(config.permission.websearch).toBe("ask") + }) + + test("--no-egress-guard still removes guard-owned rules after a prior guard-on wiring", async () => { + const home = await makeHome() + await wire(home) + const wired = await wire(home, { egressGuard: false }) + const config = await readConfig(wired.file) + for (const key of EGRESS_PERMISSIONS) expect(config.permission?.[key]).toBeUndefined() + }) + + // Guard ownership is tracked per key (guarded_permissions), not just as a + // boolean: a user rule the guard-on wiring SKIPPED adding (because it + // already existed) must survive a later --no-egress-guard, even though the + // guard was on and did add other keys. + test("--no-egress-guard removes only the keys the guard actually added, keeping a user-set rule it skipped", async () => { + const home = await makeHome() + const dir = path.join(home, ".config", "altimate-code") + await fs.mkdir(dir, { recursive: true }) + // User independently set websearch to "ask" before ever running `altimate local`. + await fs.writeFile(path.join(dir, "altimate-code.json"), JSON.stringify({ permission: { websearch: "ask" } })) + + const on = await wire(home) + expect(on.guarded).toEqual(["webfetch", "codesearch"]) // websearch skipped: already set + + const off = await wire(home, { egressGuard: false }) + const config = await readConfig(off.file) + expect(config.permission.websearch).toBe("ask") // user's own rule survives + expect(config.permission.webfetch).toBeUndefined() // guard-owned: removed + expect(config.permission.codesearch).toBeUndefined() // guard-owned: removed + }) + + // Regression: a second guard-on run used to see every EGRESS_PERMISSIONS key already set to + // "ask" (from the first run), skip re-adding all of them, and report `guarded: []` — which then + // overwrote guarded_permissions in environment.json with an empty list. A later + // --no-egress-guard read that empty list back as "the guard owns nothing" and removed nothing. + test("--no-egress-guard still removes the rules after guard-on runs twice in a row", async () => { + const home = await makeHome() + const first = await wire(home) + expect(first.guarded).toEqual([...EGRESS_PERMISSIONS]) + + const second = await wire(home) + expect(second.guarded).toEqual([...EGRESS_PERMISSIONS]) // ownership carried forward, not dropped + + const off = await wire(home, { egressGuard: false }) + const config = await readConfig(off.file) + for (const key of EGRESS_PERMISSIONS) expect(config.permission?.[key]).toBeUndefined() + }) + + // A key a guard-on run carried forward as owned, but the user has since changed away from + // "ask" (e.g. to "deny"), must not be re-claimed as guard-owned on the next guard-on run. + test("a user override away from 'ask' drops that key from guard ownership on the next guard-on run", async () => { + const home = await makeHome() + const first = await wire(home) + expect(first.guarded).toEqual([...EGRESS_PERMISSIONS]) + + const dir = path.join(home, ".config", "altimate-code") + const file = path.join(dir, "altimate-code.json") + const contents = JSON.parse(await fs.readFile(file, "utf8")) + contents.permission.webfetch = "deny" + await fs.writeFile(file, JSON.stringify(contents)) + + const second = await wire(home) + expect(second.guarded).toEqual(["websearch", "codesearch"]) + + const off = await wire(home, { egressGuard: false }) + const config = await readConfig(off.file) + expect(config.permission.webfetch).toBe("deny") // user's override survives + expect(config.permission.websearch).toBeUndefined() + expect(config.permission.codesearch).toBeUndefined() + }) + + // The config schema accepts a bare `"permission": "deny"` shorthand string (normalized to + // `{ "*": "deny" }` by ConfigPermissionV1's decoder), but wire.ts reads the raw JSON directly + // and used to cast that string straight to a Record — Object.keys() on a string returns + // character indices, and `key in permission` in resolveEgressAction throws on a primitive. + test("a scalar permission shorthand (\"permission\": \"deny\") does not crash and is respected", async () => { + const home = await makeHome() + const dir = path.join(home, ".config", "altimate-code") + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, "altimate-code.json"), JSON.stringify({ permission: "deny" })) + + const wired = await wire(home) + expect(wired.guarded).toEqual([]) // "*": "deny" already covers every egress key + const config = await readConfig(wired.file) + expect(config.permission).toBe("deny") + + const guard = await readEgressGuard({} as NodeJS.ProcessEnv, home) + for (const key of EGRESS_PERMISSIONS) expect(guard[key]).toBe("deny") + }) + + test("respects a wildcard top-level rule instead of adding a more specific guard rule over it", async () => { + const home = await makeHome() + const dir = path.join(home, ".config", "altimate-code") + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, "altimate-code.json"), JSON.stringify({ permission: { "*": "deny" } })) + const wired = await wire(home) + expect(wired.guarded).toEqual([]) + const config = await readConfig(wired.file) + expect(config.permission["*"]).toBe("deny") + for (const key of EGRESS_PERMISSIONS) expect(config.permission[key]).toBeUndefined() + }) + + test("readEgressGuard resolves a wildcard rule instead of reporting allow (no rule)", async () => { + const home = await makeHome() + const dir = path.join(home, ".config", "altimate-code") + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, "altimate-code.json"), JSON.stringify({ permission: { "*": "deny" } })) + const guard = await readEgressGuard({} as NodeJS.ProcessEnv, home) + for (const key of EGRESS_PERMISSIONS) expect(guard[key]).toBe("deny") + }) + + test("respects a wildcard deny rule from a lower-precedence config file that the winning file doesn't repeat", async () => { + const home = await makeHome() + const dir = path.join(home, ".config", "altimate-code") + await fs.mkdir(dir, { recursive: true }) + // config.json is LOWER precedence than altimate-code.json (see + // CONFIG_PRECEDENCE); the winning file exists but says nothing about + // permissions, so only checking it would miss config.json's blanket deny. + await fs.writeFile(path.join(dir, "config.json"), JSON.stringify({ permission: { "*": "deny" } })) + await fs.writeFile(path.join(dir, "altimate-code.json"), JSON.stringify({})) + const wired = await wire(home) + expect(wired.file).toBe(path.join(dir, "altimate-code.json")) + expect(wired.guarded).toEqual([]) + const config = await readConfig(wired.file) + for (const key of EGRESS_PERMISSIONS) expect(config.permission?.[key]).toBeUndefined() + }) +}) + +describe("wireLocalProvider config file precedence", () => { + test("targets the higher-precedence .jsonc file when both .json and .jsonc already exist", async () => { + const home = await makeHome() + const dir = path.join(home, ".config", "altimate-code") + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, "altimate-code.json"), JSON.stringify({ model: "old-json" })) + await fs.writeFile(path.join(dir, "altimate-code.jsonc"), JSON.stringify({})) + const wired = await wire(home) + // Config's own load order applies altimate-code.jsonc AFTER altimate-code.json, + // so writes must land in .jsonc or they would be silently shadowed. + expect(wired.file).toBe(path.join(dir, "altimate-code.jsonc")) + const jsoncConfig = await readConfig(wired.file) + expect(jsoncConfig.provider.local).toBeDefined() + const jsonConfig = await readConfig(path.join(dir, "altimate-code.json")) + expect(jsonConfig.provider).toBeUndefined() + }) +}) + +describe("wireLocalProvider provider.local merge", () => { + test("deep-merges onto an existing provider.local instead of replacing it wholesale", async () => { + const home = await makeHome() + const dir = path.join(home, ".config", "altimate-code") + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile( + path.join(dir, "altimate-code.json"), + JSON.stringify({ + model: "local/my-model", + provider: { + local: { + options: { customFlag: true }, + models: { "my-other-model": { name: "my-other-model" } }, + }, + }, + }), + ) + const wired = await wire(home) + const config = await readConfig(wired.file) + // Custom option survives; baseURL/apiKey are still set to what we own. + expect(config.provider.local.options.customFlag).toBe(true) + expect(config.provider.local.options.baseURL).toBe("http://127.0.0.1:42625/v1") + expect(config.provider.local.options.apiKey).toBe("local") + // The pre-existing extra model survives, and our own model is added. + expect(config.provider.local.models["my-other-model"]).toEqual({ name: "my-other-model" }) + expect(config.provider.local.models["qwen3.8-27b"]).toBeDefined() + }) +}) + +describe("wireLocalProvider agent tuning", () => { + test("tunes the real 'builder' agent, not a phantom 'build' agent", async () => { + const home = await makeHome() + const wired = await wire(home) + const config = await readConfig(wired.file) + expect(config.agent.builder.temperature).toBe(TIER.agent.temperature) + expect(config.agent.builder.options.reasoningEffort).toBe(TIER.agent.reasoning_effort) + expect(config.agent.general.temperature).toBe(TIER.agent.temperature) + expect(config.agent.build).toBeUndefined() + }) + + test("does not tune the shared builder/general agents when the user's cloud default model is kept", async () => { + const home = await makeHome() + const dir = path.join(home, ".config", "altimate-code") + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, "altimate-code.json"), JSON.stringify({ model: "anthropic/claude-sonnet-5" })) + const wired = await wire(home) + expect(wired.defaultModelIsLocal).toBe(false) + const config = await readConfig(wired.file) + expect(config.agent).toBeUndefined() + }) +}) + +describe("wireLocalProvider default model reporting", () => { + test("reports the default model as local when it was unset (and got patched)", async () => { + const home = await makeHome() + const wired = await wire(home) + expect(wired.defaultModelIsLocal).toBe(true) + }) + + test("reports the default model as local when it already pointed at this local model", async () => { + const home = await makeHome() + const dir = path.join(home, ".config", "altimate-code") + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, "altimate-code.json"), JSON.stringify({ model: "local/qwen3.8-27b" })) + const wired = await wire(home) + expect(wired.defaultModelIsLocal).toBe(true) + }) + + test("reports the default model as NOT local when the user's existing model is kept (never clobbered)", async () => { + const home = await makeHome() + const dir = path.join(home, ".config", "altimate-code") + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile(path.join(dir, "altimate-code.json"), JSON.stringify({ model: "anthropic/claude-sonnet-5" })) + const wired = await wire(home) + expect(wired.defaultModelIsLocal).toBe(false) + const config = await readConfig(wired.file) + expect(config.model).toBe("anthropic/claude-sonnet-5") + }) +}) diff --git a/packages/opencode/test/provider/error.test.ts b/packages/opencode/test/provider/error.test.ts index 4d887c6d66..14eeac9c0b 100644 --- a/packages/opencode/test/provider/error.test.ts +++ b/packages/opencode/test/provider/error.test.ts @@ -139,6 +139,17 @@ describe("ProviderError.parseAPICallError: overflow detection", () => { expect(result.type).toBe("context_overflow") }) + test("detects SGLang 'exceeds the model's maximum context' pattern", () => { + const result = ProviderError.parseAPICallError({ + providerID: "local" as any, + error: makeAPICallError({ + message: "Bad Request: Requested token count exceeds the model's maximum context length of 65536 tokens", + statusCode: 400, + }), + }) + expect(result.type).toBe("context_overflow") + }) + test("detects 'exceeds the context window' pattern (OpenAI)", () => { const result = ProviderError.parseAPICallError({ providerID: "openai" as any, diff --git a/packages/opencode/test/session/compaction-fithead.test.ts b/packages/opencode/test/session/compaction-fithead.test.ts new file mode 100644 index 0000000000..539f6bfbed --- /dev/null +++ b/packages/opencode/test/session/compaction-fithead.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "bun:test" + +import { SessionCompaction } from "../../src/session/compaction" +import type { MessageV2 } from "../../src/session/message-v2" +import type { Provider } from "../../src/provider/provider" + +function userMessage(id: string, text: string): MessageV2.WithParts { + return { + info: { + id, + sessionID: "session-1", + role: "user", + time: { created: 1000 }, + model: { providerID: "local", modelID: "qwen3.8-27b" }, + }, + parts: [ + { + id: `${id}-part`, + sessionID: "session-1", + messageID: id, + type: "text", + text, + }, + ], + } as unknown as MessageV2.WithParts +} + +function model(context: number, output = 16384): Provider.Model { + return { + id: "qwen3.8-27b", + providerID: "local", + api: { npm: "@ai-sdk/openai-compatible" }, + limit: { context, output }, + } as unknown as Provider.Model +} + +describe("SessionCompaction.fitHead", () => { + test("leaves a small head untouched", async () => { + const head = [userMessage("m1", "short"), userMessage("m2", "also short")] + const result = await SessionCompaction.fitHead({ head, model: model(131072) }) + expect(result.dropped).toBe(0) + expect(result.head.length).toBe(2) + }) + + test("drops oldest messages until an oversized head fits the window", async () => { + // ~3.7-4 chars/token estimate baseline (Token.estimate): 40 messages x 20k + // chars = 800k chars ≈ 200k tokens, far over a 32k window minus output reserve. + const head = Array.from({ length: 40 }, (_, i) => userMessage(`m${i}`, "x".repeat(20_000))) + const result = await SessionCompaction.fitHead({ head, model: model(32768, 8192) }) + expect(result.dropped).toBeGreaterThan(0) + expect(result.head.length).toBeLessThan(40) + expect(result.head.length).toBeGreaterThanOrEqual(1) + // survivors are the NEWEST messages (front of head is oldest) + expect(result.head.at(-1)).toBe(head.at(-1)!) + }) + + test("zero-context models pass through unchanged", async () => { + const head = [userMessage("m1", "x".repeat(100_000))] + const result = await SessionCompaction.fitHead({ head, model: model(0) }) + expect(result.dropped).toBe(0) + }) + + // A context limit at or below the output reservation leaves no positive + // budget for any head at all. The old guard returned `input.head` unchanged + // in this case, reproducing the exact overflow fitHead exists to recover + // from — it must drop to an empty head instead (safe: the caller always + // appends its own trailing user prompt after `head`). + test("a degenerate budget (context <= maxOutput + margin) empties the head instead of returning it unchanged", async () => { + const head = [userMessage("m1", "short"), userMessage("m2", "also short")] + const result = await SessionCompaction.fitHead({ head, model: model(2000, 2000) }) + expect(result.head.length).toBe(0) + expect(result.dropped).toBe(head.length) + }) +}) + +// altimate_change start — mixed-role/turn-shaped fixture: a raw array-offset cut +// (head.slice(step)) can land inside a turn, leaving the summarization request +// starting with a non-user message — providers 400 on that, which bypasses the +// "too large to compact" fallback entirely. fitHead rounds the cut forward to +// the next user-role message instead. +function assistantMessage(id: string, text: string): MessageV2.WithParts { + return { + info: { + id, + sessionID: "session-1", + role: "assistant", + time: { created: 1000 }, + model: { providerID: "local", modelID: "qwen3.8-27b" }, + }, + parts: [ + { + id: `${id}-part`, + sessionID: "session-1", + messageID: id, + type: "text", + text, + }, + ], + } as unknown as MessageV2.WithParts +} + +describe("SessionCompaction.fitHead turn boundaries (mixed-role heads)", () => { + test("truncation lands on a user message, never mid-turn", async () => { + const head: MessageV2.WithParts[] = [] + for (let i = 0; i < 24; i++) { + head.push(userMessage(`u${i}`, "q".repeat(8_000))) + head.push(assistantMessage(`a${i}`, "r".repeat(8_000))) + } + const result = await SessionCompaction.fitHead({ head, model: model(16384, 4096) }) + expect(result.dropped).toBeGreaterThan(0) + // This is the assertion that fails without turn-boundary rounding: a raw + // step offset lands on an odd index (an assistant message) roughly half + // the time, which this fixture's alternating user/assistant shape exposes. + expect(result.head[0]!.info.role).toBe("user") + }) + + test("no truncation when a mixed-role head already fits", async () => { + const head = [userMessage("u", "small"), assistantMessage("a", "tiny")] + const result = await SessionCompaction.fitHead({ head, model: model(131072) }) + expect(result.dropped).toBe(0) + }) + + test("repeated truncation passes stays on user boundaries across multiple iterations", async () => { + // Enough turns that the while-loop in fitHead needs several passes to + // shrink under budget, exercising the boundary-rounding logic more than once. + const head: MessageV2.WithParts[] = [] + for (let i = 0; i < 80; i++) { + head.push(userMessage(`u${i}`, "q".repeat(4_000))) + head.push(assistantMessage(`a${i}`, "r".repeat(4_000))) + } + const result = await SessionCompaction.fitHead({ head, model: model(8192, 2048) }) + expect(result.dropped).toBeGreaterThan(0) + expect(result.head[0]!.info.role).toBe("user") + }) +}) +// altimate_change end + +// altimate_change start — upstream_fix: a lone oversized message must not survive fitHead +describe("SessionCompaction.fitHead single-message overflow", () => { + test("drops the last message entirely when even a single message exceeds budget", async () => { + // One assistant message whose own size already blows the window — the old + // `head.length > 1` guard exited the loop without ever re-checking this + // message against budget, silently returning it still oversized. + const head = [assistantMessage("a0", "z".repeat(200_000))] + const result = await SessionCompaction.fitHead({ head, model: model(4096, 1024) }) + expect(result.head.length).toBe(0) + expect(result.dropped).toBe(1) + }) + + test("shrinking down to a single oversized survivor still drops it, not just the earlier turns", async () => { + const head: MessageV2.WithParts[] = [] + for (let i = 0; i < 7; i++) head.push(userMessage(`u${i}`, "q".repeat(200))) + // The newest (last) message is itself larger than the whole budget. + head.push(assistantMessage("huge", "z".repeat(200_000))) + const result = await SessionCompaction.fitHead({ head, model: model(4096, 1024) }) + expect(result.head.length).toBe(0) + expect(result.dropped).toBe(head.length) + }) + + test("a single message that DOES fit is still kept (no regression)", async () => { + const head = [userMessage("u0", "short")] + const result = await SessionCompaction.fitHead({ head, model: model(131072) }) + expect(result.head.length).toBe(1) + expect(result.dropped).toBe(0) + }) +}) +// altimate_change end + +// altimate_change start — upstream_fix: no-later-user-boundary must not revert to a mid-turn cut +describe("SessionCompaction.fitHead no later user boundary", () => { + test("empties the head instead of reverting to a mid-turn cut when no user boundary follows step", async () => { + // A single leading user message followed by a long run of assistant + // messages with no further user turn: the forward scan for a user + // boundary after `step` never finds one. The old fallback reverted to + // the raw `step` offset, which starts the resulting head on an + // assistant message — the exact provider-400 shape this rounding exists + // to prevent. The fix must empty the head instead (safe: the caller + // always appends its own trailing user prompt). + const head: MessageV2.WithParts[] = [userMessage("u0", "q".repeat(8_000))] + for (let i = 0; i < 8; i++) head.push(assistantMessage(`a${i}`, "r".repeat(8_000))) + const result = await SessionCompaction.fitHead({ head, model: model(16384, 4096) }) + expect(result.head.length).toBe(0) + expect(result.dropped).toBe(head.length) + }) +}) +// altimate_change end diff --git a/packages/opencode/test/session/compaction-summarizer-integrity.test.ts b/packages/opencode/test/session/compaction-summarizer-integrity.test.ts new file mode 100644 index 0000000000..8cebf31101 --- /dev/null +++ b/packages/opencode/test/session/compaction-summarizer-integrity.test.ts @@ -0,0 +1,316 @@ +import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from "bun:test" +import { SessionCompaction } from "../../src/session/compaction" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionProcessor } from "../../src/session/processor" +import { Provider } from "../../src/provider/provider" +import { Agent } from "../../src/agent/agent" +import { Config } from "../../src/config/config" +import { Plugin } from "../../src/plugin" +import { Telemetry } from "../../src/telemetry" +import { Bus } from "../../src/bus" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" + +Log.init({ print: false }) + +// ─── Harness plan W1.5 (item 12) + W1.6 (item 3) unit gates ─────────────────── +// W1.5: the auto-compaction continue message must carry the original user +// message's format/tools/system/variant (like the replay branch), so the +// first auto-compaction cannot silently widen the permission surface. +// W1.6: the summarizer call passes explicit toolChoice "none", and a "continue" +// result with no non-empty summary text is retried ONCE, then errored — +// never committed. +// +// SessionCompaction.process wires imperative singletons directly (SessionProcessor, +// Provider, Agent, Session, ...), so these tests use the spy-based mocking pattern +// (see test/altimate/enhance-prompt.test.ts) — never mock.module() for shared +// infrastructure modules. + +const ref = { providerID: ProviderID.make("test"), modelID: ModelID.make("test-model") } + +const fakeModel = { + id: "test-model", + providerID: "test", + name: "Test", + limit: { context: 100_000, output: 32_000 }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + capabilities: { + toolcall: true, + attachment: false, + reasoning: false, + temperature: true, + input: { text: true, image: false, audio: false, video: false }, + output: { text: true, image: false, audio: false, video: false }, + }, + api: { npm: "@ai-sdk/anthropic" }, + options: {}, +} as unknown as Provider.Model + +// In-memory message/part store standing in for the session database. +const store = { + messages: [] as any[], + parts: [] as any[], +} + +type ProcessBehavior = (streamInput: any, message: any) => Promise<"continue" | "stop" | "compact"> +let processCalls: any[] = [] +let processBehaviors: ProcessBehavior[] = [] + +function writeSummary(text: string): ProcessBehavior { + return async (_streamInput, message) => { + store.parts.push({ + id: PartID.ascending(), + messageID: message.id, + sessionID: message.sessionID, + type: "text", + text, + }) + return "continue" + } +} + +const noSummary: ProcessBehavior = async () => "continue" + +// Instance.directory / Instance.worktree are getters that require ambient +// instance context; override them for the duration of this file. +const instanceDescriptors = { + directory: Object.getOwnPropertyDescriptor(Instance, "directory")!, + worktree: Object.getOwnPropertyDescriptor(Instance, "worktree")!, +} +Object.defineProperty(Instance, "directory", { configurable: true, get: () => "/tmp/compaction-test" }) +Object.defineProperty(Instance, "worktree", { configurable: true, get: () => "/tmp/compaction-test" }) + +spyOn(Config, "get").mockImplementation(async () => ({}) as any) +spyOn(Provider, "getModel").mockImplementation(async () => fakeModel) +spyOn(Agent, "get").mockImplementation( + async () => ({ name: "compaction", mode: "primary", options: {}, permission: [] }) as any, +) +spyOn(Plugin, "trigger").mockImplementation(async (_name: any, _input: any, output: any) => output) +spyOn(Telemetry, "track").mockImplementation((() => {}) as any) +spyOn(Bus, "publish").mockImplementation(async () => {}) +spyOn(MessageV2, "toModelMessages").mockImplementation(async () => []) +spyOn(MessageV2, "get").mockImplementation( + (input: any) => + ({ + info: store.messages.find((m) => m.id === input.messageID), + parts: store.parts.filter((p) => p.messageID === input.messageID), + }) as any, +) +spyOn(Session, "updateMessage").mockImplementation((async (msg: any) => { + store.messages.push(msg) + return msg +}) as any) +spyOn(Session, "updatePart").mockImplementation((async (part: any) => { + store.parts.push(part) + return part +}) as any) +spyOn(SessionProcessor, "create").mockImplementation((input: any) => { + const message = input.assistantMessage + return { + get message() { + return message + }, + partFromToolCall: () => undefined, + async process(streamInput: any) { + processCalls.push(streamInput) + const behavior = processBehaviors.shift() ?? writeSummary("summary") + return behavior(streamInput, message) + }, + } as any +}) + +afterAll(() => { + mock.restore() + Object.defineProperty(Instance, "directory", instanceDescriptors.directory) + Object.defineProperty(Instance, "worktree", instanceDescriptors.worktree) +}) + +beforeEach(() => { + store.messages = [] + store.parts = [] + processCalls = [] + processBehaviors = [] +}) + +let counter = 0 +function freshSessionID() { + counter += 1 + return SessionID.make(`ses_summarizer_test_${counter}`) +} + +function history(sessionID: SessionID, opts?: { userFields?: Record }) { + const userID = MessageID.ascending() + const assistantID = MessageID.ascending() + const markerID = MessageID.ascending() + const messages = [ + { + info: { + id: userID, + sessionID, + role: "user", + time: { created: 1 }, + agent: "build", + model: ref, + ...(opts?.userFields ?? {}), + }, + parts: [{ id: PartID.ascending(), messageID: userID, sessionID, type: "text", text: "do the task" }], + }, + { + info: { + id: assistantID, + sessionID, + role: "assistant", + parentID: userID, + time: { created: 2 }, + mode: "build", + agent: "build", + path: { cwd: "/tmp/compaction-test", root: "/tmp/compaction-test" }, + cost: 0, + tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + finish: "end_turn", + }, + parts: [{ id: PartID.ascending(), messageID: assistantID, sessionID, type: "text", text: "working on it" }], + }, + { + info: { + id: markerID, + sessionID, + role: "user", + time: { created: 3 }, + agent: "build", + model: ref, + }, + parts: [{ id: PartID.ascending(), messageID: markerID, sessionID, type: "compaction", auto: true }], + }, + ] as any[] + return { messages, markerID } +} + +function run(input: { sessionID: SessionID; messages: any[]; markerID: MessageID }) { + return SessionCompaction.process({ + sessionID: input.sessionID, + messages: input.messages, + parentID: input.markerID, + abort: new AbortController().signal, + auto: true, + }) +} + +describe("session.compaction continue-message contract (W1.5 / item 12)", () => { + test("continue message carries original tools/system/format/variant through auto-compaction", async () => { + const sessionID = freshSessionID() + const { messages, markerID } = history(sessionID, { + userFields: { + tools: { bash: true, edit: false }, + system: "custom system prompt", + variant: "high", + format: { type: "json" }, + }, + }) + processBehaviors = [writeSummary("a real summary")] + + const result = await run({ sessionID, messages, markerID }) + + expect(result).toBe("continue") + const continueMsg = store.messages.filter((m) => m.role === "user").at(-1) + expect(continueMsg).toBeDefined() + expect(continueMsg.tools).toEqual({ bash: true, edit: false }) + expect(continueMsg.system).toBe("custom system prompt") + expect(continueMsg.variant).toBe("high") + expect(continueMsg.format).toEqual({ type: "json" }) + // The continue prompt itself is unchanged. + const continuePart = store.parts.find((p) => p.messageID === continueMsg.id && p.type === "text") + expect(continuePart?.synthetic).toBe(true) + expect(continuePart?.text).toContain("Continue if you have next steps") + }) + + test("continue message leaves fields unset when the original user message never set them", async () => { + const sessionID = freshSessionID() + const { messages, markerID } = history(sessionID) + processBehaviors = [writeSummary("a real summary")] + + const result = await run({ sessionID, messages, markerID }) + + expect(result).toBe("continue") + const continueMsg = store.messages.filter((m) => m.role === "user").at(-1) + expect(continueMsg.tools).toBeUndefined() + expect(continueMsg.system).toBeUndefined() + expect(continueMsg.variant).toBeUndefined() + expect(continueMsg.format).toBeUndefined() + }) +}) + +describe("session.compaction summarizer integrity (W1.6 / item 3)", () => { + test("summarizer call passes explicit toolChoice 'none' and no tools", async () => { + const sessionID = freshSessionID() + const { messages, markerID } = history(sessionID) + processBehaviors = [writeSummary("a real summary")] + + await run({ sessionID, messages, markerID }) + + expect(processCalls.length).toBe(1) + expect(processCalls[0].toolChoice).toBe("none") + expect(processCalls[0].tools).toEqual({}) + }) + + test("does not retry when the first attempt produces summary text", async () => { + const sessionID = freshSessionID() + const { messages, markerID } = history(sessionID) + processBehaviors = [writeSummary("a real summary")] + + const result = await run({ sessionID, messages, markerID }) + + expect(result).toBe("continue") + expect(processCalls.length).toBe(1) + }) + + test("retries once with identical input when the summary step has no text", async () => { + const sessionID = freshSessionID() + const { messages, markerID } = history(sessionID) + processBehaviors = [noSummary, writeSummary("recovered summary")] + + const result = await run({ sessionID, messages, markerID }) + + expect(result).toBe("continue") + expect(processCalls.length).toBe(2) + // Retry uses the identical summarizer input. + expect(processCalls[1]).toBe(processCalls[0]) + // No error was committed. + const summaryMsg = store.messages.find((m) => m.role === "assistant" && m.summary) + expect(summaryMsg.error).toBeUndefined() + }) + + test("whitespace-only summary text counts as empty", async () => { + const sessionID = freshSessionID() + const { messages, markerID } = history(sessionID) + processBehaviors = [writeSummary(" \n\t "), writeSummary("recovered summary")] + + const result = await run({ sessionID, messages, markerID }) + + expect(result).toBe("continue") + expect(processCalls.length).toBe(2) + }) + + test("marks error and stops instead of committing when retry also produces no text", async () => { + const sessionID = freshSessionID() + const { messages, markerID } = history(sessionID) + processBehaviors = [noSummary, noSummary] + + const result = await run({ sessionID, messages, markerID }) + + expect(result).toBe("stop") + expect(processCalls.length).toBe(2) + const summaryMsg = store.messages.find((m) => m.role === "assistant" && m.summary) + expect(summaryMsg.finish).toBe("error") + expect(summaryMsg.error?.name).toBe("UnknownError") + expect(JSON.stringify(summaryMsg.error)).toContain("no summary text") + // The failed summary must NOT be committed as a compaction continue turn. + const continueTurn = store.parts.find((p) => p.type === "text" && p.synthetic) + expect(continueTurn).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 148529ad64..101e3f0813 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import path from "path" -import type { ModelMessage } from "ai" +import type { ModelMessage, Tool } from "ai" import { LLM } from "../../src/session/llm" import { Global } from "../../src/global" import { Instance } from "../../src/project/instance" @@ -83,6 +83,52 @@ describe("session.llm.toolNamesFromMessages", () => { }) }) +// Harness plan W1.6 / item 3: stub injection must be skipped entirely when the call +// exposes zero real tools (e.g. the compaction summarizer) — the provider-compat +// fallback path for toolChoice "none". +describe("session.llm.addHistoricalToolStubs", () => { + test("skips stub injection entirely when there are zero real tools", () => { + const tools: Record = {} + const result = LLM.addHistoricalToolStubs(tools, new Set(["bash", "read"])) + expect(result).toBe(tools) + expect(Object.keys(tools)).toEqual([]) + }) + + test("injects stubs for referenced tools missing from a non-empty tool set", () => { + const real = { description: "real bash" } as Tool + const tools: Record = { bash: real } + LLM.addHistoricalToolStubs(tools, new Set(["bash", "old_mcp_tool"])) + expect(Object.keys(tools).sort()).toEqual(["bash", "old_mcp_tool"]) + // Existing real tools are never overwritten. + expect(tools.bash).toBe(real) + expect(tools.old_mcp_tool.description).toContain("[Historical]") + }) + + test("is a no-op when every referenced tool already has a definition", () => { + const real = { description: "real bash" } as Tool + const tools: Record = { bash: real } + LLM.addHistoricalToolStubs(tools, new Set(["bash"])) + expect(Object.keys(tools)).toEqual(["bash"]) + expect(tools.bash).toBe(real) + }) + + test("treats a tool set containing only the SDK fallback 'invalid' tool as empty", () => { + const invalid = { description: "fallback for malformed tool calls" } as Tool + const tools: Record = { invalid } + const result = LLM.addHistoricalToolStubs(tools, new Set(["old_mcp_tool"])) + expect(result).toBe(tools) + expect(Object.keys(tools)).toEqual(["invalid"]) + }) + + test("still injects stubs for referenced tools when real tools sit alongside 'invalid'", () => { + const real = { description: "real bash" } as Tool + const invalid = { description: "fallback" } as Tool + const tools: Record = { bash: real, invalid } + LLM.addHistoricalToolStubs(tools, new Set(["bash", "old_mcp_tool"])) + expect(Object.keys(tools).sort()).toEqual(["bash", "invalid", "old_mcp_tool"]) + }) +}) + type Capture = { url: URL headers: Headers diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 92fe3f8136..86dc2a40ff 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -492,6 +492,39 @@ it.instance("loop exits without an LLM request for interrupted orphan tool calls }), ) +it.instance("re-delivering the same messageID does not duplicate user parts", () => + Effect.gen(function* () { + yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "idempotent", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const messageID = MessageID.ascending() + yield* prompt.prompt({ + sessionID: chat.id, + messageID, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello once" }], + }) + const first = MessageV2.get({ sessionID: chat.id, messageID }) + // Same messageID re-delivered (the run command's retry path after an + // ambiguous network failure) must not append a second copy of the parts. + yield* prompt.prompt({ + sessionID: chat.id, + messageID, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello once" }], + }) + const second = MessageV2.get({ sessionID: chat.id, messageID }) + expect(second.parts.length).toBe(first.parts.length) + expect(second.parts.filter((p) => p.type === "text")).toHaveLength(1) + }), +) + it.instance("loop calls LLM and returns assistant message", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) diff --git a/packages/opencode/test/session/tool-callid-sanitize.test.ts b/packages/opencode/test/session/tool-callid-sanitize.test.ts new file mode 100644 index 0000000000..ea2880e557 --- /dev/null +++ b/packages/opencode/test/session/tool-callid-sanitize.test.ts @@ -0,0 +1,228 @@ +// W1.8 — tool-call id sanitation. Malformed (non-string) tool-call ids must be +// coerced/regenerated DETERMINISTICALLY at ingestion (processor.ts) with the +// mapping propagated atomically to the paired tool-result, and the replay path +// (message-v2.ts toModelMessages) must apply the same coercion so both halves of +// a persisted pair render identical toolCallId values. A regenerated call id with +// an un-regenerated result id 400s every subsequent provider request. +import { describe, expect, test } from "bun:test" +import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionProcessor } from "../../src/session/processor" +import type { Provider } from "@/provider/provider" +import { SessionID, MessageID, PartID } from "../../src/session/schema" +import { ProviderID, ModelID } from "../../src/provider/schema" + +const sessionID = SessionID.make("session") +const providerID = ProviderID.make("test") +const model: Provider.Model = { + id: ModelID.make("test-model"), + providerID, + api: { + id: "test-model", + url: "https://example.com", + npm: "@ai-sdk/openai", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { context: 128000, output: 4096 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", +} as unknown as Provider.Model + +function basePart(messageID: string, id: string) { + return { + id: PartID.make(`prt_${id}`), + sessionID, + messageID: MessageID.make(`msg_${messageID}`), + } +} + +function userMsg(id: string): SessionV1.WithParts { + return { + info: { + id, + sessionID, + role: "user", + time: { created: 0 }, + agent: "user", + model: { providerID, modelID: ModelID.make("test") }, + tools: {}, + mode: "", + }, + parts: [{ ...basePart(id, "u1"), type: "text", text: "run tool" }], + } as unknown as SessionV1.WithParts +} + +function assistantToolMsg(id: string, callID: unknown): SessionV1.WithParts { + return { + info: { + id, + sessionID, + role: "assistant", + time: { created: 0 }, + parentID: "m-user", + modelID: model.api.id, + providerID, + mode: "", + agent: "agent", + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [ + { + ...basePart(id, "a1"), + type: "tool", + callID, + tool: "bash", + state: { + status: "completed", + input: { cmd: "ls" }, + output: "ok", + title: "Bash", + metadata: {}, + time: { start: 0, end: 1 }, + }, + }, + ], + } as unknown as SessionV1.WithParts +} + +/** Extract the tool-call and tool-result ids from replayed model messages. */ +function pairIDs(messages: Awaited>) { + const callIDs: unknown[] = [] + const resultIDs: unknown[] = [] + for (const msg of messages) { + if (!Array.isArray(msg.content)) continue + for (const item of msg.content) { + if (item.type === "tool-call") callIDs.push(item.toolCallId) + if (item.type === "tool-result") resultIDs.push(item.toolCallId) + } + } + return { callIDs, resultIDs } +} + +describe("MessageV2.sanitizeToolCallID", () => { + test("passes valid non-empty string ids through untouched", () => { + expect(MessageV2.sanitizeToolCallID("call_abc123")).toBe("call_abc123") + }) + + test("regenerates non-string ids deterministically", () => { + const a = MessageV2.sanitizeToolCallID(12345) + const b = MessageV2.sanitizeToolCallID(12345) + expect(a).toBe(b) + expect(typeof a).toBe("string") + expect(a.length).toBeGreaterThan(0) + }) + + test("distinct malformed ids map to distinct sanitized ids", () => { + expect(MessageV2.sanitizeToolCallID(1)).not.toBe(MessageV2.sanitizeToolCallID(2)) + }) + + test("uses a widened (64-bit) digest, not a 32-bit one, to keep collisions negligible", () => { + // "call_" + 8 hex chars is a 32-bit digest with only ~4B buckets; regenerated + // ids must use a wider digest so two distinct malformed ids are vanishingly + // unlikely to collide onto the same toolCallId. + const id = MessageV2.sanitizeToolCallID(12345) + expect(id).toMatch(/^call_[0-9a-f]{16}$/) + }) + + test("handles empty string, null, undefined, and objects without throwing", () => { + for (const raw of ["", null, undefined, { id: 1 }, []]) { + const out = MessageV2.sanitizeToolCallID(raw) + expect(typeof out).toBe("string") + expect(out.length).toBeGreaterThan(0) + expect(out).toBe(MessageV2.sanitizeToolCallID(raw)) + } + }) +}) + +describe("SessionProcessor.createToolCallIDCoercer (ingestion half)", () => { + test("coerces a malformed call id and propagates the SAME id to the paired result", () => { + const coerce = SessionProcessor.createToolCallIDCoercer() + const callHalf = coerce(42) // tool-input-start / tool-call event + const resultHalf = coerce(42) // tool-result event + expect(callHalf).toBe(resultHalf) + expect(typeof callHalf).toBe("string") + }) + + test("pairs survive a provider type flip (numeric call id, string result id)", () => { + const coerce = SessionProcessor.createToolCallIDCoercer() + const callHalf = coerce(42) + const resultHalf = coerce("42") // some servers stringify the id on the result event + expect(resultHalf).toBe(callHalf) + }) + + test("valid string ids are untouched so healthy providers see no behavior change", () => { + const coerce = SessionProcessor.createToolCallIDCoercer() + expect(coerce("call_ok")).toBe("call_ok") + }) + + test("reserved Object.prototype property names are not confused with cached aliases", () => { + // A plain `{}` cache would read these back as inherited functions/objects + // (Object.prototype.toString, .constructor, ...) instead of `undefined` on + // first sight — corrupting the alias for a provider-supplied id that happens + // to equal one of these names. + const coerce = SessionProcessor.createToolCallIDCoercer() + for (const reserved of ["toString", "constructor", "valueOf", "hasOwnProperty", "__proto__"]) { + expect(coerce(reserved)).toBe(reserved) + } + }) +}) + +describe("malformed-id round-trip: ingest → persist → replay", () => { + test("ingested-then-persisted id replays with matching call/result ids", async () => { + // Ingestion: the processor's coercer regenerates the malformed id; the + // sanitized value is what gets persisted as the part's callID. + const coerce = SessionProcessor.createToolCallIDCoercer() + const persistedCallID = coerce(9876) + expect(coerce("9876")).toBe(persistedCallID) // paired result resolves to the same part + + // Replay: the persisted transcript renders both halves with the same string id. + const replayed = await MessageV2.toModelMessages( + [userMsg("m-user"), assistantToolMsg("m-assistant", persistedCallID)] as unknown as MessageV2.WithParts[], + model, + ) + const { callIDs, resultIDs } = pairIDs(replayed) + expect(callIDs).toEqual([persistedCallID]) + expect(resultIDs).toEqual([persistedCallID]) + }) + + test("replay defensively coerces a malformed PERSISTED id identically on both halves", async () => { + // Transcripts written before the ingestion fix may carry non-string callIDs. + const replayed = await MessageV2.toModelMessages( + [userMsg("m-user"), assistantToolMsg("m-assistant", 12345)] as unknown as MessageV2.WithParts[], + model, + ) + const { callIDs, resultIDs } = pairIDs(replayed) + expect(callIDs).toHaveLength(1) + expect(resultIDs).toHaveLength(1) + const expected = MessageV2.sanitizeToolCallID(12345) + expect(callIDs[0]).toBe(expected) + expect(resultIDs[0]).toBe(expected) + expect(typeof callIDs[0]).toBe("string") + }) + + test("ingestion and replay halves produce identical output for the same raw id", () => { + // The contract that keeps a pair consistent across the two code paths. + const coerce = SessionProcessor.createToolCallIDCoercer() + for (const raw of [7, "7x", 0, { a: 1 }, ""]) { + expect(coerce(raw)).toBe(MessageV2.sanitizeToolCallID(raw)) + } + }) +}) diff --git a/packages/opencode/test/session/uncounted-tail.test.ts b/packages/opencode/test/session/uncounted-tail.test.ts new file mode 100644 index 0000000000..b61e4ede5f --- /dev/null +++ b/packages/opencode/test/session/uncounted-tail.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, test } from "bun:test" + +import { SessionCompaction } from "../../src/session/compaction" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import type { MessageV2 } from "../../src/session/message-v2" +import type { Provider } from "../../src/provider/provider" + +// The proactive overflow path (prompt.ts loop) adds +// SessionCompaction.uncountedTailTokens of parts appended after the last +// assistant usage reading to lastFinished.tokens before checking +// SessionCompaction.isOverflow. These tests exercise that estimator and its +// effect on the overflow decision directly, since the estimator is now an +// exported pure function rather than an inline IIFE in the prompt loop. + +function textMessage(id: string, role: "user" | "assistant", text: string): MessageV2.WithParts { + return { + info: { id, sessionID: "s", role, time: { created: 1 }, model: { providerID: "p", modelID: "m" } }, + parts: [{ id: `${id}-p`, sessionID: "s", messageID: id, type: "text", text }], + } as unknown as MessageV2.WithParts +} + +function toolMessage(id: string, output: string): MessageV2.WithParts { + return { + info: { id, sessionID: "s", role: "assistant", time: { created: 1 }, model: { providerID: "p", modelID: "m" } }, + parts: [ + { + id: `${id}-p`, + sessionID: "s", + messageID: id, + type: "tool", + callID: `${id}-call`, + tool: "bash", + state: { + status: "completed", + input: {}, + output, + title: "Bash", + metadata: {}, + time: { start: 0, end: 1 }, + }, + }, + ], + } as unknown as MessageV2.WithParts +} + +function model(context: number): Provider.Model { + return { + id: "m", + providerID: "p", + api: { npm: "@ai-sdk/openai-compatible" }, + limit: { context, output: 4096 }, + } as unknown as Provider.Model +} + +describe("SessionCompaction.uncountedTailTokens", () => { + test("returns 0 when lastFinishedId is not set", () => { + const messages = [textMessage("a", "assistant", "hello")] + expect(SessionCompaction.uncountedTailTokens({ messages, lastFinishedId: undefined })).toBe(0) + }) + + test("returns 0 when lastFinishedId is not found in messages", () => { + const messages = [textMessage("a", "assistant", "hello")] + expect(SessionCompaction.uncountedTailTokens({ messages, lastFinishedId: "missing" as MessageV2.WithParts["info"]["id"] })).toBe(0) + }) + + test("sums text and completed-tool tokens after lastFinishedId, ignoring messages before/at it", () => { + const messages = [ + textMessage("before", "user", "x".repeat(400)), // must not count + textMessage("finished", "assistant", "y".repeat(400)), // the boundary itself must not count + toolMessage("tool1", "z".repeat(400)), + textMessage("text1", "assistant", "w".repeat(400)), + ] + const tail = SessionCompaction.uncountedTailTokens({ messages, lastFinishedId: "finished" as any }) + expect(tail).toBeGreaterThan(0) + + // Only the two post-boundary messages should be counted. + const partial = SessionCompaction.uncountedTailTokens({ + messages: messages.slice(0, 3), // up through tool1, no text1 + lastFinishedId: "finished" as any, + }) + expect(partial).toBeLessThan(tail) + expect(partial).toBeGreaterThan(0) + }) + + test("mixed tool+text tail is content-aware, not a flat chars/4 estimate", () => { + // Same length, different content shape: dense JSON should estimate to MORE + // tokens than the same-length plain text once the 0.8 safety margin (which + // divides through evenly on both) is factored out — chars/4 would give the + // same count for both regardless of content. + const jsonBody = JSON.stringify({ a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8 }).repeat(20) + const plainText = "the quick brown fox jumps over the lazy dog and keeps on going ".repeat( + Math.ceil(jsonBody.length / 65), + ) + + const jsonMessages = [textMessage("finished", "assistant", "x"), toolMessage("tool1", jsonBody)] + const textMessages = [textMessage("finished", "assistant", "x"), toolMessage("tool1", plainText.slice(0, jsonBody.length))] + + const jsonTokens = SessionCompaction.uncountedTailTokens({ messages: jsonMessages, lastFinishedId: "finished" as any }) + const textTokens = SessionCompaction.uncountedTailTokens({ messages: textMessages, lastFinishedId: "finished" as any }) + + expect(jsonTokens).toBeGreaterThan(textTokens) + }) + + test("applies a safety margin so the estimate is inflated, not a raw sum", () => { + const output = "a".repeat(4000) + const messages = [textMessage("finished", "assistant", "x"), toolMessage("tool1", output)] + const tail = SessionCompaction.uncountedTailTokens({ messages, lastFinishedId: "finished" as any }) + // Raw Token.estimate would land well under the margined figure. + expect(tail).toBeGreaterThan(output.length / 4.0) + }) + + // A step's own tool results complete on the SAME message that carries its + // finish-step usage snapshot (see processor.ts: "tool-result" is handled + // before "finish-step" within a step), so that message's recorded tokens never + // include its own tool output. The pre-fix slice (strictly AFTER lastFinishedId) + // missed this window entirely. + test("counts a completed tool part living ON the lastFinished message itself", () => { + const finishedWithToolOutput: MessageV2.WithParts = { + info: { id: "finished", sessionID: "s", role: "assistant", time: { created: 1 }, model: { providerID: "p", modelID: "m" } }, + parts: [ + { id: "finished-text", sessionID: "s", messageID: "finished", type: "text", text: "ok" }, + { + id: "finished-tool", + sessionID: "s", + messageID: "finished", + type: "tool", + callID: "finished-call", + tool: "bash", + state: { + status: "completed", + input: {}, + output: "x".repeat(200_000), + title: "Bash", + metadata: {}, + time: { start: 0, end: 1 }, + }, + }, + ], + } as unknown as MessageV2.WithParts + + const tail = SessionCompaction.uncountedTailTokens({ + messages: [finishedWithToolOutput], + lastFinishedId: "finished" as any, + }) + expect(tail).toBeGreaterThan(0) + }) + + test("does not double-count the lastFinished message's own text (already in recorded output tokens)", () => { + const finishedTextOnly = textMessage("finished", "assistant", "y".repeat(400)) + const withToolOnly: MessageV2.WithParts = { + ...finishedTextOnly, + parts: [ + ...finishedTextOnly.parts, + { + id: "finished-tool", + sessionID: "s", + messageID: "finished", + type: "tool", + callID: "finished-call", + tool: "bash", + state: { + status: "completed", + input: {}, + output: "small", + title: "Bash", + metadata: {}, + time: { start: 0, end: 1 }, + }, + }, + ], + } as unknown as MessageV2.WithParts + const textOnlyTail = SessionCompaction.uncountedTailTokens({ + messages: [finishedTextOnly], + lastFinishedId: "finished" as any, + }) + const withToolTail = SessionCompaction.uncountedTailTokens({ + messages: [withToolOnly], + lastFinishedId: "finished" as any, + }) + // text-only lastFinished contributes nothing (its text is already recorded usage); + // adding a completed tool part is the only thing that should move the estimate. + expect(textOnlyTail).toBe(0) + expect(withToolTail).toBeGreaterThan(0) + }) +}) + +describe("proactive overflow: isOverflow triggers on an oversized post-lastFinished tool result", () => { + test("a huge tool result appended after lastFinished pushes isOverflow to true even though recorded usage alone would not", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const testModel = model(32_768) + const lastFinishedTokens = { + input: 1_000, + output: 500, + reasoning: 0, + cache: { read: 0, write: 0 }, + total: 1_500, + } + + // Recorded usage alone is nowhere near the window. + const overflowFromRecordedOnly = await SessionCompaction.isOverflow({ + tokens: lastFinishedTokens, + model: testModel, + }) + expect(overflowFromRecordedOnly).toBe(false) + + // A huge tool result lands after lastFinished but before the next usage + // reading — exactly the gap uncountedTailTokens exists to cover. + const messages = [ + textMessage("finished", "assistant", "ok"), + toolMessage("huge-tool", "x".repeat(200_000)), + ] + const uncountedTail = SessionCompaction.uncountedTailTokens({ messages, lastFinishedId: "finished" as any }) + expect(uncountedTail).toBeGreaterThan(0) + + const overflowIncludingTail = await SessionCompaction.isOverflow({ + tokens: { + ...lastFinishedTokens, + input: lastFinishedTokens.input + uncountedTail, + total: lastFinishedTokens.total + uncountedTail, + }, + model: testModel, + }) + expect(overflowIncludingTail).toBe(true) + }, + }) + }) + + test("a small post-lastFinished tail does not falsely trigger overflow", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const testModel = model(32_768) + const lastFinishedTokens = { + input: 1_000, + output: 500, + reasoning: 0, + cache: { read: 0, write: 0 }, + total: 1_500, + } + const messages = [textMessage("finished", "assistant", "ok"), toolMessage("small-tool", "done")] + const uncountedTail = SessionCompaction.uncountedTailTokens({ messages, lastFinishedId: "finished" as any }) + + const overflow = await SessionCompaction.isOverflow({ + tokens: { + ...lastFinishedTokens, + input: lastFinishedTokens.input + uncountedTail, + total: lastFinishedTokens.total + uncountedTail, + }, + model: testModel, + }) + expect(overflow).toBe(false) + }, + }) + }) +}) diff --git a/packages/opencode/test/telemetry/classify-provider.test.ts b/packages/opencode/test/telemetry/classify-provider.test.ts index 82e85d17d0..c093811bc1 100644 --- a/packages/opencode/test/telemetry/classify-provider.test.ts +++ b/packages/opencode/test/telemetry/classify-provider.test.ts @@ -27,6 +27,26 @@ import { describe, expect, test } from "bun:test" import { Telemetry } from "../../src/altimate/telemetry" +// Regression: classifyProvider("local") produces `provider: "local"` (curated entry above), +// but the tui.ts emit site forwards it through an `as` cast, which hides any mismatch with the +// declared provider_selected union from the type checker. This only catches a regression at +// `bun run typecheck` time — if "local" is ever dropped from the union again, this assignment +// stops compiling even though the runtime assertion below would still pass. +describe("provider_selected event union stays honest about classifyProvider's output", () => { + test("'local' is assignable to provider_selected.provider", () => { + type ProviderSelected = Extract + // The literal "local" (not a cast) is what actually exercises the union — if "local" is + // ever dropped from provider_selected.provider, this object literal fails to typecheck. + const event: ProviderSelected = { + type: "provider_selected", + timestamp: 0, + session_id: "s", + provider: "local", + } + expect(Telemetry.classifyProvider("local").provider).toBe(event.provider) + }) +}) + describe("Telemetry.classifyProvider — allowlist + prototype defense", () => { describe("curated providers", () => { test.each([ @@ -34,6 +54,10 @@ describe("Telemetry.classifyProvider — allowlist + prototype defense", () => { ["anthropic", "anthropic"], ["openai", "openai"], ["google", "google"], + // The welcome picker's "Local model" row (altimate-onboarding.tsx) uses + // providerID "local" — without a curated entry it fell through to `other` + // with the id stripped, indistinguishable from any unrecognized provider. + ["local", "local"], ])("providerID %j → provider %j, keeps raw id", (providerID, expected) => { const result = Telemetry.classifyProvider(providerID) expect(result).toEqual({ provider: expected, provider_id: providerID }) diff --git a/packages/opencode/test/tool/retrieval-compact.test.ts b/packages/opencode/test/tool/retrieval-compact.test.ts new file mode 100644 index 0000000000..834463b020 --- /dev/null +++ b/packages/opencode/test/tool/retrieval-compact.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" + +import { Retrieval } from "../../src/tool/retrieval" + +describe("Retrieval.compactDescription", () => { + test("keeps a short single sentence unchanged", () => { + expect(Retrieval.compactDescription("Analyze dbt models.")).toBe("Analyze dbt models.") + }) + + test("takes only the first sentence of a multi-sentence description", () => { + const text = "Find flaky tests. Runs hourly to find real-world gaps. Uses a critic." + expect(Retrieval.compactDescription(text)).toBe("Find flaky tests.") + }) + + test("collapses whitespace and newlines", () => { + expect(Retrieval.compactDescription("Analyze\n dbt models. More detail follows.")).toBe("Analyze dbt models.") + }) + + test("caps very long sentences with an ellipsis", () => { + const long = "A".repeat(400) + "." + const result = Retrieval.compactDescription(long, 160) + expect(result.length).toBeLessThanOrEqual(160) + expect(result.endsWith("…")).toBe(true) + }) + + test("handles undefined and empty input", () => { + expect(Retrieval.compactDescription(undefined)).toBe("") + expect(Retrieval.compactDescription(" ")).toBe("") + }) + + test("does not mis-cut on an abbreviation's period", () => { + expect(Retrieval.compactDescription("e.g. run the linter before committing.")).toBe( + "e.g. run the linter before committing.", + ) + }) + + test("does not mis-cut on a decimal version number", () => { + expect(Retrieval.compactDescription("Supports v2.0 models and newer releases.")).toBe( + "Supports v2.0 models and newer releases.", + ) + }) + + test("does not mis-cut on a bare URL", () => { + expect(Retrieval.compactDescription("See https://example.com for details and more context here.")).toBe( + "See https://example.com for details and more context here.", + ) + }) +}) diff --git a/packages/opencode/test/tool/truncate-core.test.ts b/packages/opencode/test/tool/truncate-core.test.ts new file mode 100644 index 0000000000..c6438f596a --- /dev/null +++ b/packages/opencode/test/tool/truncate-core.test.ts @@ -0,0 +1,175 @@ +import { describe, test, expect } from "bun:test" +import { TruncateCore } from "@/tool/truncate-core" + +// Pure algorithm tests for the module shared by tool/truncate.ts (the Effect +// Service wired into every Tool.define() output, including bash) and +// tool/truncation.ts (the plain-async twin). Exercising the shared function +// directly is what guarantees a change here cannot silently apply to only +// one of the two call paths. + +function assembleDefault(text: string, opts: Partial = {}) { + const resolved: TruncateCore.ResolvedOptions = { + maxLines: TruncateCore.MAX_LINES, + maxBytes: TruncateCore.MAX_BYTES, + direction: TruncateCore.DEFAULT_DIRECTION, + headRatio: TruncateCore.DEFAULT_HEAD_RATIO, + ...opts, + } + const lines = text.split("\n") + const totalBytes = Buffer.byteLength(text, "utf-8") + if (TruncateCore.fits(lines, totalBytes, resolved.maxLines, resolved.maxBytes)) { + return { truncated: false as const, content: text } + } + const p = TruncateCore.preview(lines, totalBytes, resolved) + return { truncated: true as const, content: TruncateCore.assemble(p, "[hint]", resolved.direction), preview: p } +} + +describe("TruncateCore", () => { + test("defaults to middle direction", () => { + expect(TruncateCore.DEFAULT_DIRECTION).toBe("middle") + }) + + test("defaults to a 1/3 head : 2/3 tail split", () => { + expect(TruncateCore.DEFAULT_HEAD_RATIO).toBeCloseTo(1 / 3) + }) + + test("fits() reports false only when a limit is exceeded", () => { + expect(TruncateCore.fits(["a", "b"], 2, 10, 10)).toBe(true) + expect(TruncateCore.fits(["a", "b", "c"], 2, 2, 10)).toBe(false) + expect(TruncateCore.fits(["a", "b"], 100, 10, 10)).toBe(false) + }) + + test("head direction keeps only the leading lines", () => { + const text = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") + const result = assembleDefault(text, { maxLines: 3, direction: "head" }) + expect(result.truncated).toBe(true) + expect(result.content).toContain("line0") + expect(result.content).toContain("line2") + expect(result.content).not.toContain("line9") + }) + + test("tail direction keeps only the trailing lines", () => { + const text = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") + const result = assembleDefault(text, { maxLines: 3, direction: "tail" }) + expect(result.truncated).toBe(true) + expect(result.content).toContain("line9") + expect(result.content).toContain("line7") + expect(result.content).not.toContain("line0") + }) + + test("middle direction keeps head and tail, never the same line twice", () => { + const text = Array.from({ length: 12 }, (_, i) => `line${i}`).join("\n") + // maxLines 6 -> 1/3 head = 2 lines, 2/3 tail = 4 lines. + const result = assembleDefault(text, { maxLines: 6, direction: "middle" }) + expect(result.truncated).toBe(true) + expect(result.content).toContain("line0") + expect(result.content).toContain("line1") + expect(result.content).toContain("line8") + expect(result.content).toContain("line9") + expect(result.content).toContain("line10") + expect(result.content).toContain("line11") + expect(result.content).not.toContain("line5") + }) + + test("middle direction with maxLines=1 keeps exactly one line, not one from each half", () => { + const text = Array.from({ length: 5 }, (_, i) => `line${i}`).join("\n") + const result = assembleDefault(text, { maxLines: 1, direction: "middle" }) + expect(result.truncated).toBe(true) + expect(result.preview!.head.split("\n").filter(Boolean).length + result.preview!.tail.split("\n").filter(Boolean).length).toBe( + 1, + ) + }) + + test("middle direction respects a custom head ratio", () => { + const text = Array.from({ length: 30 }, (_, i) => `line${i}`).join("\n") + // headRatio 0.5 with maxLines 10 -> 5 head lines, 5 tail lines. + const result = assembleDefault(text, { maxLines: 10, direction: "middle", headRatio: 0.5 }) + expect(result.truncated).toBe(true) + for (let i = 0; i < 5; i++) expect(result.content).toContain(`line${i}`) + for (let i = 25; i < 30; i++) expect(result.content).toContain(`line${i}`) + expect(result.content).not.toContain("line15") + }) + + test(">50KB log: a trailing success line survives default middle truncation", () => { + const noise = Array.from({ length: 3000 }, (_, i) => `build step ${i}: compiling module_${i}.ts`) + const successLine = "Done. PASS=42 FAIL=0" + const text = [...noise, successLine].join("\n") + expect(Buffer.byteLength(text, "utf-8")).toBeGreaterThan(50 * 1024) + + const result = assembleDefault(text) + expect(result.truncated).toBe(true) + expect(result.content).toContain(successLine) + }) + + test(">50KB log: the first error line survives default middle truncation", () => { + const firstError = "ERROR: schema.sql:1: syntax error near CREAT" + const noise = Array.from({ length: 3000 }, (_, i) => `build step ${i}: compiling module_${i}.ts`) + const text = [firstError, ...noise].join("\n") + expect(Buffer.byteLength(text, "utf-8")).toBeGreaterThan(50 * 1024) + + const result = assembleDefault(text) + expect(result.truncated).toBe(true) + expect(result.content).toContain(firstError) + }) + + test(">50KB log with both a leading error and a trailing success line: both survive", () => { + const firstError = "ERROR: schema.sql:1: syntax error near CREAT" + const successLine = "Done. PASS=42 FAIL=0" + const noise = Array.from({ length: 3000 }, (_, i) => `build step ${i}: compiling module_${i}.ts`) + const text = [firstError, ...noise, successLine].join("\n") + expect(Buffer.byteLength(text, "utf-8")).toBeGreaterThan(50 * 1024) + + const result = assembleDefault(text) + expect(result.truncated).toBe(true) + expect(result.content).toContain(firstError) + expect(result.content).toContain(successLine) + // and the elided middle noise is gone + expect(result.content).not.toContain("build step 1500") + }) + + test("reports unit as bytes when the byte budget (not the line budget) is the binding constraint", () => { + const text = "a".repeat(2000) + const result = assembleDefault(text, { maxLines: 1_000_000, maxBytes: 100, direction: "middle" }) + expect(result.truncated).toBe(true) + expect(result.content).toContain("bytes truncated") + }) + + test("middle direction: a boundary line too big for either half's split budget still survives if it fits the overall maxBytes", () => { + // Two 80-byte lines (161 bytes total, over the 100-byte cap, so this doesn't + // fit and truncation runs). maxBytes=100 with the default 1/3 head ratio + // splits into a ~33-byte head budget and a ~67-byte tail budget — neither + // half can hold an 80-byte line on its own, even though the FIRST line alone + // fits the undivided 100-byte total. + const firstLine = "x".repeat(80) + const secondLine = "y".repeat(80) + const text = `${firstLine}\n${secondLine}` + const result = assembleDefault(text, { maxLines: 10, maxBytes: 100, direction: "middle" }) + expect(result.truncated).toBe(true) + expect(result.content).toContain(firstLine) + }) + + test("middle direction: an out-of-range headRatio is clamped instead of blowing the byte budget", () => { + const text = Array.from({ length: 20 }, (_, i) => `line${i}`).join("\n") + for (const badRatio of [5, -3, Number.NaN, Number.POSITIVE_INFINITY]) { + const result = assembleDefault(text, { maxLines: 6, maxBytes: 40, direction: "middle", headRatio: badRatio }) + expect(result.truncated).toBe(true) + // The two halves together must never exceed the byte budget they were split from. + const headBytes = Buffer.byteLength(result.preview!.head, "utf-8") + const tailBytes = Buffer.byteLength(result.preview!.tail, "utf-8") + expect(headBytes + tailBytes).toBeLessThanOrEqual(40) + } + }) + + test("assemble() places the elision marker and hint between head and tail for middle direction", () => { + const p: TruncateCore.Preview = { head: "HEAD", tail: "TAIL", removed: 5, unit: "lines" } + const content = TruncateCore.assemble(p, "HINT", "middle") + const headIdx = content.indexOf("HEAD") + const markerIdx = content.indexOf("...5 lines truncated...") + const hintIdx = content.indexOf("HINT") + const tailIdx = content.lastIndexOf("TAIL") + expect(headIdx).toBeGreaterThanOrEqual(0) + expect(markerIdx).toBeGreaterThan(headIdx) + expect(hintIdx).toBeGreaterThan(markerIdx) + expect(tailIdx).toBeGreaterThan(hintIdx) + }) +}) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 450559c7d2..1f8d93a45d 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -74,12 +74,31 @@ describe("Truncate", () => { }), ) - it.live("truncates from head by default", () => + // altimate_change start — W1.7: default direction is "middle" (head+tail, + // tail-weighted), not pure head. Pure head truncation is still available + // via an explicit `direction: "head"` override, covered below. + it.live("truncates from the middle by default (head+tail, tail-weighted)", () => Effect.gen(function* () { const svc = yield* Truncate.Service const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") const result = yield* svc.output(lines, { maxLines: 3 }) + // 1/3 head : 2/3 tail split of a 3-line budget = 1 head line + 2 tail lines. + expect(result.truncated).toBe(true) + expect(result.content).toContain("line0") + expect(result.content).toContain("line8") + expect(result.content).toContain("line9") + expect(result.content).not.toContain("line1") + expect(result.content).not.toContain("line5") + }), + ) + + it.live("explicit direction 'head' still truncates from the head only", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") + const result = yield* svc.output(lines, { maxLines: 3, direction: "head" }) + expect(result.truncated).toBe(true) expect(result.content).toContain("line0") expect(result.content).toContain("line1") @@ -88,6 +107,39 @@ describe("Truncate", () => { }), ) + it.live("default middle truncation preserves a trailing success line in a >50KB log", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const noise = Array.from({ length: 3000 }, (_, i) => `build step ${i}: compiling module_${i}.ts`) + const successLine = "Done. PASS=42 FAIL=0" + const text = [...noise, successLine].join("\n") + expect(text.split("\n").length).toBeGreaterThan(Truncate.MAX_LINES) + expect(Buffer.byteLength(text, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES) + + const result = yield* svc.output(text) + + expect(result.truncated).toBe(true) + expect(result.content).toContain(successLine) + }), + ) + + it.live("default middle truncation preserves the first error line in a >50KB log", () => + Effect.gen(function* () { + const svc = yield* Truncate.Service + const firstError = "ERROR: schema.sql:1: syntax error near CREAT" + const noise = Array.from({ length: 3000 }, (_, i) => `build step ${i}: compiling module_${i}.ts`) + const text = [firstError, ...noise].join("\n") + expect(text.split("\n").length).toBeGreaterThan(Truncate.MAX_LINES) + expect(Buffer.byteLength(text, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES) + + const result = yield* svc.output(text) + + expect(result.truncated).toBe(true) + expect(result.content).toContain(firstError) + }), + ) + // altimate_change end + it.live("truncates from tail when direction is tail", () => Effect.gen(function* () { const svc = yield* Truncate.Service diff --git a/packages/tui/src/component/altimate-onboarding.tsx b/packages/tui/src/component/altimate-onboarding.tsx index a3e0f2e1fe..56c10a6e61 100644 --- a/packages/tui/src/component/altimate-onboarding.tsx +++ b/packages/tui/src/component/altimate-onboarding.tsx @@ -77,9 +77,9 @@ export function useSetupComplete() { } // First-run welcome picker (presentation only; reuses the same action handlers as -// DialogModel/createDialogProviderOptions). A curated six: five recommended -// providers + a "Search all providers…" row that hands off to the full DialogModel -// picker. The long tail stays behind search. +// DialogModel/createDialogProviderOptions). A curated seven: six recommended +// rows (five providers + the local model) + a "Search all providers…" row that +// hands off to the full DialogModel picker. The long tail stays behind search. const NAME_W = 24 type WelcomeTone = "success" | "warning" | "muted" @@ -103,7 +103,7 @@ export function DialogModelWelcome(props: { // declining Big Pickle, and from the prompt gate, so without this every impression would read // as a fresh first run. Defaults to the /connect case since that is the only caller that does // not pass one explicitly. - trigger?: "first_run" | "connect_command" | "big_pickle_back" | "prompt_gate" + trigger?: "first_run" | "connect_command" | "big_pickle_back" | "local_model_back" | "prompt_gate" }) { const { theme } = useTheme() const dialog = useDialog() @@ -123,7 +123,7 @@ export function DialogModelWelcome(props: { /** * Reuse the exact provider onSelect (gateway flow for altimate-backend, auth-method screens for * the BYOK providers). Returns whether an action was actually dispatched: the server filters - * providers via `enabled_providers` / `disabled_providers` while these five rows are hardcoded, + * providers via `enabled_providers` / `disabled_providers` while these curated rows are hardcoded, * so a row can legitimately have no matching option and this would otherwise no-op in silence. */ function connectProvider(id: string): boolean { @@ -138,6 +138,11 @@ export function DialogModelWelcome(props: { return true } + function chooseLocalModel(): boolean { + dialog.replace(() => ) + return true + } + function openFullCatalog(): boolean { // altimate_change — viaSearch marks this as the genuine search path; the catalogue's other // entry points must not inherit it. @@ -182,6 +187,13 @@ export function DialogModelWelcome(props: { modelID: "big-pickle", activate: chooseBigPickle, }, + { + name: "Local model", + note: "no account · runs on this machine", + tone: "muted", + providerID: "local", + activate: chooseLocalModel, + }, { name: "Search all providers…", note: "/", @@ -226,8 +238,8 @@ export function DialogModelWelcome(props: { }) } - // Indices 0-4 are providers, 5 is the search row (rendered below a divider). - const COUNT = 6 + // Indices 0-5 are providers, 6 is the search row (rendered below a divider). + const COUNT = 7 function move(direction: number) { setSelected((prev) => (prev + direction + COUNT) % COUNT) } @@ -246,7 +258,7 @@ export function DialogModelWelcome(props: { evt.preventDefault() // altimate_change — the "/" shortcut is the same intent as the "Search all providers…" // row, so it routes through the same guarded path. - activateRow(rows()[5]) + activateRow(rows()[6]) } }) @@ -319,10 +331,10 @@ export function DialogModelWelcome(props: { — you can change this anytime with /model - {(row, i) => } + {(row, i) => } - + ) @@ -463,3 +475,108 @@ export function DialogBigPickleConfirm(props: { ) } + +// Local-model interstitial — the picker cannot run the multi-minute `altimate local` +// setup (model download + certification is a CLI-side flow), so this explains what +// it is and hands the user the one command. Mirrors DialogBigPickleConfirm's +// structure, keyboard handling, and funnel-telemetry discipline. +export function DialogLocalModelInfo() { + const { theme } = useTheme() + const dialog = useDialog() + const [selected, setSelected] = createSignal(0) // 0 = Got it (default) + const trackOnboarding = useOnboardingTelemetry() + const firstRunActive = useFirstRunActive() + let decided = false + onMount(() => { + if (firstRunActive()) trackOnboarding({ name: "local_model_info_shown" }) + }) + // Escape / click-away never reach this component's handlers (DialogProvider owns them), + // so onCleanup is the only hook that sees every undecided close. + onCleanup(() => { + if (decided) return + decided = true + if (firstRunActive()) trackOnboarding({ name: "local_model_choice", choice: "cancel" }) + }) + + function acknowledge() { + if (decided) return + decided = true + if (firstRunActive()) trackOnboarding({ name: "local_model_choice", choice: "acknowledge" }) + dialog.clear() + } + function back() { + if (decided) return + decided = true + if (firstRunActive()) trackOnboarding({ name: "local_model_choice", choice: "back" }) + dialog.replace(() => ) + } + const options = [ + { label: "Got it — I'll run `altimate local`", hint: "(default)", run: acknowledge }, + { label: "Back — pick something else", hint: "", run: back }, + ] + + useKeyboard((evt) => { + if (evt.name === "up" || evt.name === "down") { + setSelected((prev) => (prev + 1) % 2) + evt.preventDefault() + return + } + if (evt.name === "return") { + evt.preventDefault() + evt.stopPropagation() + options[selected()].run() + } + }) + + const selFg = selectedForeground(theme) + const transparent = RGBA.fromInts(0, 0, 0, 0) + + return ( + + + + Run a local model? + + dialog.clear()}> + esc + + + + No account, no API key — a certified open model runs on this machine, and web tools ask before anything + leaves it. Needs Apple Silicon or a 24GB+ GPU, plus a one-time ~16GB download. Exit and run: + + + {" altimate local"} + + + Then start altimate again — the local model will be selected automatically. + + + + {(option, index) => ( + setSelected(index())} onMouseUp={() => option.run()}> + + {selected() === index() ? "›" : " "} + + + + {option.label} + + + + {option.hint} + + + )} + + + + ) +} diff --git a/packages/tui/src/context/onboarding-telemetry.tsx b/packages/tui/src/context/onboarding-telemetry.tsx index 7d667e1c60..d0693dba62 100644 --- a/packages/tui/src/context/onboarding-telemetry.tsx +++ b/packages/tui/src/context/onboarding-telemetry.tsx @@ -21,7 +21,7 @@ export type OnboardingTelemetryEvent = name: "model_picker_shown" /** The picker also opens from /connect, from declining Big Pickle, and from the prompt * gate — without this the event reads as a first-run impression every time. */ - trigger: "first_run" | "connect_command" | "big_pickle_back" | "prompt_gate" + trigger: "first_run" | "connect_command" | "big_pickle_back" | "local_model_back" | "prompt_gate" } | { name: "provider_selected" @@ -36,6 +36,8 @@ export type OnboardingTelemetryEvent = } | { name: "big_pickle_confirm_shown"; origin: "welcome" | "model" } | { name: "big_pickle_choice"; choice: "accept" | "cancel" } + | { name: "local_model_info_shown" } + | { name: "local_model_choice"; choice: "acknowledge" | "back" | "cancel" } | { name: "scan_gate_shown" } | { name: "scan_gate_choice"; choice: "scan" | "skip" | "dismissed" } | { name: "onboarding_completed" } diff --git a/packages/tui/test/cli/tui/dialog-model-welcome.test.tsx b/packages/tui/test/cli/tui/dialog-model-welcome.test.tsx index cfa7a50842..2931701f5d 100644 --- a/packages/tui/test/cli/tui/dialog-model-welcome.test.tsx +++ b/packages/tui/test/cli/tui/dialog-model-welcome.test.tsx @@ -35,11 +35,11 @@ const ALL_PROVIDER_IDS = ["altimate-backend", "anthropic", "openai", "google", " async function mountPicker( trigger?: PickerTrigger, availableProviders: string[] = ALL_PROVIDER_IDS, - { firstRun = true }: { firstRun?: boolean } = {}, + { firstRun = true, interstitial = false }: { firstRun?: boolean; interstitial?: boolean } = {}, ) { const [ { DialogProvider }, - { DialogModelWelcome }, + { DialogModelWelcome, DialogLocalModelInfo }, { OnboardingTelemetryProvider }, { ArgsProvider }, { KVProvider }, @@ -115,7 +115,7 @@ async function mountPicker( {/* above DialogProvider, mirroring app.tsx */} { events.push(e) }}> - + {interstitial ? : } @@ -251,3 +251,56 @@ test("a row for a provider the server filtered out does not brick the dialog", a await picker.cleanup() } }) + +test("the Local model row opens the interstitial and records the pick", async () => { + const picker = await mountPicker("first_run") + try { + await wait(() => picker.events.length > 0) + // Index 5 is the Local model row (0-4 are providers, 6 is search). + for (let i = 0; i < 5; i++) picker.app.mockInput.pressArrow("down") + picker.app.mockInput.pressEnter() + await wait(() => picker.events.some((e) => e.name === "local_model_info_shown")) + + // The row is identified like any provider pick in the funnel. + await wait(() => picker.events.some((e) => e.name === "provider_selected")) + expect(picker.events.filter((e) => e.name === "provider_selected")[0]).toMatchObject({ providerID: "local" }) + } finally { + await picker.cleanup() + } +}) + +// The interstitial mounts directly here: the picker harness above renders the welcome +// dialog outside the DialogProvider outlet, so after dialog.replace() BOTH keyboard +// handlers stay live and the welcome's stopPropagation eats Enter — an artifact of the +// harness, not the app, where replace() unmounts the welcome. +test("Local model interstitial: Enter records the acknowledge choice", async () => { + const picker = await mountPicker(undefined, ALL_PROVIDER_IDS, { interstitial: true }) + try { + await wait(() => picker.events.some((e) => e.name === "local_model_info_shown")) + picker.app.mockInput.pressEnter() + await wait(() => picker.events.some((e) => e.name === "local_model_choice")) + + const choices = picker.events.filter((e) => e.name === "local_model_choice") + expect(choices).toHaveLength(1) + expect(choices[0]).toMatchObject({ choice: "acknowledge" }) + } finally { + await picker.cleanup() + } +}) + +test("Local model interstitial: backing out returns to the picker with its own trigger", async () => { + const picker = await mountPicker(undefined, ALL_PROVIDER_IDS, { interstitial: true }) + try { + await wait(() => picker.events.some((e) => e.name === "local_model_info_shown")) + picker.app.mockInput.pressArrow("down") + picker.app.mockInput.pressEnter() + await wait(() => picker.events.some((e) => e.name === "local_model_choice")) + + expect(picker.events.filter((e) => e.name === "local_model_choice")[0]).toMatchObject({ choice: "back" }) + await wait(() => + picker.events.some((e) => e.name === "model_picker_shown" && (e as any).trigger === "local_model_back"), + ) + } finally { + await picker.cleanup() + } +})