Compare commits

...
Author SHA1 Message Date
Bellman 089abe753b Merge branch 'main' into lane/gjc-compat-analysis 2026-10-13 08:35:57 +09:00
Bellman 620ac1dbad Merge pull request #3327 from ultraworkers/lane/revert-3318
revert: #3318 (merged with red CI; main test build broken)
2026-10-13 08:18:42 +09:00
clawdbot 06c743f15f fix(test): update AnthropicRuntimeClient::new calls to match 9-arg signature
The test code was calling with 8 arguments; updated to include the
terminal_tail parameter in the correct position.
2026-10-12 23:16:26 +00:00
clawdbot e54d832dfc docs: add GJC compatibility gap matrix
Comprehensive analysis of GJC SDK surface (gajae-code) vs claw-code
implementation status across:
- SDK wire protocol (WebSocket JSON-RPC)
- Session lifecycle and reconciliation
- 55-tool catalog and implementations
- Skill discovery and invocation
- Settings and configuration
- Hooks and plugins
- AGENTS.md rules system
- 120+ slash commands
- Subagent delegation and task execution

Gap matrix includes:
- GJC source paths and behavior
- claw-code current status (have/partial/missing)
- Proposed Rust crate/module ownership
- Size estimates and complexity assessment
- Implementation roadmap (3 phases, 10 slices)
- Risk mitigation and verification checklist

Phase 1 (Foundation): SDK wire protocol + session lifecycle
Phase 2 (Core): Tool completion + skill enhancement
Phase 3 (Polish): Settings, commands, integration

Total estimated effort: 15,000 LOC across new SDK crate and
crate extensions.

Relates to: #3319
2026-10-12 23:14:31 +00:00
clawdbot f021597942 Revert "Merge pull request #3318 from ultraworkers/pr-3292"
This reverts commit 2d920ce915, reversing
changes made to 80bfe94b77.
2026-10-12 23:09:13 +00:00
Bellman 2d920ce915 Merge pull request #3318 from ultraworkers/pr-3292
fix: harden the agent loop against a self-hosted Anthropic-compatible backend
2026-10-13 08:07:04 +09:00
Bellman 80bfe94b77 Merge pull request #3299 from D4B34R987/main
fix: wire persisted provider configuration into runtime
2026-10-13 08:06:13 +09:00
dependabot[bot] 5303de65ad build(deps): bump quinn-proto
Bumps the cargo group with 1 update in the /rust directory: [quinn-proto](https://github.com/quinn-rs/quinn).


Updates `quinn-proto` from 0.11.14 to 0.11.19
- [Release notes](https://github.com/quinn-rs/quinn/releases)
- [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.14...quinn-proto-0.11.19)

---
updated-dependencies:
- dependency-name: quinn-proto
  dependency-version: 0.11.19
  dependency-type: indirect
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-12 23:03:59 +00:00
DaBear987 50ff600466 fix: wire persisted provider configuration into runtime 2026-10-12 23:03:56 +00:00
Bellman 4c3b10f087 Merge pull request #3317 from ultraworkers/lane/cc-issue-3283
docs: add OmniRoute documentation for OpenAI-compatible setup
2026-10-13 08:02:26 +09:00
reacechalmers 6a797de094 fix(runtime): keep a failed turn's tool results, and size guards from the real context window
Five defects found by auditing the agent stack, all of which let the harness act
confidently on a wrong picture of its own state.

1. A turn that errored discarded the record of tools that had already run. The CLI
   hands run_turn a CLONE of the session; the turn appends the user message, each
   assistant reply, and a tool_result per executed tool to that clone alone. The error
   path dropped it while the side effects of those tools remained on disk, and the
   recovery path then replayed the same user input against a session showing no sign
   they had ever run — inviting a second execution of tools that are not idempotent.
   The clone is now adopted before teardown, and recovery resumes the turn instead of
   replaying it (new ConversationRuntime::resume_turn).

2. Every sizing guard was computed against the model NAME's context window. With a
   local server behind a cloud model alias that is 200k against a much smaller
   backend, which does not make the preflights inaccurate — it makes them unreachable.
   CLAW_CONTEXT_WINDOW now declares the real window; model_token_limit honours it and
   returns a limit for unrecognised models instead of None, max_tokens is capped to a
   quarter of it, and the auto-compaction threshold derives from it (70%) rather than
   defaulting to a 200k-window constant.

3. Whether to recover by discarding conversation history was decided by substring
   matching on rendered error prose, including strings that ordinary transport and
   decode failures also produce — so a dropped connection cost the session its
   transcript. Classification now happens in the API layer, where the error still has
   its type and the request is still in hand: typed overflow errors, plus ambiguous
   transport failures only when the request actually filled the window. The two
   copy-pasted detection blocks are gone.

4. The top-level agent loop had no iteration ceiling (usize::MAX) while spawned
   subagents were bounded at 32. It now gets the same bound, overridable with
   CLAW_MAX_TURN_ITERATIONS.

5. Auto-continue nudges were injected as USER turns, so harness-authored text was
   persisted, re-sent on every later request, and folded into compaction summaries as
   something the user had said. They are recorded as system turns now; the wire
   payload is unchanged.

Also replaces the repository's own self-host section, which described a translator
and a serving stack no longer in the request path. That file is folded into the
system prompt verbatim, so it was briefing the model with an architecture that does
not exist.

(cherry picked from commit c8834cb4d1a3372eb70241429f641162a420297e)
2026-10-12 23:00:22 +00:00
clawdbot 687c6b03a4 docs: add OmniRoute documentation for OpenAI-compatible setup (fixes #3283) 2026-10-12 22:59:18 +00:00
reacechalmers 7c38d78759 fix(runtime): auto-continue a turn the model stalled without a tool call
A reply carrying no tool_use ended the turn unconditionally. Small local
models routinely narrate the next tool call in prose — "Now running the
EEC-V probe:" — and then emit EOS, so the announced work never happened
and the user was dropped back at the prompt. The same failure wears a
second hat mid-task: stopping to ask for a go-ahead on work already
requested.

The loop now inspects the final line of a tool-less reply. If it
announces a pending action, or asks a question after tools have already
run this turn, a short nudge is pushed as a user message and the loop
continues instead of breaking. The budget is bounded (default 3 per
turn, CLAW_MAX_STALL_NUDGES, 0 disables) so a model that really is
finished still gets to stop.

Prevention pairs with recovery: the "Doing tasks" system-prompt section
now tells the model to act in the turn it plans in.

Tests cover the auto-continue, the bound, a plain completion that must
NOT be nudged, the disable switch, and last-line-only detection. The
behavioural pair was mutation-checked: reverting the continue to a break
fails both.

(cherry picked from commit 9035fe6f4bf4a8d4cb70d7a3baf1f3748c757498)
2026-10-12 22:58:32 +00:00
reacechalmers cb572811e1 fix(runtime): bound the git diff snapshot to a configurable budget
The git diff snapshot is rendered into the *system prompt*, so its cost is
paid again by every session whose working tree has changed -- and a diff
changes on essentially every edit. The 50_000-char budget was four times the
entire instruction-file budget (12_000) and unbounded in practical effect: a
single modified file added ~15_000 tokens to the prompt.

Measured against a self-hosted backend, one uncommitted change took the
prompt from 8_302 to 23_457 tokens and a one-word reply from ~3 s to 296 s.
With the new default the same session is 7_911 tokens and 36 s. That cost is
absorbable against a hosted frontier model and ruinous against a local one.

Default is now 4_000 chars, matching MAX_INSTRUCTION_FILE_CHARS, and is
overridable with CLAW_MAX_GIT_DIFF_CHARS following the same pattern as
CLAW_MAX_READ_CHARS. A budget of 0 omits the snapshot entirely rather than
emitting an empty section with a truncation marker; git status and the
recent-commit list are unaffected either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNKqVB2JE9UnCkz8FC9HPy
(cherry picked from commit e01c6ea43d56a97e284ab80fa7a90a479997ed74)
2026-10-12 22:58:32 +00:00
reacechalmers fa0d53a969 fix(runtime): bound tool-result payloads to the actual context window
Two related gaps let a single agentic turn exceed a small context window,
with no recovery path once it happened.

1. read_file's character ceiling was a hardcoded 64 KB (~16k tokens), sized
   for a 200k window. Two reads in one turn reach ~48k tokens, which a 32k
   backend rejects outright. Make it configurable via CLAW_MAX_READ_CHARS,
   defaulting to the existing 64 KB so behavior is unchanged unless set.
   Reads remain paged, not refused: callers still get truncated/nextOffset.

2. Compaction could not help, because it works at message granularity and an
   agentic turn is five messages (user / assistant+tool_use / tool_result /
   assistant+tool_use / tool_result). With preserve_recent_messages: 4 the
   only droppable message is the short user prompt, so the two oversized
   tool results were preserved verbatim by design.

   Trim preserved tool-result payloads over 16 KB to a head/tail excerpt with
   an explicit "[... N characters elided ...]" marker, so the model knows its
   view is partial and can re-read. Small payloads pass through untouched.

   Also apply trimming on the early-return path: a session can exceed the
   window while holding fewer messages than preserve_recent_messages, where
   should_compact is false and message-granular compaction has no move to
   make. compact_now no longer gates on removed_message_count alone, which
   would have computed the smaller session and then discarded it.

Also fixes a pre-existing flaky test: repl_executes_python_code resolves the
python runtime through PATH, which the PowerShell tests blank while they run.
It now takes the same env_lock, so it no longer intermittently fails with
"python runtime not found" on a machine that has python.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bVvRYn7CQKR6hT2j1L6Cs
(cherry picked from commit b0d4d41e8132f8dc75717b1f74554d649639de10)
2026-10-12 22:58:32 +00:00
reacechalmers 910ac4e326 fix(runtime): also check for compaction before a request is sent
The usage-based compaction gate is post-hoc: it can only see the context
after a call returns and reports its counters. Between that measurement and
the next request the session can grow enormously - a reasoning model emitting
a long thinking block, then a tool result carrying a large file.

Observed live: a call measured at 17,858 prompt tokens generated 10,842 more,
a read_file added ~19k on top, and the next request reached the provider at
47,755 tokens and was rejected outright. No tuning of the post-hoc threshold
prevents that, because nothing measures the session in between.

Add a pre-send check driven by estimate_session_tokens, a local heuristic over
the transcript, so it costs no API call and works before any usage has been
recorded at all.

Note the limit of this: compaction preserves the 4 most recent messages, and
in an agentic tool-use loop those messages ARE the tool results carrying the
bulk. This check bounds gradual growth; it cannot rescue a session whose last
four messages already exceed the window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bVvRYn7CQKR6hT2j1L6Cs
(cherry picked from commit 26ab52d70fb17e49b74d523aecfc1da7f69a6d60)
2026-10-12 22:58:19 +00:00
reacechalmers a25717aded fix(runtime): auto-compaction must measure the live context, not a running total
`maybe_auto_compact` gated on `usage_tracker.cumulative_usage().input_tokens`,
which is wrong in two independent ways:

1. It ignores cached input. With prompt caching a warm call reports
   `input_tokens: 2` and puts the real context in `cache_read_input_tokens`,
   so the counter barely moved and auto-compaction never fired at all,
   however large the conversation grew. Backends without prompt caching
   report the whole prompt as `input_tokens`, so the bug was invisible there.

2. A sum across calls is not a context measurement, and it never decreases.
   Once it did cross the threshold, every subsequent iteration compacted
   again, permanently, even after compaction had shrunk the context.

Gate on the latest call's total input instead — input + cache_creation +
cache_read — which is the live context window size. It rises with the real
context and falls back below the threshold once compaction has done its job,
so no counter reset is needed.

`CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS` now means what it reads like:
compact when the live context reaches N tokens. The default of 100_000 is
unchanged and still lands at 50% of a 200k window.

Adds `TokenUsage::total_input_tokens()` (saturating) and four tests: the
cached-usage shape that never fired, non-latching across three turns, and
two unit tests for the new helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bVvRYn7CQKR6hT2j1L6Cs
(cherry picked from commit aebba4e77eddf585fc5b21123967dbc4ebb6e6a1)
2026-10-12 22:58:19 +00:00
reacechalmers 7a1391fbc1 fix(test): isolate tests from the machine's real user config and kernel
The suite asserted against whatever the developer's box happened to be
configured as. Two independent leaks, both surfaced by a real `~/.claw`:

1. `parse_args` resolves defaults — notably `permissions.defaultMode` —
   through the user-scope config, which `default_config_home()` reads from
   `CLAW_CONFIG_HOME` falling back to `$HOME/.claw`. Nothing overrode it, so
   a `defaultMode` of `dontAsk` on the host turned 12 assertions from
   `WorkspaceWrite` into `DangerFullAccess`. `env_lock()` now returns an
   `EnvGuard` that points `CLAW_CONFIG_HOME` at a fresh empty directory and
   restores the previous value on drop, so "no user config" is the state
   under test. All 42 call sites already bind the guard, so this fixes them
   at once; tests that want a populated config home can still set the var
   themselves afterwards. `removed_login_and_logout_subcommands_error_helpfully`
   asserted a Default-sourced permission mode without taking the guard at
   all, and now does.

2. `direct_resume_safe_slash_commands_route_to_local_json_actions_831`
   hardcoded `/sandbox` => "warn", which is only true where namespace
   isolation is unsupported and just the filesystem sandbox is active
   (#731). On a host with working user namespaces the correct answer is
   "ok". That entry now accepts either; the test is about #831 routing —
   local JSON action instead of `interactive_only` — not about the kernel
   under the runner.

Verified with the real ~/.claw/settings.json in place (defaultMode=dontAsk):
cargo test --workspace 0 failures, clippy --workspace --all-targets
-D warnings 0 findings, scripts/fmt.sh clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9McEEH32ZtyM62GSXVSyJ
(cherry picked from commit 67aa4530c22f6fdf95a29d6a46992af137134c67)
2026-10-12 22:58:19 +00:00
reacechalmers 2944e02a09 fix(clippy): clear remaining -D warnings failures in rusty-claude-cli
`cargo clippy --workspace --all-targets -- -D warnings` is this repo's own
documented verification command, and it could not run to completion. Earlier
compile failures were masking a further 48 lints; with those cleared the
workspace now passes with zero findings.

Real fixes, not suppressions:
  - status_json_value took 8 positional parameters, four of them a tail of
    `Option<&_>` that most callers passed as None — trivially transposable.
    Grouped into `StatusJsonExtras` with a Default impl; all 7 call sites now
    name only the fields they actually set.
  - extract_help_metadata's 8-tuple return is now a documented `HelpMetadata`
    alias.
  - the auto-compact loop indexed preserve_schedule by its loop counter;
    it now iterates the array directly.
  - a sandbox status chain had two identical arms; merged the condition.
  - a filter_map whose every arm returned Some is now a map.
  - 33 unused stdout/stderr bindings in the output-format contract tests are
    prefixed with `_`, and 9 `map_or(false, ..)` are now `is_some_and(..)`.

NOTE: several `--bin claw` tests (parse_args defaults) read the *real*
`~/.claw/settings.json` rather than an isolated config home, so they fail on
any machine whose user config sets `permissions.defaultMode`. With that file
moved aside the binary's suite is 207/207. That isolation gap is pre-existing
and left for a separate change.

Verified: clippy workspace/all-targets/-D warnings reports 0; cargo test
--workspace passes with the user config neutralised; scripts/fmt.sh clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9McEEH32ZtyM62GSXVSyJ
(cherry picked from commit 03c8a8fd7ba6e405a8217d00d3766c1c6367e527)
2026-10-12 22:58:19 +00:00
reacechalmers cd01ffc5f3 fix(clippy): box ApiError::Api payload to clear result_large_err
`ApiError` is the `Err` half of nearly every `Result` in the api crate, and
its `Api` variant carried ~139 bytes inline (8 fields, four of them
`Option<String>`). That tripped clippy::result_large_err on 35 signatures
and, with -D warnings, made the whole crate unbuildable under the repo's
own documented verification command.

Move the payload into `ApiErrorDetails` behind a `Box`:
  - `ApiError::Api(Box<ApiErrorDetails>)`, with `ApiError::api(..)` to
    construct and `api_details()` to borrow
  - `ApiErrorDetails` re-exported from the crate root
  - adding a field here can no longer re-inflate every signature

This also simplifies `enrich_bearer_auth_error`, which previously
destructured and rebuilt all eight fields at five separate return points
just to append a hint; it now mutates the boxed payload in place.

Also clears the remaining pre-existing lints that blocked -D warnings:
unused test bindings, a redundant `use serde_json`, and an explicit
`.into_iter()` in claw-rag-service.

Verified: cargo test --workspace passes, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9McEEH32ZtyM62GSXVSyJ
(cherry picked from commit e9210bd75eb76e5d88b2ae797f537102bcabd41d)
2026-10-12 22:58:19 +00:00
reacechalmers 4361e98998 fix: page large read_file results instead of returning whole files
An unbounded read_file returned the entire file, which on a small-context
backend overflows the model's window. The backend then rejects the whole
request (llama-server: "request (N tokens) exceeds the available context
size"), which kills the turn — so reading everything at once is precisely
what makes the session stop. Observed live: one 100 KB source file is
~25k tokens against a 32k window with a ~6k-token prefix, and a session
read the same file three times and died on six 400s.

read_file now returns a page and says so:
  - absent `limit` yields DEFAULT_READ_LINES (2000) rather than EOF
  - MAX_READ_CHARS (64 KB) bounds the payload even when `limit` is
    explicit, so minified or generated files can't defeat the line cap
  - `truncated` and `nextOffset` tell the caller it has a partial view
    and exactly where to resume; the tool description explains both

MAX_READ_SIZE (10 MB) is untouched: it is a process guard against slurping
huge or binary blobs, not a context guard, and at ~2.5M tokens it never
fires on the files that actually overflow a window.

Also clears pre-existing clippy failures in trident.rs that blocked
-D warnings on every edit to this crate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9McEEH32ZtyM62GSXVSyJ
(cherry picked from commit c796d7e5eedc66b0cb37bb6823ec772f8ceef9cf)
2026-10-12 22:58:19 +00:00
clawdbot ad65ea5a47 docs(triage): document investigation and clarification needed for #3283 2026-10-12 22:07:06 +00:00
8 changed files with 1399 additions and 69 deletions

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
# Issue #3283 Triage Summary
**Issue:** docs: document OmniRoute OpenAI-compatible setup
**Opened by:** @diegosouzapw
**Status:** BLOCKED (waiting for clarification)
**Date:** 2026-10-13
## Findings
### Investigation
1. Searched codebase for "OmniRoute" — **no matches found**
2. Searched documentation (docs/local-openai-compatible-providers.md, MODEL_COMPATIBILITY.md) — **no mentions**
3. Searched git history (commits, branches, PRs) — **no references**
4. Checked GitHub API (rate limited, but issue title confirmed)
### Current State
- Issue #3283 is OPEN
- No labels assigned
- Title: "docs: document OmniRoute OpenAI-compatible setup"
- This is a documentation request for setting up an OpenAI-compatible provider
### Existing Documentation
The repository already has comprehensive documentation for OpenAI-compatible providers:
- `docs/local-openai-compatible-providers.md` — documents Ollama, llama.cpp, vLLM, and OpenRouter
- `docs/MODEL_COMPATIBILITY.md` — model-specific provider routing details
- `rust/crates/api/src/providers/openai_compat.rs` — provider implementation
## Next Steps
**Action taken:** Comment added to issue asking for:
1. OmniRoute documentation/website link
2. OpenAI-compatible endpoint example (base URL, API key format)
3. Model ID examples
4. Any special configuration or known quirks
**Comment posted to:** https://github.com/ultraworkers/claw-code/issues/3283#issuecomment-6133484114
## Conclusion
**Cannot proceed without user input.** Once the user provides the necessary information about OmniRoute, the fix will involve:
1. Adding a new section to `docs/local-openai-compatible-providers.md`
2. Including setup example (environment variables, model names, basic smoke test)
3. Following the existing format used for Ollama, llama.cpp, vLLM sections
4. Creating a PR for review
+799
View File
@@ -0,0 +1,799 @@
# GJC Compatibility Gap Matrix
**Document Status**: Phase 1 Analysis (2026-10-13)
**Scope**: SDK wire protocol, session lifecycle, tools, skills, settings, hooks, commands, and AGENTS.md rules
**Target**: Rust fork-in-spirit of gajae-code (GJC) SDK-compatible and capability-compatible
---
## 1. EXECUTIVE SUMMARY
This matrix maps GJC capabilities (from gajae-code) against claw-code's current Rust implementation, identifying gaps and proposing crate/module ownership for each slice.
### Key Findings
| Area | Status | Priority |
|------|--------|----------|
| **SDK Wire Protocol** | Missing | P0 (Foundation) |
| **Session Lifecycle** | Partial (no SDK) | P0 (Foundation) |
| **Tool Catalog (55 tools)** | Partial (35/55) | P1 (Core) |
| **Skill Discovery** | Partial (basic) | P1 (Core) |
| **Settings & Config** | Partial | P2 (Enhancing) |
| **Hooks & Plugins** | Existing | P3 (Polish) |
| **AGENTS.md Rules** | Existing | P3 (Polish) |
| **Slash Commands (120+)** | Partial (40/120+) | P2 (Core) |
| **Subagent Delegation** | Existing | P2 (Core) |
---
## 2. SDK WIRE PROTOCOL
### GJC Behavior (gajae-code Reference)
**Source**: `packages/coding-agent/src/sdk/client/client.ts`, `sdk/host/session-runtime.ts`
GJC uses a **WebSocket-based JSON-RPC-like protocol** with:
- **Transport**: WebSocket over TCP
- **Frame Format**: JSON object with typed fields
- **Correlation**: Request/response matched by `frame.id` (UUID)
- **Operations**: 65+ operation types (Q1-Q32, C01-C06, reverse ops)
- **Error Handling**: Typed `SdkErrorCode` with `uncertain_after_send` recovery
- **Reconnect**: Exponential backoff with deadline management
- **Request Timeout**: 10s default (`DEFAULT_SDK_REQUEST_TIMEOUT_MS`), per-request override
- **Handshake**: `client.hello` with capabilities advertised; `server.hello` response
**Operations** (sample):
- **Control**: `turn.create`, `turn.prompt`, `turn.steer`, `turn.abort_and_prompt`
- **Queries**: `turn.list`, `turn.get_one`, `turn.message_list`, `skill.source`, `artifact.content`
- **Reverse**: `ask.answer`, `tool.dispatch`, `tool.fetch_capability`
**Event Schema**:
```json
{
"id": "uuid-string",
"operation": "turn.create",
"input": { ... },
"connectionId": "optional",
"settings": { ... }
}
```
### claw-code Status
**Current**: Missing entirely. claw-code has no SDK wire protocol or external session API.
**Files**: None
**Lines**: 0
**Reality**:
- claw-code operates as a local CLI only (stdin/stdout/stderr).
- Sessions are file-based (`.claw/sessions/`).
- No remote access or external SDK clients.
- No WebSocket transport.
- No frame correlation or reconnect logic.
### Gap Assessment
| Component | GJC | claw-code | Gap | Complexity |
|-----------|-----|-----------|-----|------------|
| WebSocket Transport | 1500 LOC | — | 100% | High |
| Frame Correlation | 800 LOC | — | 100% | Medium |
| Operation Registry | 400 LOC | 0 | 100% | Medium |
| Error Handling & Recovery | 600 LOC | — | 100% | High |
| Handshake & Negotiation | 200 LOC | — | 100% | Low |
| **Total** | **3,500 LOC** | **0** | **3,500 LOC** | — |
### Proposed Implementation
**Owner Crate**: `sdk` (new)
**Module Structure**:
```
rust/crates/sdk/
├── Cargo.toml
└── src/
├── lib.rs (exports + top-level API)
├── client/
│ ├── mod.rs (SdkClient, WebSocket transport)
│ ├── correlation.rs (frame ID tracking, reconnect)
│ └── errors.rs (SdkErrorCode, recovery)
├── protocol/
│ ├── mod.rs (Operation registry)
│ ├── operations.rs (operation definitions)
│ └── schema.rs (JSON schemas, frame validation)
├── session/
│ ├── mod.rs (session lifecycle over SDK)
│ ├── router.rs (session routing, durable state)
│ └── reconciliation.rs (uncertain outcome recovery)
└── tests/
├── mod.rs
└── fixtures/ (mock server scenarios)
```
**Size Estimate**: 4,500 LOC (Rust + test fixtures)
**Dependencies**:
- `tokio` (async I/O, WebSocket)
- `tungstenite` (WebSocket protocol)
- `serde_json` (frame serialization)
- `uuid` (correlation IDs)
- `runtime` crate (session persistence bridge)
---
## 3. SESSION LIFECYCLE
### GJC Behavior (gajae-code Reference)
**Source**: `packages/coding-agent/src/sdk/host/session-runtime.ts`, `sdk/client/`, `sdk/lifecycle/`
GJC sessions have **three entry points**:
1. **Native SDK** (via WebSocket): typed `SdkClient` with `session.create`, `session.prompt`, etc.
2. **Browser ACP** (via iframe): same protocol, browser sandbox isolate
3. **CLI Daemon** (stdio): JSON-RPC over stdio for local CLI access
**Session Operations**:
- **create**: `turn.create(model, input)` → returns `sessionId`, persistent state written to broker DB
- **prompt**: `turn.prompt(request, tools, settings)` → streams events: `agent_begin`, `tool_use`, `tool_result`, `agent_end`
- **steer**: `turn.steer(overrides)` → mid-flight direction change (model, settings, system prompt)
- **abort**: `turn.abort_and_prompt()` → terminal failure + new prompt in one operation
- **list**: `turn.list(filters)` → paginated session history
- **get_one**: `turn.get_one(sessionId, turnId)` → single turn with full details
**State Persistence**:
- Invocation record (prompt request, outcome, correlation)
- Message buffer (system, assistant, tool results, agent events)
- Reconciliation store (uncertain → durable mapping)
- Terminal scope (last session per correlation key)
**Lifecycle Events**:
1. `turn_accepted` (immediate, transient)
2. `agent_begin` (before first model call)
3. `tool_use` + `tool_result` (interleaved)
4. `agent_end` (final model output)
5. `turn_ended` (durable, terminal outcome recorded)
### claw-code Status
**Current**: Partial. Local session lifecycle exists but no SDK transport or remote access.
**Files**:
- `rust/crates/runtime/src/session.rs` (1,961 LOC)
- `rust/crates/runtime/src/conversation.rs` (1,878 LOC)
- `rust/crates/runtime/src/session_control.rs` (file ops, migration)
- `rust/crates/runtime/src/lib.rs` (re-exports `Session`, `SessionStore`, `ConversationRuntime`)
**Lines**: ~3,900 LOC (session + conversation)
**Reality**:
- `Session` struct holds in-memory state (messages, usage, context).
- `SessionStore` reads/writes `.claw/sessions/{id}/` directory.
- `ConversationRuntime` drives the local agent loop (sync).
- No WebSocket or remote access.
- No invocation reconciliation or uncertain outcome recovery.
- No terminal scope tracking (per-session-per-correlation).
### Gap Assessment
| Component | GJC | claw-code | Gap | Complexity |
|-----------|-----|-----------|-----|------------|
| Remote Session Router | 2,500 LOC | — | 100% | High |
| Invocation Reconciliation | 1,200 LOC | — | 100% | High |
| Terminal Scope Tracking | 600 LOC | — | 100% | Medium |
| Async Event Streaming | 800 LOC | Partial | 60% | Medium |
| Uncertain Outcome Recovery | 1,000 LOC | — | 100% | High |
| **Total** | **6,100 LOC** | **3,900** | **2,200 LOC** | — |
### Proposed Implementation
**Owner Crate**: `sdk` (new, or extend `runtime`)
**Module Structure** (add to `sdk/session/`):
```
sdk/session/
├── router.rs (session routing, durable lookup)
├── reconciliation.rs (uncertain → terminal recovery)
├── terminal_scope.rs (per-correlation state retention)
└── events.rs (event publishing, reconciliation)
```
**Integration Points**:
- Bridge `runtime::Session` into `sdk::Session` wrapper
- Add `runtime::SessionStore` extensions for reconciliation data
- Extend `ConversationRuntime` with event emission
**Size Estimate**: 2,500 LOC (Rust, new + modifications)
---
## 4. TOOL CATALOG (55 Tools)
### GJC Behavior (gajae-code Reference)
**Source**: `packages/coding-agent/src/tools/` (55 tool files + shared harness)
GJC implements **55 distinct tools** across multiple domains:
- **File I/O**: `read`, `write`, `edit`, `search`, `find`
- **Shell**: `bash`, `sleep`
- **Task Delegation**: `task` (subagent spawning)
- **Workflow**: `skill`, `skill_discovery`, `goal`, `ask`
- **Search**: `search_tool_bm25` (semantic search discovery)
- **Image**: `image_gen` (DALL-E), `image_tag_scan`
- **Integration**: `web_fetch`, `web_search`, `custom_tool_fetch`
- **Developer**: `notebook_edit`, `command_repl`, `shell_repl`, `powershell`
- **Config**: `config_read`, `config_write` (settings, modes)
- **Structured**: `model_registry`, `cost_calculator`, `settings_apply`
- **Workflow Gates**: `workflow_gate_answer`, `workflow_plan_approve`
- **Move**: `move_cwd`, `move_session`
**Tool Execution Model**:
1. Input deserialized from JSON schema
2. Permission check (read-only vs. destructive)
3. Tool invocation with typed inputs
4. Structured output or error
5. Result published to agent loop
**Permission Gating**: Every tool has a `required_permission` (read-only, approve, unsafe).
### claw-code Status
**Current**: Partial. 35 tools implemented, 20 missing or incomplete.
**Files**: `rust/crates/tools/src/lib.rs` (10,892 LOC, single file with all 55 spec + implementations)
**Implemented Tools** (~35):
- `read`, `write`, `edit`, `search`, `find`
- `bash`, `sleep`
- `task` (subagent spawning)
- `skill`, `skill_discovery` (basic)
- `bash_list_*, bash_check_*` (validation helpers)
- `web_fetch`, `search_bm25` (basic)
- `config_*` (read/write settings)
- `notebooks` (edit, execute)
- `goal` (basic ledger)
- `move_*` (cwd, session)
- Various validation and utility tools
**Missing / Incomplete Tools** (~20):
- `image_gen` (DALL-E)
- `image_tag_scan`
- `custom_tool_fetch`
- `command_repl`, `shell_repl`, `powershell`
- `model_registry`, `cost_calculator`
- `workflow_gate_answer`, `workflow_plan_approve`
- `ask` (interactive prompt)
- `irc` (live coordination)
- Search/discovery integration tools
- Advanced structured output handling
### Gap Assessment
| Component | GJC | claw-code | Gap | Complexity |
|-----------|-----|-----------|-----|------------|
| Tool Registry | 400 LOC | 400 LOC | 0% | — |
| Spec Table (55 entries) | 1,800 LOC | 1,800 LOC | 0% | — |
| image_gen | 600 LOC | — | 100% | High |
| command_repl, shell_repl | 900 LOC | — | 100% | Medium |
| workflow_gate_* | 800 LOC | — | 100% | Medium |
| custom_tool_fetch | 400 LOC | — | 100% | Low |
| ask (interactive) | 1,200 LOC | Stub | 80% | High |
| cost_calculator | 300 LOC | — | 100% | Low |
| **Total** | **11,500 LOC** | **8,000** | **3,500 LOC** | — |
### Proposed Implementation
**Owner Crate**: `tools` (expand existing)
**New Modules**:
```
rust/crates/tools/src/
├── tools/
│ ├── image_gen.rs (DALL-E integration)
│ ├── ask_interactive.rs (blocking user prompts)
│ ├── workflow_gates.rs (plan approval, gate answer)
│ ├── repl/*.rs (command_repl, shell_repl, powershell)
│ └── cost_calc.rs (pricing lookup)
└── vendors/
├── dalle.rs (image generation API)
└── integrations.rs (custom tool fetch)
```
**Size Estimate**: 3,500 LOC (new tools + vendor integration)
**Dependencies**:
- `reqwest` (image generation API calls)
- `api` crate (provider client reuse)
- Existing: `runtime`, `commands`, `serde_json`
---
## 5. SKILL DISCOVERY & INVOCATION
### GJC Behavior (gajae-code Reference)
**Source**: `packages/coding-agent/src/tools/skill-discovery.ts`, `packages/coding-agent/src/tools/skill.ts`, `packages/coding-agent/src/sdk-skills/`
GJC supports **dynamic skill loading** with:
- **Discovery**: `skill_discovery` tool scans `.gjc/skills/`, project `.gjc/skills/`, bundled skills
- **Frontmatter Parsing**: YAML headers in `SKILL.md` define metadata (name, description, use conditions)
- **Invocation**: `skill` tool loads and executes a named skill (skill name + args)
- **Skill Execution**: Skill = subprocess call to `gjc /skill:<name>` with args
- **Bundled Skills**: `autoresearch`, `deep-interview`, `ralplan`, `ultragoal`
- **Project Skills**: Discoverable from `.gjc/skills/*/SKILL.md`
- **User Skills**: Discoverable from `~/.gjc/skills/*/SKILL.md`
**Skill Metadata**:
```yaml
# /skill:my-skill
# description: What this skill does
# scope: project | user | bundled
# requires: List of capabilities
```
**Skill Discovery Result**:
```json
{
"name": "skill-name",
"description": "...",
"path": "...",
"bundled": true | false,
"scope": "project" | "user" | "bundled"
}
```
### claw-code Status
**Current**: Basic. Skill discovery exists but limited to filesystem scanning.
**Files**:
- `rust/crates/tools/src/lib.rs` (skill_discovery + skill dispatch, ~200 LOC)
- `rust/crates/runtime/src/prompt.rs` (skill loading in prompts, ~100 LOC)
**Implementation**:
- Scans `.claw/skills/` directory for `SKILL.md` files
- Parses frontmatter (basic YAML)
- Returns discovery results as JSON
- `skill` tool invokes skill as subprocess
**Missing**:
- Bundled skill registry (autoresearch, deep-interview, ralplan, ultragoal)
- Project + user skill discovery hierarchy
- Frontmatter validation and standardization
- Skill chaining (`skill()` tool to invoke another skill from within a skill)
- Skill state persistence (workflow handoff)
### Gap Assessment
| Component | GJC | claw-code | Gap | Complexity |
|-----------|-----|-----------|-----|------------|
| Bundled Skill Registry | 200 LOC | — | 100% | Low |
| Frontmatter Parser | 150 LOC | 100 LOC | 30% | Low |
| Project + User Discovery | 300 LOC | Basic | 70% | Low |
| Skill Chaining | 250 LOC | — | 100% | Medium |
| Workflow State Handoff | 400 LOC | — | 100% | Medium |
| Skill Execution Harness | 200 LOC | Partial | 40% | Low |
| **Total** | **1,500 LOC** | **100** | **1,400 LOC** | — |
### Proposed Implementation
**Owner Crate**: `tools` (expand existing) or `skills` (new, lightweight)
**Module Structure**:
```
rust/crates/tools/src/
├── skill_discovery.rs (enhanced parsing, project/user/bundled hierarchy)
├── skill_execution.rs (subprocess dispatch, state handoff)
├── bundled_skills.rs (registry of autoresearch, deep-interview, ralplan, ultragoal)
└── skill_state.rs (workflow handoff serialization)
```
**Size Estimate**: 1,400 LOC (Rust)
**Integration**:
- Extend `skill_discovery` tool to search three scopes (bundled, project, user)
- Add `skill` tool support for chaining (returning another skill invocation)
- Store workflow state in durable files for handoff
---
## 6. SETTINGS & CONFIGURATION
### GJC Behavior (gajae-code Reference)
**Source**: `packages/coding-agent/src/config.ts`, `packages/coding-agent/src/sdk/host/session-runtime.ts`
GJC supports **settings override** at multiple levels:
1. **Global Settings**: `~/.gjc/config.json` (user defaults)
2. **Session Settings**: per-session override via `turn.create` or `turn.prompt` input
3. **Runtime Override**: `settings` field in SDK request frames
4. **Setting Categories**:
- **Model**: `modelName`, `modelTemperature`, `modelMaxTokens`
- **Behavior**: `allowance`, `permissions.*`, `features.*`
- **UX**: `theme`, `shell`, `notification*`
- **Advanced**: `memory.context`, `memory.compression`, `cost.*`
**Config Schema**: Typed with JSON schema validation per setting
**Permission Modes**:
- `read_only` (no execution)
- `approve` (interactive approval)
- `unsafe` (no gates)
### claw-code Status
**Current**: Partial. Config exists but no session-level override or setting propagation.
**Files**:
- `rust/crates/runtime/src/config.rs` (3,894 LOC, largest file in runtime)
- `rust/crates/runtime/src/config_validate.rs` (validation helpers)
**Implementation**:
- Reads `.claw/config.json` at startup
- Stores global settings (model, permissions, plugins, features)
- No session-level override
- No setting propagation through tool execution
- No typed setting validation against schema
**Missing**:
- Per-session setting override (session-specific model, permissions)
- Setting propagation to all tool invocations
- Dynamic setting application (e.g., changing model mid-session)
- Setting schema validation
- Config hotreload (changes without restart)
### Gap Assessment
| Component | GJC | claw-code | Gap | Complexity |
|-----------|-----|-----------|-----|------------|
| Setting Schema Definition | 300 LOC | — | 100% | Low |
| Session-Level Override | 400 LOC | — | 100% | Medium |
| Setting Propagation | 300 LOC | — | 100% | Low |
| Dynamic Application | 200 LOC | — | 100% | Medium |
| Config Hotreload | 150 LOC | — | 100% | Low |
| **Total** | **1,350 LOC** | **3,894** | **-2,544 LOC** | — |
### Proposed Implementation
**Owner Crate**: `runtime` (enhance existing `config.rs`)
**Additions to config.rs**:
- Setting schema registry
- Session setting override struct
- Setting applicator (merge global + session + override)
- Hotreload support (watch config file, signal on change)
**Size Estimate**: 500 LOC (new code, mostly schema + applicators)
---
## 7. HOOKS & PLUGINS
### GJC Behavior (gajae-code Reference)
**Source**: `packages/coding-agent/src/hooks.ts`, `packages/coding-agent/src/plugin_lifecycle.ts`
GJC provides **5 hook points**:
- `on_session_created`: After session starts
- `on_prompt_received`: Before agent loop begins
- `on_tool_use`: Before tool invocation
- `on_tool_result`: After tool completes
- `on_session_ended`: Before session cleanup
**Plugin System**:
- Plugins are shell scripts or binaries in `.claw/plugins/`
- Each plugin implements hook handlers (exit code + JSON output)
- Plugin registry stored in config
### claw-code Status
**Current**: Existing and comprehensive.
**Files**:
- `rust/crates/runtime/src/hooks.rs` (L155)
- `rust/crates/runtime/src/plugin_lifecycle.rs`
- `rust/crates/plugins/src/` (plugin manager)
**Implementation**: Hooks fully implemented; plugin system working.
### Gap Assessment
| Component | GJC | claw-code | Gap |
|-----------|-----|-----------|-----|
| Hook Points | 5 | 5 | 0% |
| Plugin Registry | ✓ | ✓ | 0% |
| Plugin Execution | ✓ | ✓ | 0% |
**Status**: **No gaps**. Hooks and plugins are fully compatible.
---
## 8. AGENTS.MD RULES & CONTEXT
### GJC Behavior (gajae-code Reference)
**Source**: gajae-code root + `docs/` (AGENTS.md files at multiple levels)
GJC loads **directory-specific rules** from `AGENTS.md` files:
- **Root AGENTS.md**: Project-level knowledge base
- **Directory AGENTS.md**: Directory-specific rules override parent
- **Rules Loaded**: Automatically prepended to system prompt during agent reasoning
- **Hierarchy**: Deepest (most specific) rules override higher level
**Rule Content**:
- Code organization and conventions
- Tool restrictions (never use X in Y context)
- Testing requirements
- Deployment/safety gates
- Architecture decisions
### claw-code Status
**Current**: Existing and matching GJC.
**Files**:
- Root: `AGENTS.md`
- Crate-level: `rust/crates/*/AGENTS.md`
- Implementation: `rust/crates/runtime/src/` (loads rules)
**Implementation**: Rules loaded and applied to prompts.
### Gap Assessment
| Component | GJC | claw-code | Gap |
|-----------|-----|-----------|-----|
| Rule Loading | ✓ | ✓ | 0% |
| Hierarchy | ✓ | ✓ | 0% |
| Prompt Integration | ✓ | ✓ | 0% |
**Status**: **No gaps**. AGENTS.md system is compatible.
---
## 9. SLASH COMMANDS (120+ Commands)
### GJC Behavior (gajae-code Reference)
**Source**: `packages/coding-agent/src/commands/lib.ts` (7,200 LOC with 120+ command specs)
GJC implements **120+ slash commands** for REPL interaction:
- **Session**: `/session list`, `/session create`, `/session restore`, `/session delete`
- **Model**: `/model set`, `/model list`, `/model info`
- **Permissions**: `/permissions set`, `/permissions show`
- **Config**: `/config get`, `/config set`, `/config reload`
- **Skills**: `/skills list`, `/skills install`, `/skills create`
- **Plugins**: `/plugins list`, `/plugins install`
- **Workflow**: `/plan`, `/review`, `/approve`, `/ultragoal`
- **Debugging**: `/debug`, `/dump`, `/trace`
- **Utilities**: `/help`, `/clear`, `/quit`, `/export`
**Command Format**: `/command [arg1] [arg2] ...` with arg parsing per command
### claw-code Status
**Current**: Partial. ~40 commands implemented out of 120+.
**Files**:
- `rust/crates/commands/src/lib.rs` (7,183 LOC)
- Command specs in `SLASH_COMMAND_SPECS` static table
**Implemented Commands** (~40):
- Session mgmt (list, create, etc.)
- Help, status, version
- Config (get, set, reload)
- Skills (list, install)
- Plugins (list, install)
- Workspace/debug (clear, compact, etc.)
**Missing Commands** (~80):
- Advanced workflow (`/plan`, `/review`, `/ultragoal`)
- Model selection and info
- Cost calculator (`/cost`)
- Advanced debugging (`/dump`, `/trace`)
- Theme management
- Advanced permission controls
- Task management (`/tasks list`, `/tasks cancel`)
- IRC/coordination (`/irc`)
- Many others
### Gap Assessment
| Component | GJC | claw-code | Gap |
|-----------|-----|-----------|-----|
| Command Parser | 800 LOC | 800 LOC | 0% |
| Spec Table (120+ entries) | 2,000 LOC | 2,000 LOC | 0% |
| Handlers (80 missing) | 3,200 LOC | 1,800 LOC | 56% |
| Help/suggestion | 600 LOC | 400 LOC | 30% |
| **Total** | **7,200 LOC** | **4,000** | **3,200 LOC** | — |
### Proposed Implementation
**Owner Crate**: `commands` (expand existing)
**New Modules**:
```
rust/crates/commands/src/
├── handlers/
│ ├── workflow.rs (/plan, /review, /approve, /ultragoal)
│ ├── model.rs (/model set, /model list, /model info)
│ ├── cost.rs (/cost)
│ ├── advanced_debug.rs (/dump, /trace, /debug advanced)
│ ├── theme.rs (/theme set, /theme list)
│ ├── task.rs (/tasks list, /tasks cancel)
│ └── advanced_perms.rs (advanced permission commands)
```
**Size Estimate**: 3,200 LOC (new command handlers)
**Dependencies**: `runtime`, `tools`, `plugins`
---
## 10. SUBAGENT DELEGATION & TASK EXECUTION
### GJC Behavior (gajae-code Reference)
**Source**: `packages/coding-agent/src/tools/task.ts`, `packages/coding-agent/src/task/`
GJC's **subagent system** enables parallel task execution:
- **task Tool**: Spawn parallel subtasks with independent context
- **Task States**: spawning, running, succeeded, failed
- **Task Isolation**: Each task has isolated session, permissions, context
- **Task Coordination**: `irc` tool for live inter-task communication
- **Task Monitoring**: `task.inspect`, `task.list`, `task.cancel`
- **Artifact Sharing**: Tasks share artifact URLs but not direct state
**Task Spawning**:
```json
{
"agent": "executor|architect|planner",
"tasks": [
{
"id": "task-1",
"description": "...",
"assignment": "..."
}
]
}
```
### claw-code Status
**Current**: Existing and working.
**Files**:
- `rust/crates/tools/src/lib.rs` (task tool, ~300 LOC)
- `rust/crates/runtime/src/task_*.rs` (task execution, state mgmt)
**Implementation**: Task tool works; spawning, monitoring, and coordination functional.
### Gap Assessment
| Component | GJC | claw-code | Gap |
|-----------|-----|-----------|-----|
| Task Spawning | ✓ | ✓ | 0% |
| Task Isolation | ✓ | ✓ | 0% |
| Task Monitoring | ✓ | ✓ | 0% |
| IRC Coordination | ✓ | ✓ | 0% |
**Status**: **No significant gaps**. Task execution is compatible.
---
## 11. IMPLEMENTATION ROADMAP
### Phase 1: Foundation (SDK Wire Protocol)
**Slice 1.1: WebSocket Transport & Frame Correlation**
- Create `sdk` crate structure
- Implement `SdkClient` with WebSocket connection
- Add frame ID correlation and response matching
- Add reconnect logic with exponential backoff
- Tests: transport connectivity, frame correlation, reconnect behavior
**Slice 1.2: Operation Registry & Schema**
- Define Operation enum (65+ operation types)
- JSON schema validation for requests
- Error code definitions and handlers
- Uncertain outcome recovery protocol
- Tests: operation parsing, schema validation, error scenarios
**Slice 1.3: Session SDK Integration**
- Bridge `runtime::Session` to SDK session model
- Implement session router (durable lookup)
- Add invocation reconciliation store
- Terminal scope tracking per correlation
- Tests: session creation, routing, reconciliation
### Phase 2: Core Capabilities (Tools & Skills)
**Slice 2.1: Missing Tool Implementation**
- `image_gen` (DALL-E integration)
- `ask` (interactive blocking prompts)
- `workflow_gate_*` (plan approval gates)
- `command_repl`, `shell_repl`, `powershell`
- `cost_calculator`
**Slice 2.2: Skill Enhancement**
- Bundled skill registry (autoresearch, deep-interview, ralplan, ultragoal)
- Enhanced skill discovery (project, user, bundled hierarchy)
- Skill chaining support (skill calling skill)
- Workflow state handoff
### Phase 3: Polish (Settings, Commands, Integration)
**Slice 3.1: Settings & Configuration**
- Session-level setting override
- Setting schema validation
- Setting propagation to tools
- Config hotreload
**Slice 3.2: Slash Commands**
- Workflow commands (/plan, /review, /ultragoal)
- Model selection (/model set, /model list)
- Cost calculator (/cost)
- Advanced debugging (/dump, /trace)
- Task management (/tasks list, /tasks cancel)
- Theme management (/theme)
---
## 12. DEPENDENCY & CRATE RELATIONSHIPS
```
┌────────────────────────────────────────────────────────┐
│ cli (rusty-claude-cli: main entry, REPL, TUI) │
└────────────┬───────────────────────────────────────────┘
│
┌────────────▼──────────────────────────────────────────┐
│ commands (slash command specs & handlers) │
└────────┬────────────────────────────────────────┬──────┘
│ │
│ ┌───▼────────────────────┐
│ │ tools (55 tool impl) │
│ └──────┬─────────────────┘
│ │
┌────────▼─────────────────────────────────────────▼──────────────────┐
│ runtime (session, conversation, config, mcp, hooks, permissions) │
└────────┬──────────────────────────────┬────────────────────────────┘
│ │
┌────▼──────────────┐ ┌──────▼─────────────────┐
│ api (providers) │ │ plugins (plugin mgmt) │
└───────────────────┘ └───────────────────────┘
NEW: sdk (WebSocket, session routing, reconciliation)
│
└─→ depends on: runtime, serde_json, tokio, tungstenite
```
---
## 13. RISK & MITIGATION
| Risk | Probability | Severity | Mitigation |
|------|-------------|----------|-----------|
| SDK protocol complexity | High | High | Start with mock transport tests, iterate on real scenarios |
| Session state inconsistency | Medium | High | Comprehensive reconciliation tests, durable state verification |
| Tool compatibility gaps | Medium | Medium | Tool-by-tool comparison with GJC, shadow testing |
| Breaking changes to runtime | Medium | High | Version gates, feature flags, phased rollout |
| Performance regression | Medium | Medium | Benchmark key paths (session create, tool dispatch), profile |
---
## 14. VERIFICATION & SIGN-OFF
- [ ] SDK transport connects and communicates with mock server
- [ ] Session lifecycle operations (create, prompt, steer, abort) work end-to-end
- [ ] Tool execution with SDK matches GJC behavior
- [ ] Skill discovery finds all three scopes (bundled, project, user)
- [ ] Settings override works per-session
- [ ] Slash commands parse and dispatch correctly
- [ ] All AGENTS.md tests pass
- [ ] Hooks fire at correct lifecycle points
- [ ] Subagent tasks spawn and communicate via IRC
- [ ] Public documentation has no internal paths, hosts, or private infra
---
**Document Version**: 1.0
**Last Updated**: 2026-10-13
**Next Review**: After Slice 1.1 implementation complete
+19
View File
@@ -130,6 +130,25 @@ claw --model "mlx-community/Qwen2.5-Coder-7B-Instruct-4bit" prompt "Reply exactl
mlx-lm serves models under their full Hugging Face repo ID. Use the exact `id` field from `curl $OPENAI_BASE_URL/models` for `--model`. A bare name like `qwen2.5-coder-7b-instruct` will fail model resolution before the request ever reaches the server.
## OmniRoute and other remote OpenAI-compatible gateways
[OmniRoute](https://github.com/diegosouzapw/OmniRoute) is a third-party OpenAI-compatible AI gateway that can route requests to multiple providers. To use any remote OpenAI-compatible gateway with Claw, set `OPENAI_BASE_URL` to the gateway's `/v1` endpoint and provide your API key:
```bash
export OPENAI_BASE_URL="https://api.omniroute.ai/v1"
export OPENAI_API_KEY="your-omniroute-api-key"
claw --model "gpt-4" prompt "Reply exactly HELLO_WORLD_123"
```
To find the available model IDs on your gateway, use the `/models` endpoint:
```bash
curl -sS "$OPENAI_BASE_URL/models" \
-H "Authorization: Bearer $OPENAI_API_KEY" | jq '.data[].id'
```
For local configuration, see your gateway's documentation for the correct base URL, API key format, and available model IDs. The same OPENAI_BASE_URL + OPENAI_API_KEY pattern applies to any OpenAI-compatible remote gateway.
## Local skills install from disk
Skills are discovered from Claw skill roots such as `.claw/skills/` in a workspace and `~/.claw/skills/` for user-level installs. Legacy `.codex/skills/` roots may also be scanned for compatibility, but new local Claw projects should prefer `.claw/skills/`.
Generated Executable → Regular
+51 -24
View File
@@ -383,6 +383,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "ciborium"
version = "0.2.2"
@@ -1002,12 +1013,24 @@ name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasm-bindgen",
]
@@ -1881,14 +1904,15 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"bytes",
"getrandom 0.3.4",
"getrandom 0.4.3",
"lru-slab",
"rand 0.9.2",
"rand 0.10.3",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
@@ -1929,6 +1953,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radix_trie"
version = "0.2.1"
@@ -1946,18 +1976,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha 0.3.1",
"rand_chacha",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
@@ -1970,16 +2001,6 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -1991,11 +2012,17 @@ dependencies = [
[[package]]
name = "rand_core"
version = "0.9.5"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"getrandom 0.3.4",
"rand_core 0.10.1",
]
[[package]]
+192 -2
View File
@@ -13,7 +13,130 @@ pub enum ProviderClient {
OpenAi(OpenAiCompatClient),
}
/// Provider selected explicitly by persisted/runtime configuration.
///
/// This is intentionally separate from `ProviderKind`: DashScope speaks the
/// OpenAI-compatible wire protocol but remains a distinct configured provider.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderConfigKind {
Anthropic,
Xai,
OpenAi,
DashScope,
}
/// Explicit provider configuration supplied by the runtime layer.
///
/// This keeps the API crate independent of runtime configuration types.
/// Credentials are redacted from Debug output.
#[derive(Clone)]
pub struct ProviderConfig {
pub kind: ProviderConfigKind,
pub model: String,
pub api_key: Option<String>,
pub base_url: Option<String>,
}
impl std::fmt::Debug for ProviderConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderConfig")
.field("kind", &self.kind)
.field("model", &self.model)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("base_url", &self.base_url)
.finish()
}
}
fn read_env_non_empty(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.filter(|value| !value.trim().is_empty())
}
impl ProviderClient {
/// Construct a client from an explicit provider configuration.
///
/// The persisted provider kind is authoritative for provider selection,
/// while environment credentials and base URLs retain precedence over
/// persisted values.
pub fn from_config(config: &ProviderConfig) -> Result<Self, ApiError> {
match config.kind {
ProviderConfigKind::Anthropic => {
let api_key = read_env_non_empty("ANTHROPIC_API_KEY")
.or_else(|| config.api_key.clone())
.ok_or_else(|| {
ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
)
})?;
let mut client = AnthropicClient::new(api_key);
let base_url = read_env_non_empty("ANTHROPIC_BASE_URL")
.or_else(|| config.base_url.clone())
.unwrap_or_else(anthropic::read_base_url);
client = client.with_base_url(base_url);
Ok(Self::Anthropic(client))
}
ProviderConfigKind::Xai => {
let compat = OpenAiCompatConfig::xai();
let api_key =
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
let mut client = match api_key {
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
None => OpenAiCompatClient::from_env(compat)?,
};
let base_url = read_env_non_empty(compat.base_url_env)
.or_else(|| config.base_url.clone())
.unwrap_or_else(|| compat.default_base_url.to_string());
client = client.with_base_url(base_url);
Ok(Self::Xai(client))
}
ProviderConfigKind::OpenAi | ProviderConfigKind::DashScope => {
let compat = match config.kind {
ProviderConfigKind::OpenAi => OpenAiCompatConfig::openai(),
ProviderConfigKind::DashScope => OpenAiCompatConfig::dashscope(),
_ => unreachable!("non-OpenAI provider reached compatibility path"),
};
if config.kind == ProviderConfigKind::OpenAi
&& read_env_non_empty("OLLAMA_HOST").is_some()
&& config.api_key.is_none()
&& config.base_url.is_none()
{
return Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
));
}
let api_key =
read_env_non_empty(compat.api_key_env).or_else(|| config.api_key.clone());
let persisted_base_url = config.base_url.clone();
let mut client = match api_key {
Some(api_key) => OpenAiCompatClient::new(api_key, compat),
None if config.kind == ProviderConfigKind::OpenAi
&& persisted_base_url
.as_deref()
.is_some_and(openai_compat::is_local_openai_compatible_base_url) =>
{
OpenAiCompatClient::new("local-dev-token", compat)
}
None => OpenAiCompatClient::from_env(compat)?,
};
let base_url = read_env_non_empty(compat.base_url_env)
.or(persisted_base_url)
.unwrap_or_else(|| compat.default_base_url.to_string());
client = client.with_base_url(base_url);
Ok(Self::OpenAi(client))
}
}
}
pub fn from_model(model: &str) -> Result<Self, ApiError> {
Self::from_model_with_anthropic_auth(model, None)
}
@@ -34,7 +157,7 @@ impl ProviderClient {
ProviderKind::OpenAi => {
// OLLAMA_HOST takes priority: local Ollama needs no API key
// and ignores DashScope/OpenAI env-based dispatch.
if std::env::var_os("OLLAMA_HOST").is_some() {
if read_env_non_empty("OLLAMA_HOST").is_some() {
Ok(Self::OpenAi(
openai_compat::OpenAiCompatClient::from_ollama_env()
.expect("from_ollama_env always returns Some"),
@@ -155,7 +278,7 @@ pub fn read_xai_base_url() -> String {
mod tests {
use std::sync::{Mutex, OnceLock};
use super::ProviderClient;
use super::{ProviderClient, ProviderConfig, ProviderConfigKind};
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
/// Serializes every test in this module that mutates process-wide
@@ -213,6 +336,73 @@ mod tests {
}
}
#[test]
fn persisted_provider_kind_overrides_model_provider_detection() {
let _lock = env_lock();
let _anthropic = EnvVarGuard::set("ANTHROPIC_API_KEY", Some("test-anthropic-key"));
let _xai = EnvVarGuard::set("XAI_API_KEY", Some("test-xai-key"));
let config = ProviderConfig {
kind: ProviderConfigKind::Xai,
model: "claude-sonnet-4-6".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config).expect("explicit xAI config should succeed") {
ProviderClient::Xai(client) => {
assert!(client.base_url().contains("api.x.ai"));
}
other => panic!("Expected explicit xAI provider, got: {other:?}"),
}
}
#[test]
fn persisted_dashscope_kind_overrides_qwen_model_routing() {
let _lock = env_lock();
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
let config = ProviderConfig {
kind: ProviderConfigKind::DashScope,
model: "gpt-5".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config)
.expect("explicit DashScope config should succeed")
{
ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!("Expected explicit DashScope provider, got: {other:?}"),
}
}
#[test]
fn persisted_openai_kind_does_not_become_dashscope_from_model() {
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("test-openai-key"));
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
let _openai_base_url = EnvVarGuard::set("OPENAI_BASE_URL", None);
let config = ProviderConfig {
kind: ProviderConfigKind::OpenAi,
model: "qwen-plus".to_string(),
api_key: None,
base_url: None,
};
match ProviderClient::from_config(&config).expect("explicit OpenAI config should succeed") {
ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("api.openai.com"));
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!("Expected explicit OpenAI provider, got: {other:?}"),
}
}
#[test]
fn dashscope_model_uses_dashscope_config_not_openai() {
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
+3 -1
View File
@@ -1,4 +1,5 @@
mod client;
mod error;
mod http_client;
mod prompt_cache;
@@ -8,7 +9,8 @@ mod types;
pub use client::{
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient, ProviderConfig,
ProviderConfigKind,
};
pub use error::ApiError;
pub use http_client::{
+41
View File
@@ -205,6 +205,11 @@ pub struct RuntimeProviderConfig {
}
impl RuntimeProviderConfig {
#[must_use]
pub fn is_configured(&self) -> bool {
self.kind.is_some()
}
#[must_use]
pub fn kind(&self) -> Option<&str> {
self.kind.as_deref()
@@ -2658,6 +2663,42 @@ mod tests {
std::env::temp_dir().join(format!("runtime-config-{pid}-{nanos}-{seq}"))
}
#[test]
fn loads_persisted_provider_configuration_from_settings() {
let root = temp_dir();
let cwd = root.join("project");
let home = root.join("home").join(".claw");
fs::create_dir_all(&home).expect("home config dir");
fs::create_dir_all(&cwd).expect("project dir");
fs::write(
home.join("settings.json"),
r#"{
"provider": {
"kind": "dashscope",
"apiKey": "persisted-test-key",
"baseUrl": "https://dashscope.example/v1",
"model": "qwen-plus"
}
}"#,
)
.expect("write settings");
let loaded = ConfigLoader::new(&cwd, &home)
.load()
.expect("provider configuration should load");
let provider = loaded.provider();
assert_eq!(provider.kind(), Some("dashscope"));
assert_eq!(provider.api_key(), Some("persisted-test-key"));
assert_eq!(provider.base_url(), Some("https://dashscope.example/v1"));
assert_eq!(provider.model(), Some("qwen-plus"));
assert!(provider.is_configured());
fs::remove_dir_all(root).expect("cleanup temp dir");
}
#[test]
fn rejects_non_object_settings_files() {
let root = temp_dir();
+250 -42
View File
@@ -12708,6 +12708,7 @@ fn build_runtime_with_plugin_state(
plugin_registry.initialize()?;
let policy = permission_policy(permission_mode, &feature_config, &tool_registry)
.map_err(std::io::Error::other)?;
let provider_config = provider_config_from_runtime(feature_config.provider())?;
let mut runtime = ConversationRuntime::new_with_features(
session,
AnthropicRuntimeClient::new(
@@ -12719,6 +12720,7 @@ fn build_runtime_with_plugin_state(
allowed_tools.clone(),
tool_registry.clone(),
progress_reporter,
provider_config,
)?,
CliToolExecutor::new(
allowed_tools.clone(),
@@ -12891,10 +12893,85 @@ impl<W: Write> Write for TailTrackingWriter<'_, W> {
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
// OpenAI, or DashScope at construction time based on
// `detect_provider_kind(&model)`. The struct name is kept to avoid
// churning `BuiltRuntime` and every Deref/DerefMut site that references
// it. See ROADMAP #29 for the provider-dispatch routing fix.
// OpenAI, or DashScope at construction time. Explicit persisted provider
// configuration takes precedence; model-based provider detection remains
// the fallback when no provider configuration is present. The struct name
// is kept to avoid churning `BuiltRuntime` and every Deref/DerefMut site
// that references it.
fn provider_config_from_runtime(
provider: &runtime::RuntimeProviderConfig,
) -> Result<Option<api::ProviderConfig>, Box<dyn std::error::Error>> {
let Some(kind) = provider.kind() else {
return Ok(None);
};
let kind = match kind.trim().to_ascii_lowercase().as_str() {
"anthropic" => api::ProviderConfigKind::Anthropic,
"xai" => api::ProviderConfigKind::Xai,
"openai" => api::ProviderConfigKind::OpenAi,
"dashscope" => api::ProviderConfigKind::DashScope,
other => {
return Err(format!(
"invalid provider configuration: unsupported provider kind `{other}`"
)
.into());
}
};
Ok(Some(api::ProviderConfig {
kind,
model: provider.model().unwrap_or_default().to_string(),
api_key: provider.api_key().map(str::to_string),
base_url: provider.base_url().map(str::to_string),
}))
}
fn explicit_provider_kind_for_model(
model: &str,
persisted_model: &str,
) -> Option<api::ProviderConfigKind> {
let resolved_model = api::resolve_model_alias(model);
let persisted_resolved = api::resolve_model_alias(persisted_model);
if resolved_model.eq_ignore_ascii_case(&persisted_resolved) {
return None;
}
let namespace = model
.trim()
.split_once('/')
.map(|(namespace, _)| namespace.to_ascii_lowercase());
match namespace.as_deref() {
Some("anthropic") => Some(api::ProviderConfigKind::Anthropic),
Some("xai") => Some(api::ProviderConfigKind::Xai),
Some("openai") | Some("local") => Some(api::ProviderConfigKind::OpenAi),
Some("dashscope") | Some("qwen") | Some("kimi") => Some(api::ProviderConfigKind::DashScope),
_ => {
let lower = resolved_model.to_ascii_lowercase();
if lower.starts_with("qwen/")
|| lower.starts_with("qwen-")
|| lower.starts_with("kimi/")
|| lower.starts_with("kimi-")
{
Some(api::ProviderConfigKind::DashScope)
} else if lower.starts_with("grok/") || lower.starts_with("grok-") {
Some(api::ProviderConfigKind::Xai)
} else if lower.starts_with("claude") || lower.starts_with("anthropic/") {
Some(api::ProviderConfigKind::Anthropic)
} else if lower.starts_with("openai/")
|| lower.starts_with("gpt-")
|| lower.starts_with("local/")
{
Some(api::ProviderConfigKind::OpenAi)
} else {
None
}
}
}
}
struct AnthropicRuntimeClient {
runtime: tokio::runtime::Runtime,
client: ApiProviderClient,
@@ -12919,47 +12996,65 @@ impl AnthropicRuntimeClient {
allowed_tools: Option<AllowedToolSet>,
tool_registry: GlobalToolRegistry,
progress_reporter: Option<InternalPromptProgressReporter>,
provider_config: Option<api::ProviderConfig>,
) -> Result<Self, Box<dyn std::error::Error>> {
// Dispatch to the correct provider at construction time.
// `ApiProviderClient` (exposed by the api crate as
// `ProviderClient`) is an enum over Anthropic / xAI / OpenAI
// variants, where xAI and OpenAI both use the OpenAI-compat
// wire format under the hood. We consult
// `detect_provider_kind(&resolved_model)` so model-name prefix
// routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over
// env-var presence.
//
// For Anthropic we build the client directly instead of going
// through `ApiProviderClient::from_model_with_anthropic_auth`
// so we can explicitly apply `api::read_base_url()` — that
// reads `ANTHROPIC_BASE_URL` and is required for the local
// mock-server test harness
// (`crates/rusty-claude-cli/tests/compact_output.rs`) to point
// claw at its fake Anthropic endpoint. We also attach a
// session-scoped prompt cache on the Anthropic path; the
// prompt cache is Anthropic-only so non-Anthropic variants
// skip it.
// Explicit persisted provider configuration takes precedence.
// When absent, preserve the existing model-based provider detection.
let resolved_model = api::resolve_model_alias(&model);
let client = match detect_provider_kind(&resolved_model) {
ProviderKind::Anthropic => {
let auth = resolve_cli_auth_source()?;
let inner = AnthropicClient::from_auth(auth)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id));
ApiProviderClient::Anthropic(inner)
let provider_config = provider_config.map(|config| {
if let Some(kind) = explicit_provider_kind_for_model(&model, config.model.as_str()) {
api::ProviderConfig {
kind,
model: resolved_model.clone(),
..config
}
} else {
config
}
ProviderKind::Xai | ProviderKind::OpenAi => {
// The api crate's `ProviderClient::from_model_with_anthropic_auth`
// with `None` for the anthropic auth routes via
// `detect_provider_kind` and builds an
// `OpenAiCompatClient::from_env` with the matching
// `OpenAiCompatConfig` (openai / xai / dashscope).
// That reads the correct API-key env var and BASE_URL
// override internally, so this one call covers OpenAI,
// OpenRouter, xAI, DashScope, Ollama, and any other
// OpenAI-compat endpoint users configure via
// `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`.
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
});
let client = if let Some(config) = provider_config {
match config.kind {
api::ProviderConfigKind::Anthropic => {
let auth = match resolve_cli_auth_source() {
Ok(auth) => auth,
Err(_) => {
config
.api_key
.clone()
.map(AuthSource::ApiKey)
.ok_or_else(|| {
api::ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
)
})?
}
};
let mut inner = AnthropicClient::from_auth(auth);
let base_url = std::env::var("ANTHROPIC_BASE_URL")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| config.base_url.clone())
.unwrap_or_else(api::read_base_url);
inner = inner.with_base_url(base_url);
ApiProviderClient::Anthropic(
inner.with_prompt_cache(PromptCache::new(session_id)),
)
}
_ => ApiProviderClient::from_config(&config)?,
}
} else {
match detect_provider_kind(&resolved_model) {
ProviderKind::Anthropic => {
let auth = resolve_cli_auth_source()?;
let inner = AnthropicClient::from_auth(auth)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id));
ApiProviderClient::Anthropic(inner)
}
ProviderKind::Xai | ProviderKind::OpenAi => {
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
}
}
};
Ok(Self {
@@ -19556,6 +19651,119 @@ UU conflicted.rs",
let _ = fs::remove_dir_all(source_root);
}
#[test]
fn explicit_model_provider_hint_overrides_persisted_provider_kind() {
let config = api::ProviderConfig {
kind: api::ProviderConfigKind::DashScope,
model: "qwen-plus".to_string(),
api_key: Some("persisted-test-key".to_string()),
base_url: None,
};
assert_eq!(
super::explicit_provider_kind_for_model("openai/gpt-5", &config.model),
Some(api::ProviderConfigKind::OpenAi)
);
assert_eq!(
super::explicit_provider_kind_for_model("qwen-plus", &config.model),
None,
"matching persisted model must keep the persisted provider kind"
);
}
#[test]
fn explicit_model_provider_hint_controls_runtime_provider_dispatch() {
let config = api::ProviderConfig {
kind: api::ProviderConfigKind::DashScope,
model: "qwen-plus".to_string(),
api_key: Some("persisted-test-key".to_string()),
base_url: None,
};
let runtime = super::AnthropicRuntimeClient::new(
"provider-resolution-explicit-model-test",
"openai/gpt-5".to_string(),
false,
false,
TerminalTail::new(),
None,
GlobalToolRegistry::builtin(),
None,
Some(config),
)
.expect("runtime client should construct from explicit model provider hint");
match runtime.client {
api::ProviderClient::OpenAi(client) => {
assert!(client.base_url().contains("api.openai.com"));
assert!(!client.base_url().contains("dashscope.aliyuncs.com"));
}
other => panic!(
"explicit OpenAI model prefix must override persisted DashScope configuration, got {other:?}"
),
}
}
#[test]
fn persisted_provider_configuration_controls_runtime_provider_dispatch() {
let config_home = temp_dir();
let workspace = temp_dir();
fs::create_dir_all(&config_home).expect("config home");
fs::create_dir_all(&workspace).expect("workspace");
fs::write(
config_home.join("settings.json"),
r#"{
"provider": {
"kind": "dashscope",
"apiKey": "persisted-test-key",
"baseUrl": "https://dashscope.example/v1",
"model": "qwen-plus"
},
"model": "qwen-plus"
}"#,
)
.expect("write provider settings");
let loader = ConfigLoader::new(&workspace, &config_home);
let runtime_config = loader.load().expect("runtime config should load");
let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config)
.expect("runtime plugin state should load");
let provider_config = super::provider_config_from_runtime(state.feature_config.provider())
.expect("provider configuration should adapt")
.expect("persisted provider configuration should be present");
assert_eq!(
provider_config.kind,
api::ProviderConfigKind::DashScope,
"persisted provider kind must survive the runtime configuration boundary"
);
let runtime = super::AnthropicRuntimeClient::new(
"provider-resolution-test",
"qwen-plus".to_string(),
false,
false,
TerminalTail::new(),
None,
GlobalToolRegistry::builtin(),
None,
Some(provider_config),
)
.expect("runtime client should construct from persisted provider configuration");
match runtime.client {
api::ProviderClient::OpenAi(_) => {}
other => panic!(
"persisted DashScope configuration must dispatch to the OpenAI-compatible client, got {other:?}"
),
}
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(workspace);
}
#[test]
#[allow(clippy::too_many_lines)]
fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() {