diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bef2970..ce55d274 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,6 +159,9 @@ jobs: - name: Embedder mode resumable serve streams (#330) run: python3 scripts/test-serve-resume.py zig-out/bin/graff + - name: A reloaded session keeps its compaction-state count (#1686) + run: python3 scripts/test-acp-reload-compaction-count.py zig-out/bin/graff + - name: Over-cap tool output spills to a session artifact (#409) run: python3 scripts/test-spill-artifact.py zig-out/bin/graff diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf0d222..9bb9be6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,111 @@ The release workflow uses a tag's section here as its release notes (a hand-written `docs/releases/.md` wins if present), so keeping this file current is part of cutting a release. +## v0.302.68 + +- Claude Agent compaction sends its summary request as the request. Cut mid tool loop, the request was folded into the conversation checkpoint and followed by "Continue from where the checkpoint ends.", so in a long chat the model carried on with the task or asked a question, and that reply was installed as the summary (#1792, #1793, #1797). +- Claude Agent: a prompt sent after a turn that ended on a tool call keeps its text and images instead of joining the checkpoint as text (#1792, #1793). +- Retains all changes from v0.302.67. + +## v0.302.67 + +- Claude Agent refreshes a changed tool list in place. Removing and re-adding graff's tools (to make Claude Code re-read them) failed the `load_tool_schemas` call that caused the change with "Connection closed", and the model often ended its turn or got "No such tool available"; graff now sends `tools/list_changed` and keeps the connection (#1789, ADR 0311). +- A turn started by unread peer mail has `peer_message` loaded with its first request (#1789). +- A task that ended with `attempt_completion` still hears mail addressed to it, as a task that ended with an ordinary answer does; room mail still waits for the next prompt (#1789). +- Retains all changes from v0.302.66. + +## v0.302.66 + +- A one-shot (`-p`) answer built on the provider's hosted search is kept. Hosted search runs inside the model call and never counted as a local tool call, so graff took a search-backed answer for a described change, printed "[described a change with no tool call — asking once more]", and the follow-up reply replaced the answer (#1786, #1787). +- Retains all changes from v0.302.65. + +## v0.302.65 + +- Claude Agent models report and use their 1M context window. Claude Code runs the current Claude models at 1M natively, but graff listed every `claude-agent` model at 200k, so it compacted at a fifth of the window and clients showed "200k context". A `[1m]` name now resolves to 1M as well (#1783). +- Retains all changes from v0.302.64. + +## v0.302.64 + +- An expired Anthropic Console sign-in that can't be renewed is skipped instead of sent. With no refresh token stored, `ant auth print-credentials` exits 0 and prints the same expired token; graff took that as a refresh, so the dead token was used (or kept as the fallback behind a Claude plan) and every request came back 401. Errors now say "Anthropic Console sign-in has expired: run `graff login console` again", including after a plan handoff, and a spent plan whose fallback was skipped says why (#1781, ADR 0310). +- Retains all changes from v0.302.63. + +## v0.302.63 + +- An authentication failure names the credential that was rejected. A sign-in says " sign-in was rejected: sign in again ()"; an API key says " API key was rejected (): replace or remove the key. Your sign-in is not affected."; and a key graff fell back to after a plan ran out says " plan is out of usage, and the fallback API key was rejected ()". Before, every case read "provider authentication failed: sign in again or check the API key", so a client showed "Log in" when the key was the problem (#1779, ADR 0310). +- Over ACP a rejected key carries `{"kind":"api_key_rejected","provider":""}` (v1 error data on -32603; v2 `_meta["graff/recovery"]`), so clients can offer "Update key" instead of a sign-in. `reauth_required` is unchanged. +- Retains all changes from v0.302.62. + +## v0.302.62 + +- `rlm`'s `llm_query` works on Claude Agent. Each sub-query runs on its own short-lived, tools-off `claude` process, the way titles and recaps do, so the chat's `claude` process and its prompt cache carry on untouched. Before, it answered "llm_query is not available on Claude Agent" (#1777, ADR 0309). +- Retains all changes from v0.302.61. + +## v0.302.61 + +- Claude Agent: Claude models through your own Claude Code (`claude`) and its login. Sign in with `graff login claude-agent` (or `/login claude-agent`); graff checks `claude auth status`, never reads Claude Code's credentials, and runs your installed `claude` with graff's tools, prompt, history and loop. Calls are flat-rate on a Claude plan (#1773, ADR 0309). +- Once chosen it is your Claude plan: `/model` lists `claude-opus-5-5`, `claude-fable-5-1`, `claude-sonnet-5-5` and `claude-haiku-5-5` under Claude Agent, and those names route to it. `anthropic/` still reaches the API route (#1774). +- Without a terminal (a client such as the desktop app), `graff login claude-agent` says to run `claude auth login` instead of starting it. +- Lean mode keeps `agent_output` once a subagent request loads it, so a turn that starts background workers can wait for them instead of ending early. +- Catalog tests wait for the workers they abandon, which crashed the Linux test job with SIGIO (#1772). +- Retains all changes from v0.302.60. + +## v0.302.60 + +- A resumed session keeps the provider's count of its Responses compaction state. A restart no longer re-estimates that opaque state from its size, which could show the context meter far over 100% and force a compaction the session did not need. The count is saved with the session and reapplied only to the same state, provider and model (#1686). +- The count is learned from chained (WebSocket) replies as well as full requests, so chatgpt-new sessions are counted during normal use. +- WebSocket accepts events as large as SSE does (128 MiB). A large compaction item over WebSocket previously failed, retried, and fell back to SSE, re-running the turn each time. +- `GRAFF_CHATGPT_URL` points chatgpt-new at a local mock for testing. +- Retains all changes from v0.302.59. + +## v0.302.59 + +- Large, steadily arriving SSE events no longer trigger false stream stalls or replay a request merely because the next newline has not arrived (#1765). +- Genuine stalled streams still time out. Unfinished events have bounded assembly time and size, and local assembly-limit failures are not retried. +- Retains all changes from v0.302.58. + +## v0.302.58 + +- ACP startup and `graff/models` no longer wait on slow model-catalog requests. A cached catalog, fresh or stale, loads immediately and refreshes in the background; only a provider with no cache waits, at most 800ms in total. A cold DNS lookup or slow host can no longer make a client report that model discovery timed out. The Kimi account catalog is now cached for 6 hours. +- `note_constraint` states its 240-byte limit in the tool description and the `text` field, so a model learns it before writing a rule rather than from a rejection (#1764). +- Retains all changes from v0.302.57. + +## v0.302.57 + +- Images are typed by their bytes rather than their file extension. A WebP saved as `.png` is sent as WebP, and a file that is not PNG, JPEG, GIF, or WebP is refused before it reaches the transcript instead of failing every later turn (#1762). +- A turn that fails before any reply records the failure in history, so resending the same prompt makes a new request instead of being skipped as a duplicate. The boundary is saved with the session (#1729). +- Streams that carry only an error event surface the provider's error type and message on every wire instead of being retried as truncated bodies; deterministic invalid-request errors are no longer retried (#748). +- Checklist items the user withdrew or delegated can be marked `cancelled` with a required, visible reason, and the completion gate accepts them without counting them as verified. An `ask_user` answer counts as the user's scope change (#1520). +- ACP clients receive `session_info_update` as soon as `workspace use` moves the session, with the new folder as `_meta["graff/cwd"]`, so client-served tools can follow the switch within the same turn (#1737). +- Retained workers remember the folder their checkpoint was saved under, so `subagent_resume` still finds it after a workspace switch; refusals now explain what happened and what to do (#1724). +- The repeated-tool-call guard no longer counts polls of a background subagent that is still running (#1706). +- Retains all changes from v0.302.56. + +## v0.302.56 + +- Native file tools can follow dependency symlinks whose targets remain inside their selected tool root. Reads, directory listings, edits, and writes share the same confinement policy. +- Escaping, dangling, and cyclic links remain denied. Missing ordinary paths remain available for new writes, and explicitly allowed extra roots keep their own boundaries. +- Retains all changes from v0.302.55. + +## v0.302.55 + +- Command-line help, release history, and bundled playbooks are compressed until requested, reducing duplicated runtime text without removing content or changing the build's safety settings. +- Long home paths no longer prevent installation of the macOS background-launch shim. +- Includes the quiet terminal startup and live thinking controls from v0.302.54, and retains all changes from v0.302.53. + +## v0.302.54 + +- Normal terminal startup keeps background connection receipts and legacy-listener diagnostics behind `GRAFF_REPL_DEBUG=1`; boot timing stays behind `GRAFF_BOOT_DEBUG=1`. Explicit `/mcp` and `graff servers` inspection remains available, and listener ownership protections are unchanged. +- Live thinking shows `Ctrl+T collapse` and `Ctrl+T expand` beside its header. Enable it with `/thinking on`; completed terminal scrollback remains read-only. +- Retains all changes from v0.302.53. + +## v0.302.53 + +- Embedded runtime resources are compressed, prompt variants share their common text, and optional tool catalogs are built on first use to keep release binaries smaller without changing their contents. +- Release builds retain runtime safety checks while sharing library code more efficiently. Local learning remains available in development builds rather than being included in release binaries. +- Routed requests use stable session affinity, and usage prices follow the router's published catalog. +- Workflow synthesis preserves its tool catalog at the final report-only boundary while refusing further tool execution. +- Retains all changes from v0.302.52. + ## v0.302.52 - The terminal REPL has a clearer session strip, a subtle divider, and distinct styling for active and completed checklist items. The bare prompt, scrollback, and plain output remain intact; no native window or graphical toolkit is required. diff --git a/build.zig b/build.zig index 63c86187..b4625e82 100644 --- a/build.zig +++ b/build.zig @@ -183,6 +183,9 @@ pub fn build(b: *std.Build) void { const usage_trace_test = b.addSystemCommand(&.{ "python3", "scripts/test-usage-trace.py" }); usage_trace_test.addArtifactArg(exe); test_step.dependOn(&usage_trace_test.step); + const claude_agent_test = b.addSystemCommand(&.{ "python3", "scripts/test-claude-agent.py" }); + claude_agent_test.addArtifactArg(exe); + test_step.dependOn(&claude_agent_test.step); const acp_view_meta_test = b.addSystemCommand(&.{ "python3", "scripts/test-acp-view-meta.py" }); acp_view_meta_test.addArtifactArg(exe); test_step.dependOn(&acp_view_meta_test.step); diff --git a/docs/acp-compatibility.md b/docs/acp-compatibility.md index d855b220..bd7399b4 100644 --- a/docs/acp-compatibility.md +++ b/docs/acp-compatibility.md @@ -25,7 +25,7 @@ optional feature or draft extension is enabled. | Initialization | Return protocol version 1 and explicit capabilities. Clients must reject a version they cannot speak. | `src/acp_protocol.zig`, `scripts/test-acp-startup.py`, `sdk/ts/acp.test.ts` | | Authentication | Credential-free initialization is available. Operations requiring a live agent remain authentication-gated. | `scripts/test-acp-preauth.py` | | Sessions | Create a session in its selected workspace. A live process owns one session; duplicate creation cannot replace its history. Load is advertised only when the live implementation can replay saved history. | `scripts/test-acp-session-load.py`, `scripts/test-acp-released-fixes.py` | -| Workspace and worktrees | `session/new` and `session/load` adopt the client's absolute `cwd`; naming the checkout that owns a `-w` or auto-isolated tree keeps that tree. Replies carry `_meta["graff/worktree"]` (`name`, `path`, `branch`, `base`, `baseSha`, `root`, `generated`, or `null` in a main checkout). A workspace switch emits `session_info_update` with the same `_meta`. | `scripts/test-acp-workspace.py`, `src/acp_workspace.zig`, ADR 0202 | +| Workspace and worktrees | `session/new` and `session/load` adopt the client's absolute `cwd`; naming the checkout that owns a `-w` or auto-isolated tree keeps that tree. Replies carry `_meta["graff/worktree"]` (`name`, `path`, `branch`, `base`, `baseSha`, `root`, `generated`, or `null` in a main checkout). A workspace switch emits `session_info_update` with the same `_meta` plus `graff/cwd`, right after the `workspace` tool result (before the next call), or before the reply for `/workspace use`. | `scripts/test-acp-workspace.py`, `src/acp_workspace.zig`, ADR 0202 | | Prompt lifecycle | Stream session-scoped updates and return the terminal stop reason. Cancellation interrupts the active turn and cannot grant a pending permission. | `scripts/test-acp-failure.py`, `scripts/test-acp-permissions.py` | | Tool calls | Preserve call identity, status and terminal results. Tool-like prose is not execution evidence. | `src/acp_stream.zig`, `src/agent_steps_tests.zig` | | Questions (`ask_user`) | A client that advertises `clientCapabilities.elicitation.form` gets a standard `elicitation/create` form request (one required `answer` string, an `enum` when the model offered choices); `accept` returns the answer, `decline`/`cancel` end the question. A client that opts in with `_meta["graff/askUser"]` gets graff's `gui_ask_user` and answers with `session/answer`. Any other client cannot show the question, so `ask_user` returns at once and tells the model to assume or ask in its reply; it never waits on a client that cannot answer. | `scripts/test-acp-ask-user.py`, `src/acp_elicit.zig` | diff --git a/docs/adr/0202-acp-sessions-adopt-client-cwd.md b/docs/adr/0202-acp-sessions-adopt-client-cwd.md index 7c7db101..6b9105d2 100644 --- a/docs/adr/0202-acp-sessions-adopt-client-cwd.md +++ b/docs/adr/0202-acp-sessions-adopt-client-cwd.md @@ -33,8 +33,14 @@ build. The top-level `cwd` field is removed. No other folder is searched. When `/workspace use` or the `workspace` tool moves the root session, the -prompt reply is preceded by `session_info_update` whose `_meta` carries the -new value. +client gets `session_info_update` whose `_meta` carries the new +`graff/worktree` value and `graff/cwd`, the session's absolute working +directory (so a switch into a main checkout or a non-graff tree is still +addressable). The tool's switch is reported right after its tool result, +before the model's next call, not at turn end: a client that serves tools +resolving paths against the session's folder (image attachment) must follow +it within the turn (#1737). A slash switch is reported before the prompt +reply. Each switch is reported once. `graff worktree` exits 1 when it refuses or fails and 2 when it does nothing (`kept`), so hosts can branch on the status instead of the text. diff --git a/docs/adr/0230-mcp-connects-in-the-background.md b/docs/adr/0230-mcp-connects-in-the-background.md index bfa5e370..a2fc8df9 100644 --- a/docs/adr/0230-mcp-connects-in-the-background.md +++ b/docs/adr/0230-mcp-connects-in-the-background.md @@ -23,12 +23,16 @@ ahead without a server that is not ready, and reports each server's startup. - No model request waits for a handshake. Each request merges the starts that have finished (ADR 0035, 0221), and the catalog rebuilds when one joined. -- The root request reports what changed: "MCP still connecting — native - tools this turn" on the first request that goes out while servers connect, - then "mcp: NAME connected (N tool(s))" or "mcp: NAME did not connect" when - a start finishes. The registry queues the notices under its lock; the root - agent's request emits them through the agent's own sink, which is what the - TUI shows. +- The root request drains connection receipts after each merge. Normal + interactive sessions keep these diagnostics quiet, avoiding startup chatter + interleaved with the prompt. `GRAFF_REPL_DEBUG=1` exposes "MCP still + connecting — native tools this turn", followed by each server's connection + result. The registry's existing diagnostic switch gates emission, not + connection, catalog updates, consent, or the explicit `/mcp` status view. + Companion auto-connect receipts and legacy-listener startup probes use the + same opt-in; `graff servers` remains available and no ownership or stop + authority is inferred from a marker. Boot timing remains in the trace; + `GRAFF_BOOT_DEBUG=1` opts into phase lines on stderr, even for slow phases. - ACP `session/new` and `session/load` wait at most 250 ms for the servers the client named: enough for one restored from the tool cache, never a real handshake. diff --git a/docs/adr/0302-sse-idle-watchdogs-follow-body-byte-progress.md b/docs/adr/0302-sse-idle-watchdogs-follow-body-byte-progress.md new file mode 100644 index 00000000..0584054c --- /dev/null +++ b/docs/adr/0302-sse-idle-watchdogs-follow-body-byte-progress.md @@ -0,0 +1,38 @@ +# 0302. SSE idle watchdogs follow body-byte progress + +Status: accepted + +## Context + +An SSE event can contain a large completed output item rather than small text +fragments. Racing a whole newline-delimited read against an inactivity timer +mistakes a steadily arriving partial event for a silent connection (#1686). +Reconnect then discards progress and repeats the request. The existing prose, +reasoning and reconnect policies choose the idle allowance; they do not make a +missing newline evidence of missing bytes. + +## Decision + +Keep those allowances and cancellation rules. During each line read, publish +body progress through an atomic timestamp and expire inactivity only when that +progress stops. The HTTP/1 delimiter reader uses an unbuffered forwarding writer; +the HTTP/2 reader consumes `LineStream.readChunk`, preserving bytes after a +newline for its next read. HTTP/2 control traffic is not response-body progress. +Buffers remain owned by the read task, and cancellation joins it before cleanup. + +Bound an unfinished event separately: at most 128 MiB and fifteen minutes of +assembly. An explicitly longer idle allowance also raises the assembly deadline, +so the new deadline does not shorten an explicit silence setting. These bounds +prevent oversized events and indefinitely dripping partial lines from disabling +recovery. Event-size and assembly-deadline failures have distinct errors and are +not replayed as transient network failures. Normal completion, delayed usage +trailers, and genuine silence retain their existing outcomes. + +## Consequences + +A large event may take longer than one idle allowance while its body continues +to arrive, without reconnecting. HTTP/2 reports body progress at DATA-chunk +boundaries exposed by the pinned transport; no dependency change or raw +connection/control-frame activity is needed. Synthetic HTTP/1 and HTTP/2 tests +cover flowing and stopped partial events; `sse-partial-line-byte-progress` runs +the actual engine against a fragmented scripted response and asserts one request. diff --git a/docs/adr/0304-file-tools-allow-in-root-symlinks.md b/docs/adr/0304-file-tools-allow-in-root-symlinks.md new file mode 100644 index 00000000..8732ae39 --- /dev/null +++ b/docs/adr/0304-file-tools-allow-in-root-symlinks.md @@ -0,0 +1,35 @@ +# 0304. File tools allow symlinks that resolve inside their tool root + +Status: accepted 2026-10-26 + +## Context + +The native path guard rejected every symlink component, even when its target +stayed inside the checkout. Package-manager dependency links therefore caused +`read_file` and `codedb list_dir` to report a working-directory escape (#1748). +Lexical path validation alone cannot distinguish these links from real escapes. + +## Decision + +Keep lexical confinement, then inspect every path prefix, including the final +component. A symlink is allowed only when its canonical resolved target is +inside the canonical tool root, compared at a path-component boundary. Relative +paths use the selected agent workspace or cwd; explicitly allowed absolute paths +use their matching `--add-dir` root. Root ancestors may themselves be symlinked. + +Dangling and cyclic links, unresolved link targets, and unexpected probe errors +fail closed. Ordinary missing components remain allowed so writes can create +new paths, including beneath an existing in-root directory link. A relative +path cannot use an extra root to escape its own workspace. + +## Consequences + +Dependency links work without weakening lexical traversal or genuine escape +denial. The shared predicate keeps native reads, listings, edits and writes on +the same policy. Listing a linked root does not make recursive listing follow +arbitrary symlink entries. + +Canonicalizing the root adds a filesystem lookup to each check. This remains a +preflight guard followed by ordinary filesystem operations, not an atomic +sandbox against concurrent symlink replacement or rename; race-resistant +confinement would require handle-relative operations throughout the callers. diff --git a/docs/adr/0305-cancelled-checklist-items-carry-a-reason.md b/docs/adr/0305-cancelled-checklist-items-carry-a-reason.md new file mode 100644 index 00000000..7a4da47f --- /dev/null +++ b/docs/adr/0305-cancelled-checklist-items-carry-a-reason.md @@ -0,0 +1,46 @@ +# 0305. Cancelled checklist items carry a reason + +Status: accepted 2026-10-28 + +## Context + +Verification items stay on the checklist until the user changes scope (#844), +so a session whose user withdrew publication steps, or handed them to another +session, could not finish: leaving the items out of `todo_write` restored them, +and `attempt_completion` refused with "completion deferred: N unresolved +verification item(s) remain" (#1520). #1545 added a `cancelled` status, honored +for a verification item only after a newer user message. An answer given +through `ask_user` did not count, and a cancelled item said nothing about why. + +## Decision + +`cancelled` is the one status for work the user withdrew or delegated. Every +cancelled item needs a short `reason` (one line, capped at 160 bytes). A +`todo_write` that cancels an item without one is rejected whole and leaves the +list unchanged, so a client never shows a cancel that did not happen. An item +that was already cancelled keeps its recorded reason when re-listed without +one. + +A verification item can be cancelled only after the user's latest word on the +task changed since the item was first written: a newer user message, as +before, or an answered `ask_user`. The request fingerprint folds in the count +of recorded answers. + +A cancelled item stays in the list and in the saved session with its reason. +It no longer blocks completion or counts as open work, and it never counts as +verified (`goal_verify.taskVerified`). The checklist renders it as +`[-] content (cancelled: reason)`; the line REPL's WORKING block draws it as +`⊘ content (reason)`, outside the done/total count. + +## Consequences + +The model cannot drop a verification item silently: the item stays visible, +its reason is on the record, and verified-success telemetry still reads the +task as unverified. Any answered `ask_user` unlocks cancellation, the same +trust a newer user message already had. + +ACP has no plan update. ACP clients see the `todo_write` call's raw input, +which now carries `reason`. A rejected write closes its row as failed. A +cancel refused for lack of a scope change is still applied per item (the item +keeps its previous status, and the reply says so), so that row's raw input can +overstate what happened. diff --git a/docs/adr/0306-model-catalogs-never-hold-startup.md b/docs/adr/0306-model-catalogs-never-hold-startup.md new file mode 100644 index 00000000..6c2014b9 --- /dev/null +++ b/docs/adr/0306-model-catalogs-never-hold-startup.md @@ -0,0 +1,39 @@ +# 0306. Model catalogs never hold startup or `graff/models` + +Status: accepted 2026-10-28 + +## Context + +ACP clients discover models by spawning `graff acp` and sending +`initialize`, `session/new`, and `graff/models` under one short timeout. +Boot awaited every live catalog GET: Kimi's account catalog on every start +(it had no disk cache), and every router catalog whose cache was older than +its TTL. The `graff/models` gateway refresh had a 500ms deadline, but on +timeout it cancelled and joined its HTTP worker, and a worker parked in DNS +or TLS cannot be cancelled. A cold DNS lookup or a slow host held +`initialize` for 10 to 40 seconds and the client reported "graff model +discovery timed out". The fleet elite pull used `io.async`, which runs +inline when the pool is busy, so boot also waited on its 3s deadline. + +## Decision + +Catalog GETs run on detached threads (`catalog_bounded.zig`) with their own +`Io` and allocator, and their only output is the disk cache. + +- A cached catalog, fresh or stale, activates immediately. A stale one (and + xAI's, always) refreshes behind it; nothing waits. +- Only catalogs with no cache wait, together, for at most + `startup_budget` (800ms). A fetch that misses it is abandoned, not + cancelled, and still writes its cache for the next process. +- Kimi's catalog is cached at `~/.codegraff/kimi-models.json` with a 6h TTL. +- The `graff/models` gateway refresh uses the same worker, so its 500ms + deadline holds. +- The fleet elite pull starts with `io.concurrent`, falling back to `async`. + +`/models` and `graff models` remain the blocking freshness moments. + +## Consequences + +The first start on a slow network, with no cache, may show baked rows for a +provider until the next process. An abandoned worker keeps running until its +own HTTP timeout; it touches no session state, so exit does not wait for it. diff --git a/docs/adr/0307-resumed-sessions-keep-their-compaction-state-count.md b/docs/adr/0307-resumed-sessions-keep-their-compaction-state-count.md new file mode 100644 index 00000000..5bd49949 --- /dev/null +++ b/docs/adr/0307-resumed-sessions-keep-their-compaction-state-count.md @@ -0,0 +1,50 @@ +# 0307. Resumed sessions keep their compaction-state count + +Status: accepted 2026-10-29 + +## Context + +A Responses compaction item carries opaque encrypted state. Its byte size is a +poor proxy for its token cost, so graff estimates it at bytes/16 until the +provider counts a full request carrying it; that count then replaces the +estimate (#1686, `opaque_state_usage.zig`). The count lived only in +process-global slots in `context_tokens.zig`. + +A new process restoring the same session therefore went back to bytes/16. In a +controlled reproduction, an 8,000,000-byte state counted at about 80k tokens +was restored as about 508k in a 270k window. The ACP meter showed 188%, and the +next ordinary prompt paid for a forced compaction that the warm process did not +need. Saving the paired meter counters was not enough, because restore +recomputes the local component without the count. + +## Decision + +The session file records the count as `opaque_state_count`: + +- `provider` and `model` the count was made under, +- `state_bytes`, the history's total compaction-state size (the calibration key), +- `state_digest`, an order-independent digest of every state blob, in hex, +- `tokens`, the provider's count for that state. + +It is written only when the process has a count for the saved history's state. +`loadSession` reapplies it before rebasing the context meter, and only when the +restored history has the same size and digest and the session's provider and +model match. A changed state, a different model or provider, or a missing or +malformed record leaves the bytes/16 estimate in place. + +The count is learned from every Responses reply that carries usage, chained +or not. With `previous_response_id` (the WebSocket delta, chatgpt-new's +default transport) OpenAI bills every earlier input token in the chain as +input, so `input_tokens` covers the whole context, which is how graff's meter +and Codex's own occupancy already read it. Learning only from unchained +requests left a WebSocket session uncounted until a compaction forced a full +resend, so its reload still fell back to bytes/16. + +## Consequences + +A resumed session reports the occupancy the provider last counted, and does not +compact solely because the process restarted. Genuine overflow protection is +unchanged: the compaction threshold and the near-limit gate still apply to the +restored estimate, and history added after the count is still counted locally. +Sessions saved before this change, or whose state was never counted, restore as +before and recalibrate on their next full request. diff --git a/docs/adr/0309-claude-agent-runs-the-users-claude.md b/docs/adr/0309-claude-agent-runs-the-users-claude.md new file mode 100644 index 00000000..1893c249 --- /dev/null +++ b/docs/adr/0309-claude-agent-runs-the-users-claude.md @@ -0,0 +1,109 @@ +# 0309. Claude Agent runs the user's own `claude` + +Status: accepted 2026-10-31 + +## Context + +ADR 0281 signs in to an Anthropic Console account (API credits) and ADR 0288 +added a native subscription PKCE login; ADR 0288 left room for "a separate +Claude CLI transport" that a user selects explicitly. The Agent SDK route is +that transport: a host application runs the user's installed `claude` +program through the SDK's control protocol, the user signs in with +`claude auth login`, and the host never reads Claude Code's credentials or +sits between Claude Code and Anthropic. The SDK's branding rules allow +"Claude Agent" as the product name and do not allow "Claude Code". + +The SDK itself is a TypeScript/Python library; graff is Zig. Its protocol is +plain newline-delimited JSON over the child's stdio, verified against +`claude` 2.1.304: + +- `--input-format stream-json --output-format stream-json + --include-partial-messages` streams the raw Anthropic Messages events + (`stream_event.event`), the same events graff's HTTP transport parses. +- One `initialize` control request carries the host's in-process MCP servers + (`sdkMcpServers`) and a system prompt (`systemPrompt` replaces Claude + Code's, `appendSystemPrompt` extends it). A replaced prompt works on a + subscription login. +- MCP JSON-RPC is tunnelled through `mcp_message` control requests. Every + `tools/call` carries the model's tool_use id in + `_meta["claudecode/toolUseId"]`. +- `mcp_set_servers` removes and re-adds an SDK server, after which Claude + Code initializes it again and re-reads `tools/list`; `mcp_reconnect` + refuses SDK servers. +- `client_composed: true` on a user message stops Claude Code from reading + the text as its own commands or adding its per-turn context (2.1.248+). + +## Decision + +`claude-agent` is a provider (`.kind = .anthropic`, login `claude_cli`). It +is seated only after `graff login claude-agent` (which runs +`claude auth status`, then `claude auth login` if needed, and writes a +marker naming the binary) or `GRAFF_CLAUDE_AGENT=1`. Once chosen it is the Claude plan: its catalog lists +the models Claude Code's aliases resolve to under their real names +(`claude-opus-5-5`, `claude-fable-5-1`, `claude-sonnet-5-5`, +`claude-haiku-5-5`, and the 5.0 models it still offers), and those names +route to Claude Agent ahead of the `anthropic` provider, the way the ChatGPT +plan's sign-in outranks the Codex login. `anthropic/` still reaches +the API route; `claude-agent/` passes any id Claude Code accepts; the +first `claude-agent-` names keep working. + +`Agent.postLive` routes the provider to `claude_agent.zig`, which returns an +Anthropic SSE body, so `assembleAnthropic`, `stepAnthropic`, `runTools`, +approvals, sinks, ACP, usage and compaction run unchanged: + +- One long-running child per graff agent, started with Claude Code's + built-in tools, settings files, hooks, CLAUDE.md, memory, auto-compaction, + tool search and advisor off, `dontAsk` permissions, `mcp__graff__*` as the + only allowed tools, and no saved session. The inherited identity of an + enclosing Claude Code session (CLAUDECODE, the entrypoint, its messaging + socket) is removed from the child's environment; the user's own settings + (CLAUDE_CONFIG_DIR, CLAUDE_CODE_OAUTH_TOKEN, cloud switches) pass through. +- graff's tools are served as the `graff` SDK server. A `tools/call` is + held: the step ends at the response's `tool_use` stop, graff runs the + calls (in parallel where its own batching allows), and the next step + answers each held call by its tool_use id. Results for calls Claude Code + has not asked for yet are kept until it does. +- graff's history is the source of truth. Each request sends only messages + the child has not seen; a fresh child (first use, a changed model, effort, + prompt or folder, a compaction or edit, five idle minutes) gets everything + it missed as one `` user message, without + thinking text. A changed tool list refreshes the server in place. +- Titles, recaps, judges and compaction summaries run on a throwaway child. +- graff's system prompt replaces Claude Code's by default; + `GRAFF_CLAUDE_AGENT_PROMPT=claude` keeps Claude Code's and appends graff's. +- Usage is Claude Code's per-response counts; billing is the flat-rate plan. +- `rlm`'s nested `llm_query` runs on a throwaway child too, tools off at low + effort, with a short system prompt in place of Claude Code's. It never + takes the chat's held session, so that child and its cache carry on. + +## Measurements + +graff-evals core + rlm + swe (22 tasks; `bind-reuse` left out because it asks +for graff's own `rlm` tool), Sonnet on one subscription login, two +interleaved reps, global MCP off on both sides, `claude -p` with no user +settings, hooks, plugins or CLAUDE.md (`claude-code-clean`): + +| arm | pass | wall | median/task | prompt tokens | output tokens | +|---|---|---|---|---|---| +| `claude -p` | 44/44 | 428 s | 8.2 s | 2.19M | 29.0k | +| claude-agent, Claude Code prompt + graff's | 44/44 | 362 s | 6.3 s | 1.06M | 26.2k | +| claude-agent, graff's prompt | 44/44 | 360 s | 6.3 s | 0.85M | 26.5k | + +Paired by task and rep, graff's prompt was faster in 33 of 44 runs (median +1.3 s). Calls per task were the same; the difference is request size: +Claude Code's own prompt and tool definitions are about twice graff's, which +is why graff's prompt is the default. A plan's limits count these tokens. + +## Consequences + +- Claude Code makes the API calls, so graff's Claude request shaping (cache + breakpoints, effort routing beyond `--effort`, retries and the stall + ladder, request headers) does not apply; Claude Code retries by itself and + a failure it reports ends the turn as a readable reply. +- It works only where `claude` is installed and signed in on the same + machine. Hosted or remote sessions cannot use it. +- An imported conversation still continues on graff's configured provider; + this provider is never chosen implicitly for one. +- Revisit if the protocol drops `_meta["claudecode/toolUseId"]` (calls would + then need a PreToolUse hook to learn their id and run one at a time) or + `client_composed`. diff --git a/docs/adr/0310-auth-failures-name-the-rejected-credential.md b/docs/adr/0310-auth-failures-name-the-rejected-credential.md new file mode 100644 index 00000000..56f871d2 --- /dev/null +++ b/docs/adr/0310-auth-failures-name-the-rejected-credential.md @@ -0,0 +1,70 @@ +# 0310. Authentication failures name the rejected credential + +Status: accepted 2026-11-01 + +## Context + +A provider can hold a sign-in (a flat-rate plan) and a metered API key at the +same time (#471). The plan is seated first; when it reports a quota cap, +`handOffExhaustedPlan` promotes the parked key for the rest of the session. +Every terminal authentication rejection used one text, "provider +authentication failed: sign in again or check the API key", whichever seat +failed. Clients read "sign in again" as signed out, so a rejected API key — +including the key a spent plan just handed over to — showed a sign-in prompt +although the sign-in was fine and could not fix it. + +ADR 0285 already carries a `reauth_required` action for sign-in seats on the +ChatGPT routes; metered seats carried nothing. + +## Decision + +`auth_recovery.failureText` words the rejection after the seat +(`Provider.source`) and whether the seat is a promoted key +(`credential_failover.promoted`): + +- Sign-in seat: ` sign-in was rejected: sign in again ()`. + It keeps "sign in again", so existing clients still read it as signed out. +- Key seat: ` API key was rejected (): replace or remove + the key. Your sign-in is not affected.` +- Promoted key: ` plan is out of usage, and the fallback API key was + rejected (): replace or remove the key, or wait for the plan + to reset.` + +Both key texts carry the stable phrase "API key was rejected" and never "sign +in again", "log in again", "not signed in", "unauthorized", "authentication +failed" or "invalid api key". The same text is used when the failure lands +after async tools started. Provider failover and subagent failure classes +treat the new texts as the old one. + +A key seat's failure carries fixed metadata with no login action: + +```json +{ "kind": "api_key_rejected", "provider": "anthropic" } +``` + +ACP v1 returns it as `error.data` on `-32603` (only a sign-in action is +`-32000`, authentication required). ACP v2 puts it on the failed idle update's +`_meta["graff/recovery"]`, never on `_meta["graff/reauth"]`, which clients read +as signed out. `reauth_required` and its wire positions are unchanged. + +## Consequences + +A client can tell "replace the key" from "sign in" without parsing provider +words, and an old client no longer offers a sign-in for a rejected key. The +generic message text is gone; anything matching it must match the new phrases. + +## Addendum: an expired Console sign-in + +An Anthropic Console token past `expires_at` with no refresh token stored is +not renewable: `ant auth print-credentials --access-token` warns, exits 0 and +prints the same expired token, which graff used to accept as a refresh and +then send, getting a 401 on every request (also when it was parked behind a +Claude plan as the fallback). `anthropic_profile` now treats a renewal that +returns the old token as failed: at startup such a token is neither seated nor +parked, and mid-session `fresh` offers no token instead of the dead one. The +texts say what happened: "Anthropic Console sign-in has expired: run `graff +login console` again. Your sign-in is not affected." (or "...the fallback +Console sign-in has expired..." after a plan handoff), and a spent plan with no +fallback for that reason says "Anthropic plan is out of quota, and the Console +sign-in that would take over has expired". The `api_key_rejected` data is +unchanged. diff --git a/docs/adr/0311-tool-refresh-and-addressed-wakes.md b/docs/adr/0311-tool-refresh-and-addressed-wakes.md new file mode 100644 index 00000000..603779ba --- /dev/null +++ b/docs/adr/0311-tool-refresh-and-addressed-wakes.md @@ -0,0 +1,61 @@ +# 0311. Claude Agent refreshes tools in place; a finished task still hears addressed mail + +Status: accepted 2026-11-02 + +## Context + +Two defects came out of one test: a finished worker owns a task, and three +follow-ups for it arrive as peer mail while five unrelated room lines arrive +around them. + +**Claude Agent dropped the call that loaded a tool.** When graff's tool list +changed (usually `load_tool_schemas`, loading `peer_message`), the next step +removed graff's SDK MCP server from Claude Code and added it back +(`mcp_set_servers {}` then `{graff}`), so Claude Code would re-read +`tools/list`. The `load_tool_schemas` call was still held at that moment. +Removing the server failed it with "Connection closed", and the model often +ended its turn on that error. In other turns the model went on to call the +tool and got "No such tool available". Against real Claude Code 2.1.304, a +held call failed on every reset. With `notifications/tools/list_changed` +sent instead, Claude Code re-read the list and the model called the new tool +in the same turn. + +**Whether a follow-up woke the worker depended on how its last turn ended.** +Commit a53242d4 (#1137) made a final answer settle the task: room mail +stays parked for the next human prompt, and only mail addressed to the +session starts a turn. `attempt_completion` kept an older, stricter latch +that blocked addressed mail too. A model that ends its turns with +`attempt_completion` never heard a follow-up sent straight to it; a model +that ends with a plain answer did. + +## Decision + +- The claude-agent transport refreshes a changed tool list by sending + Claude Code an MCP `notifications/tools/list_changed` for graff's server (a + client-to-CLI `mcp_message`), then serving the `tools/list` it sends back + before anything else. The server stays connected, so held calls stay open. + graff's MCP `initialize` result declares `tools.listChanged`. If Claude Code + does not re-read the list within 15 seconds, graff starts a fresh session, + which reads the list at start. +- The results sent after a refresh carry one line telling the model that + graff's tools are named `mcp__graff__`. `load_tool_schemas` prints + schemas under graff's own names, and the model's first call to a tool it + had just loaded used that bare name, which Claude Code rejects ("No such + tool available"); the turn went on, but that call was wasted. +- A turn started by a peer wake ("peer_message action=inbox") preloads + `peer_message` with its first request, as a request naming subagents + preloads `subagent` (ADR 0231). Without it the woken turn's first call was + to the unloaded tool; on Claude Agent, Claude Code refuses that call itself, + so graff never answered it, and the transcript mismatch forced a fresh + session. +- `attempt_completion` settles the task exactly as a final answer does: + room mail stays parked and readable until the next human prompt, and mail + addressed to this session (a DM, or the user's own `/tell`) can start a + turn. `yield_turn` keeps its strict latch: it waits on the person. + +## Consequences + +Loading a tool on Claude Agent no longer costs the turn or a fresh process. +A peer can hand a finished worker a follow-up by addressing it, whatever the +model ended with; unaddressed room chatter still never wakes it. Revisit the +second rule if addressed mail is used to spam finished workers. diff --git a/docs/adr/README.md b/docs/adr/README.md index f743114e..0716838e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -256,7 +256,7 @@ record only when you need the evidence or the edge cases. | [0227](0227-children-inherit-the-roots-loaded-mcp-tools.md) | A spawned child's catalog gains the MCP tools its root loaded, with full schemas (`worker_mcp.inherit` into `Agent.worker_tools`, served first by `toolsJson`). Unloaded tools stay out; children still have no load tool. | | [0228](0228-read-only-child-calls-declared-read-only-mcp-tools.md) | An MCP tool is a read when its server declares `readOnlyHint: true` (`mcp.Tool.read_only`). A read-only (informational) child may call those and inherits only those from its root; undeclared MCP tools stay refused. | | [0229](0229-every-chatgpt-login-is-chatgpt-new.md) | The Codex client's login no longer works, so every ChatGPT login name (`chatgpt`, `codex`, `openai`, `gpt`, `oai`) and the model picker run the `chatgpt-new` sign-in, and `chatgpt-new` precedes `codex` in the provider table. The `codex` provider stays for an existing Codex CLI `auth.json`. Replaces ADR 0221's "Codex first". | -| [0230](0230-mcp-connects-in-the-background.md) | Every session but `--json` connects MCP servers in the background (interactive after consent, `--yolo`, `-p`, ACP); no request waits for a handshake, finished starts join the next request, and the root request says which server connected or did not. ACP `session/new` waits at most 250 ms; `/mcp` lists without waiting. | +| [0230](0230-mcp-connects-in-the-background.md) | Every session but `--json` connects MCP servers in the background (interactive after consent, `--yolo`, `-p`, ACP); no request waits for a handshake, finished starts join the next request, and debug diagnostics report connection results without normal startup chatter. ACP `session/new` waits at most 250 ms; `/mcp` lists without waiting. | | [0231](0231-no-round-trip-the-model-does-not-need.md) | No call the task did not ask for: a named small MCP server (and subagents, when asked for) loads with the first request, with the slim-shape note; `write_file` refuses to replace an unread file unless `replace: true` and reports created/replaced and JSON validity; inherited child MCP tools are never deferred; a piped bracketed paste is one prompt; unwatched (`-p`, piped) sessions get no `ask_user` and no low-effort reasoning summaries. | | [0232](0232-sub-agents-at-parity-with-the-codex-route.md) | Sub-agents measured (`subagents` suite and its `subagents-solo` counterfactual, `subagent_split.py`) and brought to parity: `write_file` creates a new file's directory; unwatched sessions get no `todo_write` nudges; `agent_output` takes `ids` and spawn receipts name the running children; a fresh child starts with the user's prompts and its parent's live effort (`/effort` or Jev) unless pinned, and can ask Jev mid-run for its own; a read-only child's shell runs under macOS seatbelt instead of an allowlist; one `subagent` trace line per child; a script that computes a file writes it. | | [0233](0233-tool-results-the-model-already-has.md) | No tool result the model already has: interactive roots ignore a model's shell `timeout` (the 15s park of ADR 0154 stands) and unattended roots floor it at 5s, so a `timeout: 1000` habit no longer parks quick commands and ends the turn; `todo_write` answers the model with counts and the items graff kept, not the list it just sent (the UI still gets the full list). | @@ -328,6 +328,15 @@ record only when you need the evidence or the edge cases. | [0302](0302-terminal-repl-chrome-stays-in-the-terminal.md) | Refine the line REPL's terminal sinks: quiet status hierarchy, mode-first width priority and active-work emphasis, without native windows or changing the editor, engine or external fullscreen client. | | [0303](0303-terminal-markdown-and-worker-inspection.md) | The line REPL renders inline emphasis and offers a temporary mouse/keyboard `/workers` inspector over the existing bounded ACP child feed, without replacing the editor or changing engine behavior. | +| [0304](0304-file-tools-allow-in-root-symlinks.md) | File tools allow canonical in-root symlinks, reject escaping or unresolved links, and retain lexical confinement and missing write targets; the check is not an atomic sandbox. | +| [0305](0305-cancelled-checklist-items-carry-a-reason.md) | A `cancelled` todo needs a short reason or the write is rejected whole; it stays listed with that reason, stops blocking completion and never counts as verified. A verification item can be cancelled after a newer user message or an answered `ask_user`. | +| [0306](0306-model-catalogs-never-hold-startup.md) | Boot and `graff/models` never wait on a catalog GET past a budget: cached catalogs activate now and refresh behind on detached workers whose only output is the disk cache; only uncached ones wait, together, up to 800ms. | +| [0307](0307-resumed-sessions-keep-their-compaction-state-count.md) | A session saves the provider's count of its compaction state, scoped to provider, model, size and digest; loading reapplies it before rebasing the meter, so a restart neither inflates the meter nor forces a compaction. | +| [0309](0309-claude-agent-runs-the-users-claude.md) | Claude Agent runs the user's own `claude` over the SDK control protocol: graff's tools as an in-process MCP server with held calls, graff's history as the source of truth, never reading Claude Code's login. | +| [0310](0310-auth-failures-name-the-rejected-credential.md) | A terminal auth rejection names the seat that failed: sign-in text keeps "sign in again"; key and promoted-key texts say "API key was rejected" and never the signed-out phrases. Key seats carry `{"kind":"api_key_rejected","provider":…}` (v1 `-32603` data, v2 `_meta["graff/recovery"]`); `reauth_required` is unchanged. | +| [0311](0311-tool-refresh-and-addressed-wakes.md) | Claude Agent refreshes a changed tool list with MCP `tools/list_changed` (served before anything else; fresh session if not re-read in 15 s) instead of removing and re-adding its server, which failed held calls. `attempt_completion` settles a task like a final answer: room mail waits for the next human prompt, addressed mail can still start a turn; `yield_turn` stays strict. A peer wake preloads `peer_message`. | + +| [0302](0302-sse-idle-watchdogs-follow-body-byte-progress.md) | SSE idle watchdogs observe partial body-byte progress, while separate unfinished-event time and size bounds preserve recovery. | ## When to write one diff --git a/docs/releases/v0.302.53.md b/docs/releases/v0.302.53.md new file mode 100644 index 00000000..d9bfac40 --- /dev/null +++ b/docs/releases/v0.302.53.md @@ -0,0 +1,12 @@ +# v0.302.53 + +Smaller release binaries without changing prompt content or runtime safety checks. + +- Embedded runtime pages and installer resources are compressed until needed. +- Prompt variants share their common text, and optional tool catalogs are built on first use rather than stored repeatedly. +- Release builds keep runtime safety checks while reducing duplicated library code. +- Local learning is excluded from release binaries; development builds retain it. +- Routed requests use stable session affinity, and usage prices follow the router's published catalog. +- Workflow synthesis preserves its tool catalog when entering the final report-only phase, while refusing further tool execution. + +Includes the changes from v0.302.52. diff --git a/docs/releases/v0.302.54.md b/docs/releases/v0.302.54.md new file mode 100644 index 00000000..631853b9 --- /dev/null +++ b/docs/releases/v0.302.54.md @@ -0,0 +1,8 @@ +# v0.302.54 + +Quieter terminal startup and clearer live-thinking controls. + +- Normal terminal startup keeps background connection receipts and listener diagnostics behind `GRAFF_REPL_DEBUG=1`; timing remains behind `GRAFF_BOOT_DEBUG=1`. Explicit inspection commands and listener ownership protections remain available. +- Live thinking shows `Ctrl+T collapse` and `Ctrl+T expand` beside its header. Enable thinking with `/thinking on`; completed scrollback remains read-only. + +Includes all changes from v0.302.53. diff --git a/docs/releases/v0.302.55.md b/docs/releases/v0.302.55.md new file mode 100644 index 00000000..c8d229a1 --- /dev/null +++ b/docs/releases/v0.302.55.md @@ -0,0 +1,12 @@ +# v0.302.55 + +Compressed runtime text together with the quiet terminal startup and live-thinking controls from v0.302.54. + +- Command-line help, release history, and bundled playbooks are compressed until requested. Existing content is preserved. +- Normal terminal startup keeps background connection receipts and listener diagnostics behind `GRAFF_REPL_DEBUG=1`; timing remains behind `GRAFF_BOOT_DEBUG=1`. Explicit inspection commands and listener ownership protections remain available. +- Live thinking shows `Ctrl+T collapse` and `Ctrl+T expand` beside its header. Enable thinking with `/thinking on`; completed scrollback remains read-only. +- Long home paths no longer prevent installation of the macOS background-launch shim. + +The build settings are unchanged: graff's application code uses ReleaseSafe; the standard library and thin entry wrapper use ReleaseSmall. + +Includes all changes from v0.302.53. diff --git a/docs/releases/v0.302.56.md b/docs/releases/v0.302.56.md new file mode 100644 index 00000000..67bc2eb2 --- /dev/null +++ b/docs/releases/v0.302.56.md @@ -0,0 +1,10 @@ +# v0.302.56 + +Dependency symlinks now work with native file tools without allowing paths to escape their selected tool root. + +- Reads, directory listings, edits, and writes accept links whose resolved targets remain within the permitted root. +- Escaping, dangling, and cyclic links remain blocked, including links at the final path component. +- Ordinary missing paths remain available for new writes. Explicitly allowed extra roots retain their own boundaries. +- The path check is a preflight guard, not an atomic sandbox against concurrent filesystem replacement. + +Includes all changes from v0.302.55. Build optimization and runtime-safety settings are unchanged. diff --git a/docs/releases/v0.302.57.md b/docs/releases/v0.302.57.md new file mode 100644 index 00000000..60a48e61 --- /dev/null +++ b/docs/releases/v0.302.57.md @@ -0,0 +1,13 @@ +# v0.302.57 + +Reliability fixes for images, failed requests, streamed errors, and long-running sessions. + +- **Images are typed by their bytes.** `read_image`, `/image`, paste, ACP and MCP image blocks, and JSON image input detect PNG, JPEG, GIF, and WebP from the file's contents. A WebP saved as `.png` is sent as WebP. A file that is none of these is refused before it is queued, so a mislabelled screenshot can no longer leave a block in the transcript that fails every later turn (#1762). +- **Retrying after a failed request works.** A turn that fails before any reply now ends with a `[request failed: …]` marker in history, saved with the session. Resending the same prompt makes a new request; a genuine duplicate submission of a pending prompt is still skipped (#1729). +- **Streamed error events surface as errors.** An SSE body holding only an error event is reported with the provider's error type and message on chat-completions, Anthropic Messages, OpenAI Responses, and Gemini Interactions, instead of failing reassembly and being retried as a truncated response. Invalid-request errors are not retried; overload and server errors keep their bounded retry (#748). +- **Withdrawn checklist items can be closed.** `todo_write` accepts `cancelled` with a required `reason` for work the user withdrew or delegated. The item stays visible as `[-] item (cancelled: reason)`, is never counted as verified, and no longer blocks completion. A verification item can be cancelled after a newer user message or an `ask_user` answer (#1520). +- **Workspace switches reach ACP clients immediately.** `session_info_update` is sent right after the `workspace` tool moves the session, and carries the session's absolute folder as `_meta["graff/cwd"]` (#1737). +- **Retained workers survive a workspace switch.** `subagent_resume` finds a retained worker's checkpoint under the folder it was saved in, and refusals explain whether the id is malformed, the checkpoint is missing, or the worker is still running (#1724). +- **Polling a running subagent is not a repeat.** The identical-tool-call guard skips `agent_output` snapshots that report a subagent still running (#1706). + +Includes all changes from v0.302.56. diff --git a/docs/releases/v0.302.58.md b/docs/releases/v0.302.58.md new file mode 100644 index 00000000..de380534 --- /dev/null +++ b/docs/releases/v0.302.58.md @@ -0,0 +1,8 @@ +# v0.302.58 + +Faster, more reliable model discovery for ACP clients. + +- **Model discovery never waits on a slow host.** `graff acp` startup and `graff/models` used to wait for every live model-catalog request, and a cold DNS lookup or slow TLS handshake could hold `initialize` for 10 to 40 seconds until the client gave up. Catalog requests now run in the background and write the disk cache. A cached catalog, fresh or stale, is used immediately and refreshed behind it; only providers with no cache wait, together, for at most 800ms. A request that misses the budget still caches its result for the next start. The Kimi account catalog is now cached for 6 hours. See ADR 0306. +- **`note_constraint` advertises its limit.** The tool description and the `text` field state the 240-byte limit that recording enforces, so a longer rule is recognized before the call rather than after a rejection (#1764). + +Includes all changes from v0.302.57. diff --git a/docs/releases/v0.302.59.md b/docs/releases/v0.302.59.md new file mode 100644 index 00000000..2c8b12f1 --- /dev/null +++ b/docs/releases/v0.302.59.md @@ -0,0 +1,8 @@ +# v0.302.59 + +More reliable streaming for large responses. + +- **Partial SSE progress no longer looks like a stall.** A large event can arrive steadily before its next newline. Both HTTP/1 and HTTP/2 now track response-body byte progress so those events are not discarded and their requests replayed (#1765). +- **Real stalls still recover.** Cancellation and genuine inactivity detection remain in place. Unfinished events have bounded assembly time and size; local assembly-limit failures stop rather than retrying a request that cannot repair them. + +Includes all changes from v0.302.58. diff --git a/docs/releases/v0.302.60.md b/docs/releases/v0.302.60.md new file mode 100644 index 00000000..a557f5a7 --- /dev/null +++ b/docs/releases/v0.302.60.md @@ -0,0 +1,12 @@ +# v0.302.60 + +Compaction state survives a restart. + +- **Resumed sessions keep their real context size.** After a Responses provider counts a session's compaction state, that count is saved with the session and reapplied when it is loaded, for the same state, provider and model. A restart used to re-estimate the opaque state from its byte size: in a controlled test an 8 MB state counted at about 80k tokens restored as about 508k, so the meter showed 188% and the next prompt paid for a forced compaction. It now restores at the counted size (#1686, ADR 0307). +- **WebSocket sessions are counted during normal use.** The count is learned from chained replies (`previous_response_id`) as well as full requests; OpenAI bills the whole chain as input, which is how the meter already reads it. chatgpt-new uses WebSocket by default. +- **Large compaction items work over WebSocket.** The WebSocket client accepts events up to 128 MiB, matching SSE. A larger item used to fail, retry on a fresh socket, and fall back to SSE, re-running the turn each time. +- **chatgpt-new can be tested offline.** `GRAFF_CHATGPT_URL` points its Responses endpoint, WebSocket and model discovery at a local mock, as `GRAFF_CODEX_URL` does for codex. + +Sessions saved before this release carry no count, so their first reload still estimates the state until the next reply. + +Includes all changes from v0.302.59. diff --git a/docs/releases/v0.302.61.md b/docs/releases/v0.302.61.md new file mode 100644 index 00000000..9c49b06c --- /dev/null +++ b/docs/releases/v0.302.61.md @@ -0,0 +1,13 @@ +# v0.302.61 + +Claude Agent: Claude models through your own Claude Code login. + +- **Sign in once.** `graff login claude-agent` (or `/login claude-agent` in a session) checks that your installed Claude Code (`claude`) is signed in and records the choice. graff never reads Claude Code's credentials; if Claude Code has no login, it says to run `claude auth login`. +- **graff's harness, your Claude plan.** graff runs your `claude` with its built-in tools, settings, hooks, CLAUDE.md, memory and auto-compaction off, and offers graff's own tools. Tool calls go through graff's loop as usual: approvals, parallel batches, ACP and subagents work unchanged. graff's history stays the source of truth, so switching between Claude Agent and any other model keeps the conversation. Calls are flat-rate on a Claude plan (ADR 0309). +- **It is your Claude plan in `/model`.** Claude Agent lists `claude-opus-5-5`, `claude-fable-5-1`, `claude-sonnet-5-5` and `claude-haiku-5-5` (and the 5.0 models Claude Code still offers) under their real names, and those names route to it once chosen. `anthropic/` still reaches the API route, and a session saved on it keeps it. +- **Subagents wait for their workers in lean mode.** A request for subagents loads `agent_output`; lean mode used to drop it from the tool list, so a turn that started background workers could end before they reported. +- **A Linux CI crash is gone.** Catalog tests wait for the workers they abandon, which had crashed the test job with SIGIO (#1772). + +Claude Agent needs Claude Code 2.1.248 or newer, installed and signed in on the same machine. `GRAFF_CLAUDE_AGENT_PROMPT=claude` keeps Claude Code's system prompt and appends graff's. + +Includes all changes from v0.302.60. diff --git a/docs/releases/v0.302.62.md b/docs/releases/v0.302.62.md new file mode 100644 index 00000000..ab9a0794 --- /dev/null +++ b/docs/releases/v0.302.62.md @@ -0,0 +1,7 @@ +# v0.302.62 + +`rlm`'s `llm_query` works on Claude Agent. + +- **Sub-queries run on Claude Agent.** Inside `rlm`, `llm_query` used to answer "llm_query is not available on Claude Agent" because that provider has no HTTP endpoint. Each sub-query now runs on its own short-lived `claude` process with no tools, the way titles and recaps already do, and the text comes back as usual. The chat's own `claude` process and its prompt cache are never touched (#1777, ADR 0309). + +Includes all changes from v0.302.61. diff --git a/docs/releases/v0.302.63.md b/docs/releases/v0.302.63.md new file mode 100644 index 00000000..c19547ac --- /dev/null +++ b/docs/releases/v0.302.63.md @@ -0,0 +1,9 @@ +# v0.302.63 + +Authentication errors say which credential was rejected. + +- **Sign-in or API key, named.** A provider can hold a sign-in and an API key at once. When one is rejected, the error now says which: "Anthropic sign-in was rejected: sign in again (graff login anthropic)", or "Anthropic API key was rejected (): replace or remove the key. Your sign-in is not affected." Before, both read "provider authentication failed: sign in again or check the API key", so a desktop client offered "Log in" even when the sign-in was fine and the key was the problem. +- **The fallback key, too.** When a plan runs out and graff switches to the API key parked behind it, and that key is then rejected, the error says so: "Anthropic plan is out of usage, and the fallback API key was rejected (): replace or remove the key, or wait for the plan to reset." +- **Typed for clients.** Over ACP a rejected key carries `{"kind":"api_key_rejected","provider":""}` (v1: error data on -32603; v2: `_meta["graff/recovery"]`), so a client can show "Update key" instead of a sign-in. A rejected sign-in still carries `reauth_required` as before (ADR 0310). + +Includes all changes from v0.302.62. diff --git a/docs/releases/v0.302.64.md b/docs/releases/v0.302.64.md new file mode 100644 index 00000000..1148ab86 --- /dev/null +++ b/docs/releases/v0.302.64.md @@ -0,0 +1,8 @@ +# v0.302.64 + +An expired Anthropic Console sign-in is skipped, and the error says so. + +- **No more dead Console token.** When a Console sign-in's token has expired and no refresh token is stored, `ant auth print-credentials --access-token` warns but exits 0 and prints the same expired token. graff took that as a successful refresh, so it kept using the token, or kept it as the fallback behind a Claude plan, and every request came back 401. A renewal that returns the old token now counts as failed: the expired sign-in is neither used nor kept as a fallback, and mid-session graff stops sending it. +- **The error names it.** "Anthropic Console sign-in has expired: run `graff login console` again. Your sign-in is not affected." After a plan handoff it says the fallback Console sign-in has expired, and when a plan runs out with no fallback for that reason: "Anthropic plan is out of quota, and the Console sign-in that would take over has expired: run `graff login console` again, or wait for the plan to reset." (#1781, ADR 0310) + +Includes all changes from v0.302.63. diff --git a/docs/releases/v0.302.65.md b/docs/releases/v0.302.65.md new file mode 100644 index 00000000..9c69abf3 --- /dev/null +++ b/docs/releases/v0.302.65.md @@ -0,0 +1,8 @@ +# v0.302.65 + +Claude Agent gets its full 1M context window. + +- **1M, not 200k.** Claude Code runs the current Claude models at a 1M window natively, but graff listed every Claude Agent model at 200k. graff compacted the conversation at a fifth of the window, and clients showed "200k context". The Claude Agent models now carry 1M, the same as the Anthropic provider's. +- **`[1m]` names.** A model id with Claude Code's `[1m]` suffix (for example `claude-agent/claude-sonnet-5-5[1m]`) resolves to 1M instead of the 200k default for unknown models. (#1783) + +Includes all changes from v0.302.64. diff --git a/docs/releases/v0.302.66.md b/docs/releases/v0.302.66.md new file mode 100644 index 00000000..099685a9 --- /dev/null +++ b/docs/releases/v0.302.66.md @@ -0,0 +1,7 @@ +# v0.302.66 + +A one-shot answer built on hosted search is kept. + +- **No false bounce.** In a one-shot `-p` run, graff re-asks once when the first reply describes a change without calling a tool. Hosted search runs inside the model call, so it never counted as a tool call, and a search-backed answer was taken for a described change. graff printed "[described a change with no tool call — asking once more]", and the follow-up reply replaced the answer. A reply whose model call ran a hosted search now stands. (#1786, #1787) + +Includes all changes from v0.302.65. diff --git a/docs/releases/v0.302.67.md b/docs/releases/v0.302.67.md new file mode 100644 index 00000000..a32e4a16 --- /dev/null +++ b/docs/releases/v0.302.67.md @@ -0,0 +1,9 @@ +# v0.302.67 + +Claude Agent keeps a tool load from ending the turn, and a finished task still hears a peer who writes to it directly. + +- **A tool load no longer drops the call.** When graff's tool list changed (usually `load_tool_schemas`), Claude Agent removed graff's tools from Claude Code and added them back. The call that loaded the tool was still open, so Claude Code failed it with "Connection closed", and the model often stopped there or got "No such tool available" for the tool it had just loaded. graff now tells Claude Code that the list changed and keeps the connection, so the call completes and the model uses the new tool in the same turn. (#1789) +- **Peer wakes come with `peer_message` loaded.** A turn started by unread peer mail has the inbox tool ready with its first request, so it no longer spends a call loading it. On Claude Agent, that first call used to be refused and forced a fresh session. (#1789) +- **Direct messages reach a finished task.** After a task ended with `attempt_completion`, nothing could start another turn, not even a message addressed to that session. A task that ended with an ordinary answer did hear one, so whether a follow-up arrived depended on how the model happened to finish. Both now behave the same: room messages wait for your next prompt, and messages addressed to the session start a turn. (#1789) + +Includes all changes from v0.302.66. diff --git a/docs/releases/v0.302.68.md b/docs/releases/v0.302.68.md new file mode 100644 index 00000000..d3b33208 --- /dev/null +++ b/docs/releases/v0.302.68.md @@ -0,0 +1,8 @@ +# v0.302.68 + +Claude Agent compaction writes a real summary again, and a prompt after a finished turn keeps its images. + +- **Compaction summarizes instead of carrying on.** Claude Agent runs compaction on a fresh Claude Code process. When it was cut in the middle of a tool loop, the summary request was folded into the conversation text and followed by "Continue from where the checkpoint ends." In a long chat the model kept working on the task, or asked you a question, and that reply was installed as the summary. The summary request is now always sent as the request. (#1792, #1793, #1797) +- **A prompt after a finished turn is read as a prompt.** The same fault hit a new message sent after a turn that ended on a tool call such as `attempt_completion`: it joined the old conversation as text, and its images arrived as "[image]". The message now reaches the model as itself, images included. (#1792, #1793) + +Includes all changes from v0.302.67. diff --git a/err.txt b/err.txt new file mode 100644 index 00000000..62677876 --- /dev/null +++ b/err.txt @@ -0,0 +1,12 @@ +{"type":"run_started","run_id":"0923cccfa4a5ab6f","session_id":"session-1793428962555-2630","parent_session_id":null} +calling claude-agent-sonnet +› +› +› +› + [main] I couldn't find a `logs/` directory, so I haven't counted anything or written `summary.json`. + +The working directory is `/Users/rachpradhan/.harness/worktrees/codegraff/swift-falcon`. There's no `logs/` in it, and a search up to three levels deep turned up no directory with that name. I didn't search outside the worktree. + +Where are the logs? If you give me the path, I'll start one sub-agent per service (auth, billing, search, mailer) in parallel, then combine their counts into `summary.json`. +[usage] 4 api call(s) · 44589 in (37470 cached, 7111 cache writes) + 522 out tokens · $0.00000000 · 4 subscription call(s), flat-rate (not in $) diff --git a/evals/harness_behavior.jsonl b/evals/harness_behavior.jsonl index 3b59414d..8a15756b 100644 --- a/evals/harness_behavior.jsonl +++ b/evals/harness_behavior.jsonl @@ -157,3 +157,6 @@ {"id": "issue-recurrence-search-failed", "why": "#1701: search-failed. Scripted policy delivery and local issue-state roundtrip, not live-model judgment. Independent verifiers reject incorrect state, missing evidence, extra mutations and missing final reads.", "scripted_only": true, "args": ["--old"], "env": {"GRAFF_NO_CODEDB_GUARD": "1", "GRAFF_AUTO_ISOLATE": "off"}, "github_fixture": {"repo": "fixture/primary", "issues": {"101": {"number": 101, "title": "recurrence fixture", "body": "Previously fixed fixture recurrence.", "state": "CLOSED", "stateReason": "COMPLETED", "comments": [], "url": "https://github.com/fixture/primary/issues/101"}}, "issue_list_denied": true}, "workspace_seed": {"recurrence.md": "**graff:** fixture (latest: fixture)\n**Harness:** not used\n**Surface:** terminal\n**Platform:** fixture\n\n## Symptom\nThe fixture failure recurs on the current fixture release.\n\n## Repro\nRepeat the local fixture operation.\n"}, "prompt": "Report the supplied fixture findings using only the installed local gh. Inspect resolution and tested-version evidence, follow canonical duplicate tracking, avoid duplicate issues and report any failed operation accurately.", "script": [{"tool": "bash", "arguments": {"command": "gh issue list --repo fixture/primary --state all --search 'recurrence fixture' --json number,title,body,state,stateReason"}}, {"text": "Issue search failed with permission denied. No issue was created, commented on or reopened; canonical tracking could not be verified."}], "verify_command": ["python3", "-c", "import json,pathlib\np=pathlib.Path\nexpected=json.loads('{\"101\": {\"number\": 101, \"title\": \"recurrence fixture\", \"body\": \"Previously fixed fixture recurrence.\", \"state\": \"CLOSED\", \"stateReason\": \"COMPLETED\", \"comments\": [], \"url\": \"https://github.com/fixture/primary/issues/101\"}}')\nstate=json.loads(p('gh-state.json').read_text())\nassert state['issues']==expected, ('issue state/evidence mismatch',state['issues'],expected)\nreads=[json.loads(x) for x in p('gh-argv.jsonl').read_text().splitlines()] if p('gh-argv.jsonl').exists() else []\nmutations=[json.loads(x) for x in p('mutations.jsonl').read_text().splitlines()] if p('mutations.jsonl').exists() else []\nassert [(r[1],r[2]) for r in mutations]==[], ('incorrect mutations',mutations)\nassert any(r[:2]==['issue','list'] and '--state' in r and r[r.index('--state')+1]=='all' for r in reads), 'must search all states'\nprint('Independent extended issue lifecycle verified')\n"], "assert": [{"request_contains": {"index": 0, "text": "A comment alone does not complete a recurrence report."}}, {"final_text_contains": "Issue search failed with permission denied. No issue was created, commented on or reopened; canonical tracking could not be verified."}, {"events_at_least": {"match": {"type": "tool_call", "name": "bash"}, "count": 1}}, {"events_at_least": {"match": {"type": "tool_call_finished", "name": "bash", "is_error": true}, "count": 1}}, {"events_at_most": {"match": {"type": "tool_call_finished", "name": "bash", "is_error": true}, "count": 1}}], "fixture_exit_codes": {"gh issue list --repo fixture/primary --state all --search 'recurrence fixture' --json number,title,body,state,stateReason": 1}, "fixture_negative_omissions": ["issue list"]} {"why": "#1701: custom root prompts must retain issue lifecycle guidance; scripted policy-delivery coverage, not live-model judgment.", "scripted_only": true, "prompt": "Prepare a generic fixture report; do not publish anything.", "script": [{"text": "Fixture report prepared locally."}], "assert": [{"request_contains": {"index": 0, "text": "A comment alone does not complete a recurrence report."}}, {"request_contains": {"index": 0, "text": "CUSTOM_ROOT"}}, {"final_text_contains": "Fixture report prepared locally."}], "id": "issue-policy-custom-root-startup", "args": ["--system-prompt", "CUSTOM_ROOT"]} {"why": "#1701: custom root prompts must retain issue lifecycle guidance; scripted policy-delivery coverage, not live-model judgment.", "scripted_only": true, "prompt": "Prepare a generic fixture report; do not publish anything.", "script": [{"text": "Fixture report prepared locally."}], "assert": [{"request_contains": {"index": 0, "text": "A comment alone does not complete a recurrence report."}}, {"request_contains": {"index": 0, "text": "CUSTOM_ROOT"}}, {"final_text_contains": "Fixture report prepared locally."}], "id": "issue-policy-custom-root-replaced", "controls_before_prompt": [{"type": "set_system_prompt", "text": "CUSTOM_ROOT"}]} +{"id": "dependency-symlink-confinement", "why": "#1748: native reads and directory listings must accept in-checkout dependency links while still refusing symlink escapes.", "scripted_only": true, "workspace_seed": {"node_modules/.store/pkg/docs/guide.md": "synthetic dependency guide\n"}, "prompt": "Read and list the synthetic linked dependency, then check that the escape link is refused.", "script": [{"tool": "bash", "arguments": {"command": "ln -s .store/pkg node_modules/pkg && ln -s / escape"}}, {"tool": "read_file", "arguments": {"path": "node_modules/pkg/docs/guide.md"}}, {"tool": "codedb", "arguments": {"command": "list_dir node_modules/pkg"}}, {"tool": "read_file", "arguments": {"path": "escape/no-such-synthetic-file"}}, {"text": "Dependency links accepted; escape refused."}], "assert": [{"request_contains": {"index": 2, "text": "synthetic dependency guide"}}, {"request_contains": {"index": 3, "text": "- node_modules/pkg/"}}, {"request_contains": {"index": 3, "text": "guide.md"}}, {"request_contains": {"index": 4, "text": "outside the working directory"}}, {"event": {"type": "tool_call_finished", "name": "read_file", "is_error": false}}, {"event": {"type": "tool_call_finished", "name": "codedb", "is_error": false}}, {"event": {"type": "tool_call_finished", "name": "read_file", "is_error": true}}, {"final_text_contains": "Dependency links accepted; escape refused."}]} +{"id": "retry-after-failed-request-1729", "why": "#1729: a turn that failed before any reply left its prompt as the last history row, so resending the identical prompt read as a host replay and finished without a request. The failed turn must leave a boundary row so the retry makes a new request that carries it.", "args": [], "prompt": "Reply with retry ready.", "second_prompt": "Reply with retry ready.", "script": [{"http_status": 418, "error": "scripted outage"}, {"text": "retry ready"}], "assert": [{"request_count": 2}, {"request_contains": {"index": 1, "text": "[request failed:"}}, {"final_text_contains": "retry ready"}]} +{"id": "sse-partial-line-byte-progress", "why": "#1686: bytes inside an unfinished SSE event reset inactivity; a long, steadily arriving tool-call event must not reconnect or replay the request.", "env": {"GRAFF_STREAM_STALL_SECS": "1", "GRAFF_STREAM_HEAD_STALL_SECS": "1"}, "prompt": "Complete the synthetic transfer fixture.", "script": [{"tool": "attempt_completion", "arguments": {"result": "Partial-line transfer completed."}, "stream_fragment_bytes": 16, "stream_fragment_delay_ms": 100}], "assert": [{"request_count": 1}, {"event": {"type": "tool_call", "name": "attempt_completion"}}, {"final_text_contains": "Partial-line transfer completed."}, {"no_event": {"type": "error"}}]} diff --git a/examples/edge-worker/src/remote.ts b/examples/edge-worker/src/remote.ts index 8d8df43a..abfc3d74 100644 --- a/examples/edge-worker/src/remote.ts +++ b/examples/edge-worker/src/remote.ts @@ -11,7 +11,7 @@ export const HARNESS_VERSION = "0.13"; export type ModelName = "MiniMax-M2.5" | "MiniMax-M2.7" | "MiniMax-M3" | "accounts/fireworks/models/deepseek-v4-flash" | "accounts/fireworks/models/deepseek-v4-pro" | "accounts/fireworks/models/glm-5p2" | "accounts/fireworks/models/gpt-oss-120b" | "accounts/fireworks/models/kimi-k2p6" | "accounts/fireworks/models/kimi-k2p7-code" | "accounts/fireworks/models/minimax-m3" | "accounts/fireworks/models/qwen3p7-plus" | "alibaba/qwen3.8-27b" | "anthropic/claude-sonnet-4.6" | "claude-fable-5" | "claude-fable-5-1" | "claude-haiku-5-5" | "claude-opus-4-5" | "claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8" | "claude-opus-4.8" | "claude-opus-5" | "claude-opus-5-5" | "claude-sonnet-4-5" | "claude-sonnet-4-6" | "claude-sonnet-4.6" | "claude-sonnet-5" | "claude-sonnet-5-5" | "deepseek-chat" | "deepseek-reasoner" | "deepseek-v4-flash" | "deepseek-v4-pro" | "flint" | "fugu" | "fugu-ultra" | "fugu-ultra-20260615" | "gemini-3-flash-preview" | "gemini-3.1-flash-lite" | "gemini-3.1-pro-preview" | "gemini-3.5-flash" | "gemini-3.5-flash-lite" | "gemini-3.6-flash" | "gemini-3.7-flash" | "gemini-3.8-flash" | "gemini-4-argon" | "gemma-4-26b-a4b-it" | "gemma-4-31b" | "gemma-4-31b-it" | "glm-4.5" | "glm-4.7" | "glm-5" | "glm-5-turbo" | "glm-5.2" | "glm-5.3" | "glm-5.3-flash" | "glm-5v-turbo" | "gpt-5-codex" | "gpt-5.2" | "gpt-5.3-codex-spark" | "gpt-5.4" | "gpt-5.4-mini" | "gpt-5.4-pro" | "gpt-5.5" | "gpt-5.6" | "gpt-5.6-luna" | "gpt-5.6-sol" | "gpt-5.6-terra" | "gpt-6-astra" | "gpt-6-luna" | "gpt-6-sol" | "gpt-6.1-sol" | "gpt-oss-120b" | "grok-4.3" | "grok-4.6" | "grok-4.7" | "grok-build" | "k3" | "kilo-auto/small" | "kimi-for-coding" | "kimi-for-coding-highspeed" | "kimi-k2.6" | "kimi-k2.7-code" | "kimi-k3" | "kimi-latest" | "lmstudio" | "mimo-v2-flash" | "mimo-v2.5" | "mimo-v2.5-pro" | "mimo-v2.5-pro-ultraspeed" | "mimo-v2.6-flash" | "mimo-v2.6-pro" | "mimo-v2.6-pro-ultraspeed" | "minimax-m3" | "mistral-medium-latest" | "mlx-community/Qwen3.6-27B-OptiQ-4bit" | "muse-spark-1.2" | "muse-spark-1.2-contributor" | "omlx" | "openai/gpt-oss-120b" | (string & {}); export type ToolName = "shell" | "read_file" | "edit_file" | "write_file" | "webfetch" | "skill" | "codedb" | "read_tool_result" | "render_html" | "todo_write" | "todo_read" | "eval" | "note_constraint" | "ask_user" | "attempt_completion" | "load_tool_schemas" | "mcp_search_tools" | "mcp_select_tool" | "clock_sleep" | "subagent" | "workflow" | "agent_output" | "agent_message" | "subagent_resume" | "learn_candidate" | "peer_message" | "workspace" | "yield_turn" | "imagegen" | "rlm"; -export type ProviderId = "anthropic" | "codegraff" | "deepseek" | "openai" | "google" | "minimax" | "xiaomi" | "kilo" | "groq" | "cerebras" | "mistral" | "kimi" | "moonshot" | "xai" | "zai" | "meta" | "vercel" | "openrouter" | "fugu" | "fireworks" | "mlx" | "lmstudio" | "omlx" | "chatgpt-new" | "codex"; +export type ProviderId = "anthropic" | "codegraff" | "deepseek" | "openai" | "google" | "minimax" | "xiaomi" | "kilo" | "groq" | "cerebras" | "mistral" | "kimi" | "moonshot" | "xai" | "zai" | "meta" | "vercel" | "openrouter" | "fugu" | "fireworks" | "mlx" | "lmstudio" | "omlx" | "chatgpt-new" | "codex" | "claude-agent"; /** Events streamed by the bridge (same `--json` contract as the stdio SDK). * `seq` is a monotonic, gap-free, 1-based per-session id the bridge forwards diff --git a/graff-evals/claude_code_run.py b/graff-evals/claude_code_run.py index eef9b14c..a226fd61 100644 --- a/graff-evals/claude_code_run.py +++ b/graff-evals/claude_code_run.py @@ -20,6 +20,11 @@ "--output-format", "stream-json", "--verbose"] if os.path.exists(".mcp.json"): cmd += ["--mcp-config", ".mcp.json", "--strict-mcp-config"] +elif os.environ.get("CLAUDE_CODE_RUN_ISOLATED"): + cmd += ["--strict-mcp-config"] +if os.environ.get("CLAUDE_CODE_RUN_ISOLATED"): + # No user/project settings: hooks, plugins and permissions stay out. + cmd += ["--setting-sources", ""] r = subprocess.run(cmd, stdin=subprocess.DEVNULL, capture_output=True, text=True) per_message, answer, result = {}, "", None diff --git a/graff-evals/harnesses.json b/graff-evals/harnesses.json index b1ffc7c5..9d2844d9 100644 --- a/graff-evals/harnesses.json +++ b/graff-evals/harnesses.json @@ -434,5 +434,31 @@ "model_prefix": "codegraff", "default_model": "gemini-4-argon", "note": "graff --json on the Codegraff gateway, set_effort default before the prompt (-p has no effort flag)" + }, + "graff-dev-claude-agent": { + "cmd": ["{repo}/zig-out/bin/graff", "-p", "{prompt}", "--model", "claude-agent/{model}", "--yolo"], + "answer": "stdout", + "usage": "graff-stderr", + "capabilities": [], + "env": {"GRAFF_CLAUDE_AGENT": "1", "GRAFF_MCP_CONFIG": "{repo}/graff-evals/no-global-mcp.json", "GRAFF_POST_DEADLINE_SECS": "90"}, + "default_model": "sonnet", + "note": "graff on the Claude Agent provider (ADR 0309): the user's own `claude` login runs the model; graff's prompt, tools and loop. GRAFF_MCP_CONFIG names a file that does not exist: no global MCP servers, while a task's .mcp.json still loads (an existing empty file would switch MCP off entirely). Pass a Claude Code model alias (sonnet, opus, haiku)." + }, + "graff-dev-claude-agent-ccprompt": { + "cmd": ["{repo}/zig-out/bin/graff", "-p", "{prompt}", "--model", "claude-agent/{model}", "--yolo"], + "answer": "stdout", + "usage": "graff-stderr", + "capabilities": [], + "env": {"GRAFF_CLAUDE_AGENT": "1", "GRAFF_CLAUDE_AGENT_PROMPT": "claude", "GRAFF_MCP_CONFIG": "{repo}/graff-evals/no-global-mcp.json", "GRAFF_POST_DEADLINE_SECS": "90"}, + "default_model": "sonnet", + "note": "graff-dev-claude-agent keeping Claude Code's system prompt with graff's appended." + }, + "claude-code-clean": { + "cmd": ["python3", "{repo}/graff-evals/claude_code_run.py", "{model}", "{prompt}"], + "answer": "stdout", + "usage": "graff-stderr", + "env": {"CLAUDE_CODE_RUN_ISOLATED": "1", "CLAUDE_CODE_DISABLE_CLAUDE_MDS": "1", "DISABLE_TELEMETRY": "1", "DISABLE_AUTOUPDATER": "1", "DISABLE_ERROR_REPORTING": "1"}, + "default_model": "sonnet", + "note": "`claude -p` on its own login with no user settings, hooks, plugins, CLAUDE.md or global MCP: the isolation graff-dev-claude-agent runs with. Launch the runner without an enclosing Claude Code session's CLAUDE* variables." } } diff --git a/lean-proofs/Graff/Provider.lean b/lean-proofs/Graff/Provider.lean index 0bb93be9..4f7c4cec 100644 --- a/lean-proofs/Graff/Provider.lean +++ b/lean-proofs/Graff/Provider.lean @@ -1,7 +1,7 @@ /- Provider table kernel. - Twenty-five baked rows. Each row is data: an id plus the axes `Kind`, + Twenty-six baked rows. Each row is data: an id plus the axes `Kind`, `Auth`, login, catalog, and two bools. The program does not branch per brand. Adding a vendor is one constructor here and one line in `provider.zig`; the Zig fixture test is the ratchet. @@ -26,6 +26,7 @@ inductive Login where | zaiCli | chatgptBrowser | anthropicBrowser + | claudeCli deriving DecidableEq, Repr, BEq inductive Catalog where @@ -72,6 +73,7 @@ def specs : List Row := , { id := "omlx", kind := .openai, auth := .bearer, catalog := .openai, takesEffort := true } , { id := "chatgpt-new", kind := .responses, auth := .bearer, login := .chatgptBrowser, catalog := .openai, subLogin := true } , { id := "codex", kind := .responses, auth := .bearer, login := .codexDevice, catalog := .codex, subLogin := true } + , { id := "claude-agent", kind := .anthropic, auth := .bearer, login := .claudeCli, subLogin := true, takesEffort := true } ] def ids : List String := specs.map (·.id) @@ -90,7 +92,7 @@ def xApiKeyCount : Nat := countWhere (fun r => r.auth == .xApiKey) specs def providerRows : Nat := specs.length -theorem provider_cube : providerRows = 25 := by native_decide +theorem provider_cube : providerRows = 26 := by native_decide theorem responses_vendors : responsesCount = 3 := by native_decide theorem one_x_api_key : xApiKeyCount = 1 := by native_decide diff --git a/out.txt b/out.txt new file mode 100644 index 00000000..c1aaa25a --- /dev/null +++ b/out.txt @@ -0,0 +1,5 @@ +I couldn't find a `logs/` directory, so I haven't counted anything or written `summary.json`. + +The working directory is `/Users/rachpradhan/.harness/worktrees/codegraff/swift-falcon`. There's no `logs/` in it, and a search up to three levels deep turned up no directory with that name. I didn't search outside the worktree. + +Where are the logs? If you give me the path, I'll start one sub-agent per service (auth, billing, search, mailer) in parallel, then combine their counts into `summary.json`. diff --git a/scripts/eval-tier1.sh b/scripts/eval-tier1.sh index 37a679c1..962e7f77 100755 --- a/scripts/eval-tier1.sh +++ b/scripts/eval-tier1.sh @@ -41,7 +41,7 @@ checks, in order: build zig build shell durable shell IDs and stale-handle isolation after a crash tests zig build test, and the suite count never shrinks - terminal PTY cleanup integrity and line-REPL prompt reflow + terminal PTY cleanup, line-REPL prompt reflow, and live thinking controls invariants the named goal/loop/todo tests actually ran, not just compiled sdk the committed SDKs match `graff --schema` EOF @@ -187,6 +187,7 @@ if wanted shell; then python3 scripts/eval/process_guard.py --timeout 90 python3 scripts/test-acp-released-fixes.py zig-out/bin/graff && python3 scripts/eval/process_guard.py --timeout 120 python3 scripts/test-acp-permissions.py zig-out/bin/graff && python3 scripts/eval/process_guard.py --timeout 90 python3 scripts/test-acp-session-load.py zig-out/bin/graff && + python3 scripts/eval/process_guard.py --timeout 120 python3 scripts/test-acp-reload-compaction-count.py zig-out/bin/graff && python3 scripts/eval/process_guard.py --timeout 90 python3 scripts/test-acp-session-fork.py zig-out/bin/graff && python3 scripts/eval/process_guard.py --timeout 90 python3 scripts/test-acp-resume-identity.py zig-out/bin/graff && python3 scripts/eval/process_guard.py --timeout 120 python3 scripts/test-acp-workspace.py zig-out/bin/graff && @@ -266,8 +267,10 @@ if wanted terminal; then if ((!build_ok)); then skip_dependent terminal else - announce terminal "PTY cleanup integrity and line-REPL prompt reflow" - if python3 scripts/eval/test_pty_cleanup.py && python3 scripts/eval/process_guard.py --timeout 90 python3 scripts/test-repl-prompt-reflow.py zig-out/bin/graff; then :; else + announce terminal "PTY cleanup, line-REPL prompt reflow, and live thinking controls" + if python3 scripts/eval/test_pty_cleanup.py && + python3 scripts/eval/process_guard.py --timeout 90 python3 scripts/test-repl-prompt-reflow.py zig-out/bin/graff && + python3 scripts/eval/process_guard.py --timeout 90 python3 scripts/test-repl-thinking.py zig-out/bin/graff; then :; else record_fail terminal fi fi diff --git a/scripts/eval/mock_model.py b/scripts/eval/mock_model.py index 30332a7e..c7d1ee75 100644 --- a/scripts/eval/mock_model.py +++ b/scripts/eval/mock_model.py @@ -32,6 +32,7 @@ import json import socketserver import threading +import time from typing import Any @@ -129,6 +130,8 @@ def _whole(self, reply: dict[str, Any]) -> None: }).encode(), "application/json") def _stream(self, reply: dict[str, Any]) -> None: + self._fragment_bytes = reply.get("stream_fragment_bytes", 0) + self._fragment_delay = reply.get("stream_fragment_delay_ms", 0) / 1000 self.send_response(200) self.send_header("content-type", "text/event-stream") self.send_header("cache-control", "no-cache") @@ -176,7 +179,14 @@ def _stream(self, reply: dict[str, Any]) -> None: def _chunk(self, payload: dict[str, Any]) -> None: framed = dict(payload, id="mock", object="chat.completion.chunk", model="mock") - self.wfile.write(b"data: " + json.dumps(framed).encode() + b"\n\n") + wire = b"data: " + json.dumps(framed).encode() + b"\n\n" + if not self._fragment_bytes: + self.wfile.write(wire) + return + for start in range(0, len(wire), self._fragment_bytes): + self.wfile.write(wire[start:start + self._fragment_bytes]) + self.wfile.flush() + time.sleep(self._fragment_delay) def log_message(self, *_args) -> None: pass diff --git a/scripts/eval/tier1-manifest.json b/scripts/eval/tier1-manifest.json index 61d3f491..de3f3655 100644 --- a/scripts/eval/tier1-manifest.json +++ b/scripts/eval/tier1-manifest.json @@ -14,7 +14,7 @@ "src/main.zig", "src/repl.zig" ], - "test_count_baseline": 3502, + "test_count_baseline": 3587, "test_count_slack": 25, "required_invariants": [ { diff --git a/scripts/test-acp-reload-compaction-count.py b/scripts/test-acp-reload-compaction-count.py new file mode 100644 index 00000000..35c2b597 --- /dev/null +++ b/scripts/test-acp-reload-compaction-count.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""#1686: a reloaded ACP session keeps the provider's count of its compaction state. + +For each first-party OpenAI Responses route (codex over SSE, chatgpt-new over +its default WebSocket transport), against a scripted loopback mock: + +1. process A: the first reply carries an 8 MB opaque compaction item and the + mock reports 80,000 input tokens; one warm follow-up lets graff count it; +2. process B: a NEW graff process loads the session, `/help` (no model call) + reports the restored meter, then one ordinary prompt runs. + +Asserted: the saved session records the count; the restored meter stays at +the counted ~80k instead of bytes/16 (~508k, 188% of the window); the +prompt after reload needs no forced compaction (threshold 1000); and the wire +follows OpenAI's compaction guide (store=false, the compaction item replayed +as-is). Offline: fake credentials, no provider calls. + +Usage: python3 scripts/test-acp-reload-compaction-count.py [path/to/graff] +""" +import json, os, queue, signal, subprocess, sys, tempfile, threading, time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "scripts")) +from codex_ws_mock import CodexMock # noqa: E402 + +GRAFF = Path(sys.argv[1] if len(sys.argv) > 1 else REPO / "zig-out/bin/graff").resolve() +BLOB = "A" * 8_000_000 +USAGE = {"input_tokens": 80_000, "input_tokens_details": {"cached_tokens": 0}, "output_tokens": 1, "total_tokens": 80_001} + + +def threshold(body): + for cm in body.get("context_management") or []: + if isinstance(cm, dict) and cm.get("type") == "compaction": + return cm.get("compact_threshold") + return None + + +class Script: + """The mock emits the state once (as an in-stream compaction would), then plain replies.""" + + def __init__(self): + self.emitted = False + + def __call__(self, req): + n = req.ordinal + compaction = {"type": "compaction", "id": "cmp_1", "encrypted_content": BLOB} + reply = {"type": "message", "id": f"msg_{n}", "status": "completed", "role": "assistant", + "content": [{"type": "output_text", "text": "ok", "annotations": []}]} + if threshold(req.body) == 1000: + out = [compaction] + elif not self.emitted: + self.emitted = True + out = [compaction, reply] + else: + out = [reply] + events = [{"type": "response.output_item.done", "output_index": i, "item": it} for i, it in enumerate(out)] + events.append({"type": "response.completed", "response": {"id": f"resp_{n}", "output": out, "usage": USAGE}}) + return events + + +class Acp: + def __init__(self, cwd, env, model): + self.err = open(cwd / f"acp-{time.time_ns()}.stderr", "w") + self.proc = subprocess.Popen([str(GRAFF), "acp", "--yolo", "--model", model], cwd=cwd, env=env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.err, + start_new_session=True) + self.chunks, self.pending, self.next_id, self.events = queue.SimpleQueue(), b"", 0, [] + threading.Thread(target=self._read, daemon=True).start() + + def _read(self): + try: + while chunk := os.read(self.proc.stdout.fileno(), 1 << 20): + self.chunks.put(chunk) + except OSError: + pass + self.chunks.put(None) + + def request(self, method, params, timeout=90): + self.next_id += 1 + target = self.next_id + self.proc.stdin.write((json.dumps({"jsonrpc": "2.0", "id": target, "method": method, "params": params}) + "\n").encode()) + self.proc.stdin.flush() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if b"\n" not in self.pending: + try: + chunk = self.chunks.get(timeout=.1) + except queue.Empty: + continue + if chunk is None: + raise AssertionError(f"ACP exited during {method}") + self.pending += chunk + while b"\n" in self.pending: + line, self.pending = self.pending.split(b"\n", 1) + item = json.loads(line) + self.events.append(item) + if item.get("id") == target and "method" not in item: + return item + raise AssertionError(f"ACP {method} timed out") + + def prompt(self, sid, text): + mark = len(self.events) + r = self.request("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": text}]}) + assert (r.get("result") or {}).get("stopReason") == "end_turn", (text, r) + return [(u.get("used"), u.get("size")) for e in self.events[mark:] + for u in [((e.get("params") or {}).get("update") or {})] if u.get("sessionUpdate") == "usage_update"] + + def close(self): + try: + self.proc.stdin.close() + self.proc.wait(timeout=10) + except Exception: + os.killpg(self.proc.pid, signal.SIGKILL) + self.proc.wait(timeout=5) + self.err.close() + + +def run(route, tmp): + script = Script() + mock = CodexMock(events_for_request=script) + port = mock.start() + try: + home, cwd = tmp / route / "home", tmp / route / "work" + cwd.mkdir(parents=True) + (home / ".harness").mkdir(parents=True) + (home / ".harness" / "settings.json").write_text(json.dumps({"ai_title": False, "skills": {"codedbpro": False}})) + (tmp / "mcp.json").write_text('{"mcpServers":{}}') + env = {k: v for k, v in os.environ.items() if not k.endswith("_API_KEY") and not k.startswith(("GRAFF_", "CODEX_", "HARNESS_"))} + env.update(HOME=str(home), NO_COLOR="1", GRAFF_FLEET="off", GRAFF_NO_TELEMETRY="1", GRAFF_LEARN_AUTO="off", + GRAFF_BEHAVIOR_UPLOAD="off", GRAFF_MCP_CONFIG=str(tmp / "mcp.json")) + if route == "codex": + codex_home = home / "codex" + codex_home.mkdir() + (codex_home / "auth.json").write_text(json.dumps({"tokens": {"access_token": "mock", "account_id": "acct"}})) + env.update(CODEX_HOME=str(codex_home), GRAFF_CODEX_WS="off", + GRAFF_CODEX_URL=f"http://127.0.0.1:{port}/backend-api/codex/responses") + model = "codex/gpt-5.5" + else: + creds = home / ".graff" / "credentials" + creds.mkdir(parents=True, mode=0o700) + record = creds / "chatgpt-new.json" + record.write_text(json.dumps({"access_token": "mock", "scopes": ["chatgpt.tokens.use.direct"], + "expires_at": int(time.time()) + 86_400})) + record.chmod(0o600) + env.update(GRAFF_CHATGPT_URL=f"http://127.0.0.1:{port}/v1/responses") + model = "chatgpt-new/gpt-6.1-sol" + new = {"cwd": str(cwd), "mcpServers": []} + + a = Acp(cwd, env, model) + a.request("initialize", {"protocolVersion": 1}) + sid = a.request("session/new", new)["result"]["sessionId"] + a.prompt(sid, "start") + warm = a.prompt(sid, "warm follow-up") + a.close() + saved = [json.loads(f.read_text()) for f in (cwd / ".graff" / "sessions").glob("*.session.json")] + assert len(saved) == 1, saved + count = saved[0].get("opaque_state_count") or {} + assert count.get("state_bytes") == len(BLOB) and count.get("tokens", 0) > 0, count + + b = Acp(cwd, env, model) + b.request("initialize", {"protocolVersion": 1}) + b.request("session/new", new) + b.request("session/load", {"sessionId": sid, **new}) + before = len(mock.recorded_requests()) + restored = b.prompt(sid, "/help") + assert len(mock.recorded_requests()) == before, "an informational command called the model" + b.prompt(sid, "after reload") + b.close() + after = mock.recorded_requests()[before:] + + used, size = restored[-1] + warm_used = warm[-1][0] + assert used <= warm_used + 1_000, f"{route}: restored meter {used}/{size}, warm {warm_used} (bytes/16 would be ~508k)" + assert not any(threshold(q.body) == 1000 for q in after), f"{route}: reload forced a compaction" + assert len(after) == 1, f"{route}: {len(after)} requests after reload" + for q in mock.recorded_requests(): + assert q.body.get("store") is False, (route, q.ordinal, q.body.get("store")) + for item in q.body.get("input") or []: + if isinstance(item, dict) and item.get("type") == "compaction": + assert item == {"type": "compaction", "id": "cmp_1", "encrypted_content": BLOB}, f"{route}: compaction item altered" + print(f"ok {route}: warm {warm_used}/{size}, restored {used}/{size}, {len(after)} request after reload, no forced compaction") + finally: + mock.stop() + + +def main(): + with tempfile.TemporaryDirectory() as tmp: + for route in ("codex", "chatgpt-new"): + run(route, Path(tmp)) + + +if __name__ == "__main__": + main() diff --git a/scripts/test-acp-session-load.py b/scripts/test-acp-session-load.py index c410861c..ca959925 100644 --- a/scripts/test-acp-session-load.py +++ b/scripts/test-acp-session-load.py @@ -50,6 +50,28 @@ def _read_stdout(self): finally: self.chunks.put(None) + def exit_detail(self): + """How graff ended: its exit status (a signal by name) and the tail of + its stderr, so a crash in CI names its cause instead of only that it + happened.""" + try: + code = self.proc.wait(timeout=2) + except subprocess.TimeoutExpired: + code = None + how = f"exit {code}" + if code is not None and code < 0: + try: + how += f" ({signal.Signals(-code).name})" + except ValueError: + pass + self.err.flush() + try: + with open(self.err.name, errors="replace") as handle: + tail = handle.read()[-2000:] + except OSError: + tail = "" + return f"{how}; stderr tail:\n{tail}" + def request(self, method, params=None, timeout=25): self.next_id += 1 target = self.next_id @@ -64,7 +86,7 @@ def request(self, method, params=None, timeout=25): except queue.Empty: continue if chunk is None: - raise AssertionError(f"ACP exited during {method}") + raise AssertionError(f"ACP exited during {method}: {self.exit_detail()}") self.pending += chunk while b"\n" in self.pending: line, self.pending = self.pending.split(b"\n", 1) diff --git a/scripts/test-claude-agent.py b/scripts/test-claude-agent.py new file mode 100755 index 00000000..fe0ef671 --- /dev/null +++ b/scripts/test-claude-agent.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Claude Agent (ADR 0309) end to end against a fake `claude`, offline. + +The fake speaks the Agent SDK control protocol the way `claude` 2.1.x does: +it answers `initialize`, lists graff's tools over the tunnelled MCP server, +streams a response that calls `write_file`, holds the call until graff's own +loop has run it, then answers. Asserts: + +- graff's tools reach the model as the `graff` SDK server; the user line is + client-composed and graff's system prompt replaces Claude Code's +- the held `tools/call` is answered with graff's real result, matched by + `_meta["claudecode/toolUseId"]`, and the file graff wrote exists +- the second step reuses the same `claude` process (one spawn per run) +- Claude Code's own login is never read: the fake sees no graff credential + +A second run has the model call `rlm` with `llm_query`: the sub-query runs on +its own tools-off `claude` process and its answer reaches the held call, +while the chat's process carries on (no restart, one process each). +""" +import json, os, shutil, subprocess, sys, tempfile +from pathlib import Path + +FAKE = r'''#!/usr/bin/env python3 +import json, os, sys +log = open(os.environ["FAKE_CLAUDE_LOG"], "a") +def note(kind, obj): log.write(json.dumps({"kind": kind, "obj": obj}) + "\n"); log.flush() +note("argv", sys.argv[1:]) +note("env", {k: v for k, v in os.environ.items() if k.startswith("CLAUDE") or k in ("MCP_TOOL_TIMEOUT", "DISABLE_AUTO_COMPACT")}) +def out(obj): sys.stdout.write(json.dumps(obj) + "\n"); sys.stdout.flush() +def read(): + line = sys.stdin.readline() + if not line: sys.exit(0) + obj = json.loads(line); note("in", obj); return obj +def ev(e): out({"type": "stream_event", "parent_tool_use_id": None, "event": e}) +def mcp(rid, msg): + out({"type": "control_request", "request_id": rid, "request": {"subtype": "mcp_message", "server_name": "graff", "message": msg}}) + return read() +init = read() +usage = {"input_tokens": 10, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0, "output_tokens": 1} +def ready(): out({"type": "control_response", "response": {"subtype": "success", "request_id": init["request_id"], "response": {"claude_code_version": "2.1.304", "models": []}}}) +def answer(msg_id, parts): + ev({"type": "message_start", "message": {"id": msg_id, "type": "message", "role": "assistant", "model": "claude-test", "content": [], "usage": usage}}) + ev({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}) + for p in parts: ev({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": p}}) + ev({"type": "content_block_stop", "index": 0}) + ev({"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 3}}) + ev({"type": "message_stop"}) + out({"type": "result", "subtype": "success", "is_error": False, "result": "".join(parts)}) +if "sdkMcpServers" not in init["request"]: + # A tools-off side call (llm_query): answer once, then wait to be stopped. + note("mode", "side"); ready(); read() + answer("msg_side", ["SUB-", "ANSWER-42"]) + while True: read() +note("mode", "main") +# As claude does: connect the SDK server before answering initialize. +mcp("m1", {"method": "initialize", "params": {"protocolVersion": "2025-11-25"}, "jsonrpc": "2.0", "id": 0}) +mcp("m2", {"method": "tools/list", "jsonrpc": "2.0", "id": 1}) +ready() +user = read() +if os.environ.get("FAKE_SCENARIO") == "llm_query": + name, args = "rlm", {"code": 'a = llm_query("what is six times seven")\nprint(a)'} +else: + name, args = "write_file", {"path": "out.txt", "content": "from claude agent\n"} +ev({"type": "message_start", "message": {"id": "msg_1", "type": "message", "role": "assistant", "model": "claude-test", "content": [], "usage": usage}}) +ev({"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_1", "name": "mcp__graff__" + name, "input": {}}}) +ev({"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": json.dumps(args)}}) +ev({"type": "content_block_stop", "index": 0}) +ev({"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": None}, "usage": {"output_tokens": 20}}) +ev({"type": "message_stop"}) +reply = mcp("m3", {"method": "tools/call", "params": {"name": name, "arguments": args, "_meta": {"claudecode/toolUseId": "toolu_1"}}, "jsonrpc": "2.0", "id": 2}) +ev({"type": "message_start", "message": {"id": "msg_2", "type": "message", "role": "assistant", "model": "claude-test", "content": [], "usage": usage}}) +ev({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}) +ev({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "FAKE-DONE"}}) +ev({"type": "content_block_stop", "index": 0}) +ev({"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 3}}) +ev({"type": "message_stop"}) +out({"type": "result", "subtype": "success", "is_error": False, "result": "FAKE-DONE"}) +while True: read() +''' + + +def main(): + binary = str(Path(sys.argv[1] if len(sys.argv) > 1 else 'zig-out/bin/graff').resolve()) + if os.name == 'nt': + print('SKIP claude-agent fake child needs a POSIX shebang', flush=True) + return + temp = Path(tempfile.mkdtemp(prefix='graff-claude-agent-')) + try: + fake = temp / 'bin' / 'claude' + fake.parent.mkdir() + fake.write_text(FAKE) + fake.chmod(0o755) + log = temp / 'fake.log' + work = temp / 'proj' + work.mkdir() + mcp_off = temp / 'mcp.json' + mcp_off.write_text('{"mcpServers": {}}') + env = {k: v for k, v in os.environ.items() if not k.endswith('_API_KEY') and not k.startswith('CLAUDE')} + env.update(HOME=str(temp), USERPROFILE=str(temp), GRAFF_NO_TELEMETRY='1', GRAFF_FLEET='off', GRAFF_NO_SMOLIFY='1', + GRAFF_NO_CODEDB_GUARD='1', GRAFF_MCP_CONFIG=str(mcp_off), GRAFF_CLAUDE_AGENT='1', + GRAFF_CLAUDE_PATH=str(fake), FAKE_CLAUDE_LOG=str(log), + # An enclosing Claude Code session's identity must not reach the child. + CLAUDECODE='1', CLAUDE_CODE_ENTRYPOINT='cli', CLAUDE_CONFIG_DIR=str(temp / 'cc')) + run = subprocess.run([binary, '--model', 'claude-agent/sonnet', '--yolo', '-p', 'write the file'], cwd=work, env=env, + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=120) + assert run.returncode == 0, run.stderr[-2000:] + assert 'FAKE-DONE' in run.stdout, (run.stdout[-500:], run.stderr[-1500:]) + assert (work / 'out.txt').read_text() == 'from claude agent\n' + rows = [json.loads(l) for l in log.read_text().splitlines() if l.strip()] + spawns = [r['obj'] for r in rows if r['kind'] == 'argv'] + assert len(spawns) == 1, f'expected one claude process, got {len(spawns)}' + argv = spawns[0] + assert argv[argv.index('--tools') + 1] == '' and argv[argv.index('--allowedTools') + 1] == 'mcp__graff__*', argv + assert argv[argv.index('--model') + 1] == 'sonnet' and '--no-session-persistence' in argv, argv + child_env = [r['obj'] for r in rows if r['kind'] == 'env'][0] + assert 'CLAUDECODE' not in child_env and 'CLAUDE_CODE_ENTRYPOINT' not in child_env, child_env + assert child_env.get('CLAUDE_CONFIG_DIR') == str(temp / 'cc'), child_env + assert child_env.get('DISABLE_AUTO_COMPACT') == '1', child_env + inputs = [r['obj'] for r in rows if r['kind'] == 'in'] + init = inputs[0]['request'] + assert init['subtype'] == 'initialize' and init['sdkMcpServers'] == ['graff'], init + assert init['systemPrompt'] and 'appendSystemPrompt' not in init, init + replies = {i['response']['request_id']: i['response'] for i in inputs if i.get('type') == 'control_response'} + listed = replies['m2']['response']['mcp_response']['result']['tools'] + assert any(t['name'] == 'write_file' and 'inputSchema' in t for t in listed), [t['name'] for t in listed] + user = [i for i in inputs if i.get('type') == 'user'][0] + assert user['client_composed'] is True and user['message']['content'][0]['text'] == 'write the file', user + call = replies['m3']['response']['mcp_response'] + assert call['id'] == 2 and not call['result'].get('isError'), call + assert 'out.txt' in call['result']['content'][0]['text'], call + print('PASS claude-agent: graff tools as the SDK server, a held call answered with graff\'s result, one process', flush=True) + llm_query_case(binary, temp, env) + finally: + shutil.rmtree(temp, ignore_errors=True) + + +def llm_query_case(binary, temp, base_env): + """rlm's llm_query on Claude Agent: a throwaway tools-off process answers + it, and the chat's own process is neither restarted nor shared.""" + log = temp / 'llm_query.log' + work = temp / 'proj-llm-query' + work.mkdir() + env = dict(base_env, FAKE_CLAUDE_LOG=str(log), FAKE_SCENARIO='llm_query') + run = subprocess.run([binary, '--model', 'claude-agent/sonnet', '--yolo', '-p', 'ask the sub-model'], cwd=work, env=env, + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=120) + assert run.returncode == 0, run.stderr[-2000:] + assert 'FAKE-DONE' in run.stdout, (run.stdout[-500:], run.stderr[-1500:]) + rows = [json.loads(l) for l in log.read_text().splitlines() if l.strip()] + inputs = [r['obj'] for r in rows if r['kind'] == 'in'] + replies = {i['response']['request_id']: i['response'] for i in inputs if i.get('type') == 'control_response'} + call = replies['m3']['response']['mcp_response'] + text = call['result']['content'][0]['text'] + assert not call['result'].get('isError') and 'SUB-ANSWER-42' in text, call + modes = [r['obj'] for r in rows if r['kind'] == 'mode'] + assert sorted(modes) == ['main', 'side'], f'expected the chat process and one side process, got {modes}' + side_argv = [r['obj'] for r in rows if r['kind'] == 'argv' and '--allowedTools' not in r['obj']] + assert len(side_argv) == 1 and side_argv[0][side_argv[0].index('--model') + 1] == 'sonnet', side_argv + side_init = [i['request'] for i in inputs if i.get('type') == 'control_request' and 'sdkMcpServers' not in i['request']] + assert len(side_init) == 1 and side_init[0]['systemPrompt'], side_init + users = [i for i in inputs if i.get('type') == 'user'] + assert any('what is six times seven' in json.dumps(u['message']['content']) for u in users), users + print('PASS claude-agent: llm_query runs on its own tools-off process; the chat\'s process carries on', flush=True) + + +if __name__ == '__main__': + main() diff --git a/scripts/test-mcp-async-start.py b/scripts/test-mcp-async-start.py index 6e53747a..e6131233 100644 --- a/scripts/test-mcp-async-start.py +++ b/scripts/test-mcp-async-start.py @@ -4,7 +4,8 @@ An interactive graff (no --yolo) in a pty, one project MCP server that sleeps before answering `initialize`, and a scripted model. After the consent prompt is answered, the first message reaches the model while the handshake is still -running, and the request after it finishes names the server as connected. +running. Its tools join the next request in quiet and debug modes; only debug +mode prints the connection receipt. """ import json, os, select, sys, tempfile, time from pathlib import Path @@ -41,12 +42,13 @@ def __init__(self, *a, **k): self.arrivals = [] def next_reply(self, body): + if not body.get("tools"): + return {"text": "Fixture session"} self.arrivals.append(time.monotonic()) return super().next_reply(body) -def main(): - binary = str(Path(sys.argv[1] if len(sys.argv) > 1 else "zig-out/bin/graff").resolve()) +def run(binary, debug): model = Timed([{"text": "ok"}, {"text": "ok again"}]) port = model.start(0) with tempfile.TemporaryDirectory(prefix="graff-mcp-async-") as tmp: @@ -57,11 +59,14 @@ def main(): (work / ".mcp.json").write_text(json.dumps({"mcpServers": {"slow": { "command": sys.executable, "args": [str(root / "server.py"), str(HANDSHAKE_S)]}}})) # NO_COLOR (zig build sets it) turns off the interactive consent path. - env = {k: v for k, v in os.environ.items() if not k.endswith("_API_KEY") and k != "NO_COLOR"} + env = {k: v for k, v in os.environ.items() if not k.endswith("_API_KEY") + and k not in ("NO_COLOR", "GRAFF_REPL_DEBUG", "GRAFF_BOOT_DEBUG")} env.update(HOME=str(root), AI_GATEWAY_API_KEY="local", GRAFF_VERCEL_URL=f"http://127.0.0.1:{port}/v1/chat/completions", GRAFF_NO_PLUGINS="1", GRAFF_MCP_PROBE="0", GRAFF_FLEET="off", GRAFF_NO_SMOLIFY="1", GRAFF_NO_CODEDB_GUARD="1", GRAFF_NO_TELEMETRY="1", GRAFF_BEHAVIOR_TRACE="0", TERM="xterm-256color", COLUMNS="120", LINES="40") + if debug: + env["GRAFF_REPL_DEBUG"] = "1" pid, fd = pty.fork() if pid == 0: # A fresh pty is 0x0 on Linux; give the TUI a real screen. @@ -94,16 +99,29 @@ def read_until(pred, limit): time.sleep(max(0.0, HANDSHAKE_S + 1.0 - (time.monotonic() - consent))) os.write(fd, b"again\r") - assert read_until(lambda: b"slow connected" in out, 20), "no notice that the slow server joined:\n" + out[-800:].decode("utf-8", "replace") - print("PASS the request after the handshake names the server as connected", flush=True) + assert read_until(lambda: len(model.requests) >= 2 and b"ok again" in out, 20), "the second request did not finish" + assert "slow_echo" not in json.dumps(model.requests[0]), "the first request waited for the slow catalog" + assert "slow_echo" in json.dumps(model.requests[1]), "the slow server's tools did not join the next request" + if debug: + assert out.count(b"slow connected") == 1, "debug mode must name the joined server once" + else: + assert b"mcp: slow" not in out and b"MCP still connecting" not in out, "normal startup exposed connection diagnostics" + print("PASS the catalog joins without blocking; receipts are " + ("visible in debug mode" if debug else "quiet by default"), flush=True) finally: try: os.kill(pid, 9) os.waitpid(pid, 0) except (ProcessLookupError, ChildProcessError): pass + os.close(fd) model.stop() +def main(): + binary = str(Path(sys.argv[1] if len(sys.argv) > 1 else "zig-out/bin/graff").resolve()) + for debug in (False, True): + run(binary, debug) + + if __name__ == "__main__": main() diff --git a/scripts/test-repl-thinking.py b/scripts/test-repl-thinking.py new file mode 100644 index 00000000..f98b742b --- /dev/null +++ b/scripts/test-repl-thinking.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Offline real-terminal check of discoverable live thinking fold controls.""" +from pathlib import Path +import json +import os +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +sys.path.insert(0, str(ROOT / "scripts/eval")) +from pty_harness import PtySession +from frontend_model import FrontendModel + + +def run(binary): + deltas = [{"role": "assistant", "reasoning_content": "Sample work.\n"}] + deltas.extend({"reasoning_content": "."} for _ in range(120)) + deltas.append({"content": "THINKING_DEMO_COMPLETE"}) + requests_dir = tempfile.TemporaryDirectory(prefix="graff-thinking-requests-") + model = FrontendModel([{"stream_deltas": deltas, "finish": "stop"}], + Path(requests_dir.name) / "requests.json") + model.after_stream_delta = lambda _: time.sleep(0.04) + port = model.start(0) + try: + with tempfile.TemporaryDirectory(prefix="graff-thinking-") as temporary: + base = Path(temporary) + home = base / "home" + work = base / "workspace" + (home / ".harness").mkdir(parents=True) + work.mkdir() + (home / ".harness/settings.json").write_text(json.dumps({ + "ai_title": False, "skills": {"codedbpro": False}, + })) + config = home / "empty-mcp.json" + config.write_text('{"mcpServers":{}}') + env = { + "HOME": str(home), "AI_GATEWAY_API_KEY": "local-fixture", + "GRAFF_VERCEL_URL": f"http://127.0.0.1:{port}/v1/chat/completions", + "GRAFF_FLEET": "off", "GRAFF_LEARN_AUTO": "off", + "GRAFF_MCP_CONFIG": str(config), "GRAFF_NO_TELEMETRY": "1", + "GRAFF_NO_SMOLIFY": "1", "GRAFF_BEHAVIOR_UPLOAD": "off", + "NO_PROXY": "*", "no_proxy": "*", + } + env.update({key: "" for key in ( + "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", + "http_proxy", "https_proxy", "all_proxy", + )}) + unset = tuple(key for key in os.environ if key not in env and ( + key.startswith(("GRAFF_", "HARNESS_")) or + any(part in key.upper() for part in ("API_KEY", "ACCESS_TOKEN", "AUTH_TOKEN")) + )) + ("CODEX_HOME", "NO_COLOR") + with PtySession(str(binary), ["--model", "vercel", "--old", "--yolo", "--no-telemetry"], + cwd=str(work), env=env, unset_env=unset, + color=True, timeout=15, rows=30, cols=80) as session: + session.wait_for_prompt() + for diagnostic in ("possible pre-registry listener", "[boot +", "companion:"): + assert diagnostic not in session.text, "normal startup exposed diagnostics: " + diagnostic + cursor = len(session.raw) + session.send_line("/thinking on") + session.wait_for_prompt(start=cursor) + cursor = len(session.raw) + session.send_line("Reply briefly to this sample request.") + session.wait_for_literal("Ctrl+T collapse", start=cursor) + cursor = len(session.raw) + session.send_text("\x14") + session.wait_for_literal("Ctrl+T expand", start=cursor) + cursor = len(session.raw) + session.send_text("\x14") + session.wait_for_literal("Ctrl+T collapse", start=cursor) + session.wait_for_literal("THINKING_DEMO_COMPLETE", start=cursor) + session.wait_for_prompt(start=cursor) + session.send_text("\x04") + result = session.read_until_exit(5) + assert not result.timed_out and result.exit_code == 0, result.text + assert len(model.requests) == 1, "unexpected extra fixture requests" + print("PASS: quiet startup; visible Ctrl+T collapse/expand; live replay; clean exit") + finally: + model.stop() + requests_dir.cleanup() + + +if __name__ == "__main__": + run(Path(sys.argv[1] if len(sys.argv) > 1 else ROOT / "zig-out/bin/graff").resolve()) diff --git a/sdk/py/harness_sdk.py b/sdk/py/harness_sdk.py index 571cf349..6c62cbd2 100644 --- a/sdk/py/harness_sdk.py +++ b/sdk/py/harness_sdk.py @@ -15,7 +15,7 @@ MODELS = ["MiniMax-M2.5", "MiniMax-M2.7", "MiniMax-M3", "accounts/fireworks/models/deepseek-v4-flash", "accounts/fireworks/models/deepseek-v4-pro", "accounts/fireworks/models/glm-5p2", "accounts/fireworks/models/gpt-oss-120b", "accounts/fireworks/models/kimi-k2p6", "accounts/fireworks/models/kimi-k2p7-code", "accounts/fireworks/models/minimax-m3", "accounts/fireworks/models/qwen3p7-plus", "alibaba/qwen3.8-27b", "anthropic/claude-sonnet-4.6", "claude-fable-5", "claude-fable-5-1", "claude-haiku-5-5", "claude-opus-4-5", "claude-opus-4-6", "claude-opus-4-7", "claude-opus-4-8", "claude-opus-4.8", "claude-opus-5", "claude-opus-5-5", "claude-sonnet-4-5", "claude-sonnet-4-6", "claude-sonnet-4.6", "claude-sonnet-5", "claude-sonnet-5-5", "deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash", "deepseek-v4-pro", "flint", "fugu", "fugu-ultra", "fugu-ultra-20260615", "gemini-3-flash-preview", "gemini-3.1-flash-lite", "gemini-3.1-pro-preview", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash", "gemini-4-argon", "gemma-4-26b-a4b-it", "gemma-4-31b", "gemma-4-31b-it", "glm-4.5", "glm-4.7", "glm-5", "glm-5-turbo", "glm-5.2", "glm-5.3", "glm-5.3-flash", "glm-5v-turbo", "gpt-5-codex", "gpt-5.2", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-pro", "gpt-5.5", "gpt-5.6", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-6-astra", "gpt-6-luna", "gpt-6-sol", "gpt-6.1-sol", "gpt-oss-120b", "grok-4.3", "grok-4.6", "grok-4.7", "grok-build", "k3", "kilo-auto/small", "kimi-for-coding", "kimi-for-coding-highspeed", "kimi-k2.6", "kimi-k2.7-code", "kimi-k3", "kimi-latest", "lmstudio", "mimo-v2-flash", "mimo-v2.5", "mimo-v2.5-pro", "mimo-v2.5-pro-ultraspeed", "mimo-v2.6-flash", "mimo-v2.6-pro", "mimo-v2.6-pro-ultraspeed", "minimax-m3", "mistral-medium-latest", "mlx-community/Qwen3.6-27B-OptiQ-4bit", "muse-spark-1.2", "muse-spark-1.2-contributor", "omlx", "openai/gpt-oss-120b"] TOOLS = ["shell", "read_file", "edit_file", "write_file", "webfetch", "skill", "codedb", "read_tool_result", "render_html", "todo_write", "todo_read", "eval", "note_constraint", "ask_user", "attempt_completion", "load_tool_schemas", "mcp_search_tools", "mcp_select_tool", "clock_sleep", "subagent", "workflow", "agent_output", "agent_message", "subagent_resume", "learn_candidate", "peer_message", "workspace", "yield_turn", "imagegen", "rlm"] -PROVIDERS = ["anthropic", "codegraff", "deepseek", "openai", "google", "minimax", "xiaomi", "kilo", "groq", "cerebras", "mistral", "kimi", "moonshot", "xai", "zai", "meta", "vercel", "openrouter", "fugu", "fireworks", "mlx", "lmstudio", "omlx", "chatgpt-new", "codex"] +PROVIDERS = ["anthropic", "codegraff", "deepseek", "openai", "google", "minimax", "xiaomi", "kilo", "groq", "cerebras", "mistral", "kimi", "moonshot", "xai", "zai", "meta", "vercel", "openrouter", "fugu", "fireworks", "mlx", "lmstudio", "omlx", "chatgpt-new", "codex", "claude-agent"] class ImageUrlInput(TypedDict): diff --git a/sdk/ts/harness.ts b/sdk/ts/harness.ts index 8dcf6ae1..904687d6 100644 --- a/sdk/ts/harness.ts +++ b/sdk/ts/harness.ts @@ -13,7 +13,7 @@ export const HARNESS_VERSION = "0.13"; export type ModelName = "MiniMax-M2.5" | "MiniMax-M2.7" | "MiniMax-M3" | "accounts/fireworks/models/deepseek-v4-flash" | "accounts/fireworks/models/deepseek-v4-pro" | "accounts/fireworks/models/glm-5p2" | "accounts/fireworks/models/gpt-oss-120b" | "accounts/fireworks/models/kimi-k2p6" | "accounts/fireworks/models/kimi-k2p7-code" | "accounts/fireworks/models/minimax-m3" | "accounts/fireworks/models/qwen3p7-plus" | "alibaba/qwen3.8-27b" | "anthropic/claude-sonnet-4.6" | "claude-fable-5" | "claude-fable-5-1" | "claude-haiku-5-5" | "claude-opus-4-5" | "claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8" | "claude-opus-4.8" | "claude-opus-5" | "claude-opus-5-5" | "claude-sonnet-4-5" | "claude-sonnet-4-6" | "claude-sonnet-4.6" | "claude-sonnet-5" | "claude-sonnet-5-5" | "deepseek-chat" | "deepseek-reasoner" | "deepseek-v4-flash" | "deepseek-v4-pro" | "flint" | "fugu" | "fugu-ultra" | "fugu-ultra-20260615" | "gemini-3-flash-preview" | "gemini-3.1-flash-lite" | "gemini-3.1-pro-preview" | "gemini-3.5-flash" | "gemini-3.5-flash-lite" | "gemini-3.6-flash" | "gemini-3.7-flash" | "gemini-3.8-flash" | "gemini-4-argon" | "gemma-4-26b-a4b-it" | "gemma-4-31b" | "gemma-4-31b-it" | "glm-4.5" | "glm-4.7" | "glm-5" | "glm-5-turbo" | "glm-5.2" | "glm-5.3" | "glm-5.3-flash" | "glm-5v-turbo" | "gpt-5-codex" | "gpt-5.2" | "gpt-5.3-codex-spark" | "gpt-5.4" | "gpt-5.4-mini" | "gpt-5.4-pro" | "gpt-5.5" | "gpt-5.6" | "gpt-5.6-luna" | "gpt-5.6-sol" | "gpt-5.6-terra" | "gpt-6-astra" | "gpt-6-luna" | "gpt-6-sol" | "gpt-6.1-sol" | "gpt-oss-120b" | "grok-4.3" | "grok-4.6" | "grok-4.7" | "grok-build" | "k3" | "kilo-auto/small" | "kimi-for-coding" | "kimi-for-coding-highspeed" | "kimi-k2.6" | "kimi-k2.7-code" | "kimi-k3" | "kimi-latest" | "lmstudio" | "mimo-v2-flash" | "mimo-v2.5" | "mimo-v2.5-pro" | "mimo-v2.5-pro-ultraspeed" | "mimo-v2.6-flash" | "mimo-v2.6-pro" | "mimo-v2.6-pro-ultraspeed" | "minimax-m3" | "mistral-medium-latest" | "mlx-community/Qwen3.6-27B-OptiQ-4bit" | "muse-spark-1.2" | "muse-spark-1.2-contributor" | "omlx" | "openai/gpt-oss-120b" | (string & {}); export type ToolName = "shell" | "read_file" | "edit_file" | "write_file" | "webfetch" | "skill" | "codedb" | "read_tool_result" | "render_html" | "todo_write" | "todo_read" | "eval" | "note_constraint" | "ask_user" | "attempt_completion" | "load_tool_schemas" | "mcp_search_tools" | "mcp_select_tool" | "clock_sleep" | "subagent" | "workflow" | "agent_output" | "agent_message" | "subagent_resume" | "learn_candidate" | "peer_message" | "workspace" | "yield_turn" | "imagegen" | "rlm"; -export type ProviderId = "anthropic" | "codegraff" | "deepseek" | "openai" | "google" | "minimax" | "xiaomi" | "kilo" | "groq" | "cerebras" | "mistral" | "kimi" | "moonshot" | "xai" | "zai" | "meta" | "vercel" | "openrouter" | "fugu" | "fireworks" | "mlx" | "lmstudio" | "omlx" | "chatgpt-new" | "codex"; +export type ProviderId = "anthropic" | "codegraff" | "deepseek" | "openai" | "google" | "minimax" | "xiaomi" | "kilo" | "groq" | "cerebras" | "mistral" | "kimi" | "moonshot" | "xai" | "zai" | "meta" | "vercel" | "openrouter" | "fugu" | "fireworks" | "mlx" | "lmstudio" | "omlx" | "chatgpt-new" | "codex" | "claude-agent"; /** Events streamed by `harness --json` (one JSON object per stdout line), each * discriminated by `type` — the analogue of codegraff's AgentEvent stream. diff --git a/sdk/ts/remote.ts b/sdk/ts/remote.ts index 8d8df43a..abfc3d74 100644 --- a/sdk/ts/remote.ts +++ b/sdk/ts/remote.ts @@ -11,7 +11,7 @@ export const HARNESS_VERSION = "0.13"; export type ModelName = "MiniMax-M2.5" | "MiniMax-M2.7" | "MiniMax-M3" | "accounts/fireworks/models/deepseek-v4-flash" | "accounts/fireworks/models/deepseek-v4-pro" | "accounts/fireworks/models/glm-5p2" | "accounts/fireworks/models/gpt-oss-120b" | "accounts/fireworks/models/kimi-k2p6" | "accounts/fireworks/models/kimi-k2p7-code" | "accounts/fireworks/models/minimax-m3" | "accounts/fireworks/models/qwen3p7-plus" | "alibaba/qwen3.8-27b" | "anthropic/claude-sonnet-4.6" | "claude-fable-5" | "claude-fable-5-1" | "claude-haiku-5-5" | "claude-opus-4-5" | "claude-opus-4-6" | "claude-opus-4-7" | "claude-opus-4-8" | "claude-opus-4.8" | "claude-opus-5" | "claude-opus-5-5" | "claude-sonnet-4-5" | "claude-sonnet-4-6" | "claude-sonnet-4.6" | "claude-sonnet-5" | "claude-sonnet-5-5" | "deepseek-chat" | "deepseek-reasoner" | "deepseek-v4-flash" | "deepseek-v4-pro" | "flint" | "fugu" | "fugu-ultra" | "fugu-ultra-20260615" | "gemini-3-flash-preview" | "gemini-3.1-flash-lite" | "gemini-3.1-pro-preview" | "gemini-3.5-flash" | "gemini-3.5-flash-lite" | "gemini-3.6-flash" | "gemini-3.7-flash" | "gemini-3.8-flash" | "gemini-4-argon" | "gemma-4-26b-a4b-it" | "gemma-4-31b" | "gemma-4-31b-it" | "glm-4.5" | "glm-4.7" | "glm-5" | "glm-5-turbo" | "glm-5.2" | "glm-5.3" | "glm-5.3-flash" | "glm-5v-turbo" | "gpt-5-codex" | "gpt-5.2" | "gpt-5.3-codex-spark" | "gpt-5.4" | "gpt-5.4-mini" | "gpt-5.4-pro" | "gpt-5.5" | "gpt-5.6" | "gpt-5.6-luna" | "gpt-5.6-sol" | "gpt-5.6-terra" | "gpt-6-astra" | "gpt-6-luna" | "gpt-6-sol" | "gpt-6.1-sol" | "gpt-oss-120b" | "grok-4.3" | "grok-4.6" | "grok-4.7" | "grok-build" | "k3" | "kilo-auto/small" | "kimi-for-coding" | "kimi-for-coding-highspeed" | "kimi-k2.6" | "kimi-k2.7-code" | "kimi-k3" | "kimi-latest" | "lmstudio" | "mimo-v2-flash" | "mimo-v2.5" | "mimo-v2.5-pro" | "mimo-v2.5-pro-ultraspeed" | "mimo-v2.6-flash" | "mimo-v2.6-pro" | "mimo-v2.6-pro-ultraspeed" | "minimax-m3" | "mistral-medium-latest" | "mlx-community/Qwen3.6-27B-OptiQ-4bit" | "muse-spark-1.2" | "muse-spark-1.2-contributor" | "omlx" | "openai/gpt-oss-120b" | (string & {}); export type ToolName = "shell" | "read_file" | "edit_file" | "write_file" | "webfetch" | "skill" | "codedb" | "read_tool_result" | "render_html" | "todo_write" | "todo_read" | "eval" | "note_constraint" | "ask_user" | "attempt_completion" | "load_tool_schemas" | "mcp_search_tools" | "mcp_select_tool" | "clock_sleep" | "subagent" | "workflow" | "agent_output" | "agent_message" | "subagent_resume" | "learn_candidate" | "peer_message" | "workspace" | "yield_turn" | "imagegen" | "rlm"; -export type ProviderId = "anthropic" | "codegraff" | "deepseek" | "openai" | "google" | "minimax" | "xiaomi" | "kilo" | "groq" | "cerebras" | "mistral" | "kimi" | "moonshot" | "xai" | "zai" | "meta" | "vercel" | "openrouter" | "fugu" | "fireworks" | "mlx" | "lmstudio" | "omlx" | "chatgpt-new" | "codex"; +export type ProviderId = "anthropic" | "codegraff" | "deepseek" | "openai" | "google" | "minimax" | "xiaomi" | "kilo" | "groq" | "cerebras" | "mistral" | "kimi" | "moonshot" | "xai" | "zai" | "meta" | "vercel" | "openrouter" | "fugu" | "fireworks" | "mlx" | "lmstudio" | "omlx" | "chatgpt-new" | "codex" | "claude-agent"; /** Events streamed by the bridge (same `--json` contract as the stdio SDK). * `seq` is a monotonic, gap-free, 1-based per-session id the bridge forwards diff --git a/spec/conformance.py b/spec/conformance.py index 09e831c7..539d8eab 100644 --- a/spec/conformance.py +++ b/spec/conformance.py @@ -312,8 +312,8 @@ def check_provider() -> int: ids = [r["id"] for r in PROVIDER_SPECS] if len(ids) != len(set(ids)): raise Counterexample("provider-unique", None, f"ids={ids}") - if len(PROVIDER_SPECS) != 25: - raise Counterexample("provider-count", None, f"n={len(PROVIDER_SPECS)} want=25") + if len(PROVIDER_SPECS) != 26: + raise Counterexample("provider-count", None, f"n={len(PROVIDER_SPECS)} want=26") responses = [r for r in PROVIDER_SPECS if r["kind"] == "responses"] if [r["id"] for r in responses] != ["openai", "chatgpt-new", "codex"]: raise Counterexample("responses-vendors", None, f"{responses}") diff --git a/spec/kernels/providers.json b/spec/kernels/providers.json index 931fc05a..50972f0f 100644 --- a/spec/kernels/providers.json +++ b/spec/kernels/providers.json @@ -226,6 +226,15 @@ "catalog": "codex", "sub_login": true, "takes_effort": false + }, + { + "id": "claude-agent", + "kind": "anthropic", + "auth": "bearer", + "login": "claude_cli", + "catalog": "baked", + "sub_login": true, + "takes_effort": true } ] } diff --git a/spec/ref/provider.py b/spec/ref/provider.py index 30c1f106..2a589615 100644 --- a/spec/ref/provider.py +++ b/spec/ref/provider.py @@ -6,7 +6,7 @@ Kind = Literal["anthropic", "openai", "responses", "interactions"] Auth = Literal["x_api_key", "bearer", "goog_api_key"] -Login = Literal["api_key", "codegraff_device", "codex_device", "kimi_device", "xai_device", "zai_cli", "chatgpt_browser", "anthropic_browser"] +Login = Literal["api_key", "codegraff_device", "codex_device", "kimi_device", "xai_device", "zai_cli", "chatgpt_browser", "anthropic_browser", "claude_cli"] Catalog = Literal["baked", "codex", "kimi", "openai", "anthropic"] @@ -56,6 +56,7 @@ def row( row("omlx", "openai", "bearer", catalog="openai", takes_effort=True), row("chatgpt-new", "responses", "bearer", login="chatgpt_browser", catalog="openai", sub_login=True), row("codex", "responses", "bearer", login="codex_device", catalog="codex", sub_login=True), + row("claude-agent", "anthropic", "bearer", login="claude_cli", sub_login=True, takes_effort=True), ] diff --git a/src/acp.zig b/src/acp.zig index a58cecc5..ba3262f4 100644 --- a/src/acp.zig +++ b/src/acp.zig @@ -126,7 +126,7 @@ fn liveModels(ctx: *anyopaque, arena: Allocator, w: *Io.Writer, req: proto.Reque // Hydrate deferred local metadata without network refresh or MCP startup. @import("acp_model_option.zig").hydrate(live); const er = @import("effort_route.zig"); - if (@import("gateway_picker_catalog.zig").requested(req.params)) @import("gateway_picker_catalog.zig").refresh(root.gpa, root.io, root.arena, keys.*); + if (@import("gateway_picker_catalog.zig").requested(req.params)) @import("gateway_picker_catalog.zig").refresh(root.io, root.arena, root.home, keys.*); const catalog = pricing.models(); const Row = struct { name: []const u8, diff --git a/src/acp_engine.zig b/src/acp_engine.zig index 4f373739..e41e9c14 100644 --- a/src/acp_engine.zig +++ b/src/acp_engine.zig @@ -177,7 +177,7 @@ fn turnError(d: *Dispatch, w: *Io.Writer, req: proto.Request, err: anyerror) !vo return respond(w, req, .{ .stopReason = "max_turn_requests" }); const message = if (d.error_message) |describe| describe(d.ctx, err) else @errorName(err); if (d.error_recovery) |recover| if (recover(d.ctx, err)) |data| { - if (req.id != null) try proto.writeErrorData(w, req.id, err_auth_required, message, data); + if (req.id != null) try proto.writeErrorData(w, req.id, data.rpcCode(), message, data); return; }; return respondError(w, req, err_internal, message); @@ -199,7 +199,7 @@ fn promptTurn(d: *Dispatch, arena: Allocator, w: *Io.Writer, req: proto.Request) if (d.bind_session) |bind| bind(d.ctx, sid); @import("acp_citations.zig").endTurn(sid); // a failed turn must not leave a marker open const config_before = configOptions(d, arena) catch |err| return turnError(d, w, req, err); - const workspace_before = acp_workspace.snapshot(d.workspace, arena); + acp_workspace.snapshot(d.workspace); // Before slash handling: an agent's room line must not run as a command. const flat = try flattenPrompt(arena, if (obj) |o| o.get("prompt") else null); const text = try @import("acp_room.zig").frame(arena, req.params, flat); @@ -211,7 +211,7 @@ fn promptTurn(d: *Dispatch, arena: Allocator, w: *Io.Writer, req: proto.Request) if (reply) |plain| { if (plain.len > 0) try writeSessionUpdate(w, sid, try @import("acp_t3.zig").slashReply(arena, plain)); emitConfigChange(d, arena, w, sid, config_before) catch |config_err| return turnError(d, w, req, config_err); - try acp_workspace.emitChange(d.workspace, arena, w, sid, workspace_before); + try acp_workspace.emitChange(d.workspace, arena, w, sid); try emitMeter(d, w, sid); if (resend_key) |key| resent.finish(key, 0, "end_turn"); return respondStop(d, w, req, sid, "end_turn"); @@ -224,7 +224,7 @@ fn promptTurn(d: *Dispatch, arena: Allocator, w: *Io.Writer, req: proto.Request) }; if (final.len > 0) try writeSessionUpdate(w, sid, final); emitConfigChange(d, arena, w, sid, config_before) catch |config_err| return turnError(d, w, req, config_err); - try acp_workspace.emitChange(d.workspace, arena, w, sid, workspace_before); + try acp_workspace.emitChange(d.workspace, arena, w, sid); try emitMeter(d, w, sid); const extra = if (extra_cancelled) |f| f() else false; const stop: []const u8 = if (cancel_flag.load(.acquire) or extra) "cancelled" else "end_turn"; diff --git a/src/acp_images.zig b/src/acp_images.zig index b7028a46..be8b2b54 100644 --- a/src/acp_images.zig +++ b/src/acp_images.zig @@ -88,6 +88,13 @@ test "stage: a codex model receives ACP images as the next request's input_image try std.testing.expect(std.mem.indexOf(u8, wire, vision_queue.attached_note) != null); // image-only prompt try std.testing.expectEqual(@as(u8, 0), codex.pending_image_len); + // #1762: WebP bytes labelled image/png go out as image/webp. + const mislabeled = try std.json.parseFromSliceLeaky(Value, a, + \\[{"type":"image","data":"UklGRiQAAABXRUJQVlA4IA==","mimeType":"image/png"}] + , .{}); + try std.testing.expectEqual(@as(usize, 1), stage(&codex, mislabeled)); + try std.testing.expectEqualStrings("image/webp", codex.pending_image.?.media_type); + var text_only = testAgent(a, "openrouter", "qwen3.8-27b"); try std.testing.expectEqual(@as(usize, 0), stage(&text_only, prompt)); try std.testing.expectEqual(@as(u8, 0), text_only.pending_image_len); diff --git a/src/acp_live_turn.zig b/src/acp_live_turn.zig index 84d5a02a..3af3b240 100644 --- a/src/acp_live_turn.zig +++ b/src/acp_live_turn.zig @@ -222,6 +222,8 @@ pub const LiveTurn = struct { defer output_lock.unlock(self.root.io); try proto.writeSessionUpdate(self.out, self.session_id, marker); } + // #1729: same row as the REPL, so resending this prompt is a new ask. + try @import("turn_dedup.zig").settleFailedTurn(self.root, arena, err); // #753: an API interruption is a failed turn, not a dead ACP // process. Save so the next prompt (and a respawn --resume) still // sees the tool results and the background-agent ledger. diff --git a/src/acp_reauth_tests.zig b/src/acp_reauth_tests.zig index 6a9eb552..5a2060c4 100644 --- a/src/acp_reauth_tests.zig +++ b/src/acp_reauth_tests.zig @@ -78,7 +78,22 @@ fn assertLogin(data: std.json.ObjectMap, id: []const u8, command: []const u8) !v try std.testing.expectEqual(@as(usize, 3), data.count()); } -fn assertV2Failure(a: std.mem.Allocator, output: []const u8, id: ?[]const u8) !void { +fn assertKeyRejected(a: std.mem.Allocator, output: []const u8, id: []const u8, suffix: []const u8) !void { + const result = try std.json.parseFromSliceLeaky(std.json.Value, a, std.mem.sliceTo(output, '\n'), .{}); + const err = result.object.get("error").?.object; + try std.testing.expectEqual(@as(i64, -32603), err.get("code").?.integer); + const data = err.get("data").?.object; + try std.testing.expectEqualStrings("api_key_rejected", data.get("kind").?.string); + try std.testing.expectEqualStrings(id, data.get("provider").?.string); + try std.testing.expectEqual(@as(usize, 2), data.count()); + const message = err.get("message").?.string; + try std.testing.expect(std.mem.endsWith(u8, message, suffix)); + for (recovery.signed_out_phrases) |phrase| try std.testing.expect(@import("util.zig").indexOfIgnoreCase(message, phrase) == null); +} + +const V2Expect = union(enum) { none, reauth: []const u8, key_rejected: []const u8 }; + +fn assertV2Failure(a: std.mem.Allocator, output: []const u8, expect: V2Expect) !void { var lines = std.mem.tokenizeScalar(u8, output, '\n'); var accepted: usize = 0; var failures: usize = 0; @@ -95,9 +110,26 @@ fn assertV2Failure(a: std.mem.Allocator, output: []const u8, id: ?[]const u8) !v if (meta.object.get("graff/error") == null) continue; try std.testing.expectEqualStrings("idle", update.object.get("state").?.string); try std.testing.expectEqualStrings("_error", update.object.get("stopReason").?.string); - if (id) |expected| { - try assertLogin(meta.object.get("graff/reauth").?.object, expected, if (std.mem.eql(u8, expected, "codex")) "codex" else "graff"); - } else try std.testing.expect(meta.object.get("graff/reauth") == null); + switch (expect) { + .reauth => |expected| { + try assertLogin(meta.object.get("graff/reauth").?.object, expected, if (std.mem.eql(u8, expected, "codex")) "codex" else "graff"); + try std.testing.expect(meta.object.get("graff/recovery") == null); + }, + .key_rejected => |expected| { + // Never on `graff/reauth`: clients read that as signed out. + try std.testing.expect(meta.object.get("graff/reauth") == null); + const data = meta.object.get("graff/recovery").?.object; + try std.testing.expectEqualStrings("api_key_rejected", data.get("kind").?.string); + try std.testing.expectEqualStrings(expected, data.get("provider").?.string); + try std.testing.expectEqual(@as(usize, 2), data.count()); + const message = meta.object.get("graff/error").?.string; + try std.testing.expect(std.mem.indexOf(u8, message, recovery.api_key_rejected_phrase) != null); + }, + .none => { + try std.testing.expect(meta.object.get("graff/reauth") == null); + try std.testing.expect(meta.object.get("graff/recovery") == null); + }, + } failures += 1; } try std.testing.expectEqual(@as(usize, 1), accepted); @@ -150,10 +182,24 @@ test "typed reauth: production terminal handler gates actions and clears stale m try std.testing.expectError(case.err, quota.handle(&agent, case.etype, case.code, case.msg, &refreshed)); try std.testing.expect(refreshed); const action = case.action and source == .login and !std.mem.eql(u8, id, "openai"); - try std.testing.expectEqual(action, agent.last_api_reauth != null); + // ADR 0310: a metered seat names the rejected key, never a login. + const key_rejected = case.action and source != .login; + try std.testing.expectEqual(action or key_rejected, agent.last_api_reauth != null); if (agent.last_api_reauth) |data| { try std.testing.expectEqualStrings(id, data.provider); - try std.testing.expectEqualStrings(if (std.mem.eql(u8, id, "codex")) "codex" else "graff", data.login.command); + if (key_rejected) { + try std.testing.expectEqualStrings("api_key_rejected", data.kind); + try std.testing.expect(data.login == null); + } else try std.testing.expectEqualStrings(if (std.mem.eql(u8, id, "codex")) "codex" else "graff", data.login.?.command); + } + if (case.err == error.ProviderAuthenticationFailed) { + const text = agent.last_api_error.?; + if (source == .login) { + try std.testing.expect(std.mem.indexOf(u8, text, "sign-in was rejected: sign in again") != null); + } else { + try std.testing.expect(std.mem.indexOf(u8, text, recovery.api_key_rejected_phrase) != null); + for (recovery.signed_out_phrases) |phrase| try std.testing.expect(@import("util.zig").indexOfIgnoreCase(text, phrase) == null); + } } } agent.last_api_reauth = recovery.forFailure(route("codex"), "401", ""); @@ -191,7 +237,10 @@ test "typed reauth: v1 parsed HTTP and stream auth errors carry route-specific l try std.testing.expectEqual(@as(i64, -32000), err.get("code").?.integer); try assertLogin(err.get("data").?.object, id, if (std.mem.eql(u8, id, "codex")) "codex" else "graff"); try std.testing.expect(result.object.get("result") == null); - try std.testing.expectEqualStrings("provider authentication failed: sign in again or check the API key", err.get("message").?.string); + try std.testing.expectEqualStrings(if (std.mem.eql(u8, id, "codex")) + "Codex (ChatGPT) sign-in was rejected: sign in again (codex login)" + else + "ChatGPT plan (new sign-in) sign-in was rejected: sign in again (graff login chatgpt-new)", err.get("message").?.string); try std.testing.expect(LiveTurn.errorRecovery(&live, error.StreamDropped) == null); try std.testing.expect(LiveTurn.errorRecovery(&live, error.ProviderQuotaExceeded) == null); try std.testing.expect(LiveTurn.errorRecovery(&live, error.ProviderAccessDenied) == null); @@ -209,16 +258,23 @@ test "typed reauth: v1 parsed HTTP and stream auth errors carry route-specific l try std.testing.expectEqual(@as(i64, -32603), err.get("code").?.integer); try std.testing.expect(err.get("data") == null); } + // ADR 0310: a metered seat stays an internal error, with data naming + // the rejected key and no login action. agent.provider.source = .environment; for (auth_bodies) |body| { out.clearRetainingCapacity(); try engine.handleLine(&d, a, &out.writer, try prompt(a, body)); - const result = try std.json.parseFromSliceLeaky(std.json.Value, a, std.mem.sliceTo(out.writer.buffered(), '\n'), .{}); - const err = result.object.get("error").?.object; - try std.testing.expectEqual(@as(i64, -32603), err.get("code").?.integer); - try std.testing.expect(err.get("data") == null); + try assertKeyRejected(a, out.writer.buffered(), id, "API key was rejected (environment): replace or remove the key. Your sign-in is not affected."); } } + // A key a spent plan handed over to says so. + failover.park("anthropic", "metered", .profile); + _ = failover.promote("anthropic").?; + agent.provider = route("anthropic"); + agent.provider.source = .profile; + out.clearRetainingCapacity(); + try engine.handleLine(&d, a, &out.writer, try prompt(a, "http:401")); + try assertKeyRejected(a, out.writer.buffered(), "anthropic", "plan is out of usage, and the fallback API key was rejected (Console profile): replace or remove the key, or wait for the plan to reset."); out.clearRetainingCapacity(); agent.last_api_reauth = recovery.forFailure(route("codex"), "invalid_token", "Unauthorized"); try engine.handleLine(&d, a, &out.writer, try prompt(a, "ok")); @@ -250,19 +306,19 @@ test "typed reauth: v2 accepted prompt carries the same action on the failed idl for (auth_bodies) |body| { out.clearRetainingCapacity(); try engine.handleLine(&d, a, &out.writer, try prompt(a, body)); - try assertV2Failure(a, out.writer.buffered(), id); + try assertV2Failure(a, out.writer.buffered(), .{ .reauth = id }); } for (non_auth_bodies) |body| { out.clearRetainingCapacity(); agent.last_api_reauth = recovery.forFailure(agent.provider, "401", ""); try engine.handleLine(&d, a, &out.writer, try prompt(a, body)); - try assertV2Failure(a, out.writer.buffered(), null); + try assertV2Failure(a, out.writer.buffered(), .none); } agent.provider.source = .environment; for (auth_bodies) |body| { out.clearRetainingCapacity(); try engine.handleLine(&d, a, &out.writer, try prompt(a, body)); - try assertV2Failure(a, out.writer.buffered(), null); + try assertV2Failure(a, out.writer.buffered(), .{ .key_rejected = id }); } } out.clearRetainingCapacity(); @@ -272,3 +328,22 @@ test "typed reauth: v2 accepted prompt carries the same action on the failed idl try std.testing.expect(std.mem.indexOf(u8, out.writer.buffered(), "end_turn") != null); try std.testing.expect(std.mem.indexOf(u8, out.writer.buffered(), "reauth_required") == null); } + +test "typed reauth: a failure after async tools started names the rejected credential too" { + var state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer state.deinit(); + var agent = root(state.allocator()); + agent.provider = route("openai"); + agent.provider.source = .stored; + try quota.afterToolsStarted(&agent, "invalid_api_key", "Incorrect API key provided", "raw provider diagnostic"); + try std.testing.expectEqualStrings("OpenAI API key was rejected (secure store): replace or remove the key. Your sign-in is not affected.", agent.last_api_error.?); + try std.testing.expectEqualStrings("api_key_rejected", agent.last_api_reauth.?.kind); + agent.provider = route("chatgpt-new"); + try quota.afterToolsStarted(&agent, "token_expired", "Provided authentication token is expired.", "raw provider diagnostic"); + try std.testing.expectEqualStrings("ChatGPT plan (new sign-in) sign-in was rejected: sign in again (graff login chatgpt-new)", agent.last_api_error.?); + try std.testing.expectEqualStrings("reauth_required", agent.last_api_reauth.?.kind); + // A non-auth failure keeps the provider's own diagnostic and no metadata. + try quota.afterToolsStarted(&agent, "server_error", "upstream unavailable", "raw provider diagnostic"); + try std.testing.expectEqualStrings("raw provider diagnostic", agent.last_api_error.?); + try std.testing.expect(agent.last_api_reauth == null); +} diff --git a/src/acp_stream.zig b/src/acp_stream.zig index b1ee1dff..ca222775 100644 --- a/src/acp_stream.zig +++ b/src/acp_stream.zig @@ -221,6 +221,7 @@ pub fn translateTracked( return .tool_end; } try writeToolDone(w, session_id, use_id, is_error, text, @import("run_task_meta.zig").fromEvent(ev)); + try @import("acp_workspace.zig").emitMoved(w, session_id); // #1737: `workspace use` mid-turn return .tool_end; } if (std.mem.eql(u8, typ, "ask_user")) { diff --git a/src/acp_v2.zig b/src/acp_v2.zig index 076d7b69..ba3e3ca4 100644 --- a/src/acp_v2.zig +++ b/src/acp_v2.zig @@ -117,6 +117,14 @@ pub fn writeFailed(w: *Io.Writer, sid: []const u8, message: []const u8) !void { pub fn writeFailedRecovery(w: *Io.Writer, sid: []const u8, message: []const u8, recovery: ?@import("auth_recovery.zig").Recovery) !void { const data = recovery orelse return writeFailed(w, sid, message); breakMessage(); + // ADR 0310: only a sign-in action rides `graff/reauth`, which clients read + // as "signed out"; a rejected API key rides `graff/recovery`. + if (data.login == null) return notify(w, sid, .{ + .sessionUpdate = "state_update", + .state = "idle", + .stopReason = "_error", + ._meta = .{ .@"graff/error" = message, .@"graff/recovery" = data }, + }); try notify(w, sid, .{ .sessionUpdate = "state_update", .state = "idle", diff --git a/src/acp_v2_prompt.zig b/src/acp_v2_prompt.zig index 53a89e48..2e08aba0 100644 --- a/src/acp_v2_prompt.zig +++ b/src/acp_v2_prompt.zig @@ -23,7 +23,7 @@ pub fn promptTurn(d: *engine.Dispatch, arena: Allocator, w: *Io.Writer, req: pro // JSON-RPC error is still the right answer. const config_before = engine.configOptions(d, arena) catch |err| return engine.respondError(w, req, proto.err_internal, @errorName(err)); - const workspace_before = acp_workspace.snapshot(d.workspace, arena); + acp_workspace.snapshot(d.workspace); // Before slash handling: an agent's room line must not run as a command. const text = try @import("acp_room.zig").frame(arena, req.params, try proto.flattenPrompt(arena, prompt)); var id_buf: [48]u8 = undefined; @@ -35,7 +35,7 @@ pub fn promptTurn(d: *engine.Dispatch, arena: Allocator, w: *Io.Writer, req: pro const outcome = run(d, arena, w, sid, text, prompt); engine.emitConfigChange(d, arena, w, sid, config_before) catch {}; - acp_workspace.emitChange(d.workspace, arena, w, sid, workspace_before) catch {}; + acp_workspace.emitChange(d.workspace, arena, w, sid) catch {}; try engine.emitMeter(d, w, sid); switch (outcome) { .stop => |stop| try v2.writeIdle(w, sid, stop), diff --git a/src/acp_workspace.zig b/src/acp_workspace.zig index fff6f8b4..387abe96 100644 --- a/src/acp_workspace.zig +++ b/src/acp_workspace.zig @@ -140,21 +140,63 @@ pub fn enterSavedTree(env: Env, arena: Allocator, session_id: []const u8) bool { return false; } -/// Taken before a prompt turn; compared by `emitChange` after it. -pub fn snapshot(env: ?Env, arena: Allocator) []const u8 { - if (env == null) return ""; - return arena.dupe(u8, main_mod.g_cwd_display) catch ""; +/// `session_info_update` meta: the tree plus the session's absolute cwd, so a +/// client can re-root cwd-relative work (image attachment, files panel) even +/// when the new folder is a main checkout or a non-graff worktree (#1737). +pub const ChangeMeta = struct { @"graff/worktree": ?Info, @"graff/cwd": []const u8 }; + +/// The live session's env and the cwd the client was last told about. The +/// event stream reads them to report a mid-turn switch (`emitMoved`) without +/// threading the dispatch through it. Embeds and the REPL leave env null. +var live_env: ?Env = null; +var reported_buf: [std.fs.max_path_bytes]u8 = undefined; +var reported_len: usize = 0; + +fn remember(cwd: []const u8) void { + reported_len = @min(cwd.len, reported_buf.len); + @memcpy(reported_buf[0..reported_len], cwd[0..reported_len]); } -/// `/workspace use` and the `workspace` tool move the root session. Tell the -/// client with `session_info_update` so it can re-record the chat's tree. -pub fn emitChange(env: ?Env, arena: Allocator, w: *Io.Writer, session_id: []const u8, before: []const u8) !void { - const e = env orelse return; - if (std.mem.eql(u8, before, main_mod.g_cwd_display)) return; +/// Taken before a prompt turn: the client already knows this cwd. +pub fn snapshot(env: ?Env) void { + live_env = env orelse return; + remember(main_mod.g_cwd_display); +} + +fn emitIfMoved(e: Env, arena: Allocator, w: *Io.Writer, session_id: []const u8) !void { + const cwd = main_mod.g_cwd_display; + if (std.mem.eql(u8, reported_buf[0..reported_len], cwd)) return; try proto.writeNotification(w, "session/update", .{ .sessionId = session_id, - .update = .{ .sessionUpdate = "session_info_update", ._meta = meta(e, arena) }, + .update = .{ .sessionUpdate = "session_info_update", ._meta = ChangeMeta{ + .@"graff/worktree" = info(e, arena), + .@"graff/cwd" = cwd, + } }, }); + remember(cwd); +} + +/// `/workspace use` and the `workspace` tool move the root session. Tell the +/// client with `session_info_update` so it can re-record the chat's tree. +/// Silent when `emitMoved` already reported the switch during the turn. +pub fn emitChange(env: ?Env, arena: Allocator, w: *Io.Writer, session_id: []const u8) !void { + try emitIfMoved(env orelse return, arena, w, session_id); +} + +/// After a root tool result: report a switch the moment it happens, before +/// the model's next call. Waiting for the turn to end left the client +/// resolving tools it serves (show_image) against the old tree (#1737). +pub fn emitMoved(w: *Io.Writer, session_id: []const u8) !void { + const e = live_env orelse return; + if (std.mem.eql(u8, reported_buf[0..reported_len], main_mod.g_cwd_display)) return; + var arena_state = std.heap.ArenaAllocator.init(e.gpa); + defer arena_state.deinit(); + try emitIfMoved(e, arena_state.allocator(), w, session_id); +} + +pub fn resetForTest() void { + live_env = null; + reported_len = 0; } test "fromRevParse: a linked tree reports its name, branch, and owning checkout" { @@ -189,6 +231,51 @@ test "Meta serializes an explicit null for the main checkout" { try std.testing.expectEqualStrings("{\"graff/worktree\":null}", w.buffered()); } +test "#1737: a mid-turn workspace switch reaches the client with the tool result, once" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const saved = main_mod.g_cwd_display; + defer main_mod.g_cwd_display = saved; + defer resetForTest(); + const stream = @import("acp_stream.zig"); + const ev = try std.json.parseFromSliceLeaky(Value, arena.allocator(), + \\{"type":"tool_result","id":"call-7","name":"workspace","text":"now at /trees/other","is_error":false} + , .{}); + var id_buf: [64]u8 = @splat(0); + var next: u32 = 0; + var buf: [8192]u8 = undefined; + + // Without a live ACP session (REPL, embeds) nothing extra is written. + main_mod.g_cwd_display = "/trees/launch"; + var quiet: Io.Writer = .fixed(&buf); + _ = try stream.translateEvent(&quiet, "s1", ev, &id_buf, &next); + try std.testing.expect(std.mem.indexOf(u8, quiet.buffered(), "session_info_update") == null); + + // Live session: the switch is reported right after the tool's own update. + snapshot(.{ .gpa = a, .io = std.testing.io }); + main_mod.g_cwd_display = "/trees/other"; + var w: Io.Writer = .fixed(&buf); + _ = try stream.translateEvent(&w, "s1", ev, &id_buf, &next); + const out = w.buffered(); + const done = std.mem.indexOf(u8, out, "call-7") orelse return error.TestExpectedToolUpdate; + const info_at = std.mem.indexOf(u8, out, "\"session_info_update\"") orelse return error.TestExpectedInfoUpdate; + try std.testing.expect(done < info_at); + try std.testing.expect(std.mem.indexOf(u8, out, "\"graff/cwd\":\"/trees/other\"") != null); + + // Already told: neither the next tool result nor turn end repeats it. + var again: Io.Writer = .fixed(&buf); + _ = try stream.translateEvent(&again, "s1", ev, &id_buf, &next); + try emitChange(.{ .gpa = a, .io = std.testing.io }, arena.allocator(), &again, "s1"); + try std.testing.expect(std.mem.indexOf(u8, again.buffered(), "session_info_update") == null); + + // A slash switch (no tool result) is still reported at turn end. + main_mod.g_cwd_display = "/trees/third"; + var end: Io.Writer = .fixed(&buf); + try emitChange(.{ .gpa = a, .io = std.testing.io }, arena.allocator(), &end, "s1"); + try std.testing.expect(std.mem.indexOf(u8, end.buffered(), "\"graff/cwd\":\"/trees/third\"") != null); +} + test "errorText names the cwd problem" { try std.testing.expectEqualStrings("Session cwd must be an absolute path", errorText(error.RelativeCwd)); try std.testing.expectEqualStrings("Session cwd does not exist or is not a directory", errorText(error.MissingCwd)); diff --git a/src/additional_tests.zig b/src/additional_tests.zig index 43ccccd8..d2332709 100644 --- a/src/additional_tests.zig +++ b/src/additional_tests.zig @@ -28,6 +28,10 @@ test { _ = @import("engine_events_tests.zig"); _ = @import("run_task_meta.zig"); _ = @import("acp_run_task_tests.zig"); + // Claude Agent provider (ADR 0309). + _ = @import("claude_agent_tests.zig"); + _ = @import("claude_agent_login.zig"); + _ = @import("claude_agent.zig"); } test "oneshot -p still uses the live stall-watched transport" { diff --git a/src/agent.zig b/src/agent.zig index 4413b91f..95cc3eb4 100644 --- a/src/agent.zig +++ b/src/agent.zig @@ -34,9 +34,8 @@ pub const TodoItem = struct { epoch: u64 = 0, // the goal epoch that authored this item (#318); 0 = no goal retired: bool = false, // a LATER user ask retired this finished item (#394): kept as the session's archive, invisible to every epoch-scoped query ask_fp: u64 = 0, // #1545: fingerprint of the user request this item was first written under; "cancelled" needs a newer one (0 = legacy/restored) - - /// Completed or cancelled: no longer open work. A cancelled item is parked, - /// never verified (goal_verify.taskVerified still refuses it). + reason: []const u8 = "", // #1520: why a "cancelled" item was withdrawn or delegated; required, shown with it (todo_cancel.zig) + /// Completed or cancelled: no longer open work. A cancelled item is parked, never verified (goal_verify.taskVerified refuses it). pub fn closed(t: TodoItem) bool { return std.mem.eql(u8, t.status, "completed") or std.mem.eql(u8, t.status, "cancelled"); } diff --git a/src/agent_empty_completion.zig b/src/agent_empty_completion.zig index 511f9a18..a108237a 100644 --- a/src/agent_empty_completion.zig +++ b/src/agent_empty_completion.zig @@ -166,6 +166,8 @@ fn handleWithWait(self: *Agent, final_text: []const u8, hist_len: usize, comptim if (@import("task_intent.zig").current(self) == .informational) return false; if (@import("exact_reply.zig").requested(messages.latestUserText(self.messages.items))) return false; if (no_local_tools.enabled) return false; + // #1786: an answer built on the provider's own searches did its work this completion. + if (@import("xai_hosted.zig").ranServerSideSearch(self.messages.items[@min(hist_len, self.messages.items.len)..])) return false; // #1518: a question answered in text is done, and a retry the model-call // budget cannot pay for turns a valid answer into a budget failure. if (!@import("task_intent.zig").requestsAction(messages.latestUserText(self.messages.items))) return false; @@ -278,9 +280,10 @@ pub const PendingWork = struct { test { _ = @import("run_idle.zig"); + _ = @import("agent_empty_completion_tests.zig"); } -fn pendingFixture(arena: std.mem.Allocator) Agent { +pub fn pendingFixture(arena: std.mem.Allocator) Agent { var self: Agent = undefined; self.arena = arena; self.io = std.testing.io; @@ -493,40 +496,6 @@ test "exact reply bypasses only fake done while mixed requests and empty replies } } -test "#1518 lean bounce skips questions and a spent model-call budget" { - var state = std.heap.ArenaAllocator.init(std.testing.allocator); - defer state.deinit(); - const old_unattended = main_mod.unattended; - const old_lean = no_local_tools.lean; - const old_enabled = no_local_tools.enabled; - main_mod.unattended = true; - no_local_tools.lean = true; - no_local_tools.enabled = false; - defer { - main_mod.unattended = old_unattended; - no_local_tools.lean = old_lean; - no_local_tools.enabled = old_enabled; - } - var budget: @import("run_budget.zig").RunBudget = .{ .max_model_calls = 1 }; - budget.model_calls.store(1, .release); - const cases = .{ - .{ "What does parse() return for an empty string?", false, false }, - .{ "Fix the off-by-one in fib.py", true, false }, - .{ "Fix the off-by-one in fib.py", false, true }, - }; - inline for (cases) |case| { - var self = pendingFixture(state.allocator()); - self.empty_completion_retries = 0; - self.call_kind = .title; // suppress fixture notices without initializing a writer - self.tool_calls_this_turn = 0; - self.model_calls_this_turn = 1; - if (case[1]) self.run_budget = &budget; - try self.messages.append(try messages.textMessage(self.arena, "user", case[0])); - const before = self.messages.items.len; - try std.testing.expectEqual(case[2], try handle(&self, "It returns an empty list.", before)); - } -} - test "#1582 an informational question does not resume an earlier task's untouched checklist" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); diff --git a/src/agent_empty_completion_tests.zig b/src/agent_empty_completion_tests.zig new file mode 100644 index 00000000..d97839b9 --- /dev/null +++ b/src/agent_empty_completion_tests.zig @@ -0,0 +1,77 @@ +//! Lean `-p` bounce cases, split from agent_empty_completion.zig (600-line goal). + +const std = @import("std"); +const main_mod = @import("main.zig"); +const no_local_tools = @import("no_local_tools.zig"); +const messages = @import("messages.zig"); +const ec = @import("agent_empty_completion.zig"); + +const LeanRun = struct { + unattended: bool, + lean: bool, + enabled: bool, + + fn begin() LeanRun { + const saved: LeanRun = .{ .unattended = main_mod.unattended, .lean = no_local_tools.lean, .enabled = no_local_tools.enabled }; + main_mod.unattended = true; + no_local_tools.lean = true; + no_local_tools.enabled = false; + return saved; + } + + fn end(saved: LeanRun) void { + main_mod.unattended = saved.unattended; + no_local_tools.lean = saved.lean; + no_local_tools.enabled = saved.enabled; + } +}; + +test "#1518 lean bounce skips questions and a spent model-call budget" { + var state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer state.deinit(); + const saved = LeanRun.begin(); + defer saved.end(); + var budget: @import("run_budget.zig").RunBudget = .{ .max_model_calls = 1 }; + budget.model_calls.store(1, .release); + const cases = .{ + .{ "What does parse() return for an empty string?", false, false }, + .{ "Fix the off-by-one in fib.py", true, false }, + .{ "Fix the off-by-one in fib.py", false, true }, + }; + inline for (cases) |case| { + var self = ec.pendingFixture(state.allocator()); + self.empty_completion_retries = 0; + self.call_kind = .title; // suppress fixture notices without initializing a writer + self.tool_calls_this_turn = 0; + self.model_calls_this_turn = 1; + if (case[1]) self.run_budget = &budget; + try self.messages.append(try messages.textMessage(self.arena, "user", case[0])); + const before = self.messages.items.len; + try std.testing.expectEqual(case[2], try ec.handle(&self, "It returns an empty list.", before)); + } +} + +test "#1786 an answer built on the provider's own searches is not bounced as a described change" { + var state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer state.deinit(); + const saved = LeanRun.begin(); + defer saved.end(); + // "build" makes this read as an action request; the searches did the work. + const prompt = "Search posts from the people who build the app and report what they said, with links."; + const answer = "Three posts: ..."; + for ([_]?[]const u8{ "{\"type\":\"x_search_call\",\"status\":\"completed\"}", null }) |search| { + var self = ec.pendingFixture(state.allocator()); + self.empty_completion_retries = 0; + self.call_kind = .title; + self.tool_calls_this_turn = 0; + self.model_calls_this_turn = 1; + try self.messages.append(try messages.textMessage(self.arena, "user", prompt)); + const before = self.messages.items.len; + if (search) |raw| try self.messages.append(try std.json.parseFromSliceLeaky(std.json.Value, self.arena, raw, .{})); + try self.messages.append(try messages.textMessage(self.arena, "assistant", answer)); + const after = self.messages.items.len; + // With a hosted search the answer stands; with none, the bounce still fires. + try std.testing.expectEqual(search == null, try ec.handle(&self, answer, before)); + try std.testing.expectEqual(after + @intFromBool(search == null), self.messages.items.len); + } +} diff --git a/src/agent_prompt.zig b/src/agent_prompt.zig index 446ff643..dc3cc9c9 100644 --- a/src/agent_prompt.zig +++ b/src/agent_prompt.zig @@ -85,7 +85,8 @@ fn standing(self: *const Agent) engine_events.StandingWork { var items: std.ArrayList(engine_events.StandingTodo) = .empty; for (self.todos.items) |t| { if (t.epoch != epoch or t.retired) continue; - out.todos_total += 1; + const cancelled = std.mem.eql(u8, t.status, "cancelled"); // #1520: shown with its reason, not counted + if (!cancelled) out.todos_total += 1; const done = std.mem.eql(u8, t.status, "completed"); const active = std.mem.eql(u8, t.status, "in_progress"); if (done) out.todos_done += 1; @@ -94,6 +95,8 @@ fn standing(self: *const Agent) engine_events.StandingWork { .content = util.utf8Prefix(t.content, 48), .done = done, .active = active, + .cancelled = cancelled, + .reason = t.reason, }) catch {}; } } @@ -270,6 +273,11 @@ test "standing work snapshots goal, checklist, session, and a staged image" { try std.testing.expectEqualStrings("open item", sw.todos[1].content); try std.testing.expect(!sw.todos[1].done); try std.testing.expect(sw.image); + // #1520: a cancelled item is listed with its reason but kept out of the counts. + try agent.todos.append(agent.gpa, .{ .content = "publish", .status = "cancelled", .epoch = 1, .reason = "delegated" }); + sw = standing(&agent); + try std.testing.expectEqual(@as(u32, 2), sw.todos_total); + try std.testing.expect(sw.todos[2].cancelled and std.mem.eql(u8, sw.todos[2].reason, "delegated")); // A completed goal is not standing work; the default session name stays hidden. agent.goal.?.status = .complete; diff --git a/src/agent_request.zig b/src/agent_request.zig index ede0d7cf..87b7c5db 100644 --- a/src/agent_request.zig +++ b/src/agent_request.zig @@ -274,6 +274,7 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { if (err == error.Interrupted) return error.Interrupted; if (err == error.Steered) if (@import("steer_now.zig").supersede(self)) continue :rebuild; if (err == error.Canceled) return error.Canceled; + if (err == error.StreamTooLong or err == error.StreamEventDeadline) return err; // deterministic event bounds: replay cannot repair them if (quota.transportCode(err)) |code| { if (try quota.handle(self, "", code, "", &auth_refreshed)) continue; return err; @@ -439,8 +440,7 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { self.ws_api_error_pending = false; const diagnostic = try responses.failureDiagnostic(self.arena, self.provider.id, failure); if (@import("agent_async_tools.zig").started(self)) { - self.last_api_reauth = @import("auth_recovery.zig").forFailure(self.provider, failure.code, msg); - try self.sayApiError("{s}", .{diagnostic}); + try quota.afterToolsStarted(self, failure.code, msg, diagnostic); return error.ApiError; } if (ws_error_frame) if (self.tracer) |tr| tr.note("ws_api_error", diagnostic); @@ -480,7 +480,7 @@ pub fn request(self: *Agent, tools_in: ?[]const u8) !std.json.ObjectMap { // Codex Responses reports capacity failures through this // response.failed arm too. Keep it on the same bounded // overload retry path as SSE and JSON error envelopes. - if (try policy.afterServerErrorOrParseReject(self, "", failure.code, msg, &server_retries, &gw_retry)) continue :rebuild; + if (try policy.afterServerErrorOrParseReject(self, failure.etype, failure.code, msg, &server_retries, &gw_retry)) continue :rebuild; // #748: the class keeps invalid requests off the flake ladder self.last_api_reauth = @import("auth_recovery.zig").forFailure(self.provider, failure.code, msg); if (self.tracer) |tr| tr.api(self.label, self.sub, self.provider.model, ms, body.len, resp_body.len, 0, 0, true); try self.sayApiError("{s}", .{diagnostic}); diff --git a/src/agent_request_quota.zig b/src/agent_request_quota.zig index a771de07..5ed04a18 100644 --- a/src/agent_request_quota.zig +++ b/src/agent_request_quota.zig @@ -119,11 +119,18 @@ pub fn handle(self: *@import("agent.zig").Agent, etype: []const u8, code: ?[]con // faults, after the existing refresh/handoff policy has declined a retry. // Classification already established auth; retain any structured code so // a non-auth code still vetoes a login action (including generic 403). - if (fault == .authentication) - self.last_api_reauth = @import("auth_recovery.zig").forFailure(self.provider, code orelse "authentication_error", ""); + // ADR 0310: the text and metadata name the seat that failed: a sign-in, + // or a metered key (perhaps the one a spent plan handed over to). + const auth = @import("auth_recovery.zig"); + if (fault == .authentication) self.last_api_reauth = auth.forAuthFault(self.provider, code orelse "authentication_error"); self.last_api_error = switch (fault) { - .quota => "provider quota/billing cap: credit or allowance exhausted; retry requires account action", - .authentication => "provider authentication failed: sign in again or check the API key", + // The Console sign-in that would have taken over was skipped at startup + // because it expired beyond renewal (anthropic_profile.load). + .quota => if (std.mem.eql(u8, self.provider.id, "anthropic") and self.provider.source == .login and @import("anthropic_profile.zig").expired()) + "Anthropic plan is out of quota, and the Console sign-in that would take over has expired: run `graff login console` again, or wait for the plan to reset" + else + "provider quota/billing cap: credit or allowance exhausted; retry requires account action", + .authentication => auth.failureText(self.arena, self.provider, @import("credential_failover.zig").promoted(self.provider.id)), .access_denied => "provider access denied: check account permissions or route eligibility", }; if (self.tracer) |tr| tr.note("provider_terminal", self.last_api_error.?); @@ -134,6 +141,19 @@ pub fn handle(self: *@import("agent.zig").Agent, etype: []const u8, code: ?[]con }; } +/// A failure after async tools started skips the retry ladder (the turn has +/// side effects in flight), but an authentication rejection still names the +/// credential that failed, as handle() does. +pub fn afterToolsStarted(self: *@import("agent.zig").Agent, code: ?[]const u8, msg: []const u8, diagnostic: []const u8) !void { + const auth = @import("auth_recovery.zig"); + if (classify("", code, msg) != .authentication) { + self.last_api_reauth = auth.forFailure(self.provider, code, msg); + return self.sayApiError("{s}", .{diagnostic}); + } + self.last_api_reauth = if (self.provider.source == .login) auth.forFailure(self.provider, code, msg) else auth.forAuthFault(self.provider, code orelse "authentication_error"); + try self.sayApiError("{s}", .{auth.failureText(self.arena, self.provider, @import("credential_failover.zig").promoted(self.provider.id))}); +} + // ChatGPT plan errors, as OpenAI's errors-and-recovery page classifies them // (ADR 0221). Only a spent allowance is a limit (429, resets after hours). const plan_limit_code = "subscription_sharing_usage_limit_exceeded"; @@ -250,15 +270,20 @@ test "terminal provider faults: numeric HTTP status codes survive envelope parsi } test "terminal provider faults: hard caps and env auth stop without a refresh or retry" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); var self: @import("agent.zig").Agent = undefined; self.provider = .{ .id = "codegraff", .kind = .openai, .auth = .bearer, .url = "", .api_key = "", .model = "test", .context = 1000, .source = .environment }; self.tracer = null; + self.arena = arena.allocator(); var refreshed = false; try std.testing.expectError(error.ProviderQuotaExceeded, handle(&self, "server_error", null, "API key: exhausted credit; try again", &refreshed)); try std.testing.expect(!refreshed); try std.testing.expect(isQuotaExceeded(self.last_api_error.?)); try std.testing.expectError(error.ProviderAuthenticationFailed, handle(&self, "authentication_error", null, "overloaded", &refreshed)); try std.testing.expect(!refreshed); + try std.testing.expectEqualStrings("Codegraff API key was rejected (environment): replace or remove the key. Your sign-in is not affected.", self.last_api_error.?); + try std.testing.expectEqualStrings("api_key_rejected", self.last_api_reauth.?.kind); try std.testing.expectError(error.ProviderAccessDenied, handle(&self, "permission_error", "403", "", &refreshed)); try std.testing.expect(!try handle(&self, "server_error", null, "overloaded", &refreshed)); self.provider.source = .login; diff --git a/src/agent_responses.zig b/src/agent_responses.zig index 0237604a..c815063e 100644 --- a/src/agent_responses.zig +++ b/src/agent_responses.zig @@ -7,11 +7,16 @@ const Agent = @import("agent.zig").Agent; const policy = @import("agent_request_policy.zig"); const errorCode = policy.errorCode; const util = @import("util.zig"); +const sse_error = @import("sse_error_event.zig"); pub const ResponsesFailure = struct { message: []const u8, code: ?[]const u8 = null, request_id: ?[]const u8 = null, + /// The provider's error class (`error.type`, e.g. invalid_request_error), + /// so the retry classifiers can tell a deterministic rejection from a + /// transient one (#748). Empty when the provider sent none. + etype: []const u8 = "", /// The text to test for an auth failure. The ChatGPT plan route's /// subscription_sharing_invalid_user 401 names it only in the code. @@ -48,21 +53,43 @@ pub fn parseResponses(self: *Agent, body: []const u8) !ResponsesResult { var err_msg: ?[]const u8 = null; var err_code: ?[]const u8 = null; var err_rid: ?[]const u8 = null; + var err_type: []const u8 = ""; + var named_error = false; // the data lines follow an `event: error` name var it = std.mem.tokenizeScalar(u8, body, '\n'); while (it.next()) |raw_line| { const line = std.mem.trim(u8, raw_line, " \r"); - if (!std.mem.startsWith(u8, line, "data:")) continue; + if (!std.mem.startsWith(u8, line, "data:")) { + named_error = sse_error.namesError(line, named_error); + continue; + } const payload = std.mem.trim(u8, line["data:".len..], " "); if (payload.len == 0 or std.mem.eql(u8, payload, "[DONE]")) continue; const v = std.json.parseFromSliceLeaky(Value, scratch, payload, .{ .allocate = .alloc_always }) catch continue; if (v != .object) continue; - const t = v.object.get("type") orelse continue; - if (t != .string) continue; - if (std.mem.eql(u8, t.string, "response.output_item.done")) { + const named = named_error; + named_error = false; + const tname: []const u8 = if (v.object.get("type")) |tv| (if (tv == .string) tv.string else "") else ""; + // #748: `response.failed`, a typed `error` event, an untyped + // `{"error":…}` frame, or a bare frame under `event: error` all end + // the stream as a failure carrying the provider's type and message. + // Before, the last two parsed as nothing and the body as Unparseable. + if (std.mem.eql(u8, tname, "response.failed") or std.mem.eql(u8, tname, "error") or named or + (tname.len == 0 and sse_error.isErrorEvent(v.object, false))) + { + err_msg = if (errorMessage(v.object)) |m| + result_arena.dupe(u8, m) catch "codex stream reported a failure" + else + "codex stream reported a failure"; + if (errorCode(v.object)) |code| err_code = result_arena.dupe(u8, code) catch null; + if (errorRequestId(v.object)) |id| err_rid = result_arena.dupe(u8, id) catch null; + err_type = result_arena.dupe(u8, errorType(v.object)) catch ""; + continue; + } + if (std.mem.eql(u8, tname, "response.output_item.done")) { if (v.object.get("item")) |item| try items.append(try util.dupeJsonValue(result_arena, item)); - } else if (std.mem.eql(u8, t.string, "response.completed") or std.mem.eql(u8, t.string, "response.incomplete")) { - saw_completed = std.mem.eql(u8, t.string, "response.completed"); - saw_incomplete = std.mem.eql(u8, t.string, "response.incomplete"); + } else if (std.mem.eql(u8, tname, "response.completed") or std.mem.eql(u8, tname, "response.incomplete")) { + saw_completed = std.mem.eql(u8, tname, "response.completed"); + saw_incomplete = std.mem.eql(u8, tname, "response.incomplete"); if (v.object.get("response")) |r| if (r == .object) { try terminal.record(result_arena, r.object); if (r.object.get("usage")) |u| { @@ -73,24 +100,17 @@ pub fn parseResponses(self: *Agent, body: []const u8) !ResponsesResult { resp_id = try result_arena.dupe(u8, idv.string); }; }; - } else if (std.mem.eql(u8, t.string, @import("agent_ws_steer.zig").applied_type)) { + } else if (std.mem.eql(u8, tname, @import("agent_ws_steer.zig").applied_type)) { // A steer the server applied here: the user's words where the model read them. if (v.object.get("text")) |txt| if (txt == .string) try items.append(try @import("session_wake.zig").message(result_arena, txt.string)); - } else if (std.mem.eql(u8, t.string, "response.failed") or std.mem.eql(u8, t.string, "error")) { - err_msg = if (errorMessage(v.object)) |m| - result_arena.dupe(u8, m) catch "codex stream reported a failure" - else - "codex stream reported a failure"; - if (errorCode(v.object)) |code| err_code = result_arena.dupe(u8, code) catch null; - if (errorRequestId(v.object)) |id| err_rid = result_arena.dupe(u8, id) catch null; } } // A terminal failure wins even if the stream produced partial completed // items first. Committing those items as a successful compaction summary // would replace live history with a response the provider explicitly // rejected. - if (err_msg) |m| return .{ .err = .{ .message = m, .code = err_code, .request_id = err_rid } }; + if (err_msg) |m| return .{ .err = .{ .message = m, .code = err_code, .request_id = err_rid, .etype = err_type } }; if (saw_completed or saw_incomplete or items.items.len > 0) { var resp: std.json.ObjectMap = .empty; try resp.put(result_arena, "output", .{ .array = items }); @@ -120,6 +140,7 @@ pub fn parseResponses(self: *Agent, body: []const u8) !ResponsesResult { .message = result_arena.dupe(u8, m) catch "unparseable provider error", .code = code, .request_id = rid, + .etype = result_arena.dupe(u8, errorType(v.object)) catch "", } }; } } @@ -335,6 +356,56 @@ pub fn errorMessage(obj: std.json.ObjectMap) ?[]const u8 { return null; } +/// The error class from `error.type` or `response.error.type`; the frame's +/// own top-level `type` is the event name, never the class. +fn errorType(obj: std.json.ObjectMap) []const u8 { + const holder = if (obj.get("response")) |r| (if (r == .object and obj.get("error") == null) r.object else obj) else obj; + const e = holder.get("error") orelse return ""; + if (e != .object) return ""; + const t = e.object.get("type") orelse return ""; + return if (t == .string) t.string else ""; +} + +test "#748: error-only Responses SSE is a typed failure, never Unparseable" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var agent: Agent = undefined; + agent.arena = arena_state.allocator(); + agent.scratch_arena = null; + agent.message_mutation_arena = null; + const bodies = [_][]const u8{ + // Typed error event (already handled): the class now survives. + "event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"code\":\"invalid_value\",\"message\":\"Invalid tool schema\"}}\n\n", + // response.failed carries the class under response.error. + "event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"error\":{\"type\":\"invalid_request_error\",\"code\":\"invalid_value\",\"message\":\"Invalid tool schema\"}}}\n\n", + // An untyped frame from a proxy used to be skipped, leaving the body Unparseable. + "event: error\ndata: {\"error\":{\"type\":\"invalid_request_error\",\"code\":\"invalid_value\",\"message\":\"Invalid tool schema\"}}\n\n", + }; + for (bodies) |body| switch (try parseResponses(&agent, body)) { + .err => |failure| { + try std.testing.expectEqualStrings("Invalid tool schema", failure.message); + try std.testing.expectEqualStrings("invalid_value", failure.code.?); + try std.testing.expectEqualStrings("invalid_request_error", failure.etype); + }, + .ok => return error.TestUnexpectedResult, + }; + // A bare frame is a failure only because the SSE event named it one. + switch (try parseResponses(&agent, "event: error\ndata: {\"message\":\"Upstream rejected the request\",\"code\":\"bad_request\"}\n\n")) { + .err => |failure| { + try std.testing.expectEqualStrings("Upstream rejected the request", failure.message); + try std.testing.expectEqualStrings("bad_request", failure.code.?); + }, + .ok => return error.TestUnexpectedResult, + } + // The class decides the retry: an invalid request is deterministic, an + // overload stays on the transient ladder. + const gw = @import("agent_gateway_retry.zig"); + try std.testing.expect(!gw.isTransientServerError("invalid_request_error", "invalid_value", "Invalid tool schema")); + try std.testing.expect(!gw.isShortGatewayFlake("invalid_request_error", null, "An error occurred while processing your request.")); + try std.testing.expect(gw.isShortGatewayFlake("", null, "An error occurred while processing your request.")); + try std.testing.expect(gw.isTransientServerError("server_error", null, "The server is overloaded")); +} + test "parseResponses reads the ChatGPT plan route's expired-token envelope" { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); diff --git a/src/agent_steps.zig b/src/agent_steps.zig index 2676afd2..2c8e420c 100644 --- a/src/agent_steps.zig +++ b/src/agent_steps.zig @@ -283,6 +283,7 @@ pub fn assembleStream(self: *Agent, body: []const u8) !?std.json.ObjectMap { pub const assembleAnthropic = @import("anthropic_assemble.zig").assembleAnthropic; const chat_tool_calls = @import("chat_tool_calls.zig"); +const sse_error = @import("sse_error_event.zig"); pub fn assembleOpenAI(self: *Agent, body: []const u8) !?std.json.ObjectMap { const result_arena = self.messageMutationAlloc(); @@ -298,10 +299,12 @@ pub fn assembleOpenAI(self: *Agent, body: []const u8) !?std.json.ObjectMap { var extra_content: ?Value = null; var saw_chunk = false; var saw_done = false; // `data: [DONE]`: the stream ended on purpose even without a finish_reason + var named_error = false; // the data lines follow an `event: error` name var it = std.mem.tokenizeScalar(u8, body, '\n'); while (it.next()) |raw_line| { const payload = ssePayload(raw_line) orelse { saw_done = saw_done or std.mem.eql(u8, std.mem.trim(u8, raw_line, " \r"), "data: [DONE]"); + named_error = sse_error.namesError(raw_line, named_error); continue; }; const v = std.json.parseFromSliceLeaky(Value, self.scratchAlloc(), payload, .{ .allocate = .alloc_always }) catch continue; // #124: per-event parse tree is transient (deltas are appendSlice'd into session accumulators) @@ -309,15 +312,8 @@ pub fn assembleOpenAI(self: *Agent, body: []const u8) !?std.json.ObjectMap { // Error-only SSE (`event: error` / `data: {"error":…}`) has no // choices chunk. Hand the envelope back so request() reports it // instead of falling through to the truncated-body retry (#748). - if (v.object.get("error")) |err| if (err != .null) { - var env: std.json.ObjectMap = .empty; - try env.put(result_arena, "type", .{ .string = "error" }); - try env.put(result_arena, "error", try util.dupeJsonValue(result_arena, err)); - return env; - }; - if (v.object.get("type")) |t| if (t == .string and std.mem.eql(u8, t.string, "error")) { - return (try util.dupeJsonValue(result_arena, v)).object; - }; + if (try sse_error.envelope(result_arena, v.object, named_error)) |env| return env; + named_error = false; // The final usage chunk (stream_options.include_usage) may have // an empty choices array. if (v.object.get("usage")) |u| if (u == .object) { @@ -429,4 +425,5 @@ pub fn assembleOpenAI(self: *Agent, body: []const u8) !?std.json.ObjectMap { test { _ = @import("agent_steps_tests.zig"); _ = @import("anthropic_assemble.zig"); + _ = @import("sse_error_event.zig"); } diff --git a/src/agent_stream.zig b/src/agent_stream.zig index 03747230..1aa551ff 100644 --- a/src/agent_stream.zig +++ b/src/agent_stream.zig @@ -30,7 +30,8 @@ const providerUserAgent = http.providerUserAgent; const capture5xxBodyStream = http.capture5xxBodyStream; const WatchdogFired = http.WatchdogFired; const sendHeadTask = http.sendHeadTask; -const streamLineTask = http.streamLineTask; +const stream_progress = @import("stream_read_progress.zig"); +const streamLineTask = stream_progress.lineTask; const deadlineStallTask = http.deadlineStallTask; const watchdogError = http.watchdogError; const http_stall = @import("http_stall.zig"); @@ -252,7 +253,8 @@ pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []cons // #680: widened by each stall reconnect this request has already made. // A terminal finish_reason may precede usage; never extend that completed response's trailer wait. const stall_budget = if (saw_done) http_stall.interFrameBudgetMs(http.stream_stall_ms, got_body, prose_open, self.stall.widen) else policy.interFrameBudgetMs(got_body, prose_open, self.stall.widen); - rsel.concurrent(.line, streamLineTask, .{ reader, &line.writer }) catch { + var progress = stream_progress.Progress.init(self.io, .{ .idle_ms = stall_budget }); + rsel.concurrent(.line, streamLineTask, .{ reader, &line.writer, &progress }) catch { // #56 Fix-B: pool exhausted — no idle-stall watchdog for this read. // A bare blocking streamDelimiterEnding here is the last forever-hang: // on a half-open socket it never returns and nothing can trip it. Fail @@ -266,7 +268,7 @@ pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []cons if (req.connection) |conn| conn.closing = true; return error.StreamStalled; }; - rsel.concurrent(.stall, deadlineStallTask, .{ self.io, orig_tio != null, stall_budget }) catch { + rsel.concurrent(.stall, stream_progress.Progress.watch, .{ &progress, orig_tio != null }) catch { const r = rsel.await() catch |e| { rsel.cancelDiscard(); return e; @@ -308,7 +310,7 @@ pub fn postStreamWithClient(self: *Agent, client: *std.http.Client, body: []cons if (w == .deadline) self.stall.tripped_ms = stall_budget; // #680: the turn-ending message reports THIS wait if (w == .deadline) sink.emit(self.io, .{ .transport_aborted = .{ .reason = .stalled, .turn_ending = false } }) else sink.emit(self.io, .{ .stream_aborted = .interrupted }); if (req.connection) |conn| conn.closing = true; - return watchdogError(w, error.StreamStalled); + return watchdogError(w, progress.deadlineError()); }, } } diff --git a/src/agent_stream_h2.zig b/src/agent_stream_h2.zig index e3ef8a0f..f5416d66 100644 --- a/src/agent_stream_h2.zig +++ b/src/agent_stream_h2.zig @@ -18,6 +18,7 @@ const http_zig = @import("http_zig"); const WatchdogFired = http.WatchdogFired; const watchdogError = http.watchdogError; const deadlineStallTask = http.deadlineStallTask; +const stream_progress = @import("stream_read_progress.zig"); const ssePayload = Agent.ssePayload; const escPressed = Agent.escPressed; @@ -99,6 +100,7 @@ pub fn postStream(self: *Agent, body: []const u8, poll_stdin: bool) !?[]u8 { // Esc pressed while connecting / waiting for headers? Stop before the body. if (poll_stdin and escPressed(true)) return error.Interrupted; const lines = &head.lines.?; + var reader: stream_progress.H2Lines = .{ .source = lines }; // Any other status streams its body like the HTTP/1.1 path does: a 4xx // error envelope is what request()'s parser classifies. @@ -117,7 +119,7 @@ pub fn postStream(self: *Agent, body: []const u8, poll_stdin: bool) !?[]u8 { while (true) { // Buffered generation is over once finish_reason arrives; usage trailers keep the normal deadline. const stall_budget = if (saw_done) http_stall.interFrameBudgetMs(http.stream_stall_ms, got_body, prose_open, self.stall.widen) else policy.interFrameBudgetMs(got_body, prose_open, self.stall.widen); - const got = raceLine(self.io, lines, &line, gpa, poll_stdin, stall_budget) orelse { + const got = raceLine(self.io, &reader, &line, gpa, poll_stdin, stall_budget) orelse { // #56 Fix-B: no spare concurrency for the read. Fail safe. if (saw_done) break; self.stall.tripped_ms = stall_budget; @@ -127,7 +129,7 @@ pub fn postStream(self: *Agent, body: []const u8, poll_stdin: bool) !?[]u8 { switch (got) { .line => |r| { const more = r catch |e| { - if (e == error.Canceled) return e; + if (e == error.Canceled or e == error.StreamTooLong or e == error.StreamEventDeadline) return e; if (saw_done) break; if (got_body) { sink.emit(self.io, .{ .stream_aborted = .dropped }); @@ -163,7 +165,7 @@ pub fn postStream(self: *Agent, body: []const u8, poll_stdin: bool) !?[]u8 { if (self.provider.kind == .openai and openaiComplete(line.items)) saw_done = true; if (isStreamEnd(self.scratchAlloc(), self.provider.kind, line.items)) { saw_done = true; - keep = settleAfterEnd(self.io, lines, gpa); + keep = settleAfterEnd(self.io, &reader, gpa); break; } if ((poll_stdin and escPressed(true)) or (self.sub and Agent.esc_cancel.load(.acquire))) { @@ -297,42 +299,36 @@ fn captureErrorBody(gpa: std.mem.Allocator, lines: *http_zig.LineStream) void { } } -const ReadDone = union(enum) { line: anyerror!bool, stall: WatchdogFired }; +pub const ReadDone = union(enum) { line: anyerror!bool, stall: WatchdogFired }; /// One readLine raced against the between-lines watchdog. Null when there is /// no concurrency slot for the read at all. -fn raceLine(io: Io, lines: *http_zig.LineStream, line: *std.ArrayList(u8), gpa: std.mem.Allocator, poll_stdin: bool, budget_ms: u64) ?ReadDone { +pub fn raceLine(io: Io, reader: *stream_progress.H2Lines, line: *std.ArrayList(u8), gpa: std.mem.Allocator, poll_stdin: bool, budget_ms: u64) ?ReadDone { var rd_buf: [2]ReadDone = undefined; var rsel: Io.Select(ReadDone) = .init(io, &rd_buf); - rsel.concurrent(.line, lineTask, .{ lines, line, gpa }) catch return null; - rsel.concurrent(.stall, deadlineStallTask, .{ io, poll_stdin, budget_ms }) catch {}; + var progress = stream_progress.Progress.init(io, .{ .idle_ms = budget_ms }); + rsel.concurrent(.line, stream_progress.H2Lines.readLine, .{ reader, line, gpa, &progress }) catch return null; + rsel.concurrent(.stall, stream_progress.Progress.watch, .{ &progress, poll_stdin }) catch {}; const first = rsel.await() catch |e| { rsel.cancelDiscard(); return .{ .line = e }; }; rsel.cancelDiscard(); + if (first == .stall and first.stall == .deadline and progress.assembly_expired) + return .{ .line = error.StreamEventDeadline }; return first; } /// The terminal event landed. Reuse the connection only if END_STREAM /// follows promptly; a server that holds the stream open costs one dial. -fn settleAfterEnd(io: Io, lines: *http_zig.LineStream, gpa: std.mem.Allocator) bool { +fn settleAfterEnd(io: Io, reader: *stream_progress.H2Lines, gpa: std.mem.Allocator) bool { var junk: std.ArrayList(u8) = .empty; defer junk.deinit(gpa); while (true) { - if (lines.ended) { - while (lines.readLine(&junk) catch return false) {} - return true; - } - const got = raceLine(io, lines, &junk, gpa, false, trailing_end_ms) orelse return false; + const got = raceLine(io, reader, &junk, gpa, false, trailing_end_ms) orelse return false; switch (got) { - .line => |r| if (!(r catch return false)) return lines.ended, + .line => |r| if (!(r catch return false)) return reader.source.ended, .stall => return false, } } } - -fn lineTask(ls: *http_zig.LineStream, dest: *std.ArrayList(u8), gpa: std.mem.Allocator) !bool { - _ = gpa; - return ls.readLine(dest); -} diff --git a/src/agent_stream_h2_progress_test.zig b/src/agent_stream_h2_progress_test.zig new file mode 100644 index 00000000..61c0fc3d --- /dev/null +++ b/src/agent_stream_h2_progress_test.zig @@ -0,0 +1,181 @@ +//! The production H2 line race must observe DATA bytes before the first LF. +//! Scripted HTTP/2 frames only: no sockets, TLS, provider, or user data. +const std = @import("std"); +const Io = std.Io; +const http_zig = @import("http_zig"); +const h2 = @import("agent_stream_h2.zig"); +const H2Lines = @import("stream_read_progress.zig").H2Lines; + +const idle_ms: u64 = 250; +const frame_delay_ms: i64 = 25; +const fragments: usize = 40; +const control_frames: usize = 80; +const fragment: [128]u8 = @splat('A'); + +/// Like the pinned dependency's RecordReader, refill the Reader buffer instead +/// of bypassing it through the Writer. Each refill exposes one complete frame, +/// so even a read that requests more bytes cannot swallow the whole script. +const PacedReader = struct { + interface: Io.Reader = undefined, + buffer: [1024]u8 = undefined, + bytes: []const u8 = &.{}, + io: Io = undefined, + hold_open: bool = false, + data_seen: usize = 0, + controls_seen: usize = 0, + + fn init(self: *PacedReader, io: Io, bytes: []const u8, hold_open: bool) void { + self.io = io; + self.bytes = bytes; + self.hold_open = hold_open; + self.interface = .{ .vtable = &.{ .stream = stream }, .buffer = &self.buffer, .seek = 0, .end = 0 }; + } + + fn stream(r: *Io.Reader, _: *Io.Writer, _: Io.Limit) Io.Reader.StreamError!usize { + const self: *PacedReader = @alignCast(@fieldParentPtr("interface", r)); + if (self.bytes.len == 0) { + // Sleep is cancellable: raceLine.cancelDiscard joins this read + // before the fixture, connection, or destination is deinitialized. + while (self.hold_open) self.io.sleep(.fromMilliseconds(10), .awake) catch return error.ReadFailed; + return error.EndOfStream; + } + if (self.bytes.len < 9) return error.ReadFailed; + const n = 9 + (@as(usize, self.bytes[0]) << 16) + (@as(usize, self.bytes[1]) << 8) + self.bytes[2]; + if (n > self.bytes.len) return error.ReadFailed; + const typ = self.bytes[3]; + if (typ == 0 or typ == 6) { + self.io.sleep(.fromMilliseconds(frame_delay_ms), .awake) catch return error.ReadFailed; + } + const pending = r.buffered(); + if (pending.len + n > r.buffer.len) return error.ReadFailed; + @memmove(r.buffer[0..pending.len], pending); + @memcpy(r.buffer[pending.len..][0..n], self.bytes[0..n]); + r.seek = 0; + r.end = pending.len + n; + self.bytes = self.bytes[n..]; + if (typ == 0) self.data_seen += 1; + if (typ == 6) self.controls_seen += 1; + return 0; + } +}; + +fn writeHead(w: *Io.Writer) !void { + try http_zig.frame.write(w, .{ .typ = .settings, .flags = 0, .stream_id = 0, .payload = &.{} }); + // HPACK's static-table :status 200, END_HEADERS, without END_STREAM. + try http_zig.frame.write(w, .{ .typ = .headers, .flags = 0x4, .stream_id = 1, .payload = &.{0x88} }); +} + +fn writeData(w: *Io.Writer, bytes: []const u8, end_stream: bool) !void { + try http_zig.frame.write(w, .{ .typ = .data, .flags = if (end_stream) 0x1 else 0, .stream_id = 1, .payload = bytes }); +} + +fn readLine(reader: *H2Lines, line: *std.ArrayList(u8)) !bool { + const got = h2.raceLine(std.testing.io, reader, line, std.testing.allocator, false, idle_ms) orelse return error.NoReadTask; + return switch (got) { + .line => |result| try result, + .stall => error.UnexpectedStall, + }; +} + +fn elapsedMs(start: Io.Timestamp) u64 { + const ns = Io.Timestamp.now(std.testing.io, .awake).nanoseconds - start.nanoseconds; + return @intCast(@divTrunc(ns, std.time.ns_per_ms)); +} + +test "HTTP/2 SSE byte progress keeps a fragmented line alive beyond its idle budget" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var wire: Io.Writer.Allocating = .init(gpa); + defer wire.deinit(); + try writeHead(&wire.writer); + try writeData(&wire.writer, "data: ", false); + for (0..fragments) |_| try writeData(&wire.writer, &fragment, false); + // Both the blank line and an unterminated final line share the last frame: + // subsequent races must retain bytes beyond the first delimiter. + try writeData(&wire.writer, "\n\ndata: tail", true); + + var peer: PacedReader = .{}; + peer.init(io, wire.written(), false); + var client: Io.Writer.Allocating = .init(gpa); + defer client.deinit(); + var conn = http_zig.Conn.init(gpa, &peer.interface, &client.writer); + defer conn.deinit(); + var source = try conn.startLines(.{ .method = "POST", .scheme = "https", .authority = "fixture.invalid", .path = "/events", .body = "{}" }); + defer source.deinit(); + try std.testing.expectEqual(@as(u16, 200), try source.waitStatus()); + var reader: H2Lines = .{ .source = &source }; + var line: std.ArrayList(u8) = .empty; + defer line.deinit(gpa); + + const start = Io.Timestamp.now(io, .awake); + try std.testing.expect(try readLine(&reader, &line)); + // Forty 25 ms fragments take at least four idle budgets, while every + // individual DATA gap has a tenfold allowance for scheduling jitter. + try std.testing.expect(elapsedMs(start) >= 3 * idle_ms); + try std.testing.expectEqual(@as(usize, 6 + fragments * fragment.len), line.items.len); + try std.testing.expectEqualStrings("data: ", line.items[0..6]); + for (line.items[6..]) |byte| try std.testing.expectEqual(@as(u8, 'A'), byte); + try std.testing.expectEqual(@as(usize, fragments + 2), peer.data_seen); + try std.testing.expect(try readLine(&reader, &line)); + try std.testing.expectEqualStrings("", line.items); + try std.testing.expect(try readLine(&reader, &line)); + try std.testing.expectEqualStrings("data: tail", line.items); + try std.testing.expect(!try readLine(&reader, &line)); + try std.testing.expect(source.ended); +} + +fn expectStoppedPartial(with_controls: bool) !void { + const gpa = std.testing.allocator; + const io = std.testing.io; + var wire: Io.Writer.Allocating = .init(gpa); + defer wire.deinit(); + try writeHead(&wire.writer); + try writeData(&wire.writer, "data: partial", false); + if (with_controls) { + // ACKs need no response. They exercise Conn.readFrame's control loop + // without adding any response-body bytes or completing the SSE line. + for (0..control_frames) |_| try http_zig.frame.write(&wire.writer, .{ .typ = .ping, .flags = 0x1, .stream_id = 0, .payload = "12345678" }); + } + + var peer: PacedReader = .{}; + peer.init(io, wire.written(), true); + var client: Io.Writer.Allocating = .init(gpa); + defer client.deinit(); + var conn = http_zig.Conn.init(gpa, &peer.interface, &client.writer); + defer conn.deinit(); + var source = try conn.startLines(.{ .method = "POST", .scheme = "https", .authority = "fixture.invalid", .path = "/events", .body = "{}" }); + defer source.deinit(); + try std.testing.expectEqual(@as(u16, 200), try source.waitStatus()); + var reader: H2Lines = .{ .source = &source }; + var line: std.ArrayList(u8) = .empty; + defer line.deinit(gpa); + + const start = Io.Timestamp.now(io, .awake); + const got = h2.raceLine(io, &reader, &line, gpa, false, idle_ms) orelse return error.NoReadTask; + switch (got) { + .stall => |reason| try std.testing.expectEqual(.deadline, reason), + .line => |result| { + _ = try result; + return error.ExpectedIdleTimeout; + }, + } + try std.testing.expect(elapsedMs(start) >= idle_ms); + try std.testing.expectEqual(@as(usize, 1), peer.data_seen); + try std.testing.expectEqualStrings("data: partial", line.items); + try std.testing.expect(!source.ended); + if (with_controls) { + try std.testing.expect(peer.controls_seen > 0); + // A timeout only after all two seconds of PINGs would falsely treat + // transport traffic as progress. Require cancellation during them. + try std.testing.expect(peer.controls_seen < control_frames); + try std.testing.expect(peer.bytes.len > 0); + } +} + +test "HTTP/2 SSE byte progress still times out a stopped partial line" { + try expectStoppedPartial(false); +} + +test "HTTP/2 SSE byte progress ignores control traffic after a partial line" { + try expectStoppedPartial(true); +} diff --git a/src/agent_stream_progress_test.zig b/src/agent_stream_progress_test.zig new file mode 100644 index 00000000..7b95bcf1 --- /dev/null +++ b/src/agent_stream_progress_test.zig @@ -0,0 +1,93 @@ +//! Partial SSE events are active reads, not silent sockets (#1686). +//! Synthetic loopback traffic drives the production HTTP/1 SSE entry point. +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; +const Agent = @import("agent.zig").Agent; +const http = @import("http.zig"); +const agent_stream = @import("agent_stream.zig"); + +const Server = struct { + const Mode = enum { flowing, stopped }; + + fn run(io: Io, listener: *Io.net.Server, mode: Mode, done: *std.atomic.Value(bool)) void { + const conn = listener.accept(io) catch return; + defer conn.close(io); + var input_buf: [8192]u8 = undefined; + var input = Io.net.Stream.Reader.init(conn, io, &input_buf); + while (true) { + const line = (input.interface.takeDelimiter('\n') catch return) orelse return; + if (line.len == 0 or (line.len == 1 and line[0] == '\r')) break; + } + var output_buf: [1024]u8 = undefined; + var output = Io.net.Stream.Writer.init(conn, io, &output_buf); + output.interface.writeAll("HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\n" ++ + ": ready\n\ndata: {\"choices\":[],\"padding\":\"") catch return; + output.interface.flush() catch return; + const fragment: [1]u8 = @splat('A'); + for (0..18) |_| { + if (done.load(.acquire)) return; + output.interface.writeAll(&fragment) catch return; + output.interface.flush() catch return; + if (mode == .stopped) break; + io.sleep(.fromMilliseconds(40), .awake) catch return; + } + if (mode == .flowing) { + output.interface.writeAll("\"}\n\ndata: [DONE]\n\n") catch return; + output.interface.flush() catch return; + } + while (!done.load(.acquire)) io.sleep(.fromMilliseconds(10), .awake) catch return; + } +}; + +fn exercise(mode: Server.Mode) !void { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var addr = try Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var listener = try Io.net.IpAddress.listen(&addr, io, .{}); + defer listener.deinit(io); + var done: std.atomic.Value(bool) = .init(false); + var server = io.async(Server.run, .{ io, &listener, mode, &done }); + defer server.await(io); + defer done.store(true, .release); + + const saved_budget = http.stream_stall_ms; + http.stream_stall_ms = 150; + defer http.stream_stall_ms = saved_budget; + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + var client: std.http.Client = .{ .allocator = gpa, .io = io }; + defer client.deinit(); + var url_buf: [96]u8 = undefined; + const url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/v1/chat/completions", .{listener.socket.address.getPort()}); + var agent: Agent = .{ + .gpa = gpa, + .arena = arena_state.allocator(), + .io = io, + .client = &client, + .provider = .{ .id = "test", .kind = .openai, .auth = .bearer, .url = url, .api_key = "synthetic", .model = "fixture", .context = 0 }, + .messages = std.json.Array.init(arena_state.allocator()), + .sub = true, + .label = "fixture", + .out = null, + }; + const response = agent_stream.postStreamWithClient(&agent, &client, "{}"); + if (mode == .stopped) { + try std.testing.expectError(error.StreamStalled, response); + try std.testing.expectEqual(@as(u64, 150), agent.stall.tripped_ms); + } else { + const body = try response; + defer gpa.free(body); + try std.testing.expect(std.mem.indexOf(u8, body, "data: [DONE]") != null); + try std.testing.expectEqual(@as(usize, 18), std.mem.count(u8, body, "A")); + } +} + +test "SSE byte progress keeps an unfinished event alive beyond its idle budget" { + try exercise(.flowing); +} + +test "SSE byte progress does not hide a stopped partial event" { + try exercise(.stopped); +} diff --git a/src/agent_stream_render.zig b/src/agent_stream_render.zig index 0cfeb7c4..27fb56e3 100644 --- a/src/agent_stream_render.zig +++ b/src/agent_stream_render.zig @@ -110,6 +110,16 @@ pub fn spinnerStop(self: *Agent) void { } } +fn writeThinkingHeader(self: *Agent) !void { + const w = self.out orelse return; + const label = if (self.thinking_folded) "▶ Thinking · Ctrl+T expand" else "▼ Thinking · Ctrl+T collapse"; + try w.print("{s}{s}{s}\n{s}", .{ style.muted, label, style.reset, style.muted }); + self.thinking_rows = 0; + self.thinking_col = 0; + advanceThinkingRows(&self.thinking_rows, &self.thinking_col, termCols(), label); + advanceThinkingRows(&self.thinking_rows, &self.thinking_col, termCols(), "\n"); +} + /// Stream a chunk of the model's reasoning into a live, dimmed "Thinking" /// block in the terminal, opening the block (and handing the line off from /// the spinner) on the first chunk. Gated by /thinking; when off the block is @@ -125,12 +135,10 @@ pub fn streamThinking(self: *Agent, chunk: []const u8) void { if (visible.len == 0) return; if (!self.thinking_open) { self.spinnerStop(); - w.print("{s}▼ Thinking{s}\n{s}", .{ style.dim, style.reset, style.dim }) catch return; + self.thinking_overflow = false; + writeThinkingHeader(self) catch return; self.thinking_open = true; main_mod.g_thinking_open = true; - self.thinking_rows = 1; // the header newline already moved us down one line - self.thinking_col = 0; - self.thinking_overflow = false; // The block owns every row below this one and collapses them by cursor // math — a child's tick printed inside it would be erased with the // block (or shift the erase onto real output). Hold until it closes. @@ -178,18 +186,13 @@ pub fn closeThinkingBlock(self: *Agent) void { pub fn toggleThinkingFold(self: *Agent) void { if (!self.thinking_open or self.thinking_overflow or !main_mod.use_color) return; const w = self.out orelse return; + w.print("\x1b[{d}F\x1b[0J", .{self.thinking_rows}) catch return; + self.thinking_folded = !self.thinking_folded; + writeThinkingHeader(self) catch return; if (!self.thinking_folded) { - w.print("\x1b[{d}F\x1b[0J{s}▶ Thinking (folded · ^T){s}\n", .{ self.thinking_rows, style.dim, style.reset }) catch return; - self.thinking_folded = true; - self.thinking_rows = 1; - self.thinking_col = 0; - } else { - w.print("\x1b[1F\x1b[0J{s}▼ Thinking{s}\n{s}", .{ style.dim, style.reset, style.dim }) catch return; - self.thinking_folded = false; - self.thinking_rows = 1; - self.thinking_col = 0; w.writeAll(self.thinking_text.items) catch return; advanceThinkingRows(&self.thinking_rows, &self.thinking_col, termCols(), self.thinking_text.items); + if (self.thinking_rows + 1 >= termRows()) self.thinking_overflow = true; } w.flush() catch return; } diff --git a/src/agent_working.zig b/src/agent_working.zig index e51d02b9..093dbca4 100644 --- a/src/agent_working.zig +++ b/src/agent_working.zig @@ -14,10 +14,14 @@ pub const Item = struct { content: []const u8, done: bool = false, active: bool = false, + /// #1520: withdrawn or delegated. Drawn ⊘ with its reason, outside the done/total count. + cancelled: bool = false, + reason: []const u8 = "", }; const title_clip: usize = 40; const item_clip: usize = 48; +const reason_clip: usize = 40; const max_items: usize = 8; fn clip(s: []const u8, n: usize) []const u8 { @@ -30,15 +34,24 @@ pub fn write(w: *Io.Writer, title: []const u8, items: []const Item) void { if (items.len == 0 and title.len == 0) return; const shown = if (title.len > 0) clip(title, title_clip) else "work"; var done: usize = 0; + var counted: usize = 0; for (items) |it| { + if (it.cancelled) continue; + counted += 1; if (it.done) done += 1; } w.print("{s}WORKING{s} {s}{s}{s}{s}", .{ style.muted, style.reset, style.bold, style.text, shown, style.reset }) catch return; - if (items.len > 0) w.print(" {s}{d}/{d}{s}", .{ style.muted, done, items.len, style.reset }) catch return; + if (counted > 0) w.print(" {s}{d}/{d}{s}", .{ style.muted, done, counted, style.reset }) catch return; w.writeByte('\n') catch return; const n = @min(items.len, max_items); for (items[0..n], 0..) |it, i| { const branch: []const u8 = if (i + 1 == n) "└ " else "├ "; + if (it.cancelled) { + w.print("{s}{s}⊘ {s}", .{ style.faint, branch, clip(it.content, item_clip) }) catch return; + if (it.reason.len > 0) w.print(" ({s})", .{clip(it.reason, reason_clip)}) catch return; + w.print("{s}\n", .{style.reset}) catch return; + continue; + } const mark: []const u8 = if (it.done) "✓" else if (it.active) (if (style.accent.len > 0) "●" else "◌") else @@ -56,11 +69,12 @@ pub fn writeFromStanding(w: *Io.Writer, st: engine_events.StandingWork) void { defer writeWaiting(w, st.waiting); // Keep completed history, but do not redraw a finished checklist as WORKING. // Use the full counts: an open item may be beyond the displayed slice. - if (st.todos_total > 0 and st.todos_done == st.todos_total) return; + // Cancelled items sit outside the counts (#1520), so all-cancelled is finished too. + if ((st.todos_total > 0 or st.todos.len > 0) and st.todos_done == st.todos_total) return; var buf: [max_items]Item = undefined; const n = @min(st.todos.len, buf.len); for (st.todos[0..n], 0..) |t, i| { - buf[i] = .{ .content = t.content, .done = t.done, .active = t.active }; + buf[i] = .{ .content = t.content, .done = t.done, .active = t.active, .cancelled = t.cancelled, .reason = t.reason }; } write(w, st.goal, buf[0..n]); } @@ -95,6 +109,15 @@ pub fn writeFromTodoText(w: *Io.Writer, title: []const u8, text: []const u8) voi } else if (std.mem.eql(u8, mark, "[ ]")) { buf[n] = .{ .content = rest }; n += 1; + } else if (std.mem.eql(u8, mark, "[-]")) { + // goal_state.renderTodos writes "[-] content (cancelled: reason)". + const cut = std.mem.lastIndexOf(u8, rest, " (cancelled: "); + const has_reason = cut != null and std.mem.endsWith(u8, rest, ")"); + buf[n] = if (has_reason) + .{ .content = rest[0..cut.?], .reason = rest[cut.? + " (cancelled: ".len .. rest.len - 1], .cancelled = true } + else + .{ .content = rest, .cancelled = true }; + n += 1; } } if (n == 0) return; @@ -160,6 +183,27 @@ test "a checklist is WORKING plus a tree, not [~] dump lines" { ); } +test "#1520 a cancelled item is drawn with its reason, outside the done count" { + var aw: Io.Writer.Allocating = .init(std.testing.allocator); + defer aw.deinit(); + writeFromTodoText(&aw.writer, "ship", "[x] land the fix\n[ ] run the tests\n[-] verify the release (cancelled: delegated to the release session)\n"); + try std.testing.expectEqualStrings( + "WORKING ship 1/2\n" ++ + "├ ✓ land the fix\n" ++ + "├ ○ run the tests\n" ++ + "└ ⊘ verify the release (delegated to the release session)\n", + aw.writer.buffered(), + ); + aw.clearRetainingCapacity(); + // Everything else done: the idle prompt treats the list as finished. + const items = [_]engine_events.StandingTodo{ + .{ .content = "land the fix", .done = true }, + .{ .content = "verify the release", .cancelled = true, .reason = "delegated" }, + }; + writeFromStanding(&aw.writer, .{ .todos = &items, .todos_done = 1, .todos_total = 1 }); + try std.testing.expectEqualStrings("", aw.written()); +} + test "writeFromStanding uses the goal title and active/done marks" { var aw: Io.Writer.Allocating = .init(std.testing.allocator); defer aw.deinit(); diff --git a/src/agent_ws.zig b/src/agent_ws.zig index b5db7c5a..7956ca41 100644 --- a/src/agent_ws.zig +++ b/src/agent_ws.zig @@ -116,6 +116,7 @@ pub fn postLive(self: *Agent, body: []const u8) ![]u8 { emitAbort(self, .dropped, true); return error.StreamDropped; } + if (@import("claude_agent.zig").owns(self)) return @import("claude_agent.zig").post(self, body); // ADR 0309 if (!wsEligible(self)) return self.postStream(body); const response = postResponsesWs(self, body) catch |e| { if (@import("agent_async_tools.zig").started(self)) return e; diff --git a/src/agent_ws_test.zig b/src/agent_ws_test.zig index eb0aee73..5ec85476 100644 --- a/src/agent_ws_test.zig +++ b/src/agent_ws_test.zig @@ -95,9 +95,9 @@ test "the codex .responses arm refreshes auth and re-anchors before resending (# try std.testing.expect(std.mem.indexOf(u8, src[arm_end..], "retryAfterAuthRefresh(self, msg, &auth_refreshed)") != null); // response.failed uses the shared transient retry, then the gateway-artifact retry. - try std.testing.expect(std.mem.indexOf(u8, arm, "policy.afterServerErrorOrParseReject(self, \"\", failure.code, msg, &server_retries, &gw_retry)") != null); + try std.testing.expect(std.mem.indexOf(u8, arm, "policy.afterServerErrorOrParseReject(self, failure.etype, failure.code, msg, &server_retries, &gw_retry)") != null); const overflow_at = std.mem.indexOf(u8, arm, "recoverContextOverflow(self, msg, failure.code, &context_retried)") orelse return error.ResponsesPathHasNoOverflowRecovery; - try std.testing.expect(overflow_at < std.mem.indexOf(u8, arm, "policy.afterServerErrorOrParseReject(self, \"\", failure.code, msg, &server_retries, &gw_retry)").?); + try std.testing.expect(overflow_at < std.mem.indexOf(u8, arm, "policy.afterServerErrorOrParseReject(self, failure.etype, failure.code, msg, &server_retries, &gw_retry)").?); const gate = @embedFile("agent_gateway_retry.zig"); try std.testing.expect(std.mem.indexOf(u8, gate, "retryTransientServerError(self, etype, code, msg, server_retries)") != null); diff --git a/src/anthropic_assemble.zig b/src/anthropic_assemble.zig index 5940c7d7..3afcb594 100644 --- a/src/anthropic_assemble.zig +++ b/src/anthropic_assemble.zig @@ -8,6 +8,7 @@ const Value = std.json.Value; const Agent = @import("agent.zig").Agent; const util = @import("util.zig"); const tool_call_args = @import("tool_call_args.zig"); +const sse_error = @import("sse_error_event.zig"); const ssePayload = Agent.ssePayload; const sseIndex = Agent.sseIndex; @@ -47,11 +48,22 @@ pub fn assembleAnthropic(self: *Agent, body: []const u8) !?std.json.ObjectMap { var stop_details: ?Value = null; // a refusal's category (ADR 0219) var usage_delta: ?Value = null; var saw_block_stop = false; + var named_error = false; // the data lines follow an `event: error` name var it = std.mem.tokenizeScalar(u8, body, '\n'); while (it.next()) |raw_line| { - const payload = ssePayload(raw_line) orelse continue; + const payload = ssePayload(raw_line) orelse { + named_error = sse_error.namesError(raw_line, named_error); + continue; + }; const v = std.json.parseFromSliceLeaky(Value, scratch, payload, .{ .allocate = .alloc_always }) catch continue; if (v != .object) continue; + // An error event (typed, untyped from a proxy, or a bare frame under + // `event: error`) becomes the root: request()'s type=="error" check + // reports it with the provider's type and message (#748). Detached + // from scratch: the rebuild loop can reset the scratch arena before + // the message is done being read. + if (try sse_error.envelope(result_arena, v.object, named_error)) |env| return env; + named_error = false; const t = v.object.get("type") orelse continue; if (t != .string) continue; if (std.mem.eql(u8, t.string, "message_start")) { @@ -90,12 +102,6 @@ pub fn assembleAnthropic(self: *Agent, body: []const u8) !?std.json.ObjectMap { if (v.object.get("usage")) |u| if (u == .object) { usage_delta = u; }; - } else if (std.mem.eql(u8, t.string, "error")) { - // Hand the envelope back as the root: request()'s existing - // type=="error" check reports it. Detached from scratch — the - // rebuild loop can reset the scratch arena before the message - // is done being read. - return (try util.dupeJsonValue(result_arena, v)).object; } } var r = root orelse return null; diff --git a/src/anthropic_profile.zig b/src/anthropic_profile.zig index 2cc87d4d..b8de2552 100644 --- a/src/anthropic_profile.zig +++ b/src/anthropic_profile.zig @@ -52,6 +52,19 @@ var remembered: ?Located = null; /// Anthropic's CLI; tests point it at a stand-in. pub var ant_bin: []const u8 = "ant"; var last_failed_ms: i64 = 0; +/// The profile's token is past its expiry and `ant` could not renew it: with +/// no refresh token stored, `ant auth print-credentials` still exits 0 and +/// prints the same expired token. Such a token is never seated or sent. +var expired_unrenewable: bool = false; + +/// Did the Console sign-in expire beyond renewal (`graff login console` fixes it)? +pub fn expired() bool { + return expired_unrenewable; +} + +fn pastExpiry(p: Profile, now_ms: i64) bool { + return if (p.expires_at) |exp| exp * 1000 <= now_ms else false; +} pub fn locate(arena: Allocator, environ_map: anytype, home: []const u8) ?Located { const dir: []const u8 = blk: { @@ -123,9 +136,25 @@ pub fn load(io: Io, arena: Allocator, environ_map: anytype, home: []const u8, na if (named_only and !loc.named) return null; const p = read(io, arena, loc) orelse return null; remember(loc, p); + expired_unrenewable = false; + // An expired token is only worth seating (or parking behind a plan) if + // `ant` can renew it; otherwise every request it carries is a 401. + if (pastExpiry(p, @import("util.zig").unixMs(io))) { + const t = renewed(io, arena, loc, p.token) orelse { + expired_unrenewable = true; + return null; + }; + return std.heap.page_allocator.dupe(u8, t) catch null; + } return std.heap.page_allocator.dupe(u8, p.token) catch null; } +/// A token `ant` renewed, or null when it failed or handed back `old`. +fn renewed(io: Io, arena: Allocator, loc: Located, old: []const u8) ?[]const u8 { + const t = refreshViaAnt(io, arena, profileName(io, arena, loc)) orelse return null; + return if (std.mem.eql(u8, t, old)) null else t; +} + fn remember(loc: Located, p: Profile) void { const a = std.heap.page_allocator; _ = p; @@ -140,17 +169,26 @@ pub fn fresh(io: Io, gpa: Allocator, arena: Allocator, force: bool) ?[]const u8 const loc = remembered orelse return null; const p = read(io, arena, loc) orelse return null; const now_ms = @import("util.zig").unixMs(io); + _ = gpa; const near = if (p.expires_at) |exp| exp * 1000 - now_ms <= refresh_margin_s * 1000 else false; if (!force and !near) return p.token; - if (now_ms - last_failed_ms < retry_after_ms) return p.token; - return refreshViaAnt(io, gpa, arena, profileName(io, arena, loc)) orelse { + // Past expiry, the old token is a sure 401: offer nothing instead. + const fallback: ?[]const u8 = if (pastExpiry(p, now_ms)) null else p.token; + if (now_ms - last_failed_ms < retry_after_ms) return fallback; + const t = refreshViaAnt(io, arena, profileName(io, arena, loc)) orelse { last_failed_ms = now_ms; - return p.token; + return fallback; }; + if (fallback == null and std.mem.eql(u8, t, p.token)) { + last_failed_ms = now_ms; + expired_unrenewable = true; + return null; + } + expired_unrenewable = false; + return t; } -fn refreshViaAnt(io: Io, gpa: Allocator, arena: Allocator, profile: []const u8) ?[]const u8 { - _ = gpa; +fn refreshViaAnt(io: Io, arena: Allocator, profile: []const u8) ?[]const u8 { const pr = @import("process_runner.zig"); const run = pr.runCappedWithOptions(arena, io, &.{ ant_bin, "auth", "print-credentials", "--access-token", "--profile", profile }, 8192, 8192, 20_000, .{}) catch return null; if (!pr.ranOk(run)) return null; @@ -323,7 +361,9 @@ test "an expiring Console token is refreshed through ant, and sent as a bearer t last_failed_ms = 0; const env = Env{ .map = &.{.{ "ANTHROPIC_CONFIG_DIR", dir }} }; - try testing.expectEqualStrings("sk-ant-oat01-old", load(io, a, env, "", false).?); + // Already expired at startup: renewed before it is seated. + try testing.expectEqualStrings("sk-ant-oat01-new", load(io, a, env, "", false).?); + try testing.expect(!expired()); try testing.expectEqualStrings("sk-ant-oat01-new", fresh(io, testing.allocator, a, false).?); // The provider built from it authenticates with Bearer, not x-api-key. @@ -367,6 +407,45 @@ test "an expiring Console token is refreshed through ant, and sent as a bearer t try testing.expect(!policy.retryAfterAuthRefresh(&agent, "Unauthorized", &attempted)); } +test "an expired Console token ant cannot renew is never seated, parked or sent" { + if (builtin.os.tag == .windows) return error.SkipZigTest; // /bin/sh stand-in + const io = testing.io; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const dir = buf[0..try tmp.dir.realPath(io, &buf)]; + try writeFile(io, tmp.dir, "configs/default.json", + \\{"authentication":{"type":"user_oauth","client_id":"c"}} + ); + try writeFile(io, tmp.dir, "credentials/default.json", + \\{"access_token":"sk-ant-oat01-dead","expires_at":1} + ); + // With no refresh token stored, `ant` warns, exits 0 and prints the same token. + try writeFile(io, tmp.dir, "ant", "#!/bin/sh\necho 'warning: no refresh_token is stored' >&2\necho sk-ant-oat01-dead\n"); + const script = try std.fs.path.join(a, &.{ dir, "ant" }); + try Io.Dir.cwd().setFilePermissions(io, script, .fromMode(0o755), .{}); + const saved = ant_bin; + defer ant_bin = saved; + ant_bin = script; + last_failed_ms = 0; + defer expired_unrenewable = false; + + const env = Env{ .map = &.{.{ "ANTHROPIC_CONFIG_DIR", dir }} }; + try testing.expect(load(io, a, env, "", false) == null); + try testing.expect(expired()); + try testing.expect(fresh(io, testing.allocator, a, true) == null); + + var p: @import("provider.zig").Provider = .{ .id = "anthropic", .kind = .anthropic, .auth = .bearer, .url = "", .api_key = "k", .model = "m", .context = 1000, .source = .profile }; + const text = @import("auth_recovery.zig").failureText(a, p, false); + try testing.expectEqualStrings("Anthropic Console sign-in has expired: run `graff login console` again. Your sign-in is not affected.", text); + try testing.expect(std.mem.indexOf(u8, @import("auth_recovery.zig").failureText(a, p, true), "fallback Console sign-in has expired") != null); + p.source = .environment; // an ordinary key keeps the key wording + try testing.expect(std.mem.indexOf(u8, @import("auth_recovery.zig").failureText(a, p, false), "API key was rejected") != null); +} + test "sign-in pastes a code whenever no local browser can finish it" { var env: std.process.Environ.Map = .init(std.testing.allocator); defer env.deinit(); diff --git a/src/args.zig b/src/args.zig index 38e64891..41779c1b 100644 --- a/src/args.zig +++ b/src/args.zig @@ -42,11 +42,13 @@ pub const Flags = struct { refresh_flag: bool = false, codex_login: bool = false, kimi_login: bool = false, + kimi_region: ?@import("oauth_kimi.zig").Region = null, // `graff login kimi.ai` / `kimi.com` xai_login: bool = false, zai_login: bool = false, chatgpt_login: bool = false, anthropic_login: bool = false, anthropic_console_login: bool = false, + claude_agent_login: bool = false, help_flag: bool = false, version_flag: bool = false, print_flag: bool = false, @@ -270,14 +272,16 @@ pub fn parse(init: std.process.Init) !Flags { if (flags.login_flag and flags.positionals.items.len > 1) { const name = flags.positionals.items[1]; const target = loginTarget(name) orelse - std.process.fatal("unknown login '{s}' — graff login [codegraff|chatgpt|anthropic|kimi|xai|zai]", .{name}); + std.process.fatal("unknown login '{s}' — graff login [codegraff|chatgpt|anthropic|claude-agent|kimi|xai|zai]", .{name}); flags.codex_login = std.mem.eql(u8, target, "codex"); flags.kimi_login = std.mem.eql(u8, target, "kimi"); + flags.kimi_region = @import("oauth_kimi.zig").regionFromLoginName(name); flags.xai_login = std.mem.eql(u8, target, "xai"); flags.zai_login = std.mem.eql(u8, target, "zai"); flags.chatgpt_login = std.mem.eql(u8, target, "chatgpt-new"); flags.anthropic_console_login = std.ascii.eqlIgnoreCase(name, "console"); flags.anthropic_login = std.mem.eql(u8, target, "anthropic") and !flags.anthropic_console_login; + flags.claude_agent_login = std.mem.eql(u8, target, "claude-agent"); } } @@ -344,13 +348,14 @@ pub fn acpLoginIsLogin(positionals: *std.ArrayList([]const u8)) void { pub fn loginTarget(name: []const u8) ?[]const u8 { const aliases = [_][2][]const u8{ - .{ "codex", "chatgpt-new" }, .{ "chatgpt", "chatgpt-new" }, .{ "openai", "chatgpt-new" }, .{ "gpt", "chatgpt-new" }, - .{ "oai", "chatgpt-new" }, .{ "codegraff", "codegraff" }, .{ "graff", "codegraff" }, .{ "kimi", "kimi" }, - .{ "xai", "xai" }, .{ "grok", "xai" }, .{ "ant", "anthropic" }, .{ "anthropic", "anthropic" }, - .{ "console", "anthropic" }, + .{ "codex", "chatgpt-new" }, .{ "chatgpt", "chatgpt-new" }, .{ "openai", "chatgpt-new" }, .{ "gpt", "chatgpt-new" }, + .{ "oai", "chatgpt-new" }, .{ "codegraff", "codegraff" }, .{ "graff", "codegraff" }, .{ "kimi", "kimi" }, + .{ "xai", "xai" }, .{ "grok", "xai" }, .{ "ant", "anthropic" }, .{ "anthropic", "anthropic" }, + .{ "console", "anthropic" }, .{ "claude-agent", "claude-agent" }, .{ "claude", "claude-agent" }, }; for (aliases) |pair| if (std.ascii.eqlIgnoreCase(name, pair[0])) return pair[1]; if (@import("oauth_zai.zig").isLoginName(name)) return "zai"; + if (@import("oauth_kimi.zig").isLoginName(name)) return "kimi"; if (@import("oauth_chatgpt.zig").isLoginName(name)) return "chatgpt-new"; return null; } @@ -361,9 +366,12 @@ test "loginTarget: every ChatGPT name, codex included, is the plan's sign-in; un try std.testing.expectEqualStrings("codegraff", loginTarget("graff").?); try std.testing.expectEqualStrings("xai", loginTarget("grok").?); try std.testing.expectEqualStrings("kimi", loginTarget("kimi").?); + try std.testing.expectEqualStrings("kimi", loginTarget("kimi.ai").?); for ([_][]const u8{ "ant", "anthropic", "ANT", "Anthropic", "console", "Console" }) |name| try std.testing.expectEqualStrings("anthropic", loginTarget(name).?); try std.testing.expectEqualStrings("zai", loginTarget("zai").?); + try std.testing.expectEqualStrings("claude-agent", loginTarget("claude-agent").?); + try std.testing.expectEqualStrings("claude-agent", loginTarget("Claude").?); try std.testing.expect(loginTarget("chatgptt") == null); try std.testing.expect(loginTarget("") == null); } diff --git a/src/ask_user_answers.zig b/src/ask_user_answers.zig index 1bc19d14..c35f5a47 100644 --- a/src/ask_user_answers.zig +++ b/src/ask_user_answers.zig @@ -13,6 +13,13 @@ const Entry = struct { turn: u64 = 0, len: usize = 0, buf: [max_bytes]u8 = undef var ring: [slots]Entry = @splat(.{}); var next: usize = 0; var lock: std.atomic.Value(bool) = .init(false); +/// Every answer ever recorded, never reset (#1520): an answer is a user scope +/// change that todo_write's cancel guard must see, like a newer user message. +var answered: std.atomic.Value(u64) = .init(0); + +pub fn answeredCount() u64 { + return answered.load(.acquire); +} fn acquire() void { while (lock.swap(true, .acquire)) std.atomic.spinLoopHint(); @@ -35,6 +42,7 @@ pub fn record(turn: u64, answer: []const u8) void { ring[next] = .{ .turn = turn, .len = n }; @memcpy(ring[next].buf[0..n], answer[0..n]); next = (next + 1) % slots; + _ = answered.fetchAdd(1, .acq_rel); } pub fn reset() void { diff --git a/src/auth_recovery.zig b/src/auth_recovery.zig index 731a9be5..d5edf9a8 100644 --- a/src/auth_recovery.zig +++ b/src/auth_recovery.zig @@ -1,33 +1,63 @@ -//! Recovery metadata for a terminal model-auth rejection, not a diagnosis of -//! why renewal failed. Only fixed route/command names cross the client boundary. +//! Recovery metadata and wording for a terminal model-auth rejection, not a +//! diagnosis of why renewal failed. Only fixed route/command names cross the +//! client boundary. +//! +//! A provider can hold a sign-in and a metered API key at once (#471), so the +//! text and the metadata name which one was rejected (ADR 0310): a rejected +//! key must not read as "signed out" to a client while the sign-in is fine. const std = @import("std"); const provider = @import("provider.zig"); const util = @import("util.zig"); +pub const Login = struct { command: []const u8, args: []const []const u8 }; + pub const Recovery = struct { + /// "reauth_required" (sign-in seat; carries `login`) or + /// "api_key_rejected" (metered key seat; no login action). kind: []const u8 = "reauth_required", provider: []const u8, - login: struct { command: []const u8, args: []const []const u8 }, + login: ?Login = null, + + /// `login` is omitted, not null, when there is no sign-in to repair. + pub fn jsonStringify(self: Recovery, jws: anytype) !void { + try jws.beginObject(); + try jws.objectField("kind"); + try jws.write(self.kind); + try jws.objectField("provider"); + try jws.write(self.provider); + if (self.login) |login| { + try jws.objectField("login"); + try jws.write(login); + } + try jws.endObject(); + } + + /// Only a sign-in action is ACP's "authentication required"; a rejected + /// key is an ordinary failure that happens to carry data. + pub fn rpcCode(self: Recovery) i32 { + const proto = @import("acp_protocol.zig"); + return if (self.login != null) proto.err_auth_required else proto.err_internal; + } }; -pub fn forFailure(p: provider.Provider, code: ?[]const u8, message: []const u8) ?Recovery { - if (p.source != .login) return null; - // A structured non-auth code wins over incidental auth words in a message. - // Do not turn quota, server errors or expired request ids into login actions. - // 401 is preserved by the HTTP adapter/envelope parser, not guessed from text. +/// A structured non-auth code wins over incidental auth words in a message. +/// Do not turn quota, server errors or expired request ids into login actions. +/// 401 is preserved by the HTTP adapter/envelope parser, not guessed from text. +fn authSignal(code: ?[]const u8, message: []const u8) bool { if (code) |c| { - var auth = false; for ([_][]const u8{ "401", "invalid_token", "invalid_api_key", "authentication_error", "token_expired", "subscription_sharing_invalid_user", "subscription_sharing_v2_invalid_user" }) |known| { - if (std.mem.eql(u8, c, known)) auth = true; + if (std.mem.eql(u8, c, known)) return true; } - if (!auth) return null; - } else { - var auth = false; - for ([_][]const u8{ "unauthorized", "authentication token", "authentication failed", "authentication_error", "invalid_api_key", "invalid_token" }) |needle| { - if (util.indexOfIgnoreCase(message, needle) != null) auth = true; - } - if (!auth) return null; + return false; + } + for ([_][]const u8{ "unauthorized", "authentication token", "authentication failed", "authentication_error", "invalid_api_key", "invalid_token" }) |needle| { + if (util.indexOfIgnoreCase(message, needle) != null) return true; } + return false; +} + +pub fn forFailure(p: provider.Provider, code: ?[]const u8, message: []const u8) ?Recovery { + if (p.source != .login or !authSignal(code, message)) return null; if (std.mem.eql(u8, p.id, "chatgpt-new")) return .{ .provider = "chatgpt-new", .login = .{ .command = "graff", .args = &.{ "login", "chatgpt-new" } }, @@ -39,3 +69,105 @@ pub fn forFailure(p: provider.Provider, code: ?[]const u8, message: []const u8) }; return null; } + +/// The terminal ProviderAuthenticationFailed boundary: a sign-in seat keeps +/// forFailure's route-specific reauth action; any other seat ran on a metered +/// key, so the metadata says the key was rejected and offers no login. +pub fn forAuthFault(p: provider.Provider, code: ?[]const u8) ?Recovery { + if (p.source == .login) return forFailure(p, code, ""); + if (!authSignal(code, "")) return null; + return .{ .kind = "api_key_rejected", .provider = p.id }; +} + +/// Words the rejected-key texts must never contain: a client reads each of +/// them as "signed out" and offers a sign-in that cannot help. +pub const signed_out_phrases = [_][]const u8{ "sign in again", "log in again", "not signed in", "unauthorized", "authentication failed", "invalid api key" }; + +/// The stable phrase in both rejected-key texts. +pub const api_key_rejected_phrase = "API key was rejected"; + +/// The terminal text for an authentication rejection, naming the credential +/// that failed. `promoted`: the seat is the metered key a spent plan handed +/// over to (credential_failover.promoted). +pub fn failureText(a: std.mem.Allocator, p: provider.Provider, promoted: bool) []const u8 { + const name = if (provider.specFor(p.id)) |spec| spec.display_name else p.id; + if (p.source == .login) { + const login: Login = if (forFailure(p, "401", "")) |r| r.login.? else .{ .command = "graff", .args = &.{ "login", p.id } }; + const args = std.mem.join(a, " ", login.args) catch return "provider sign-in was rejected: sign in again"; + return std.fmt.allocPrint(a, "{s} sign-in was rejected: sign in again ({s} {s})", .{ name, login.command, args }) catch + "provider sign-in was rejected: sign in again"; + } + // A Console token past expiry that `ant` cannot renew: name that, not "key". + if (std.mem.eql(u8, p.id, "anthropic") and p.source == .profile and @import("anthropic_profile.zig").expired()) return if (promoted) + "Anthropic plan is out of usage, and the fallback Console sign-in has expired: run `graff login console` again, or wait for the plan to reset. Your sign-in is not affected." + else + "Anthropic Console sign-in has expired: run `graff login console` again. Your sign-in is not affected."; + if (promoted) return std.fmt.allocPrint( + a, + "{s} plan is out of usage, and the fallback " ++ api_key_rejected_phrase ++ " ({s}): replace or remove the key, or wait for the plan to reset.", + .{ name, p.source.label() }, + ) catch "provider plan is out of usage, and the fallback " ++ api_key_rejected_phrase ++ ": replace or remove the key, or wait for the plan to reset."; + return std.fmt.allocPrint( + a, + "{s} " ++ api_key_rejected_phrase ++ " ({s}): replace or remove the key. Your sign-in is not affected.", + .{ name, p.source.label() }, + ) catch "provider " ++ api_key_rejected_phrase ++ ": replace or remove the key. Your sign-in is not affected."; +} + +fn seat(id: []const u8, source: provider.Keys.CredentialSource) provider.Provider { + return .{ .id = id, .kind = .anthropic, .auth = .x_api_key, .url = "", .api_key = "k", .model = "m", .context = 1000, .source = source }; +} + +fn expectNoSignedOutPhrase(text: []const u8) !void { + for (signed_out_phrases) |phrase| try std.testing.expect(util.indexOfIgnoreCase(text, phrase) == null); + try std.testing.expect(std.mem.indexOf(u8, text, api_key_rejected_phrase) != null); +} + +test "auth failure text names the rejected credential: sign-in, key, promoted key" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + try std.testing.expectEqualStrings( + "Anthropic sign-in was rejected: sign in again (graff login anthropic)", + failureText(a, seat("anthropic", .login), false), + ); + try std.testing.expectEqualStrings( + "ChatGPT plan (new sign-in) sign-in was rejected: sign in again (graff login chatgpt-new)", + failureText(a, seat("chatgpt-new", .login), false), + ); + try std.testing.expectEqualStrings( + "Codex (ChatGPT) sign-in was rejected: sign in again (codex login)", + failureText(a, seat("codex", .login), false), + ); + const key = failureText(a, seat("anthropic", .profile), false); + try std.testing.expectEqualStrings("Anthropic API key was rejected (Console profile): replace or remove the key. Your sign-in is not affected.", key); + try expectNoSignedOutPhrase(key); + try expectNoSignedOutPhrase(failureText(a, seat("openai", .environment), false)); + try expectNoSignedOutPhrase(failureText(a, seat("xai", .stored), false)); + const handed = failureText(a, seat("anthropic", .profile), true); + try std.testing.expectEqualStrings("Anthropic plan is out of usage, and the fallback API key was rejected (Console profile): replace or remove the key, or wait for the plan to reset.", handed); + try expectNoSignedOutPhrase(handed); + // Only the sign-in text keeps the words existing clients read as signed out. + try std.testing.expect(std.mem.indexOf(u8, failureText(a, seat("kimi", .login), false), "sign in again") != null); +} + +test "api_key_rejected recovery: metered seats only, no login, internal error code" { + const r = forAuthFault(seat("anthropic", .profile), "401").?; + try std.testing.expectEqualStrings("api_key_rejected", r.kind); + try std.testing.expectEqualStrings("anthropic", r.provider); + try std.testing.expect(r.login == null); + try std.testing.expectEqual(@import("acp_protocol.zig").err_internal, r.rpcCode()); + var out: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer out.deinit(); + var s: std.json.Stringify = .{ .writer = &out.writer }; + try s.write(r); + try std.testing.expectEqualStrings("{\"kind\":\"api_key_rejected\",\"provider\":\"anthropic\"}", out.writer.buffered()); + // A structured non-auth code still vetoes the metadata. + try std.testing.expect(forAuthFault(seat("openai", .environment), "403") == null); + try std.testing.expect(forAuthFault(seat("openai", .environment), "authentication_error") != null); + // A sign-in seat is unchanged: route-specific reauth, or nothing. + const reauth = forAuthFault(seat("chatgpt-new", .login), "401").?; + try std.testing.expectEqualStrings("reauth_required", reauth.kind); + try std.testing.expectEqual(@import("acp_protocol.zig").err_auth_required, reauth.rpcCode()); + try std.testing.expect(forAuthFault(seat("anthropic", .login), "401") == null); +} diff --git a/src/catalog_bounded.zig b/src/catalog_bounded.zig new file mode 100644 index 00000000..9e9aceca --- /dev/null +++ b/src/catalog_bounded.zig @@ -0,0 +1,233 @@ +//! Live model catalogs never hold startup or `graff/models` past a budget. +//! +//! A cold DNS lookup or a slow TLS host could hold `initialize` for 10-40s, +//! because boot awaited every live catalog GET (Kimi always, router specs +//! whenever their cache was stale) and the `graff/models` gateway refresh +//! joined its HTTP worker on timeout. ACP clients then gave up on model +//! discovery. Catalog GETs now run on detached threads whose only output is +//! the disk cache; the caller waits at most a budget, then activates whatever +//! cache exists (fresh, stale, or none: the baked rows stay). +//! +//! A worker that misses the budget is abandoned, not cancelled: cancelling +//! still joins a thread parked in getaddrinfo (mcp_teardown.zig has the same +//! finding). It finishes on its own and leaves a fresh cache for the next +//! process. Each worker owns a private Io and allocator, so it never touches +//! the session Io or gpa after the caller moves on, and exit never waits for +//! it or tears down an Io it is still inside. + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; + +const provider = @import("provider.zig"); + +/// Boot waits this long for catalogs with no disk cache at all. A cached +/// catalog, fresh or stale, never waits: it activates now and refreshes behind. +pub const startup_budget: Io.Duration = .fromMilliseconds(800); + +pub const Job = union(enum) { + router: struct { spec: provider.ProviderSpec, source: provider.Keys.CredentialSource }, + kimi, +}; + +/// Thread-safe and independent of every caller's lifetime: an abandoned task +/// frees itself here, never into a session allocator's leak report. +const alloc = std.heap.smp_allocator; + +/// Workers still running, abandoned or not. A test waits for this to drain +/// (`waitIdle`) so no worker outlives the test that started it: the test +/// runner gives each test its own Io and tears it down on return, which puts +/// back the process's default SIGIO action, and a worker still inside its +/// own Io would then be killed by that Io's next SIGIO. +var live: std.atomic.Value(usize) = .init(0); + +const running: u8 = 0; +const finished: u8 = 1; +const abandoned: u8 = 2; + +const Task = struct { + state: std.atomic.Value(u8) = .init(running), + ok: bool = false, + job: Job, + home: []u8, + key: []u8, + + fn destroy(t: *Task) void { + alloc.free(t.home); + alloc.free(t.key); + alloc.destroy(t); + } + + fn run(t: *Task) void { + defer _ = live.fetchSub(1, .release); + var threaded = privateThreaded(); + defer threaded.deinit(); + const io = threaded.io(); + t.ok = switch (t.job) { + .router => |r| @import("router_catalog.zig").fetchToCache(io, alloc, t.home, r.spec, t.key, r.source), + .kimi => @import("kimi_catalog.zig").fetchToCache(io, alloc, t.home, t.key), + }; + // The waiter gave up: nobody else will free this task. + if (t.state.cmpxchgStrong(running, finished, .acq_rel, .acquire) != null) t.destroy(); + } +}; + +/// A worker's own Io. `Threaded.init` installs a process-wide no-op SIGIO and +/// SIGPIPE handler, and `deinit` restores the action it replaced. A worker +/// outlives the Io around it, so its restore can reinstate the default action +/// another Io had replaced, and that Io's next cancellation SIGIO kills the +/// process. The worker leaves the no-op handler installed instead. +fn privateThreaded() Io.Threaded { + var threaded: Io.Threaded = .init(alloc, .{}); + threaded.have_signal_handler = false; + return threaded; +} + +fn start(job: Job, home: []const u8, key: []const u8) ?*Task { + if (builtin.single_threaded or home.len == 0 or key.len == 0) return null; + const t = alloc.create(Task) catch return null; + const home_d = alloc.dupe(u8, home) catch { + alloc.destroy(t); + return null; + }; + const key_d = alloc.dupe(u8, key) catch { + alloc.free(home_d); + alloc.destroy(t); + return null; + }; + t.* = .{ .job = job, .home = home_d, .key = key_d }; + _ = live.fetchAdd(1, .acquire); + const thread = std.Thread.spawn(.{}, Task.run, .{t}) catch { + _ = live.fetchSub(1, .release); + t.destroy(); + return null; + }; + thread.detach(); + return t; +} + +/// Refresh a cache behind the caller's back; nothing waits for it. +pub fn detach(job: Job, home: []const u8, key: []const u8) void { + const t = start(job, home, key) orelse return; + if (t.state.cmpxchgStrong(running, abandoned, .acq_rel, .acquire) != null) t.destroy(); +} + +/// Wait up to `budget` for every worker, abandoned ones included, to finish. +/// For tests: production never waits on an abandoned worker. +pub fn waitIdle(io: Io, budget: Io.Duration) bool { + const step: Io.Duration = .fromMilliseconds(5); + var left = budget.nanoseconds; + while (live.load(.acquire) != 0) { + if (left <= 0) return false; + io.sleep(step, .awake) catch return false; + left -= step.nanoseconds; + } + return true; +} + +pub const max_tasks = provider.provider_specs.len + 2; + +/// Fetches that share one deadline: boot pays the slowest host, capped. +pub const Batch = struct { + tasks: [max_tasks]?*Task = @splat(null), + ok: [max_tasks]bool = @splat(false), + len: usize = 0, + + /// Start a fetch; returns its slot, or null when it could not start (no + /// home for a cache, no key, no thread). Its result is the cache file. + pub fn spawn(b: *Batch, job: Job, home: []const u8, key: []const u8) ?usize { + if (b.len == max_tasks) return null; + const t = start(job, home, key) orelse return null; + b.tasks[b.len] = t; + b.len += 1; + return b.len - 1; + } + + /// Wait until every fetch finished or `budget` passed, then settle each: + /// a finished one is reclaimed and its outcome kept, a late one abandoned. + pub fn wait(b: *Batch, io: Io, budget: Io.Duration) void { + const step: Io.Duration = .fromMilliseconds(5); + var left = budget.nanoseconds; + while (left > 0 and !b.allFinished()) { + const nap: Io.Duration = .fromNanoseconds(@min(left, step.nanoseconds)); + io.sleep(nap, .awake) catch break; + left -= nap.nanoseconds; + } + for (b.tasks[0..b.len], 0..) |maybe, i| { + const t = maybe orelse continue; + b.tasks[i] = null; + if (t.state.cmpxchgStrong(running, abandoned, .acq_rel, .acquire) == null) continue; + b.ok[i] = t.ok; // finished: the worker is done with `t` + t.destroy(); + } + } + + fn allFinished(b: *const Batch) bool { + for (b.tasks[0..b.len]) |maybe| { + const t = maybe orelse continue; + if (t.state.load(.acquire) == running) return false; + } + return true; + } + + /// Whether the fetch in `slot` finished in time and wrote its cache. + pub fn fetched(b: *const Batch, slot: ?usize) bool { + const i = slot orelse return false; + return b.ok[i]; + } +}; + +/// The abandoned worker reads its spec until the test releases it. +var test_url_buf: [64]u8 = undefined; + +test "a batch that misses its budget returns on time and abandons the fetch" { + if (builtin.single_threaded) return error.SkipZigTest; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + // Accept the connection but never answer: the worker parks in its read. + var address = try Io.net.IpAddress.parseLiteral("127.0.0.1:0"); + var server = try Io.net.IpAddress.listen(&address, io, .{}); + defer server.deinit(io); + var spec = provider.specFor("codegraff").?; + spec.models_url = try std.fmt.bufPrint(&test_url_buf, "http://127.0.0.1:{d}/v1/models", .{server.socket.address.getPort()}); + const home = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator); + defer std.testing.allocator.free(home); + + var batch: Batch = .{}; + const slot = batch.spawn(.{ .router = .{ .spec = spec, .source = .environment } }, home, "test-key"); + try std.testing.expect(slot != null); + const started = Io.Timestamp.now(io, .awake).nanoseconds; + batch.wait(io, .fromMilliseconds(100)); + try std.testing.expect(Io.Timestamp.now(io, .awake).nanoseconds - started < std.time.ns_per_s); + try std.testing.expect(!batch.fetched(slot)); + // Release the abandoned worker and let it finish inside this test. + const parked = try server.accept(io); + parked.close(io); + try std.testing.expect(waitIdle(io, .fromSeconds(10))); +} + +test "a finished worker never reinstates the default SIGIO action" { + const posix = std.posix; + if (builtin.single_threaded or posix.Sigaction == void or !@hasField(posix.SIG, "IO")) return error.SkipZigTest; + const default: posix.Sigaction = .{ .handler = .{ .handler = posix.SIG.DFL }, .mask = posix.sigemptyset(), .flags = 0 }; + var saved: posix.Sigaction = undefined; + posix.sigaction(.IO, &default, &saved); + defer posix.sigaction(.IO, &saved, null); + // The worker starts before any other Io and finishes after one exists. + var worker = privateThreaded(); + var session: Io.Threaded = .init(std.testing.allocator, .{}); + worker.deinit(); + var now: posix.Sigaction = undefined; + posix.sigaction(.IO, null, &now); + session.deinit(); + try std.testing.expect(now.handler.handler != posix.SIG.DFL); +} + +test "a batch reports nothing it could not start" { + var batch: Batch = .{}; + try std.testing.expectEqual(@as(?usize, null), batch.spawn(.kimi, "", "key")); + try std.testing.expectEqual(@as(?usize, null), batch.spawn(.kimi, "/home", "")); + batch.wait(std.testing.io, .fromMilliseconds(1)); + try std.testing.expect(!batch.fetched(null)); +} diff --git a/src/claude_agent.zig b/src/claude_agent.zig new file mode 100644 index 00000000..a60998ec --- /dev/null +++ b/src/claude_agent.zig @@ -0,0 +1,446 @@ +//! Claude Agent provider (ADR 0309): Claude models through the user's own +//! `claude` program and its login, with graff's harness around it. +//! +//! graff never reads Claude Code's saved login and never sits between Claude +//! Code and Anthropic. It starts `claude` with its built-in tools, settings +//! files, hooks, memory and compaction switched off, and offers graff's +//! tools as an in-process MCP server. When the model calls one, the call is +//! held: this step ends with a `tool_use` response, graff's loop runs the +//! tool (approvals, sinks, ACP, all unchanged), and the next step answers +//! the held call with graff's result. graff's history stays the source of +//! truth, so switching between Claude Agent and any other model works. +//! +//! The wire is Anthropic's: `claude --include-partial-messages` streams the +//! raw Messages events, which go to the same printDelta/assembleAnthropic +//! path the HTTP transport uses. Agent.postLive routes here first. + +const std = @import("std"); +const Io = std.Io; +const Value = std.json.Value; +const main_mod = @import("main.zig"); +const Agent = @import("agent.zig").Agent; +const engine_sink = @import("engine_sink.zig"); +const tty = @import("term.zig").tty; +const wire = @import("claude_agent_wire.zig"); +const history = @import("claude_agent_history.zig"); +const session = @import("claude_agent_session.zig"); +const Session = session.Session; +const Tracer = @import("trace.zig").Tracer; + +pub const provider_id = "claude-agent"; +/// Claude Code retries and backs off on its own; silence this long means the +/// child is stuck, not thinking (thinking summaries stream as deltas). +pub const turn_budget_ms: u64 = 300_000; +const init_budget_ms: u64 = 60_000; +const init_id = "graff_init"; + +pub fn owns(self: *const Agent) bool { + return std.mem.eql(u8, self.provider.id, provider_id); +} + +/// Titles, recaps, judges and compaction summaries run on a throwaway +/// process, so they never disturb the chat's session or its prompt cache. +fn sideCall(self: *const Agent) bool { + if (self.compaction_request) return true; + return switch (self.call_kind) { + .title, .recap, .compaction, .judge => true, + else => false, + }; +} + +fn nowMs(io: Io) i64 { + return @intCast(@divTrunc(Io.Timestamp.now(io, .awake).nanoseconds, std.time.ns_per_ms)); +} + +fn envGet(name: [:0]const u8) ?[]const u8 { + if (@import("mcp_lazy.zig").parent_env) |m| return m.get(name); + return if (std.c.getenv(name.ptr)) |v| std.mem.span(v) else null; +} + +pub fn post(self: *Agent, body: []const u8) ![]u8 { + const a = self.scratchAlloc(); + const req = try std.json.parseFromSliceLeaky(Value, a, body, .{ .allocate = .alloc_always }); + if (req != .object) return error.ClaudeAgentBadRequest; + const side = sideCall(self); + const tools: ?Value = if (side) null else if (req.object.get("tools")) |t| (if (t == .array and t.array.items.len > 0) t else null) else null; + const system = try history.textOf(a, req.object.get("system")); + const mode = wire.promptMode(envGet("GRAFF_CLAUDE_AGENT_PROMPT")); + const effort = wire.effortFlag(@tagName(self.reasoning)); + const exe = if (self.provider.api_key.len > 0) self.provider.api_key else "claude"; + const tools_json = if (tools) |t| try std.json.Stringify.valueAlloc(a, t, .{}) else ""; + const spawn: wire.Spawn = .{ .exe = exe, .model = wire.modelArg(self.provider.model), .effort = effort, .has_tools = tools != null }; + + // The CLI takes these at start; the tool list can change live (MCP + // servers connect and go dormant), so it is kept apart. + var fp = std.hash.Wyhash.init(0); + for ([_][]const u8{ exe, self.provider.model, effort orelse "", @tagName(mode), system, if (tools != null) "tools" else "", self.agent_cwd orelse "" }) |part| { + fp.update(part); + fp.update("\x00"); + } + const props_fp = fp.final(); + const tools_fp = std.hash.Wyhash.hash(0, tools_json); + + const sink = engine_sink.forAgent(self); + sink.emit(self.io, .stream_begin); + defer sink.emit(self.io, .stream_finished); + resetStreamState(self); + const watch_esc = !self.sub and self.in != null and main_mod.use_color and !main_mod.json_mode; + const orig_tio: ?tty.RawState = if (watch_esc) Agent.rawNonblockStdin() else null; + defer if (orig_tio) |o| { + _ = Agent.drainSteerStdin(true); + tty.restore(o); + }; + const poll = orig_tio != null; + + const items = self.messages.items; + const owner = @intFromPtr(self); + const now = nowMs(self.io); + var held: ?*Session = if (side) null else session.take(self.io, owner); + if (held) |s| if (staleBecause(a, s, props_fp, self.history_rewrites, items, now)) |why| { + if (self.tracer) |tr| tr.note("claude-agent", why); + s.stop(); + held = null; + } else if (self.tracer) |tr| tr.note("claude-agent", "reused"); + var relisted = false; + if (held) |s| if (s.tools_fp != tools_fp) { + if (refreshTools(s, a, tools, poll)) { + relisted = true; + if (self.tracer) |tr| tr.note("claude-agent", "tool list refreshed"); + } else |e| switch (e) { + error.Interrupted => { + s.stop(); + return e; + }, + else => { + // A fresh session reads the new list at start. + s.stop(); + held = null; + if (self.tracer) |tr| tr.note("claude-agent", "restart: tool list was not re-read"); + }, + } + }; + + var restarts: u8 = 0; + const s: *Session = while (true) { + const s = held orelse start(self, a, spawn, mode, system, tools, props_fp, poll) catch |e| switch (e) { + error.Interrupted => return e, + else => return failSse(self, a, startProblem(e)), + }; + held = s; + s.tools_fp = tools_fp; + if (try sendDelta(self, a, s, items, if (relisted) relisted_note else "")) break s; + // The new messages are not something this session can take: replay + // everything into a fresh one, once. + s.stop(); + held = null; + if (self.tracer) |tr| tr.note("claude-agent", "restart: new messages need a checkpoint"); + restarts += 1; + if (restarts > 1) return error.ClaudeAgentBadHistory; + }; + s.seen = items.len; + s.seen_fp = history.hashMessages(a, items); + s.rewrites = self.history_rewrites; + + const out = turn(self, a, s, tools, poll) catch |e| { + s.stop(); + return e; + }; + s.used_ms = nowMs(self.io); + if (side or out.failed) s.stop() else session.put(self.io, owner, s, s.used_ms); + return out.sse; +} + +/// Why a held session cannot serve this request, or null if it can. +fn staleBecause(a: std.mem.Allocator, s: *const Session, props_fp: u64, rewrites: u32, items: []const Value, now: i64) ?[]const u8 { + if (s.props_fp != props_fp) return "restart: model, effort, prompt or tools changed"; + if (s.rewrites != rewrites or s.seen > items.len) return "restart: history was compacted or trimmed"; + if (session.expired(s, now)) return "restart: idle past 5 minutes"; + if (history.hashMessages(a, items[0..s.seen]) != s.seen_fp) return "restart: earlier history changed"; + return null; +} + +fn start(self: *Agent, a: std.mem.Allocator, spawn: wire.Spawn, mode: wire.PromptMode, system: []const u8, tools: ?Value, props_fp: u64, poll: bool) !*Session { + const owner: Owner = .{ .gpa = self.gpa, .io = self.io, .cwd = self.agent_cwd, .tracer = self.tracer, .note = if (sideCall(self)) "started (side call)" else "started" }; + return open(owner, a, spawn, mode, system, tools, props_fp, poll); +} + +/// Who a new `claude` process is for: an Agent's step, or a query made +/// without one (claude_agent_query.zig). +pub const Owner = struct { + gpa: std.mem.Allocator, + io: Io, + cwd: ?[]const u8, + tracer: ?*Tracer, + note: []const u8, +}; + +/// Start `claude` and wait for its `initialize` answer. +pub fn open(owner: Owner, a: std.mem.Allocator, spawn: wire.Spawn, mode: wire.PromptMode, system: []const u8, tools: ?Value, props_fp: u64, poll: bool) !*Session { + const argv = try wire.argv(a, spawn); + const s = try Session.start(owner.gpa, owner.io, argv, @import("mcp_lazy.zig").parent_env, owner.cwd, props_fp); + errdefer s.stop(); + if (owner.tracer) |tr| tr.note("claude-agent", owner.note); + try s.send(try wire.initRequest(a, init_id, mode, system, spawn.has_tools)); + while (true) { + const line = try s.readLine(poll, init_budget_ms); + switch (try wire.classify(a, line)) { + .response => |r| if (std.mem.eql(u8, r.request_id, init_id)) { + if (!r.ok) return error.ClaudeAgentInitFailed; + const body = r.body orelse return s; + if (body == .object) if (wire.strField(body.object, "claude_code_version")) |v| { + if (!wire.atLeast(v, wire.min_version)) return error.ClaudeAgentTooOld; + }; + return s; + }, + .control => |c| try service(s, a, c, tools), + else => {}, + } + } +} + +/// Send a control request and wait for its answer, serving whatever Claude +/// Code asks graff in between. +fn control(s: *Session, a: std.mem.Allocator, id: []const u8, request_json: []const u8, tools: ?Value, poll: bool) !void { + try s.send(try std.fmt.allocPrint(a, "{{\"type\":\"control_request\",\"request_id\":\"{s}\",\"request\":{s}}}", .{ id, request_json })); + while (true) { + switch (try wire.classify(a, try s.readLine(poll, init_budget_ms))) { + .response => |r| if (std.mem.eql(u8, r.request_id, id)) return if (r.ok) {} else error.ClaudeAgentControlFailed, + .control => |c| try service(s, a, c, tools), + else => {}, + } + } +} + +/// How long Claude Code gets to re-read the tool list after being told. +const relist_budget_ms: u64 = 15_000; + +/// graff's tool list changed (load_tool_schemas, an MCP server joining). +/// Tell Claude Code with MCP's tools/list_changed and serve the tools/list it +/// sends back before answering anything, so the next model request already +/// has the new tools. The server stays connected, so a call graff is still +/// holding (usually the load_tool_schemas that changed the list) stays open. +/// Removing and re-adding the server instead failed that call with +/// "Connection closed", and the model often ended its turn there. +fn refreshTools(s: *Session, a: std.mem.Allocator, tools: ?Value, poll: bool) !void { + const listed = s.tools_listed; + try control(s, a, "graff_tools_changed", wire.tools_changed_request, tools, poll); + const deadline = nowMs(s.io) + @as(i64, relist_budget_ms); + while (s.tools_listed == listed) { + const left = deadline - nowMs(s.io); + if (left <= 0) return error.ClaudeAgentToolsNotRelisted; + switch (try wire.classify(a, try s.readLine(poll, @intCast(left)))) { + .control => |c| try service(s, a, c, tools), + else => {}, + } + } +} + +pub fn startProblem(e: anyerror) []const u8 { + return switch (e) { + error.FileNotFound => "Claude Code isn't installed. Install it (https://code.claude.com/docs/en/setup), sign in with `claude auth login`, then try again.", + error.ClaudeAgentTooOld => "Claude Code is too old for Claude Agent; version " ++ wire.min_version ++ " or later is required. Run `claude update`.", + error.StreamStalled => "Claude Code did not start within 60 seconds.", + else => "Claude Code exited while starting. Run `claude auth status` in a terminal to check its login.", + }; +} + +/// Told with the results that follow a tool-list refresh. load_tool_schemas +/// prints schemas under graff's own names, and without this the model's +/// first call to a tool it just loaded used that bare name, which Claude +/// Code rejects ("No such tool available"). +const relisted_note = "Your tool list was just updated. Call the newly loaded tools by their names in your tool list: graff's tools are named " ++ wire.tool_prefix ++ "."; + +/// Send what Claude Code has not seen. False when the session cannot take +/// it and a fresh one must start from a checkpoint. `note` rides with the +/// last result. +fn sendDelta(self: *Agent, a: std.mem.Allocator, s: *Session, items: []const Value, note: []const u8) !bool { + _ = self; + if (s.seen == 0) { + try s.send(try wire.userLine(a, try history.freshContent(a, items))); + return true; + } + const d = (try history.delta(a, items[s.seen..])) orelse return false; + if (d.results.len == 0) { + // Claude Code is still waiting on a call graff never answered. + if (s.pending.items.len > 0) return false; + try s.send(try wire.userLine(a, d.user.?)); + return true; + } + const last_extra = if (note.len == 0) d.extra_text else if (d.extra_text.len == 0) note else try std.mem.concat(a, u8, &.{ d.extra_text, "\n\n", note }); + for (d.results, 0..) |r, i| { + const last = i + 1 == d.results.len; + const result = try wire.toolResult(a, r.block, if (last) last_extra else "", if (last) d.extra_images else &.{}); + if (pendingIndex(s, r.id)) |pi| { + const p = s.pending.items[pi]; + try s.send(try wire.mcpReply(a, p.request_id, p.rpc_id, result)); + s.dropPending(pi); + } else try s.putResult(r.id, result); + } + return s.pending.items.len == 0; +} + +fn pendingIndex(s: *Session, tool_use_id: []const u8) ?usize { + for (s.pending.items, 0..) |p, i| if (std.mem.eql(u8, p.tool_use_id, tool_use_id)) return i; + return null; +} + +/// Answer a control request from Claude Code. A `tools/call` graff already +/// has a result for is answered at once; any other is held for graff's loop. +pub fn service(s: *Session, a: std.mem.Allocator, c: wire.Control, tools: ?Value) !void { + if (std.mem.eql(u8, c.subtype, "mcp_message")) { + const msg = c.request.get("message") orelse return s.send(try wire.controlErr(a, c.request_id, "missing message")); + if (msg != .object) return s.send(try wire.controlErr(a, c.request_id, "bad message")); + const method = wire.strField(msg.object, "method") orelse ""; + const rpc_id: ?[]const u8 = if (msg.object.get("id")) |id| try std.json.Stringify.valueAlloc(a, id, .{}) else null; + const params: ?std.json.ObjectMap = if (msg.object.get("params")) |p| (if (p == .object) p.object else null) else null; + if (std.mem.eql(u8, method, "tools/call")) { + const id = rpc_id orelse return s.send(try wire.controlErr(a, c.request_id, "tools/call without id")); + const tool_use_id = toolUseId(params) orelse ""; + if (s.takeResult(tool_use_id)) |result| { + defer s.gpa.free(result); + return s.send(try wire.mcpReply(a, c.request_id, id, result)); + } + return s.addPending(c.request_id, id, tool_use_id); + } + const result: []const u8 = if (std.mem.eql(u8, method, "initialize")) + try wire.initializeResult(a, if (params) |p| wire.strField(p, "protocolVersion") orelse "2025-06-18" else "2025-06-18") + else if (std.mem.eql(u8, method, "tools/list")) blk: { + s.tools_listed +%= 1; + break :blk try wire.toolsList(a, tools); + } else "{}"; + return s.send(try wire.mcpReply(a, c.request_id, rpc_id, result)); + } + if (std.mem.eql(u8, c.subtype, "can_use_tool")) { + const input = std.json.Stringify.valueAlloc(a, c.request.get("input") orelse Value{ .object = .empty }, .{}) catch "{}"; + return s.send(try wire.controlOk(a, c.request_id, try std.fmt.allocPrint(a, "{{\"behavior\":\"allow\",\"updatedInput\":{s}}}", .{input}))); + } + if (std.mem.eql(u8, c.subtype, "hook_callback")) return s.send(try wire.controlOk(a, c.request_id, "{}")); + return s.send(try wire.controlErr(a, c.request_id, "graff does not handle this request")); +} + +fn toolUseId(params: ?std.json.ObjectMap) ?[]const u8 { + const p = params orelse return null; + const meta = p.get("_meta") orelse return null; + if (meta != .object) return null; + return wire.strField(meta.object, "claudecode/toolUseId"); +} + +const TurnOut = struct { sse: []u8, failed: bool = false }; + +/// Read one model step: until a response stops for tool use (the held call +/// is graff's to run) or Claude Code reports the turn's result. +fn turn(self: *Agent, a: std.mem.Allocator, s: *Session, tools: ?Value, poll: bool) !TurnOut { + var full: Io.Writer.Allocating = .init(self.gpa); + errdefer full.deinit(); + var loop_guard: @import("agent_model_loop.zig").Stream = .{}; + var stop_reason: []const u8 = ""; + var responses: usize = 0; + var first_token = false; + while (true) { + const line = try s.readLine(poll, turn_budget_ms); + switch (try wire.classify(a, line)) { + .event => |event| { + var ev = event; + const kind = wire.strField(ev.object, "type") orelse ""; + if (std.mem.eql(u8, kind, "message_start")) { + // A second response in one step means Claude Code answered + // a call graff did not offer by itself; keep the last one. + responses += 1; + if (responses > 1) full.clearRetainingCapacity(); + stop_reason = ""; + } + _ = wire.unprefixToolUse(&ev); + if (std.mem.eql(u8, kind, "message_delta")) if (ev.object.get("delta")) |d| { + if (d == .object) if (wire.strField(d.object, "stop_reason")) |r| { + stop_reason = r; + }; + }; + const json = try std.json.Stringify.valueAlloc(a, ev, .{}); + try loop_guard.event(self, json, false); + const sse_line = try std.mem.concat(a, u8, &.{ "data: ", json }); + self.printDelta(sse_line); + if (!first_token and std.mem.eql(u8, kind, "content_block_delta")) { + first_token = true; + self.traceFirstToken(); + } + try full.writer.writeAll(sse_line); + try full.writer.writeByte('\n'); + if (std.mem.eql(u8, kind, "message_stop") and std.mem.eql(u8, stop_reason, "tool_use")) break; + }, + .control => |c| try service(s, a, c, tools), + .cancel => |id| { + for (s.pending.items, 0..) |p, i| if (std.mem.eql(u8, p.request_id, id)) { + s.dropPending(i); + break; + }; + }, + .result => |r| { + if (r.is_error) { + const sse = try failSse(self, a, try problem(a, r.text)); + full.deinit(); + return .{ .sse = sse, .failed = true }; + } + break; + }, + .response, .ignore => {}, + } + } + self.traceFirstToken(); + engine_sink.forAgent(self).emit(self.io, .{ .stream_complete = .{ .streamed_text = self.streamed_text } }); + return .{ .sse = try full.toOwnedSlice() }; +} + +pub fn problem(a: std.mem.Allocator, text: []const u8) ![]const u8 { + if (std.mem.indexOf(u8, text, "Not logged in") != null or std.mem.indexOf(u8, text, "/login") != null) + return "Claude Code is not logged in. Run `claude auth login` in a terminal, then try again."; + return std.fmt.allocPrint(a, "Claude Code stopped: {s}", .{text}); +} + +/// A failure the user should read, as an assistant reply on the Messages +/// wire: it ends the turn without the transport retry ladder resending a +/// request that would fail the same way. +fn failSse(self: *Agent, a: std.mem.Allocator, text: []const u8) ![]u8 { + var full: Io.Writer.Allocating = .init(self.gpa); + errdefer full.deinit(); + const quoted = try std.json.Stringify.valueAlloc(a, text, .{}); + const events = [_][]const u8{ + "{\"type\":\"message_start\",\"message\":{\"id\":\"msg_claude_agent\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-agent\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}", + "{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}", + try std.fmt.allocPrint(a, "{{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{{\"type\":\"text_delta\",\"text\":{s}}}}}", .{quoted}), + "{\"type\":\"content_block_stop\",\"index\":0}", + "{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":0}}", + "{\"type\":\"message_stop\"}", + }; + for (events) |ev| { + const sse_line = try std.mem.concat(a, u8, &.{ "data: ", ev }); + self.printDelta(sse_line); + try full.writer.writeAll(sse_line); + try full.writer.writeByte('\n'); + } + engine_sink.forAgent(self).emit(self.io, .{ .stream_complete = .{ .streamed_text = self.streamed_text } }); + return full.toOwnedSlice(); +} + +/// The same per-stream presentation resets postStream makes. +fn resetStreamState(self: *Agent) void { + self.thinking_open = false; + main_mod.g_thinking_open = false; + self.thinking_rows = 0; + self.thinking_col = 0; + self.thinking_folded = false; + self.thinking_text.clearRetainingCapacity(); + self.thinking_overflow = false; + self.md_buf.clearRetainingCapacity(); + self.arg_live = .{}; + self.md_fence = false; + self.md_kind = .classify; + self.md_span = .normal; + self.md_word.clearRetainingCapacity(); + self.md_word_vis = 0; + self.md_col = 0; + self.md_indent = 0; + self.md_width = 0; + for (self.md_table.items) |r| self.gpa.free(r); + self.md_table.clearRetainingCapacity(); + self.partial_text.clearRetainingCapacity(); +} diff --git a/src/claude_agent_history.zig b/src/claude_agent_history.zig new file mode 100644 index 00000000..db218cc2 --- /dev/null +++ b/src/claude_agent_history.zig @@ -0,0 +1,225 @@ +//! Claude Agent history (ADR 0309): graff's transcript stays the source of +//! truth. A running `claude` holds the part it has seen; each request sends +//! only what is new. A fresh process (first use, a changed prompt or tool +//! list, a compaction, an edit) gets everything it missed as one user +//! message wrapped in ``, without thinking text. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Value = std.json.Value; +const wire = @import("claude_agent_wire.zig"); + +/// Identity of a history prefix, to tell "only grew" from "rewritten". +pub fn hashMessages(a: Allocator, items: []const Value) u64 { + var h = std.hash.Wyhash.init(0x6772_6166_665f_6361); + for (items) |m| { + const bytes = std.json.Stringify.valueAlloc(a, m, .{}) catch return 0; + h.update(bytes); + h.update("\x00"); + } + return h.final(); +} + +/// Text of a `system` field (a string or an array of text blocks). +pub fn textOf(a: Allocator, v: ?Value) ![]const u8 { + const value = v orelse return ""; + switch (value) { + .string => |s| return s, + .array => |items| { + var out: std.ArrayList(u8) = .empty; + for (items.items) |b| { + if (b != .object) continue; + const t = wire.strField(b.object, "text") orelse continue; + if (out.items.len > 0) try out.append(a, '\n'); + try out.appendSlice(a, t); + } + return out.items; + }, + else => return "", + } +} + +fn role(m: Value) []const u8 { + if (m != .object) return ""; + return wire.strField(m.object, "role") orelse ""; +} + +fn hasToolResult(m: Value) bool { + if (m != .object) return false; + const c = m.object.get("content") orelse return false; + if (c != .array) return false; + for (c.array.items) |b| if (b == .object and std.mem.eql(u8, wire.strField(b.object, "type") orelse "", "tool_result")) return true; + return false; +} + +/// Content for the first message of a fresh session: the checkpoint of +/// everything up to the last assistant reply or tool result, then the user +/// messages after it as the prompt (graff may follow a prompt with notices +/// of its own). A turn that ended on a call (`attempt_completion`) leaves +/// its result first in the trailing user run: the prompt after it keeps +/// its blocks, images too, instead of joining the checkpoint as text (#1792). +pub fn freshContent(a: Allocator, items: []const Value) !Value { + var blocks: std.json.Array = .init(a); + var start = items.len; + while (start > 0 and std.mem.eql(u8, role(items[start - 1]), "user")) start -= 1; + var cut = start; + for (items[start..], start..) |m, i| if (hasToolResult(m)) { + cut = i + 1; // results of a call belong to the checkpoint + }; + const head = items[0..cut]; + if (head.len > 0) try blocks.append(try textBlock(a, try checkpoint(a, head))); + if (cut < items.len) { + for (items[cut..]) |m| try appendPromptBlocks(a, &blocks, m.object.get("content") orelse Value{ .string = "" }); + } else if (head.len > 0) { + try blocks.append(try textBlock(a, "Continue from where the checkpoint ends.")); + } + if (blocks.items.len == 0) try blocks.append(try textBlock(a, "")); + return .{ .array = blocks }; +} + +/// User blocks Claude Code takes as-is (text, image, document); anything +/// else is rendered as text. +fn appendPromptBlocks(a: Allocator, blocks: *std.json.Array, c: Value) !void { + switch (c) { + .string => |s| try blocks.append(try textBlock(a, s)), + .array => |items| for (items.items) |b| try appendBlock(a, blocks, b), + else => {}, + } +} + +fn appendBlock(a: Allocator, blocks: *std.json.Array, b: Value) !void { + if (b != .object) return; + const kind = wire.strField(b.object, "type") orelse ""; + if (std.mem.eql(u8, kind, "text") or std.mem.eql(u8, kind, "image") or std.mem.eql(u8, kind, "document")) { + try blocks.append(try stripCacheControl(a, b)); + } else { + var out: std.ArrayList(u8) = .empty; + try renderBlock(a, &out, b); + if (out.items.len > 0) try blocks.append(try textBlock(a, out.items)); + } +} + +/// A block without graff's `cache_control` marker (Claude Code places its own). +fn stripCacheControl(a: Allocator, b: Value) !Value { + if (b.object.get("cache_control") == null) return b; + var copy: std.json.ObjectMap = .empty; + var it = b.object.iterator(); + while (it.next()) |kv| if (!std.mem.eql(u8, kv.key_ptr.*, "cache_control")) try copy.put(a, kv.key_ptr.*, kv.value_ptr.*); + return .{ .object = copy }; +} + +fn textBlock(a: Allocator, text: []const u8) !Value { + var o: std.json.ObjectMap = .empty; + try o.put(a, "type", .{ .string = "text" }); + try o.put(a, "text", .{ .string = text }); + return .{ .object = o }; +} + +pub fn checkpoint(a: Allocator, items: []const Value) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(a, "\nThe conversation so far, from before this session started. Tool calls and results are shown as text; continue from where it ends.\n"); + for (items) |m| { + if (m != .object) continue; + try out.print(a, "\n[{s}]\n", .{role(m)}); + const c = m.object.get("content") orelse continue; + switch (c) { + .string => |s| try out.appendSlice(a, s), + .array => |blocks| for (blocks.items) |b| try renderBlock(a, &out, b), + else => {}, + } + try out.append(a, '\n'); + } + try out.appendSlice(a, ""); + return out.items; +} + +fn renderBlock(a: Allocator, out: *std.ArrayList(u8), b: Value) !void { + if (b != .object) return; + const kind = wire.strField(b.object, "type") orelse ""; + if (std.mem.eql(u8, kind, "text")) { + try out.appendSlice(a, wire.strField(b.object, "text") orelse ""); + try out.append(a, '\n'); + } else if (std.mem.eql(u8, kind, "tool_use")) { + const input = std.json.Stringify.valueAlloc(a, b.object.get("input") orelse Value{ .object = .empty }, .{}) catch "{}"; + try out.print(a, "{s}\n", .{ wire.strField(b.object, "name") orelse "", wire.strField(b.object, "id") orelse "", input }); + } else if (std.mem.eql(u8, kind, "tool_result")) { + try out.print(a, "", .{ wire.strField(b.object, "tool_use_id") orelse "", if (isError(b)) " error=\"true\"" else "" }); + if (b.object.get("content")) |c| switch (c) { + .string => |s| try out.appendSlice(a, s), + .array => |parts| for (parts.items) |p| { + if (p == .object and std.mem.eql(u8, wire.strField(p.object, "type") orelse "", "text")) + try out.appendSlice(a, wire.strField(p.object, "text") orelse "") + else + try out.appendSlice(a, "[image]"); + }, + else => {}, + }; + try out.appendSlice(a, "\n"); + } else if (std.mem.eql(u8, kind, "image") or std.mem.eql(u8, kind, "document")) { + try out.print(a, "[{s}]\n", .{kind}); + } + // thinking / redacted_thinking: left out of the checkpoint. +} + +fn isError(b: Value) bool { + const e = b.object.get("is_error") orelse return false; + return e == .bool and e.bool; +} + +pub const ToolRes = struct { id: []const u8, block: std.json.ObjectMap }; + +/// What changed since Claude Code's last step, from `items = messages[seen..]`. +pub const Delta = struct { + results: []const ToolRes, + /// Non-result text that arrived with results (reminders, a steer). + extra_text: []const u8, + /// Images that arrived with results (a prompt after a call, a vision + /// attachment): sent with the last result as images, not text (#1792). + extra_images: []const Value = &.{}, + /// A new prompt when there are no results. + user: ?Value, +}; + +/// Null when the new messages are not a shape a live session can take +/// (graff injected an assistant message, or nothing new at all): the caller +/// then starts a fresh session from a checkpoint. +pub fn delta(a: Allocator, items: []const Value) !?Delta { + var i: usize = 0; + // Claude Code's own reply, which graff appended after the last step. + if (items.len > 0 and std.mem.eql(u8, role(items[0]), "assistant")) i = 1; + if (i == items.len) return null; + var results: std.ArrayList(ToolRes) = .empty; + var extra: std.ArrayList(u8) = .empty; + var prompt: std.json.Array = .init(a); + for (items[i..]) |m| { + if (!std.mem.eql(u8, role(m), "user")) return null; + const c = m.object.get("content") orelse continue; + switch (c) { + .string => |s| try prompt.append(try textBlock(a, s)), + .array => |blocks| for (blocks.items) |b| { + if (b != .object) continue; + if (std.mem.eql(u8, wire.strField(b.object, "type") orelse "", "tool_result")) { + try results.append(a, .{ .id = wire.strField(b.object, "tool_use_id") orelse "", .block = b.object }); + } else try appendBlock(a, &prompt, b); + }, + else => {}, + } + } + if (results.items.len > 0) { + var images: std.ArrayList(Value) = .empty; + for (prompt.items) |b| { + const kind = wire.strField(b.object, "type") orelse ""; + if (std.mem.eql(u8, kind, "image")) { + try images.append(a, b); + continue; + } + const t = wire.strField(b.object, "text") orelse try std.fmt.allocPrint(a, "[{s}]", .{kind}); + if (t.len == 0) continue; + if (extra.items.len > 0) try extra.append(a, '\n'); + try extra.appendSlice(a, t); + } + return .{ .results = results.items, .extra_text = extra.items, .extra_images = images.items, .user = null }; + } + if (prompt.items.len == 0) return null; + return .{ .results = &.{}, .extra_text = "", .user = .{ .array = prompt } }; +} diff --git a/src/claude_agent_login.zig b/src/claude_agent_login.zig new file mode 100644 index 00000000..ea0cc66e --- /dev/null +++ b/src/claude_agent_login.zig @@ -0,0 +1,129 @@ +//! `graff login claude-agent` (ADR 0309): records that the user chose Claude +//! Agent. The login itself belongs to `claude` (`claude auth login`); graff +//! never reads Claude Code's saved credentials, it only asks `claude auth +//! status` whether a login exists. The marker holds which `claude` to run. + +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; +const credential_store = @import("credential_store.zig"); + +pub const marker_name = "claude-agent.json"; + +pub fn markerPath(arena: Allocator, home: []const u8) []const u8 { + return credential_store.graffCredentialPath(arena, home, marker_name); +} + +fn off(v: []const u8) bool { + for ([_][]const u8{ "", "0", "off", "false", "no" }) |o| if (std.ascii.eqlIgnoreCase(v, o)) return true; + return false; +} + +/// The `claude` to run when the user opted in (the marker, or +/// GRAFF_CLAUDE_AGENT=1), else null. GRAFF_CLAUDE_PATH picks the binary. +pub fn enabled(io: Io, arena: Allocator, environ_map: anytype, home: []const u8) ?[]const u8 { + const exe_env = environ_map.get("GRAFF_CLAUDE_PATH"); + if (environ_map.get("GRAFF_CLAUDE_AGENT")) |v| return if (off(v)) null else exe_env orelse "claude"; + if (home.len == 0) return null; + const data = Io.Dir.cwd().readFileAlloc(io, markerPath(arena, home), arena, .limited(4096)) catch return null; + if (exe_env) |e| return e; + const v = std.json.parseFromSliceLeaky(std.json.Value, arena, data, .{ .allocate = .alloc_always }) catch return "claude"; + if (v == .object) if (v.object.get("claude")) |c| if (c == .string and c.string.len > 0) return c.string; + return "claude"; +} + +pub const Status = struct { logged_in: bool, method: []const u8 = "", plan: []const u8 = "" }; + +/// `claude auth status` reads Claude Code's own login and prints JSON. +pub fn status(io: Io, arena: Allocator, exe: []const u8) !Status { + const pr = @import("process_runner.zig"); + const run = try pr.runCappedWithOptions(arena, io, &.{ exe, "auth", "status" }, 64 * 1024, 8192, 20_000, .{}); + const v = std.json.parseFromSliceLeaky(std.json.Value, arena, run.stdout, .{ .allocate = .alloc_always }) catch return .{ .logged_in = false }; + if (v != .object) return .{ .logged_in = false }; + const logged = if (v.object.get("loggedIn")) |l| l == .bool and l.bool else false; + const str = struct { + fn get(o: std.json.ObjectMap, k: []const u8) []const u8 { + const x = o.get(k) orelse return ""; + return if (x == .string) x.string else ""; + } + }.get; + return .{ .logged_in = logged, .method = str(v.object, "authMethod"), .plan = str(v.object, "subscriptionType") }; +} + +pub fn login(io: Io, arena: Allocator, environ_map: anytype, home: []const u8) !void { + const style = &@import("ansi.zig").style; + var buf: [1024]u8 = undefined; + var w = Io.File.stdout().writerStreaming(io, &buf); + const out = &w.interface; + const exe = environ_map.get("GRAFF_CLAUDE_PATH") orelse "claude"; + var st = status(io, arena, exe) catch |err| switch (err) { + error.FileNotFound => { + try out.writeAll( + \\✗ Claude Agent runs your own Claude Code (`claude`), which isn't installed. Install it (https://code.claude.com/docs/en/setup), then sign in to Claude Agent again. + \\ + ); + try out.flush(); + return error.ClaudeNotInstalled; + }, + else => return err, + }; + // The login belongs to `claude`. With no terminal (a client such as the + // desktop app runs this), say where to sign in rather than start it. + if (!st.logged_in and !(Io.File.stdin().isTty(io) catch false)) { + try out.writeAll("✗ Claude Code is not signed in. Run `claude auth login` in a terminal, then sign in to Claude Agent again.\n"); + try out.flush(); + return error.ClaudeNotSignedIn; + } + if (!st.logged_in) { + try out.writeAll("Claude Code is not signed in; starting `claude auth login`.\n"); + try out.flush(); + var child = try std.process.spawn(io, .{ .argv = &.{ exe, "auth", "login" }, .stdin = .inherit, .stdout = .inherit, .stderr = .inherit }); + const term = try child.wait(io); + if (term != .exited or term.exited != 0) { + try out.writeAll("✗ claude auth login did not finish; nothing changed.\n"); + try out.flush(); + return error.ClaudeLoginIncomplete; + } + st = try status(io, arena, exe); + if (!st.logged_in) { + try out.writeAll("✗ Claude Code still reports no login; nothing changed.\n"); + try out.flush(); + return error.ClaudeLoginIncomplete; + } + } + const path = markerPath(arena, home); + if (std.fs.path.dirname(path)) |dir| try Io.Dir.cwd().createDirPath(io, dir); + const data = try std.fmt.allocPrint(arena, "{{\"claude\":{f}}}\n", .{std.json.fmt(exe, .{})}); + try Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = data }); + try out.print("{s}✓{s} Claude Agent is ready: graff runs your Claude Code login", .{ style.green, style.reset }); + if (st.method.len > 0) try out.print(" ({s}{s}{s})", .{ st.method, if (st.plan.len > 0) ", " else "", st.plan }); + try out.writeAll(".\n Claude models now run through it: `graff --model claude-opus-5-5` (or sonnet, haiku, fable), or /model.\n"); + try out.flush(); +} + +/// `graff logout claude-agent`: forget the choice. Claude Code's own login stays. +pub fn logout(io: Io, arena: Allocator, home: []const u8) !void { + Io.Dir.cwd().deleteFile(io, markerPath(arena, home)) catch |err| switch (err) { + error.FileNotFound => {}, + else => return err, + }; +} + +const Env = struct { + map: []const [2][]const u8, + pub fn get(self: Env, key: []const u8) ?[]const u8 { + for (self.map) |kv| if (std.mem.eql(u8, kv[0], key)) return kv[1]; + return null; + } +}; + +test "claude-agent opt-in: env wins, 0 turns it off, no marker and no env is off" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const io = std.testing.io; + try std.testing.expectEqualStrings("claude", enabled(io, arena, Env{ .map = &.{.{ "GRAFF_CLAUDE_AGENT", "1" }} }, "").?); + try std.testing.expectEqualStrings("/opt/claude", enabled(io, arena, Env{ .map = &.{ .{ "GRAFF_CLAUDE_AGENT", "1" }, .{ "GRAFF_CLAUDE_PATH", "/opt/claude" } } }, "").?); + try std.testing.expect(enabled(io, arena, Env{ .map = &.{.{ "GRAFF_CLAUDE_AGENT", "0" }} }, "/nonexistent-home") == null); + try std.testing.expect(enabled(io, arena, Env{ .map = &.{} }, "/nonexistent-home") == null); +} diff --git a/src/claude_agent_query.zig b/src/claude_agent_query.zig new file mode 100644 index 00000000..7c93bbf4 --- /dev/null +++ b/src/claude_agent_query.zig @@ -0,0 +1,127 @@ +//! Claude Agent one-shot query (ADR 0309): a tools-off completion made +//! without an Agent, such as rlm's `llm_query`. Claude Agent has no HTTP +//! endpoint, so the Messages body runs on a throwaway `claude` process, the +//! way titles and recaps do. The chat's held session (keyed by its Agent in +//! claude_agent_session.zig) is never taken, so its process and prompt cache +//! carry on untouched. + +const std = @import("std"); +const Io = std.Io; +const Value = std.json.Value; +const Allocator = std.mem.Allocator; +const Provider = @import("provider.zig").Provider; +const Tracer = @import("trace.zig").Tracer; +const agent = @import("claude_agent.zig"); +const wire = @import("claude_agent_wire.zig"); +const history = @import("claude_agent_history.zig"); + +/// Without a system prompt Claude Code would run its own coding-agent prompt. +const default_system = "Answer the request directly. You have no tools."; + +/// gpa-owned: a Messages response (`{"content":[{"type":"text",...}]}`), or +/// a problem the caller shows as is. +pub const Reply = union(enum) { + message: []u8, + problem: []u8, + + pub fn deinit(self: Reply, gpa: Allocator) void { + switch (self) { + inline else => |bytes| gpa.free(bytes), + } + } +}; + +pub const Options = struct { + cwd: ?[]const u8 = null, + effort: ?[]const u8 = null, + tracer: ?*Tracer = null, +}; + +/// Run one Messages request body (`system`, `messages`; any `tools` ignored) +/// on a new `claude` process and stop it afterwards. +pub fn post(gpa: Allocator, io: Io, provider: Provider, body: []const u8, opts: Options) !Reply { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + const req = try std.json.parseFromSliceLeaky(Value, a, body, .{ .allocate = .alloc_always }); + if (req != .object) return error.ClaudeAgentBadRequest; + const messages = req.object.get("messages") orelse return error.ClaudeAgentBadRequest; + if (messages != .array) return error.ClaudeAgentBadRequest; + const given = try history.textOf(a, req.object.get("system")); + const system = if (given.len > 0) given else default_system; + const exe = if (provider.api_key.len > 0) provider.api_key else "claude"; + const spawn: wire.Spawn = .{ .exe = exe, .model = wire.modelArg(provider.model), .effort = opts.effort, .has_tools = false }; + const owner: agent.Owner = .{ .gpa = gpa, .io = io, .cwd = opts.cwd, .tracer = opts.tracer, .note = "started (llm_query)" }; + const s = agent.open(owner, a, spawn, .graff, system, null, 0, false) catch |e| switch (e) { + error.Interrupted => return e, + else => return .{ .problem = try gpa.dupe(u8, agent.startProblem(e)) }, + }; + defer s.stop(); + try s.send(try wire.userLine(a, try history.freshContent(a, messages.array.items))); + + var text: std.ArrayList(u8) = .empty; + while (true) { + switch (try wire.classify(a, try s.readLine(false, agent.turn_budget_ms))) { + .event => |ev| { + const kind = wire.strField(ev.object, "type") orelse ""; + // Keep only the last response's text, as the chat path does. + if (std.mem.eql(u8, kind, "message_start")) text.clearRetainingCapacity(); + if (deltaText(ev)) |t| try text.appendSlice(a, t); + }, + .control => |c| try agent.service(s, a, c, null), + .result => |r| { + if (r.is_error) return .{ .problem = try gpa.dupe(u8, try agent.problem(a, r.text)) }; + return .{ .message = try messageJson(gpa, if (text.items.len > 0) text.items else r.text) }; + }, + .cancel, .response, .ignore => {}, + } + } +} + +/// The text of a `content_block_delta` carrying a `text_delta`. +fn deltaText(ev: Value) ?[]const u8 { + if (ev != .object) return null; + if (!std.mem.eql(u8, wire.strField(ev.object, "type") orelse "", "content_block_delta")) return null; + const d = ev.object.get("delta") orelse return null; + if (d != .object or !std.mem.eql(u8, wire.strField(d.object, "type") orelse "", "text_delta")) return null; + return wire.strField(d.object, "text"); +} + +/// A minimal Messages response carrying `text`, for title.assistantText. +fn messageJson(gpa: Allocator, text: []const u8) ![]u8 { + var aw: Io.Writer.Allocating = .init(gpa); + errdefer aw.deinit(); + var s: std.json.Stringify = .{ .writer = &aw.writer }; + try s.beginObject(); + try s.objectField("type"); + try s.write("message"); + try s.objectField("role"); + try s.write("assistant"); + try s.objectField("content"); + try s.beginArray(); + try s.beginObject(); + try s.objectField("type"); + try s.write("text"); + try s.objectField("text"); + try s.write(text); + try s.endObject(); + try s.endArray(); + try s.endObject(); + return aw.toOwnedSlice(); +} + +test "claude agent query reads streamed text deltas and wraps them as a Messages reply" { + const gpa = std.testing.allocator; + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const a = arena.allocator(); + const line = "{\"type\":\"stream_event\",\"parent_tool_use_id\":null,\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"4\\\"2\"}}}"; + const ev = (try wire.classify(a, line)).event; + try std.testing.expectEqualStrings("4\"2", deltaText(ev).?); + const thinking = try std.json.parseFromSliceLeaky(Value, a, "{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"hm\"}}", .{}); + try std.testing.expect(deltaText(thinking) == null); + const json = try messageJson(gpa, "4\"2"); + defer gpa.free(json); + const parsed = try std.json.parseFromSliceLeaky(Value, a, json, .{}); + try std.testing.expectEqualStrings("4\"2", @import("title.zig").assistantText(.anthropic, parsed.object)); +} diff --git a/src/claude_agent_session.zig b/src/claude_agent_session.zig new file mode 100644 index 00000000..5adc902f --- /dev/null +++ b/src/claude_agent_session.zig @@ -0,0 +1,256 @@ +//! Claude Agent sessions (ADR 0309): one long-running `claude` child per +//! graff agent, held between model steps so a tool call can stay open while +//! graff's own loop runs it, and between user turns so Claude Code keeps its +//! prompt cache. A session idle past `idle_ms` is stopped; the next request +//! starts a fresh one and replays graff's history as a checkpoint. + +const std = @import("std"); +const Io = std.Io; +const Allocator = std.mem.Allocator; +const wire = @import("claude_agent_wire.zig"); +const http = @import("http.zig"); + +pub const idle_ms: i64 = 5 * std.time.ms_per_min; +/// Lines carry whole assistant messages and echoed tool results; a line past +/// this is skipped (it is always an echo graff does not read). +const out_buf_len = 8 << 20; +const in_buf_len = 256 << 10; + +/// A `tools/call` Claude Code is waiting on. +pub const Pending = struct { + request_id: []u8, + /// The JSON-RPC id, raw JSON. + rpc_id: []u8, + tool_use_id: []u8, +}; + +pub const Session = struct { + gpa: Allocator, + io: Io, + child: std.process.Child, + env: ?std.process.Environ.Map, + in_buf: []u8, + out_buf: []u8, + writer: Io.File.Writer, + reader: Io.File.Reader, + /// Model, effort, prompt mode, system prompt, tools and folder: a change + /// to any of them needs a new process (the CLI takes them at start). + props_fp: u64, + /// The tool list Claude Code last read from graff's server. + tools_fp: u64 = 0, + /// How many times Claude Code has read graff's tools/list. + tools_listed: u32 = 0, + /// graff messages Claude Code already holds, and a hash of them. + seen: usize = 0, + seen_fp: u64 = 0, + rewrites: u32 = 0, + pending: std.ArrayList(Pending) = .empty, + /// Results graff produced for calls Claude Code has not asked for yet + /// (it runs a response's calls one after another): tool_use id → MCP + /// result JSON. gpa-owned keys and values. + results: std.StringHashMapUnmanaged([]u8) = .empty, + used_ms: i64 = 0, + + pub fn start(gpa: Allocator, io: Io, argv: []const []const u8, parent_env: ?*const std.process.Environ.Map, cwd: ?[]const u8, props_fp: u64) !*Session { + const self = try gpa.create(Session); + errdefer gpa.destroy(self); + var env: ?std.process.Environ.Map = null; + if (parent_env) |p| { + var m = try p.clone(gpa); + errdefer m.deinit(); + var drop: std.ArrayList([]const u8) = .empty; + defer drop.deinit(gpa); + for (m.keys()) |k| if (wire.dropsEnv(k)) try drop.append(gpa, k); + for (drop.items) |k| _ = m.swapRemove(k); + for (wire.isolation_env) |kv| try m.put(kv[0], kv[1]); + env = m; + } + errdefer if (env) |*m| m.deinit(); + var child = try std.process.spawn(io, .{ + .argv = argv, + .stdin = .pipe, + .stdout = .pipe, + .stderr = .ignore, + .environ_map = if (env) |*m| m else null, + .cwd = if (cwd) |path| .{ .path = path } else .inherit, + }); + errdefer @import("mcp_stdio.zig").stopChild(io, &child); + const in_buf = try gpa.alloc(u8, in_buf_len); + errdefer gpa.free(in_buf); + const out_buf = try gpa.alloc(u8, out_buf_len); + self.* = .{ + .gpa = gpa, + .io = io, + .child = child, + .env = env, + .in_buf = in_buf, + .out_buf = out_buf, + .writer = undefined, + .reader = undefined, + .props_fp = props_fp, + }; + self.writer = self.child.stdin.?.writerStreaming(io, self.in_buf); + self.reader = self.child.stdout.?.readerStreaming(io, self.out_buf); + return self; + } + + pub fn send(self: *Session, line: []const u8) !void { + const w = &self.writer.interface; + w.writeAll(line) catch return error.ClaudeAgentExited; + w.writeByte('\n') catch return error.ClaudeAgentExited; + w.flush() catch return error.ClaudeAgentExited; + } + + /// The next line, or a stall/Esc error once `budget_ms` passes silent. + pub fn readLine(self: *Session, poll_stdin: bool, budget_ms: u64) ![]u8 { + const Done = union(enum) { line: anyerror![]u8, stall: http.WatchdogFired }; + var buf: [2]Done = undefined; + var sel: Io.Select(Done) = .init(self.io, &buf); + sel.concurrent(.line, readLineTask, .{&self.reader.interface}) catch return error.StreamStalled; + sel.concurrent(.stall, http.deadlineStallTask, .{ self.io, poll_stdin, budget_ms }) catch { + const only = sel.await() catch |e| { + sel.cancelDiscard(); + return e; + }; + sel.cancelDiscard(); + return only.line; + }; + const first = sel.await() catch |e| { + sel.cancelDiscard(); + return e; + }; + sel.cancelDiscard(); + return switch (first) { + .line => |l| l, + .stall => |w| http.watchdogError(w, error.StreamStalled), + }; + } + + pub fn addPending(self: *Session, request_id: []const u8, rpc_id: []const u8, tool_use_id: []const u8) !void { + try self.pending.append(self.gpa, .{ + .request_id = try self.gpa.dupe(u8, request_id), + .rpc_id = try self.gpa.dupe(u8, rpc_id), + .tool_use_id = try self.gpa.dupe(u8, tool_use_id), + }); + } + + pub fn dropPending(self: *Session, index: usize) void { + const p = self.pending.orderedRemove(index); + self.gpa.free(p.request_id); + self.gpa.free(p.rpc_id); + self.gpa.free(p.tool_use_id); + } + + pub fn putResult(self: *Session, tool_use_id: []const u8, result: []const u8) !void { + const gop = try self.results.getOrPut(self.gpa, tool_use_id); + if (gop.found_existing) { + self.gpa.free(gop.value_ptr.*); + } else { + gop.key_ptr.* = self.gpa.dupe(u8, tool_use_id) catch |e| { + self.results.removeByPtr(gop.key_ptr); + return e; + }; + } + gop.value_ptr.* = try self.gpa.dupe(u8, result); + } + + /// Remove and return a stored result (caller frees with gpa). + pub fn takeResult(self: *Session, tool_use_id: []const u8) ?[]u8 { + const kv = self.results.fetchRemove(tool_use_id) orelse return null; + self.gpa.free(kv.key); + return kv.value; + } + + pub fn stop(self: *Session) void { + @import("mcp_stdio.zig").stopChild(self.io, &self.child); + while (self.pending.items.len > 0) self.dropPending(self.pending.items.len - 1); + self.pending.deinit(self.gpa); + var it = self.results.iterator(); + while (it.next()) |kv| { + self.gpa.free(kv.key_ptr.*); + self.gpa.free(kv.value_ptr.*); + } + self.results.deinit(self.gpa); + if (self.env) |*m| m.deinit(); + self.gpa.free(self.in_buf); + self.gpa.free(self.out_buf); + self.gpa.destroy(self); + } +}; + +fn readLineTask(r: *Io.Reader) anyerror![]u8 { + while (true) { + const line = r.takeDelimiter('\n') catch |err| switch (err) { + error.StreamTooLong => { + _ = r.discardDelimiterInclusive('\n') catch return error.ClaudeAgentExited; + continue; + }, + error.ReadFailed => return error.ClaudeAgentExited, + }; + return line orelse error.ClaudeAgentExited; + } +} + +// Sessions are keyed by the owning Agent's address. A session is taken out +// while a request uses it, so parallel subagents never share one. A reused +// address is harmless: the history hash no longer matches, and the stale +// session is replaced. +var mutex: Io.Mutex = .init; +var sessions: std.AutoHashMapUnmanaged(usize, *Session) = .empty; +var map_gpa: ?Allocator = null; + +pub fn take(io: Io, owner: usize) ?*Session { + mutex.lockUncancelable(io); + defer mutex.unlock(io); + const kv = sessions.fetchRemove(owner) orelse return null; + return kv.value; +} + +/// Return a session after a request, stopping any other session left idle +/// past `idle_ms` (a finished subagent's, or the user walked away). +pub fn put(io: Io, owner: usize, s: *Session, now_ms: i64) void { + var idle: std.ArrayList(*Session) = .empty; + defer idle.deinit(s.gpa); + { + mutex.lockUncancelable(io); + defer mutex.unlock(io); + const gpa = map_gpa orelse blk: { + map_gpa = s.gpa; + break :blk s.gpa; + }; + var it = sessions.iterator(); + while (it.next()) |kv| if (now_ms - kv.value_ptr.*.used_ms > idle_ms) idle.append(s.gpa, kv.value_ptr.*) catch {}; + for (idle.items) |old| { + var it2 = sessions.iterator(); + while (it2.next()) |kv| if (kv.value_ptr.* == old) { + _ = sessions.remove(kv.key_ptr.*); + break; + }; + } + sessions.put(gpa, owner, s) catch { + idle.append(s.gpa, s) catch {}; + }; + } + for (idle.items) |old| old.stop(); +} + +/// Stop every held session (graff is exiting). +pub fn stopAll(io: Io) void { + var all: std.ArrayList(*Session) = .empty; + mutex.lockUncancelable(io); + const gpa = map_gpa; + if (gpa) |g| { + var it = sessions.valueIterator(); + while (it.next()) |s| all.append(g, s.*) catch {}; + sessions.deinit(g); + sessions = .empty; + } + mutex.unlock(io); + for (all.items) |s| s.stop(); + if (gpa) |g| all.deinit(g); +} + +/// The session held for `owner` is past its idle window. +pub fn expired(s: *const Session, now_ms: i64) bool { + return s.used_ms != 0 and now_ms - s.used_ms > idle_ms; +} diff --git a/src/claude_agent_tests.zig b/src/claude_agent_tests.zig new file mode 100644 index 00000000..00f7a7c7 --- /dev/null +++ b/src/claude_agent_tests.zig @@ -0,0 +1,306 @@ +//! Claude Agent (ADR 0309): wire and history facts, no child process. + +const std = @import("std"); +const Value = std.json.Value; +const wire = @import("claude_agent_wire.zig"); +const history = @import("claude_agent_history.zig"); + +fn parse(a: std.mem.Allocator, json: []const u8) !Value { + return std.json.parseFromSliceLeaky(Value, a, json, .{ .allocate = .alloc_always }); +} + +fn has(argv: []const []const u8, flag: []const u8) ?usize { + for (argv, 0..) |arg, i| if (std.mem.eql(u8, arg, flag)) return i; + return null; +} + +test "claude-agent argv: built-ins off, graff's tools the only allowed ones, no saved session" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const argv = try wire.argv(a, .{ .exe = "claude", .model = "sonnet", .effort = "high", .has_tools = true }); + try std.testing.expectEqualStrings("", argv[has(argv, "--tools").? + 1]); + try std.testing.expectEqualStrings("mcp__graff__*", argv[has(argv, "--allowedTools").? + 1]); + try std.testing.expectEqualStrings("dontAsk", argv[has(argv, "--permission-mode").? + 1]); + try std.testing.expectEqualStrings("", argv[has(argv, "--setting-sources").? + 1]); + try std.testing.expectEqualStrings("high", argv[has(argv, "--effort").? + 1]); + try std.testing.expectEqualStrings("sonnet", argv[has(argv, "--model").? + 1]); + try std.testing.expect(has(argv, "--no-session-persistence") != null); + try std.testing.expect(has(argv, "--strict-mcp-config") != null); + const cfg = try parse(a, argv[has(argv, "--mcp-config").? + 1]); + try std.testing.expectEqualStrings("sdk", cfg.object.get("mcpServers").?.object.get("graff").?.object.get("type").?.string); + // A tool-less side call offers no server at all. + const bare = try wire.argv(a, .{ .exe = "claude", .model = "haiku", .effort = null, .has_tools = false }); + try std.testing.expect(has(bare, "--mcp-config") == null and has(bare, "--allowedTools") == null and has(bare, "--effort") == null); +} + +test "claude-agent initialize: graff's prompt replaces Claude Code's, or is appended to it" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const own = try parse(a, try wire.initRequest(a, "i1", .graff, "You are graff.", true)); + const r = own.object.get("request").?.object; + try std.testing.expectEqualStrings("initialize", r.get("subtype").?.string); + try std.testing.expectEqualStrings("You are graff.", r.get("systemPrompt").?.array.items[0].string); + try std.testing.expectEqualStrings("graff", r.get("sdkMcpServers").?.array.items[0].string); + try std.testing.expect(r.get("appendSystemPrompt") == null); + const appended = try parse(a, try wire.initRequest(a, "i2", .claude, "You are graff.", false)); + const r2 = appended.object.get("request").?.object; + try std.testing.expectEqualStrings("You are graff.", r2.get("appendSystemPrompt").?.string); + try std.testing.expect(r2.get("systemPrompt") == null and r2.get("sdkMcpServers") == null); + try std.testing.expectEqual(wire.PromptMode.claude, wire.promptMode("append")); + try std.testing.expectEqual(wire.PromptMode.graff, wire.promptMode(null)); +} + +test "claude-agent user line: client-composed, so Claude Code adds no command parsing" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const line = try parse(a, try wire.userLine(a, .{ .string = "/compact hi" })); + try std.testing.expectEqualStrings("user", line.object.get("type").?.string); + try std.testing.expect(line.object.get("client_composed").?.bool); + try std.testing.expectEqualStrings("/compact hi", line.object.get("message").?.object.get("content").?.string); +} + +test "claude-agent classify: stream events, held tool calls, results; subagent events skipped" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const ev = try wire.classify(a, "{\"type\":\"stream_event\",\"parent_tool_use_id\":null,\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"mcp__graff__read_file\",\"input\":{}}}}"); + var event = ev.event; + try std.testing.expect(wire.unprefixToolUse(&event)); + try std.testing.expectEqualStrings("read_file", event.object.get("content_block").?.object.get("name").?.string); + const nested = try wire.classify(a, "{\"type\":\"stream_event\",\"parent_tool_use_id\":\"toolu_0\",\"event\":{\"type\":\"message_start\"}}"); + try std.testing.expect(nested == .ignore); + const call = try wire.classify(a, "{\"type\":\"control_request\",\"request_id\":\"r9\",\"request\":{\"subtype\":\"mcp_message\",\"server_name\":\"graff\",\"message\":{\"method\":\"tools/call\",\"params\":{\"name\":\"read_file\",\"arguments\":{},\"_meta\":{\"claudecode/toolUseId\":\"toolu_1\"}},\"jsonrpc\":\"2.0\",\"id\":2}}}"); + try std.testing.expectEqualStrings("r9", call.control.request_id); + try std.testing.expectEqualStrings("mcp_message", call.control.subtype); + const failed = try wire.classify(a, "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":true,\"result\":\"Not logged in · Please run /login\"}"); + try std.testing.expect(failed.result.is_error); + const ok = try wire.classify(a, "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"done\"}"); + try std.testing.expect(!ok.result.is_error); + try std.testing.expect(try wire.classify(a, "not json") == .ignore); + const resp = try wire.classify(a, "{\"type\":\"control_response\",\"response\":{\"subtype\":\"success\",\"request_id\":\"graff_init\",\"response\":{\"claude_code_version\":\"2.1.304\"}}}"); + try std.testing.expect(resp.response.ok); +} + +test "claude-agent MCP: a new tool list is announced with list_changed, never a server reset (ADR 0311)" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const init = try parse(a, try wire.initializeResult(a, "2025-06-18")); + const caps = init.object.get("capabilities").?.object.get("tools").?.object; + try std.testing.expect(caps.get("listChanged").?.bool); + const req = try parse(a, wire.tools_changed_request); + try std.testing.expectEqualStrings("mcp_message", req.object.get("subtype").?.string); + try std.testing.expectEqualStrings(wire.server_name, req.object.get("server_name").?.string); + const msg = req.object.get("message").?.object; + try std.testing.expectEqualStrings("notifications/tools/list_changed", msg.get("method").?.string); + try std.testing.expect(msg.get("id") == null); // a notification: Claude Code answers with a tools/list + const src = @embedFile("claude_agent.zig"); + try std.testing.expect(std.mem.indexOf(u8, src, "\\\"servers\\\":{}") == null); +} + +test "claude-agent MCP: tools/list renames input_schema; a tool_result becomes MCP content" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const tools = try parse(a, "[{\"name\":\"shell\",\"description\":\"run\",\"input_schema\":{\"type\":\"object\"}}]"); + const listed = try parse(a, try wire.toolsList(a, tools)); + const t0 = listed.object.get("tools").?.array.items[0].object; + try std.testing.expectEqualStrings("shell", t0.get("name").?.string); + try std.testing.expectEqualStrings("object", t0.get("inputSchema").?.object.get("type").?.string); + const block = try parse(a, "{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_1\",\"is_error\":true,\"content\":[{\"type\":\"text\",\"text\":\"boom\"},{\"type\":\"image\",\"source\":{\"type\":\"base64\",\"media_type\":\"image/jpeg\",\"data\":\"AAAA\"}}]}"); + const result = try parse(a, try wire.toolResult(a, block.object, "steer: also check tests", &.{})); + const content = result.object.get("content").?.array.items; + try std.testing.expectEqual(@as(usize, 3), content.len); + try std.testing.expectEqualStrings("boom", content[0].object.get("text").?.string); + try std.testing.expectEqualStrings("image/jpeg", content[1].object.get("mimeType").?.string); + try std.testing.expectEqualStrings("steer: also check tests", content[2].object.get("text").?.string); + try std.testing.expect(result.object.get("isError").?.bool); + const reply = try parse(a, try wire.mcpReply(a, "r9", "2", "{\"content\":[]}")); + const mcp = reply.object.get("response").?.object.get("response").?.object.get("mcp_response").?.object; + try std.testing.expectEqual(@as(i64, 2), mcp.get("id").?.integer); +} + +test "claude-agent env: Claude Code's session identity never reaches the child" { + try std.testing.expect(wire.dropsEnv("CLAUDECODE")); + try std.testing.expect(wire.dropsEnv("CLAUDE_CODE_ENTRYPOINT")); + try std.testing.expect(wire.dropsEnv("CLAUDE_CODE_SIMPLE")); + try std.testing.expect(wire.dropsEnv("MCP_TOOL_TIMEOUT")); + try std.testing.expect(wire.dropsEnv("CLAUDE_CODE_MESSAGING_SOCKET")); + try std.testing.expect(!wire.dropsEnv("CLAUDE_CONFIG_DIR")); + try std.testing.expect(!wire.dropsEnv("CLAUDE_CODE_OAUTH_TOKEN")); + try std.testing.expect(!wire.dropsEnv("CLAUDE_CODE_USE_BEDROCK")); + try std.testing.expect(!wire.dropsEnv("PATH")); +} + +test "claude-agent version floor" { + try std.testing.expect(wire.atLeast("2.1.304", wire.min_version)); + try std.testing.expect(wire.atLeast("2.1.248", wire.min_version)); + try std.testing.expect(!wire.atLeast("2.1.247", wire.min_version)); + try std.testing.expect(wire.atLeast("3.0.0-beta", wire.min_version)); + try std.testing.expect(!wire.atLeast("2.0.999", wire.min_version)); + try std.testing.expectEqualStrings("max", wire.effortFlag("ultra").?); + try std.testing.expectEqualStrings("sonnet", wire.modelArg("claude-agent-sonnet")); + try std.testing.expectEqualStrings("claude-opus-5-5[1m]", wire.modelArg("claude-opus-5-5[1m]")); + try std.testing.expect(wire.effortFlag("none") == null); +} + +test "claude-agent compaction mid tool loop: the summary request is the prompt, never checkpoint to continue" { + // Compaction runs on a fresh tools-off process. Cut mid loop, its history + // ends on tool results (or a call) and then the summary request. Folding + // the request into the checkpoint and adding "Continue from where the + // checkpoint ends." made the model carry on with the task, or ask the + // user a question, and that text was installed as the "summary". + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const request = "Write a handoff summary of the conversation so far."; + const shapes = [_][]const u8{ + \\[{"role":"user","content":"fix the bug"}, + \\ {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"read_file","input":{"path":"a.zig"}}]}, + \\ {"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"fn main() {}"}]}, + \\ {"role":"user","content":"Write a handoff summary of the conversation so far."}] + , + \\[{"role":"user","content":"fix the bug"}, + \\ {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"read_file","input":{"path":"a.zig"}}]}, + \\ {"role":"user","content":"Write a handoff summary of the conversation so far."}] + , + }; + for (shapes) |shape| { + const msgs = try parse(a, shape); + const blocks = (try history.freshContent(a, msgs.array.items)).array.items; + try std.testing.expectEqual(@as(usize, 2), blocks.len); + const cp = blocks[0].object.get("text").?.string; + try std.testing.expect(std.mem.indexOf(u8, cp, "read_file") != null); + try std.testing.expect(std.mem.indexOf(u8, cp, request) == null); + try std.testing.expectEqualStrings(request, blocks[1].object.get("text").?.string); + } +} + +test "claude-agent fresh session: earlier history is a checkpoint, the prompt keeps its blocks" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const msgs = try parse(a, + \\[{"role":"user","content":"fix the bug"}, + \\ {"role":"assistant","content":[{"type":"thinking","thinking":"secret","signature":"s"},{"type":"tool_use","id":"t1","name":"read_file","input":{"path":"a.zig"}}]}, + \\ {"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"fn main() {}"}]}, + \\ {"role":"assistant","content":[{"type":"text","text":"done"}]}, + \\ {"role":"user","content":[{"type":"text","text":"now add a test","cache_control":{"type":"ephemeral"}}]}] + ); + const content = try history.freshContent(a, msgs.array.items); + const blocks = content.array.items; + try std.testing.expectEqual(@as(usize, 2), blocks.len); + const cp = blocks[0].object.get("text").?.string; + try std.testing.expect(std.mem.startsWith(u8, cp, "")); + try std.testing.expect(std.mem.indexOf(u8, cp, "") != null); + try std.testing.expect(std.mem.indexOf(u8, cp, "fn main() {}") != null); + try std.testing.expect(std.mem.indexOf(u8, cp, "secret") == null); // no thinking text + try std.testing.expectEqualStrings("now add a test", blocks[1].object.get("text").?.string); + try std.testing.expect(blocks[1].object.get("cache_control") == null); + // A prompt followed by graff's own notice is still the prompt, not history. + const noticed = try parse(a, + \\[{"role":"user","content":"spawn two workers"},{"role":"user","content":"Already loaded for this turn: subagent."}] + ); + const fresh = try history.freshContent(a, noticed.array.items); + try std.testing.expectEqual(@as(usize, 2), fresh.array.items.len); + try std.testing.expectEqualStrings("spawn two workers", fresh.array.items[0].object.get("text").?.string); + // Ending on tool results (a restart mid-loop): everything is checkpoint. + const mid = try history.freshContent(a, msgs.array.items[0..3]); + try std.testing.expectEqual(@as(usize, 2), mid.array.items.len); + try std.testing.expect(std.mem.startsWith(u8, mid.array.items[1].object.get("text").?.string, "Continue")); +} + +test "claude-agent delta: Claude's own reply is skipped; results and a new prompt are told apart" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const tool_step = try parse(a, + \\[{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"shell","input":{}},{"type":"tool_use","id":"t2","name":"shell","input":{}}]}, + \\ {"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"a"},{"type":"tool_result","tool_use_id":"t2","content":"b"},{"type":"text","text":""}]}] + ); + const d = (try history.delta(a, tool_step.array.items)).?; + try std.testing.expectEqual(@as(usize, 2), d.results.len); + try std.testing.expectEqualStrings("t2", d.results[1].id); + try std.testing.expectEqualStrings("", d.extra_text); + try std.testing.expect(d.user == null); + const next = try parse(a, + \\[{"role":"assistant","content":[{"type":"text","text":"done"}]},{"role":"user","content":"thanks, now B"}] + ); + const d2 = (try history.delta(a, next.array.items)).?; + try std.testing.expectEqual(@as(usize, 0), d2.results.len); + try std.testing.expectEqualStrings("thanks, now B", d2.user.?.array.items[0].object.get("text").?.string); + // An assistant message graff injected after the reply: not a live shape. + const odd = try parse(a, + \\[{"role":"assistant","content":"x"},{"role":"assistant","content":"y"},{"role":"user","content":"z"}] + ); + try std.testing.expect(try history.delta(a, odd.array.items) == null); + try std.testing.expect(try history.delta(a, next.array.items[0..1]) == null); +} + +test "claude-agent history hash: a grown history keeps its prefix identity, a rewrite does not" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const one = try parse(a, "[{\"role\":\"user\",\"content\":\"a\"},{\"role\":\"assistant\",\"content\":\"b\"}]"); + const two = try parse(a, "[{\"role\":\"user\",\"content\":\"a\"},{\"role\":\"assistant\",\"content\":\"b\"},{\"role\":\"user\",\"content\":\"c\"}]"); + const edited = try parse(a, "[{\"role\":\"user\",\"content\":\"A\"},{\"role\":\"assistant\",\"content\":\"b\"}]"); + try std.testing.expectEqual(history.hashMessages(a, one.array.items), history.hashMessages(a, two.array.items[0..2])); + try std.testing.expect(history.hashMessages(a, one.array.items) != history.hashMessages(a, edited.array.items)); +} + +test "claude-agent routing: once chosen it is the Claude plan; otherwise Claude names stay on anthropic" { + const provider = @import("provider.zig"); + var keys: provider.Keys = .{ .values = @splat(null) }; + try std.testing.expect(keys.set("anthropic", "sk-ant-test", .environment)); + try std.testing.expectEqualStrings("anthropic", (try keys.providerFor("claude-opus-5-5")).id); + try std.testing.expect(keys.set("claude-agent", "claude", .login)); + const opus = try keys.providerFor("claude-opus-5-5"); + try std.testing.expectEqualStrings("claude-agent", opus.id); + try std.testing.expectEqualStrings("claude-agent", (try keys.providerFor("claude-opus-5")).id); + try std.testing.expectEqualStrings("claude-opus-5-5", wire.modelArg(opus.model)); + // The first aliases still work, and still mean Claude Code's alias. + const alias = try keys.providerFor("claude-agent-opus"); + try std.testing.expectEqualStrings("claude-agent", alias.id); + try std.testing.expectEqualStrings("opus", wire.modelArg(alias.model)); + // A Claude model Claude Agent does not list keeps its own route. + try std.testing.expectEqualStrings("anthropic", (try keys.providerFor("claude-opus-4-8")).id); + try std.testing.expectEqualStrings("anthropic", (try keys.providerById("anthropic", "claude-opus-5-5")).id); +} + +test "claude-agent: a prompt after a call's result keeps its image, fresh or live (#1792)" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + // The turn ended on attempt_completion; the user then sent a screenshot. + const msgs = try parse(a, + \\[{"role":"user","content":"build it"}, + \\ {"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"attempt_completion","input":{}}]}, + \\ {"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"completion recorded"}]}, + \\ {"role":"user","content":[{"type":"text","text":"what is that?"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBORw0KGgo="}}]}] + ); + const fresh = (try history.freshContent(a, msgs.array.items)).array.items; + try std.testing.expectEqual(@as(usize, 3), fresh.len); + const cp = fresh[0].object.get("text").?.string; + try std.testing.expect(std.mem.indexOf(u8, cp, "completion recorded") != null); + try std.testing.expect(std.mem.indexOf(u8, cp, "what is that?") == null); + try std.testing.expect(std.mem.indexOf(u8, cp, "[image]") == null); + try std.testing.expectEqualStrings("what is that?", fresh[1].object.get("text").?.string); + try std.testing.expectEqualStrings("image", fresh[2].object.get("type").?.string); + + // A live session waiting on that call: the image rides with the result. + const d = (try history.delta(a, msgs.array.items[1..])).?; + try std.testing.expectEqual(@as(usize, 1), d.results.len); + try std.testing.expectEqualStrings("what is that?", d.extra_text); + try std.testing.expectEqual(@as(usize, 1), d.extra_images.len); + const wire_out = try wire.toolResult(a, d.results[0].block, d.extra_text, d.extra_images); + try std.testing.expect(std.mem.indexOf(u8, wire_out, "[image]") == null); + const result = try parse(a, wire_out); + const items = result.object.get("content").?.array.items; + try std.testing.expectEqual(@as(usize, 3), items.len); + try std.testing.expectEqualStrings("image", items[2].object.get("type").?.string); + try std.testing.expectEqualStrings("iVBORw0KGgo=", items[2].object.get("data").?.string); + try std.testing.expectEqualStrings("image/png", items[2].object.get("mimeType").?.string); +} diff --git a/src/claude_agent_wire.zig b/src/claude_agent_wire.zig new file mode 100644 index 00000000..f126ac58 --- /dev/null +++ b/src/claude_agent_wire.zig @@ -0,0 +1,403 @@ +//! Claude Agent wire (ADR 0309): the stream-json lines graff writes to, and +//! reads from, the user's own `claude` program. Pure functions over strings +//! and parsed JSON, so the protocol is testable without a child process. +//! +//! graff speaks the control protocol the Agent SDK uses: one `initialize` +//! control request carrying graff's tool server (`sdkMcpServers`) and system +//! prompt, user messages as `{"type":"user",...}` lines, and MCP JSON-RPC +//! tunnelled through `mcp_message` control requests. Each `tools/call` +//! carries the model's tool_use id in `_meta["claudecode/toolUseId"]`, which +//! is how a held call is matched to the result graff's own loop produces. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Value = std.json.Value; +const ObjectMap = std.json.ObjectMap; + +pub const server_name = "graff"; +pub const tool_prefix = "mcp__graff__"; +/// Older binaries ignore `client_composed` and can read user text as Claude +/// Code commands. +pub const min_version = "2.1.248"; + +/// Whose system prompt the session runs on. `graff` replaces Claude Code's +/// prompt with graff's own; `claude` keeps Claude Code's and appends graff's. +pub const PromptMode = enum { graff, claude }; + +pub fn promptMode(value: ?[]const u8) PromptMode { + const v = value orelse return .graff; + if (std.ascii.eqlIgnoreCase(v, "claude") or std.ascii.eqlIgnoreCase(v, "append")) return .claude; + return .graff; +} + +/// graff's effort ladder onto `claude --effort` (low|medium|high|xhigh|max). +pub fn effortFlag(tag: []const u8) ?[]const u8 { + const pairs = [_][2][]const u8{ .{ "low", "low" }, .{ "medium", "medium" }, .{ "high", "high" }, .{ "xhigh", "xhigh" }, .{ "max", "max" }, .{ "ultra", "max" } }; + for (pairs) |p| if (std.mem.eql(u8, tag, p[0])) return p[1]; + return null; +} + +/// Claude Code features that duplicate graff's are switched off; graff's own +/// versions win. The three cleared names would skip the subscription login, +/// override graff's effort, or cap every graff tool call. +pub const isolation_env = [_][2][]const u8{ + .{ "CLAUDE_CODE_DISABLE_CLAUDE_MDS", "1" }, + .{ "CLAUDE_CODE_DISABLE_AUTO_MEMORY", "1" }, + .{ "DISABLE_AUTO_COMPACT", "1" }, + .{ "CLAUDE_CODE_ENABLE_AWAY_SUMMARY", "0" }, + .{ "ENABLE_TOOL_SEARCH", "false" }, + .{ "CLAUDE_CODE_DISABLE_ADVISOR_TOOL", "1" }, + .{ "MAX_MCP_OUTPUT_TOKENS", "200000" }, +}; + +/// Inherited variables that must not reach `claude`. A graff started from +/// inside Claude Code inherits that session's identity (CLAUDECODE, the +/// entrypoint, its messaging socket), which the child would otherwise adopt. +/// CLAUDE_CODE_SIMPLE would skip the subscription login, the effort names +/// would override graff's effort, and MCP_TOOL_TIMEOUT would cap every graff +/// tool call. The user's own settings (CLAUDE_CONFIG_DIR, +/// CLAUDE_CODE_OAUTH_TOKEN, the Bedrock/Vertex switches) pass through. +const dropped_env = [_][]const u8{ + "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_CHILD_SESSION", + "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_SESSION_ATTENDED", "CLAUDE_CODE_EXECPATH", + "CLAUDE_CODE_SSE_PORT", "CLAUDE_PID", "CLAUDE_EFFORT", + "CLAUDE_CODE_EFFORT_LEVEL", "CLAUDE_CODE_SIMPLE", "CLAUDE_AGENT_SDK_VERSION", + "MCP_TOOL_TIMEOUT", +}; +const dropped_prefixes = [_][]const u8{"CLAUDE_CODE_MESSAGING_"}; + +pub fn dropsEnv(name: []const u8) bool { + for (dropped_env) |d| if (std.mem.eql(u8, name, d)) return true; + for (dropped_prefixes) |p| if (std.mem.startsWith(u8, name, p)) return true; + return false; +} + +/// Whether a model name belongs to Claude Agent once it is chosen: a model in +/// its catalog, or one of the `claude-agent-` names it first shipped. +pub fn routes(model: []const u8) bool { + if (std.mem.startsWith(u8, model, "claude-agent-")) return true; + return @import("pricing.zig").providerModelInTable("claude-agent", model); +} + +/// An early `claude-agent-sonnet` is Claude Code's `sonnet`; any other id +/// (`claude-opus-5-5`, `claude-opus-5-5[1m]`) goes through as given. +pub fn modelArg(model: []const u8) []const u8 { + const prefix = "claude-agent-"; + return if (std.mem.startsWith(u8, model, prefix)) model[prefix.len..] else model; +} + +pub const Spawn = struct { + exe: []const u8, + model: []const u8, + effort: ?[]const u8, + has_tools: bool, +}; + +pub fn argv(a: Allocator, s: Spawn) ![]const []const u8 { + var out: std.ArrayList([]const u8) = .empty; + try out.appendSlice(a, &.{ + s.exe, "--output-format", "stream-json", "--verbose", + "--input-format", "stream-json", "--tools", "", + "--permission-mode", "dontAsk", "--setting-sources", "", + "--settings", "{\"disableAllHooks\":true}", "--strict-mcp-config", "--include-partial-messages", + "--thinking=adaptive", "--thinking-display", "summarized", "--no-session-persistence", + "--model", s.model, + }); + if (s.has_tools) try out.appendSlice(a, &.{ + "--allowedTools", tool_prefix ++ "*", + "--mcp-config", "{\"mcpServers\":{\"" ++ server_name ++ "\":{\"type\":\"sdk\",\"name\":\"" ++ server_name ++ "\"}}}", + }); + if (s.effort) |e| try out.appendSlice(a, &.{ "--effort", e }); + return out.items; +} + +pub fn initRequest(a: Allocator, request_id: []const u8, mode: PromptMode, system: []const u8, has_tools: bool) ![]u8 { + var aw: std.Io.Writer.Allocating = .init(a); + var s: std.json.Stringify = .{ .writer = &aw.writer }; + try s.beginObject(); + try s.objectField("type"); + try s.write("control_request"); + try s.objectField("request_id"); + try s.write(request_id); + try s.objectField("request"); + try s.beginObject(); + try s.objectField("subtype"); + try s.write("initialize"); + if (has_tools) { + try s.objectField("sdkMcpServers"); + try s.write(&[_][]const u8{server_name}); + } + if (system.len > 0) switch (mode) { + .graff => { + try s.objectField("systemPrompt"); + try s.write(&[_][]const u8{system}); + }, + .claude => { + try s.objectField("appendSystemPrompt"); + try s.write(system); + }, + }; + try s.endObject(); + try s.endObject(); + return aw.toOwnedSlice(); +} + +/// One user turn. `content` is Anthropic message content (string or blocks). +pub fn userLine(a: Allocator, content: Value) ![]u8 { + var aw: std.Io.Writer.Allocating = .init(a); + var s: std.json.Stringify = .{ .writer = &aw.writer }; + try s.beginObject(); + try s.objectField("type"); + try s.write("user"); + try s.objectField("message"); + try s.beginObject(); + try s.objectField("role"); + try s.write("user"); + try s.objectField("content"); + try s.write(content); + try s.endObject(); + try s.objectField("parent_tool_use_id"); + try s.write(null); + try s.objectField("session_id"); + try s.write(""); + // graff already expanded its own commands; Claude Code must not read the + // text as slash commands or add its per-turn context again. + try s.objectField("client_composed"); + try s.write(true); + try s.endObject(); + return aw.toOwnedSlice(); +} + +/// A success control_response; `response_json` is the raw `response` object. +pub fn controlOk(a: Allocator, request_id: []const u8, response_json: []const u8) ![]u8 { + var aw: std.Io.Writer.Allocating = .init(a); + var s: std.json.Stringify = .{ .writer = &aw.writer }; + try s.beginObject(); + try s.objectField("type"); + try s.write("control_response"); + try s.objectField("response"); + try s.beginObject(); + try s.objectField("subtype"); + try s.write("success"); + try s.objectField("request_id"); + try s.write(request_id); + try s.objectField("response"); + try s.beginWriteRaw(); + try aw.writer.writeAll(response_json); + s.endWriteRaw(); + try s.endObject(); + try s.endObject(); + return aw.toOwnedSlice(); +} + +pub fn controlErr(a: Allocator, request_id: []const u8, message: []const u8) ![]u8 { + var aw: std.Io.Writer.Allocating = .init(a); + var s: std.json.Stringify = .{ .writer = &aw.writer }; + try s.beginObject(); + try s.objectField("type"); + try s.write("control_response"); + try s.objectField("response"); + try s.beginObject(); + try s.objectField("subtype"); + try s.write("error"); + try s.objectField("request_id"); + try s.write(request_id); + try s.objectField("error"); + try s.write(message); + try s.endObject(); + try s.endObject(); + return aw.toOwnedSlice(); +} + +/// The control_response carrying a JSON-RPC reply. `rpc_id` is the request's +/// raw id JSON, or null for a notification's ack. +pub fn mcpReply(a: Allocator, request_id: []const u8, rpc_id: ?[]const u8, result_json: []const u8) ![]u8 { + const inner = if (rpc_id) |id| + try std.fmt.allocPrint(a, "{{\"mcp_response\":{{\"jsonrpc\":\"2.0\",\"id\":{s},\"result\":{s}}}}}", .{ id, result_json }) + else + try std.fmt.allocPrint(a, "{{\"mcp_response\":{{\"jsonrpc\":\"2.0\",\"result\":{s}}}}}", .{result_json}); + return controlOk(a, request_id, inner); +} + +/// Control request telling Claude Code that graff's server has a new tool +/// list (MCP `notifications/tools/list_changed`, client to CLI). +pub const tools_changed_request = "{\"subtype\":\"mcp_message\",\"server_name\":\"" ++ server_name ++ "\",\"message\":{\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}}"; + +pub fn initializeResult(a: Allocator, protocol_version: []const u8) ![]u8 { + return std.fmt.allocPrint(a, "{{\"protocolVersion\":{f},\"capabilities\":{{\"tools\":{{\"listChanged\":true}}}},\"serverInfo\":{{\"name\":\"" ++ server_name ++ "\",\"version\":\"1\"}}}}", .{std.json.fmt(protocol_version, .{})}); +} + +/// graff's Anthropic tool list (`[{name, description, input_schema}]`) as an +/// MCP `tools/list` result. +pub fn toolsList(a: Allocator, tools: ?Value) ![]u8 { + var aw: std.Io.Writer.Allocating = .init(a); + var s: std.json.Stringify = .{ .writer = &aw.writer }; + try s.beginObject(); + try s.objectField("tools"); + try s.beginArray(); + if (tools) |t| if (t == .array) for (t.array.items) |tool| { + if (tool != .object) continue; + const name = tool.object.get("name") orelse continue; + if (name != .string) continue; + try s.beginObject(); + try s.objectField("name"); + try s.write(name.string); + if (tool.object.get("description")) |d| { + try s.objectField("description"); + try s.write(d); + } + try s.objectField("inputSchema"); + try s.write(tool.object.get("input_schema") orelse Value{ .object = .empty }); + try s.endObject(); + }; + try s.endArray(); + try s.endObject(); + return aw.toOwnedSlice(); +} + +/// An Anthropic `tool_result` block as an MCP `tools/call` result. Text that +/// graff appended to the same user message (reminders, a steer) rides along +/// as one more text item, and Anthropic image blocks that came with it as +/// MCP image items (#1792): the model reads them with the result, as it +/// would on the Messages wire. +pub fn toolResult(a: Allocator, block: ObjectMap, extra_text: []const u8, extra_images: []const Value) ![]u8 { + var aw: std.Io.Writer.Allocating = .init(a); + var s: std.json.Stringify = .{ .writer = &aw.writer }; + try s.beginObject(); + try s.objectField("content"); + try s.beginArray(); + if (block.get("content")) |c| switch (c) { + .string => |text| try textItem(&s, text), + .array => |items| for (items.items) |item| { + if (item != .object) continue; + const kind = strField(item.object, "type") orelse continue; + if (std.mem.eql(u8, kind, "text")) { + try textItem(&s, strField(item.object, "text") orelse ""); + } else if (std.mem.eql(u8, kind, "image")) try imageItem(&s, item.object); + }, + else => {}, + }; + if (extra_text.len > 0) try textItem(&s, extra_text); + for (extra_images) |img| if (img == .object) try imageItem(&s, img.object); + try s.endArray(); + if (block.get("is_error")) |e| if (e == .bool and e.bool) { + try s.objectField("isError"); + try s.write(true); + }; + try s.endObject(); + return aw.toOwnedSlice(); +} + +/// An Anthropic base64 image block as an MCP image item; others are skipped. +fn imageItem(s: *std.json.Stringify, block: ObjectMap) !void { + const src = block.get("source") orelse return; + if (src != .object) return; + const data = strField(src.object, "data") orelse return; + try s.beginObject(); + try s.objectField("type"); + try s.write("image"); + try s.objectField("data"); + try s.write(data); + try s.objectField("mimeType"); + try s.write(strField(src.object, "media_type") orelse "image/png"); + try s.endObject(); +} + +fn textItem(s: *std.json.Stringify, text: []const u8) !void { + try s.beginObject(); + try s.objectField("type"); + try s.write("text"); + try s.objectField("text"); + try s.write(text); + try s.endObject(); +} + +pub fn strField(o: ObjectMap, key: []const u8) ?[]const u8 { + const v = o.get(key) orelse return null; + return if (v == .string) v.string else null; +} + +/// One line from `claude`, classified. Slices point into the parse arena. +pub const Line = union(enum) { + /// A raw Anthropic stream event (`stream_event.event`). + event: Value, + control: Control, + /// `control_cancel_request`: Claude Code abandoned a request it sent. + cancel: []const u8, + /// The answer to a control request graff sent. + response: Response, + result: Result, + ignore, +}; + +pub const Control = struct { request_id: []const u8, subtype: []const u8, request: ObjectMap }; +pub const Response = struct { request_id: []const u8, ok: bool, body: ?Value, err: []const u8 }; +pub const Result = struct { is_error: bool, text: []const u8 }; + +pub fn classify(a: Allocator, line: []const u8) !Line { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0 or trimmed[0] != '{') return .ignore; + const v = std.json.parseFromSliceLeaky(Value, a, trimmed, .{ .allocate = .alloc_always }) catch return .ignore; + if (v != .object) return .ignore; + const kind = strField(v.object, "type") orelse return .ignore; + if (std.mem.eql(u8, kind, "stream_event")) { + const ev = v.object.get("event") orelse return .ignore; + // A subagent's events carry its parent's tool_use id; graff has no + // Claude Code subagents (its tools are the only tools), so skip them. + if (v.object.get("parent_tool_use_id")) |p| if (p != .null) return .ignore; + return if (ev == .object) .{ .event = ev } else .ignore; + } + if (std.mem.eql(u8, kind, "control_request")) { + const req = v.object.get("request") orelse return .ignore; + if (req != .object) return .ignore; + return .{ .control = .{ + .request_id = strField(v.object, "request_id") orelse return .ignore, + .subtype = strField(req.object, "subtype") orelse "", + .request = req.object, + } }; + } + if (std.mem.eql(u8, kind, "control_cancel_request")) return .{ .cancel = strField(v.object, "request_id") orelse return .ignore }; + if (std.mem.eql(u8, kind, "control_response")) { + const r = v.object.get("response") orelse return .ignore; + if (r != .object) return .ignore; + return .{ .response = .{ + .request_id = strField(r.object, "request_id") orelse return .ignore, + .ok = std.mem.eql(u8, strField(r.object, "subtype") orelse "", "success"), + .body = r.object.get("response"), + .err = strField(r.object, "error") orelse "", + } }; + } + if (std.mem.eql(u8, kind, "result")) { + const is_error = if (v.object.get("is_error")) |e| e == .bool and e.bool else false; + const subtype = strField(v.object, "subtype") orelse "success"; + return .{ .result = .{ + .is_error = is_error or !std.mem.eql(u8, subtype, "success"), + .text = strField(v.object, "result") orelse subtype, + } }; + } + return .ignore; +} + +/// Strip graff's MCP server prefix from a streamed tool_use block, in place, +/// so graff's loop sees its own tool names. True if the event is a tool_use start. +pub fn unprefixToolUse(ev: *Value) bool { + if (ev.* != .object) return false; + if (!std.mem.eql(u8, strField(ev.object, "type") orelse "", "content_block_start")) return false; + const block = ev.object.getPtr("content_block") orelse return false; + if (block.* != .object or !std.mem.eql(u8, strField(block.object, "type") orelse "", "tool_use")) return false; + const name = block.object.getPtr("name") orelse return true; + if (name.* == .string and std.mem.startsWith(u8, name.string, tool_prefix)) name.* = .{ .string = name.string[tool_prefix.len..] }; + return true; +} + +/// Whether `version` (e.g. "2.1.304", suffixes ignored) is at least `floor`. +pub fn atLeast(version: []const u8, floor: []const u8) bool { + var vi = std.mem.splitScalar(u8, std.mem.sliceTo(version, '-'), '.'); + var fi = std.mem.splitScalar(u8, floor, '.'); + while (fi.next()) |fpart| { + const want = std.fmt.parseUnsigned(u32, fpart, 10) catch 0; + const have = std.fmt.parseUnsigned(u32, vi.next() orelse "0", 10) catch 0; + if (have != want) return have > want; + } + return true; +} diff --git a/src/cli_text_source.zig b/src/cli_text_source.zig index 8c35691a..55e5bf20 100644 --- a/src/cli_text_source.zig +++ b/src/cli_text_source.zig @@ -5,6 +5,72 @@ pub const changelog_text = \\What's new \\────────── + \\0.302.68 + \\ • Claude Agent: compaction writes a summary instead of continuing the task + \\ • Claude Agent: a prompt after a finished turn keeps its images + \\ + \\0.302.67 + \\ • Claude Agent: loading a tool no longer drops the call or ends the turn + \\ • a finished task still hears peers who message it directly + \\ + \\0.302.66 + \\ • a one-shot answer built on hosted search is kept, not bounced and replaced + \\ + \\0.302.65 + \\ • Claude Agent models use their full 1M context window (was listed as 200k) + \\ + \\0.302.64 + \\ • an expired Console sign-in is skipped, and the error says to run graff login console + \\ + \\0.302.63 + \\ • auth errors say which credential was rejected: the sign-in, or the API key (and when it was the fallback) + \\ + \\0.302.62 + \\ • rlm's llm_query works on Claude Agent, on a throwaway claude process + \\ + \\0.302.61 + \\ • Claude Agent: Claude models through your own Claude Code login (graff login claude-agent) + \\ • subagent requests keep agent_output in lean mode, so the turn waits for its workers + \\ + \\0.302.60 + \\ • a resumed session keeps its real context size instead of re-estimating compaction state + \\ • large compaction items work over WebSocket without re-running the turn + \\ + \\0.302.59 + \\ • steadily arriving partial SSE events no longer trigger false stalls or replay + \\ • genuine stalls still time out and unfinished events remain bounded + \\ + \\0.302.58 + \\ • ACP startup and model discovery no longer wait on slow model-catalog hosts + \\ • note_constraint states its 240-byte limit up front + \\ + \\0.302.57 + \\ • images are typed by their bytes, so a mislabelled file no longer wedges a chat + \\ • resending a prompt after a failed request makes a new request + \\ • stream error events surface the provider's error instead of retrying + \\ • withdrawn checklist items can be cancelled with a reason + \\ + \\0.302.56 + \\ • native file tools follow dependency symlinks that stay inside the workspace + \\ • escaping, dangling, and cyclic links remain blocked + \\ + \\0.302.55 + \\ • help, release history, and bundled playbooks are compressed until needed + \\ • long home paths keep the macOS background-launch protection + \\ + \\0.302.54 + \\ • normal terminal startup is quiet; diagnostics stay opt-in + \\ • live thinking shows Ctrl+T collapse/expand beside its header + \\ + \\0.302.53 + \\ • smaller binaries share prompt text and compress runtime resources + \\ • release builds keep safety checks and leave local learning optional + \\ • workflow synthesis keeps its catalog at the final report boundary + \\ + \\0.302.52 + \\ • clearer terminal status, Markdown emphasis, and checklist styling + \\ • /workers opens a read-only browser of subagent activity and reports + \\ \\0.302.51 \\ • Kimi API keys and logins work again (no more RstStream) \\ • a used-up Kimi plan window stops instead of retrying @@ -242,7 +308,8 @@ pub const usage_text = \\ graff login chatgpt ChatGPT sign-in (also: codex, openai) \\ graff login anthropic Claude subscription browser sign-in (also: ant) \\ graff login console metered Console sign-in through the `ant` CLI - \\ graff login kimi Kimi Code OAuth login (device-code) + \\ graff login claude-agent Claude through your own Claude Code login (`claude`) + \\ graff login kimi Kimi Code OAuth login (kimi.ai; kimi.com for China) \\ graff login xai Grok/SuperGrok OAuth login (device-code) \\ graff login zai Z.AI Coding Plan OAuth login \\ graff key set store a key (Keychain; POSIX 0600 file; Windows home ACL) diff --git a/src/command_catalog.zig b/src/command_catalog.zig index 19a28742..10c56269 100644 --- a/src/command_catalog.zig +++ b/src/command_catalog.zig @@ -79,7 +79,7 @@ pub const commands = [_]Item{ .{ .name = "/ultracode", .desc = "toggle persistent workflow mode; bare opens an on/off picker, or /ultracode on|off" }, .{ .name = "/fallback", .usage = "/fallback [allow|remove|off]", .desc = "opt-in cross-provider fallback for this workspace (same-provider rollout stays on)" }, .{ .name = "/key", .usage = "/key [provider secret]", .desc = "show API-key status; /key adds one live (+ Keychain)" }, - .{ .name = "/login", .usage = "/login [codegraff|chatgpt|kimi|xai|zai]", .desc = "OAuth sign-in (no key to paste); bare opens a picker (chatgpt aliases: codex, openai, oai; zai aliases: glm, z.ai)" }, + .{ .name = "/login", .usage = "/login [codegraff|chatgpt|kimi|xai|zai]", .desc = "OAuth sign-in (no key to paste); bare opens a picker (chatgpt aliases: codex, openai, oai; zai aliases: glm, z.ai; kimi sites: kimi.ai, kimi.com)" }, .{ .name = "/keepcontext", .desc = "toggle keeping the conversation when /model switches wire format (default on)" }, .{ .name = "/effort", .desc = "reasoning depth: low|medium|high|... (codex, deepseek, codegraff; persists)" }, .{ .name = "/reasoning", .desc = "alias for /effort" }, diff --git a/src/commands_model.zig b/src/commands_model.zig index 1089a0e9..0a6fe677 100644 --- a/src/commands_model.zig +++ b/src/commands_model.zig @@ -112,7 +112,7 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, const arg = std.mem.trim(u8, line["/model".len..], " \t"); providers.ensureModelQueryCatalogs(root, keys.*, arg); if (arg.len == 0) { - @import("gateway_picker_catalog.zig").refresh(root.gpa, root.io, root.arena, keys.*); + @import("gateway_picker_catalog.zig").refresh(root.io, root.arena, root.home, keys.*); if (main_mod.use_color) { // interactive TTY → fuzzy picker if (modelPicker(root, keys, arena, out)) |idx| { const m = pricing.models()[idx]; @@ -360,7 +360,8 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, .{ .name = "chatgpt", .desc = "ChatGPT sign-in (also: codex, openai)" }, .{ .name = "anthropic", .desc = "Claude subscription sign-in (also: ant)" }, .{ .name = "console", .desc = "metered Console sign-in through the ant CLI" }, - .{ .name = "kimi", .desc = "Kimi Code sign-in (device-code OAuth)" }, + .{ .name = "claude-agent", .desc = "your own Claude Code login, through `claude`" }, + .{ .name = "kimi", .desc = "Kimi Code sign-in (kimi.ai; /login kimi.com for China)" }, .{ .name = "xai", .desc = "Grok / SuperGrok sign-in (device-code OAuth)" }, .{ .name = "zai", .desc = "Z.AI Coding Plan sign-in (CLI OAuth)" }, }; @@ -380,6 +381,7 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, // Same names as `graff login`: every ChatGPT name, codex included, is // the plan's sign-in, chatgpt-new (ADR 0229). const console_login = std.ascii.eqlIgnoreCase(target, "console"); + const kimi_region = @import("oauth_kimi.zig").regionFromLoginName(target); target = @import("args.zig").loginTarget(target) orelse target; const home = root.home; @@ -391,7 +393,7 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, return true; }; } else if (std.mem.eql(u8, target, "kimi")) { - oauth.kimiLogin(root.io, root.gpa, arena, home) catch |err| { + oauth.kimiLogin(root.io, root.gpa, arena, home, kimi_region) catch |err| { try out.print("\xe2\x9c\x97 kimi login failed: {t}\n", .{err}); try out.flush(); return true; @@ -421,6 +423,13 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, try out.flush(); return true; }; + } else if (std.mem.eql(u8, target, "claude-agent")) { + const ca = @import("claude_agent_login.zig"); + ca.login(root.io, arena, @import("anthropic_profile.zig").ProcessEnv{}, home) catch |err| { + try out.print("\xe2\x9c\x97 claude-agent login failed: {t}\n", .{err}); + try out.flush(); + return true; + }; } else if (std.mem.eql(u8, target, "chatgpt-new")) { @import("oauth_chatgpt.zig").login(root.io, root.gpa, arena, home) catch |err| { try out.print("\xe2\x9c\x97 chatgpt-new login failed: {t}\n", .{err}); @@ -434,7 +443,7 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, try out.flush(); return true; } - try out.print("can't log into '{s}' \xe2\x80\x94 try /login codegraff | chatgpt | anthropic | console | kimi | xai | zai (others: /key )\n", .{target}); + try out.print("can't log into '{s}' \xe2\x80\x94 try /login codegraff | chatgpt | anthropic | console | claude-agent | kimi | xai | zai (others: /key )\n", .{target}); try out.flush(); return true; } @@ -472,7 +481,7 @@ pub fn tryHandle(root: *Agent, keys: *Keys, arena: Allocator, line: []const u8, .no_vision => try out.print("⚠ {s} can't see images — switch to a vision model first, e.g. /model claude-opus-4-8 or /model gpt-6-sol\n", .{root.provider.model}), .ok => |o| try out.print("📎 attached {s} ({s}) — sent with your next message\n", .{ path, vision.fmtBytes(mbuf[0..16], o.bytes) }), // Sized, distinct lines instead of "missing, or larger than 5MB" (#349). - .too_large, .not_found, .read_error => try out.print("{s}\n", .{vision.stageMessage(&mbuf, staged, path)}), + .too_large, .not_found, .read_error, .unsupported => try out.print("{s}\n", .{vision.stageMessage(&mbuf, staged, path)}), } try out.flush(); return true; diff --git a/src/commands_paste.zig b/src/commands_paste.zig index 27e01a6e..66b65a73 100644 --- a/src/commands_paste.zig +++ b/src/commands_paste.zig @@ -38,7 +38,7 @@ pub fn tryHandle(root: *Agent, line: []const u8, out: *Io.Writer) !bool { switch (pasted) { .ok => |o| try out.print("📎 clipboard image attached ({s}, via {s}) — sent with your next message\n", .{ vision.fmtBytes(pbuf[0..16], o.bytes), grab.flavor.name() }), .no_vision => try out.print("⚠ {s} can't see images\n", .{root.provider.model}), - .too_large, .not_found, .read_error => try out.print("{s}\n", .{vision.stageMessage(&pbuf, pasted, "the clipboard image")}), + .too_large, .not_found, .read_error, .unsupported => try out.print("{s}\n", .{vision.stageMessage(&pbuf, pasted, "the clipboard image")}), } }, } diff --git a/src/companion_boot.zig b/src/companion_boot.zig index 28082af5..32fff094 100644 --- a/src/companion_boot.zig +++ b/src/companion_boot.zig @@ -60,9 +60,8 @@ fn interactive(flags: args.Flags, json_mode: bool) bool { /// every other path still `addServer`s on this thread. pub fn connectCompanion(io: Io, arena: Allocator, registry: *mcp.Registry, flags: args.Flags, out: *Io.Writer, json_mode: bool, environ_map: anytype) !void { const sink = engine_sink.writerSink(out); - const speak = interactive(flags, json_mode); + const speak = interactive(flags, json_mode) and environ_map.get("GRAFF_REPL_DEBUG") != null; const background = deferCompanion(flags, json_mode); - _ = environ_map; connect: { for (skills.companion_servers) |c| { if (mcp_boot.alreadyStarting(registry, c.server)) break :connect; diff --git a/src/context_tokens.zig b/src/context_tokens.zig index a1be6008..4d1e0be8 100644 --- a/src/context_tokens.zig +++ b/src/context_tokens.zig @@ -52,10 +52,52 @@ pub fn stateTokens(bytes: usize) u64 { return bytes / compaction_state_bytes_per_token; } +/// The provider's count for `bytes` of compaction state, or null while this +/// process has only the bytes/16 estimate. +pub fn calibratedState(bytes: usize) ?u64 { + if (bytes == 0) return null; + for (&g_state_counts) |*slot| { + const v = slot.load(.monotonic); + if (v >> token_bits == bytes) return v & token_mask; + } + return null; +} + pub fn compactionStateBytes(value: Value) usize { return mediaStats(value).compaction_state_bytes; } +/// Which compaction state a history carries: its total size (the calibration +/// key) and an order-independent digest of every state blob, so a persisted +/// count is reapplied only to the same state, never to a different blob that +/// happens to have the same length. +pub const StateIdentity = struct { bytes: usize = 0, digest: u64 = 0 }; + +pub fn compactionStateIdentity(value: Value) StateIdentity { + var id: StateIdentity = .{}; + switch (value) { + .array => |items| for (items.items) |item| { + const child = compactionStateIdentity(item); + id.bytes +|= child.bytes; + id.digest +%= child.digest; + }, + .object => |obj| { + if (compactionStateLen(obj)) |len| { + id.bytes +|= len; + id.digest +%= std.hash.Wyhash.hash(0, stringValue(obj.get("encrypted_content")).?); + } + var it = obj.iterator(); + while (it.next()) |entry| { + const child = compactionStateIdentity(entry.value_ptr.*); + id.bytes +|= child.bytes; + id.digest +%= child.digest; + } + }, + else => {}, + } + return id; +} + /// Learn the state's share from a counted request: the reported input less /// the estimate for everything else in it (`request_estimate` includes the /// state at its current estimate). Never below one token per KiB of state. diff --git a/src/credential_store.zig b/src/credential_store.zig index 0c2a02e9..10b67da8 100644 --- a/src/credential_store.zig +++ b/src/credential_store.zig @@ -163,6 +163,12 @@ pub fn graffCredentialPath(arena: Allocator, home: []const u8, name: []const u8) /// Store an access/refresh/expiry triple at `oauthPath`, 0600 inside 0700 dirs. pub fn writeOAuth(io: Io, arena: Allocator, home: []const u8, provider_dir: []const u8, access: []const u8, refresh: []const u8, expires_at: i64) !void { + return writeOAuthFrom(io, arena, home, provider_dir, access, refresh, expires_at, ""); +} + +/// writeOAuth that also records the sign-in server (`oauth_host`), for +/// providers whose refresh must return to the host that issued the token. +pub fn writeOAuthFrom(io: Io, arena: Allocator, home: []const u8, provider_dir: []const u8, access: []const u8, refresh: []const u8, expires_at: i64, oauth_host: []const u8) !void { // createDir is one level, so make / then its credentials/. const base = try std.fmt.allocPrint(arena, "{s}/{s}", .{ resolveHome(home), provider_dir }); const credentials = try std.fmt.allocPrint(arena, "{s}/credentials", .{base}); @@ -179,6 +185,7 @@ pub fn writeOAuth(io: Io, arena: Allocator, home: []const u8, provider_dir: []co try obj.put(arena, "access_token", .{ .string = access }); try obj.put(arena, "refresh_token", .{ .string = refresh }); try obj.put(arena, "expires_at", .{ .integer = expires_at }); + if (oauth_host.len > 0) try obj.put(arena, "oauth_host", .{ .string = oauth_host }); var aw: Io.Writer.Allocating = .init(arena); var stringify: std.json.Stringify = .{ .writer = &aw.writer }; try stringify.write(Value{ .object = obj }); diff --git a/src/daddy.zig b/src/daddy.zig index eb7befe6..591d851e 100644 --- a/src/daddy.zig +++ b/src/daddy.zig @@ -212,7 +212,7 @@ test "parkedDirectives sees a [daddy] body and takeIdleText fires after completi @import("peer_idle.zig").resetForTest(); } @import("peer_idle.zig").noteCompletion(); - try std.testing.expect(@import("peer_idle.zig").idleWakeSuppressed()); + try std.testing.expect(@import("peer_idle.zig").isSettled()); try std.testing.expect(!parkedDirectives()); _ = peer_inbox.parkHeard(&.{.{ .from_session = "s-dad", .text = "[daddy] switch to the tests" }}, &.{}); try std.testing.expect(parkedDirectives()); diff --git a/src/edit_batch.zig b/src/edit_batch.zig index d71a7808..0fec8d0e 100644 --- a/src/edit_batch.zig +++ b/src/edit_batch.zig @@ -255,7 +255,7 @@ test "execBatch uses selected worktree, replace_all, one snapshot, and preserves try std.testing.expectEqual(@as(std.posix.mode_t, 0o755), (try selected.dir.statFile(io, "task.sh", .{})).permissions.toMode() & 0o777); } -test "execBatch replaces without truncating open readers and rejects symlink paths" { +test "execBatch replaces without truncating open readers and confines symlink paths" { if (@import("builtin").os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; const io = std.testing.io; @@ -296,15 +296,33 @@ test "execBatch replaces without truncating open readers and rejects symlink pat try std.testing.expectEqualStrings("real.txt", link_target[0..link_len]); const linked_input = try std.json.parseFromSliceLeaky(Value, input_arena.allocator(), - \\{"path":"link.txt","edits":[{"old_string":"after","new_string":"escaped"}]} + \\{"path":"link.txt","edits":[{"old_string":"after","new_string":"linked"}]} , .{}); - const refused = try execBatch(ctx, linked_input); + const linked = try execBatch(ctx, linked_input); + defer gpa.free(linked.text); + try std.testing.expect(!linked.is_error); + const changed = try tmp.dir.readFileAlloc(io, "real.txt", gpa, .limited(4096)); + defer gpa.free(changed); + try std.testing.expectEqualStrings("linked\n", changed); + const kept_link_len = try tmp.dir.readLink(io, "link.txt", &link_target); + try std.testing.expectEqualStrings("real.txt", link_target[0..kept_link_len]); + + var outside = std.testing.tmpDir(.{}); + defer outside.cleanup(); + try outside.dir.writeFile(io, .{ .sub_path = "secret.txt", .data = "outside\n" }); + const outside_path = try outside.dir.realPathFileAlloc(io, "secret.txt", gpa); + defer gpa.free(outside_path); + try tmp.dir.symLink(io, outside_path, "escape.txt", .{}); + const escaped_input = try std.json.parseFromSliceLeaky(Value, input_arena.allocator(), + \\{"path":"escape.txt","edits":[{"old_string":"outside","new_string":"escaped"}]} + , .{}); + const refused = try execBatch(ctx, escaped_input); defer gpa.free(refused.text); try std.testing.expect(refused.is_error); try std.testing.expect(std.mem.indexOf(u8, refused.text, "outside the working directory") != null); - const unchanged = try tmp.dir.readFileAlloc(io, "real.txt", gpa, .limited(4096)); + const unchanged = try outside.dir.readFileAlloc(io, "secret.txt", gpa, .limited(4096)); defer gpa.free(unchanged); - try std.testing.expectEqualStrings("after\n", unchanged); + try std.testing.expectEqualStrings("outside\n", unchanged); } test "execBatch and single edit share the same-file write lock" { diff --git a/src/engine_events.zig b/src/engine_events.zig index 852fd968..0eab39be 100644 --- a/src/engine_events.zig +++ b/src/engine_events.zig @@ -228,6 +228,10 @@ pub const StandingTodo = struct { done: bool = false, /// `todo_write` in_progress — the ◌ mark in the WORKING tree. active: bool = false, + /// #1520: cancelled (withdrawn or delegated) with its required reason. + /// Not counted in `todos_done`/`todos_total`. + cancelled: bool = false, + reason: []const u8 = "", }; /// Standing work the line REPL may draw above the model prompt. Empty diff --git a/src/gateway_picker_catalog.zig b/src/gateway_picker_catalog.zig index 7c539ba4..f0420a2d 100644 --- a/src/gateway_picker_catalog.zig +++ b/src/gateway_picker_catalog.zig @@ -2,6 +2,7 @@ const std = @import("std"); const Io = std.Io; const Allocator = std.mem.Allocator; +const catalog_bounded = @import("catalog_bounded.zig"); const router = @import("router_catalog.zig"); const provider = @import("provider.zig"); const util = @import("util.zig"); @@ -29,54 +30,36 @@ test "gateway refresh is skipped only by explicit false" { try std.testing.expect(!requested(parsed.value)); } -pub fn refresh(gpa: Allocator, io: Io, arena: Allocator, keys: provider.Keys) void { +pub fn refresh(io: Io, arena: Allocator, home: []const u8, keys: provider.Keys) void { const key = keys.get("codegraff") orelse return; const now = util.unixMs(io); if (last_attempt) |previous| if (now >= previous and now - previous < 30_000) return; last_attempt = now; const spec = provider.specFor("codegraff") orelse return; - _ = refreshAt(gpa, io, arena, spec, key, keys.source("codegraff"), .fromMilliseconds(500)); + _ = refreshAt(io, arena, home, spec, key, keys.source("codegraff"), .fromMilliseconds(500)); } -pub fn refreshAt(gpa: Allocator, io: Io, arena: Allocator, spec: provider.ProviderSpec, key: []const u8, source: provider.Keys.CredentialSource, deadline: Io.Duration) bool { - const buffer = gpa.alloc(u8, 2 * 1024 * 1024) catch return false; - defer gpa.free(buffer); - var writer: Io.Writer = .fixed(buffer); - var client: std.http.Client = .{ .allocator = gpa, .io = io }; - defer client.deinit(); - var headers_buffer: [4]std.http.Header = undefined; - var scratch = std.heap.ArenaAllocator.init(gpa); - defer scratch.deinit(); - const headers = router.catalogHeaders(scratch.allocator(), spec, key, source, &headers_buffer) orelse return false; - const Done = union(enum) { fetched: bool, timeout: void }; - var done_buffer: [2]Done = undefined; - var select: Io.Select(Done) = .init(io, &done_buffer); - select.concurrent(.timeout, delay, .{ io, deadline }) catch return false; - select.concurrent(.fetched, fetch, .{ &client, spec.models_url, headers, &writer }) catch { - select.cancelDiscard(); - return false; - }; - const first = select.await() catch { - select.cancelDiscard(); - return false; - }; - select.cancelDiscard(); // join the HTTP worker before releasing its storage - if (first != .fetched or !first.fetched) return false; - const snapshot = router.parseModels(arena, spec.id, writer.buffered()) orelse return false; - return router.activate(arena, spec, snapshot.models); +/// Fetch into the disk cache on a detached worker and activate it only if it +/// lands within `deadline`. Cancelling an HTTP worker joins it, and one parked +/// in DNS or TLS held `graff/models` for seconds past this deadline; a late +/// worker is abandoned instead and leaves the cache for the next caller. +pub fn refreshAt(io: Io, arena: Allocator, home: []const u8, spec: provider.ProviderSpec, key: []const u8, source: provider.Keys.CredentialSource, deadline: Io.Duration) bool { + var batch: catalog_bounded.Batch = .{}; + const slot = batch.spawn(.{ .router = .{ .spec = spec, .source = source } }, home, key); + batch.wait(io, deadline); + if (!batch.fetched(slot)) return false; + return router.activateCached(io, arena, home, spec); } -fn delay(io: Io, duration: Io.Duration) void { - io.sleep(duration, .awake) catch {}; -} - -fn fetch(client: *std.http.Client, url: []const u8, headers: []const std.http.Header, writer: *Io.Writer) bool { - const result = client.fetch(.{ .location = .{ .url = url }, .method = .GET, .extra_headers = headers, .response_writer = writer }) catch return false; - return result.status == .ok; -} +/// The 50ms call's worker is abandoned and reads its spec until the test releases it. +var test_url_buf: [64]u8 = undefined; test "gateway picker refresh uses live aliases and preserves its snapshot on timeout" { const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try tmp.dir.realPathFileAlloc(std.testing.io, ".", gpa); + defer gpa.free(home); const io = std.testing.io; const pricing = @import("pricing.zig"); const saved = pricing.active_model_table; @@ -90,18 +73,21 @@ test "gateway picker refresh uses live aliases and preserves its snapshot on tim defer group.cancel(io); try group.concurrent(io, answer, .{ io, &server }); var spec = provider.specFor("codegraff").?; - spec.models_url = try std.fmt.allocPrint(arena.allocator(), "http://127.0.0.1:{d}/v1/models", .{server.socket.address.getPort()}); - try std.testing.expect(refreshAt(gpa, io, arena.allocator(), spec, "test-key", .environment, .fromSeconds(1))); + spec.models_url = try std.fmt.bufPrint(&test_url_buf, "http://127.0.0.1:{d}/v1/models", .{server.socket.address.getPort()}); + try std.testing.expect(refreshAt(io, arena.allocator(), home, spec, "test-key", .environment, .fromSeconds(1))); try std.testing.expect(pricing.providerModelInTable("codegraff", "new-gateway-alias")); const before = pricing.active_model_table; for (0..2) |_| { - try std.testing.expect(!refreshAt(gpa, io, arena.allocator(), spec, "test-key", .environment, .fromSeconds(1))); + try std.testing.expect(!refreshAt(io, arena.allocator(), home, spec, "test-key", .environment, .fromSeconds(1))); try std.testing.expectEqual(before.ptr, pricing.active_model_table.ptr); } const started = Io.Timestamp.now(io, .awake).nanoseconds; - try std.testing.expect(!refreshAt(gpa, io, arena.allocator(), spec, "test-key", .environment, .fromMilliseconds(50))); + try std.testing.expect(!refreshAt(io, arena.allocator(), home, spec, "test-key", .environment, .fromMilliseconds(50))); try std.testing.expect(Io.Timestamp.now(io, .awake).nanoseconds - started < 2 * std.time.ns_per_s); try std.testing.expectEqual(before.ptr, pricing.active_model_table.ptr); + // Close the stalled connection and let the abandoned worker finish inside this test. + group.cancel(io); + try std.testing.expect(catalog_bounded.waitIdle(io, .fromSeconds(10))); } fn answer(io: Io, server: *Io.net.Server) void { diff --git a/src/goal_state.zig b/src/goal_state.zig index b2d487e5..2344a581 100644 --- a/src/goal_state.zig +++ b/src/goal_state.zig @@ -315,7 +315,9 @@ pub fn renderTodos(self: *Agent, epoch: u64) []const u8 { "[-]" else "[ ]"; - w.print("{s} {s}\n", .{ mark, t.content }) catch break; + w.print("{s} {s}", .{ mark, t.content }) catch break; + if (t.reason.len > 0) w.print(" (cancelled: {s})", .{t.reason}) catch break; // #1520: a cancel shows why + w.writeByte('\n') catch break; } return std.mem.trimEnd(u8, aw.writer.buffered(), "\n"); } diff --git a/src/goal_todo.zig b/src/goal_todo.zig index fc16b577..9522a9ab 100644 --- a/src/goal_todo.zig +++ b/src/goal_todo.zig @@ -30,6 +30,8 @@ const Agent = agent_mod.Agent; const TodoItem = agent_mod.TodoItem; const goal_state = @import("goal_state.zig"); const goal_verify_kind = @import("goal_verify_kind.zig"); +const todo_cancel = @import("todo_cancel.zig"); // #1520: the cancelled status's reason and authorization rules +const contentOf = todo_cancel.contentOf; pub const ReplaceClear = struct { dropped_open: usize = 0, kept_verify: usize = 0 }; @@ -133,7 +135,9 @@ pub fn applyTodoWrite(root: *Agent, list: ?Value) !WriteResult { const priors = try root.gpa.alloc(?TodoItem, incoming.items.len); defer root.gpa.free(priors); for (incoming.items, priors) |c, *p| p.* = priorItem(root.todos.items, epoch, c); - const ask_now = askFingerprint(root); + // #1520: every cancel says why. Rejected whole, before anything changes. + if (try todo_cancel.missingReason(root.arena, asArray(list).?, priors)) |msg| return .{ .text = msg, .rejected = true }; + const ask_now = todo_cancel.askFingerprint(root); const cleared = clearEpochForReplaceEx(&root.todos, epoch, incoming.items); goal_state.noteTodoWrite(root); // list changed; open verification still fails allDone (#844) var refused_cancel: usize = 0; @@ -143,8 +147,8 @@ pub fn applyTodoWrite(root: *Agent, list: ?Value) !WriteResult { const content = contentOf(item) orelse continue; const prior = priors[k]; k += 1; - var status = if (item.object.get("status")) |st| (if (st == .string) st.string else "pending") else "pending"; - if (!cancelAllowed(status, content, prior, ask_now)) { + var status = todo_cancel.statusOf(item); + if (!todo_cancel.allowed(status, content, prior, ask_now)) { status = if (prior) |p| p.status else "pending"; refused_cancel += 1; } @@ -153,37 +157,19 @@ pub fn applyTodoWrite(root: *Agent, list: ?Value) !WriteResult { .status = try root.arena.dupe(u8, status), .epoch = epoch, .ask_fp = if (prior) |p| p.ask_fp else ask_now, // the request it was FIRST written under + .reason = if (todo_cancel.isCancel(status)) try root.arena.dupe(u8, todo_cancel.reasonOf(item, prior)) else "", }); } } var res = try writeResult(root, epoch, incoming.items, cleared); if (refused_cancel > 0) { - const note = try std.fmt.allocPrint(root.arena, "\n({d} verification item(s) were not cancelled: a verification item can be cancelled only after a newer user message changes scope; they keep their previous status)", .{refused_cancel}); + const note = try std.fmt.allocPrint(root.arena, "\n({d} verification item(s) were not cancelled: a verification item can be cancelled only after a newer user message or an ask_user answer changes scope; they keep their previous status)", .{refused_cancel}); res.text = try std.mem.concat(root.arena, u8, &.{ res.text, note }); res.model_text = try std.mem.concat(root.arena, u8, &.{ res.reply(), note }); } return res; } -/// #1545: cancelling ordinary work is always allowed (omitting it drops it -/// anyway). A verification item is the #844 obligation, so only the USER can -/// lift it: the latest user request must differ from the one it was written -/// under. A brand-new item has nothing to cancel. -fn cancelAllowed(status: []const u8, content: []const u8, prior: ?TodoItem, ask_now: u64) bool { - if (!std.mem.eql(u8, status, "cancelled")) return true; - if (!goal_verify_kind.isVerification(content)) return true; - const p = prior orelse return false; - return p.ask_fp != ask_now; -} - -/// Fingerprint of the latest real user request (notices excluded). Compaction -/// keeps the current turn's opening message verbatim, so it is stable within -/// a request and changes when the user sends a new one or steers mid-run. -fn askFingerprint(root: *const Agent) u64 { - const t = @import("messages.zig").latestUserText(root.messages.items); - return std.hash.Wyhash.hash(0x1545, t) | 1; // never 0: 0 marks a legacy item -} - fn priorItem(todos: []const TodoItem, epoch: u64, content: []const u8) ?TodoItem { for (todos) |t| if (t.epoch == epoch and !t.retired and std.mem.eql(u8, t.content, content)) return t; return null; @@ -213,13 +199,6 @@ fn asArray(list: ?Value) ?[]const Value { return l.array.items; } -fn contentOf(item: Value) ?[]const u8 { - if (item != .object) return null; - const c = item.object.get("content") orelse return null; - if (c != .string) return null; - return c.string; -} - /// A root agent in the shape the todo helpers read; no live turn behind it. fn todoRoot(arena: std.mem.Allocator) Agent { var root: Agent = undefined; @@ -505,7 +484,7 @@ test "#1545 cancelled and dropped records coexist without reopening or adopting var root = todoRoot(ar); _ = try applyTodoWrite(&root, try todosArg(ar, \\{"todos":[{"content":"finished work","status":"completed"}, - \\ {"content":"cancelled work","status":"cancelled"}, + \\ {"content":"cancelled work","status":"cancelled","reason":"user moved on"}, \\ {"content":"abandoned work","status":"dropped"}]} )); try std.testing.expectEqual(@as(usize, 0), goal_state.openCount(root.todos.items, 0)); @@ -516,7 +495,7 @@ test "#1545 cancelled and dropped records coexist without reopening or adopting )); try std.testing.expectEqual(@as(usize, 4), root.todos.items.len); try std.testing.expectEqual(@as(usize, 0), res.dropped_open); - try std.testing.expect(std.mem.indexOf(u8, res.text, "[-] cancelled work") != null); + try std.testing.expect(std.mem.indexOf(u8, res.text, "[-] cancelled work (cancelled: user moved on)") != null); try std.testing.expect(std.mem.indexOf(u8, res.text, "[-] abandoned work") != null); goal_state.adoptTodos(root.todos.items, 0, 1); for (root.todos.items[0..3]) |t| try std.testing.expectEqual(@as(u64, 0), t.epoch); diff --git a/src/goal_verify.zig b/src/goal_verify.zig index a76d627a..7273f546 100644 --- a/src/goal_verify.zig +++ b/src/goal_verify.zig @@ -44,7 +44,7 @@ pub fn completionGate(arena: Allocator, agent: *Agent) !?[]const u8 { if (agent.review_mode or agent.sub) return goal_state.completionGate(arena, agent); if (hasUnresolved(agent)) { const rendered = goal_state.renderTodos(agent, goal_state.currentEpoch(agent.goal)); - return try std.fmt.allocPrint(arena, "completion deferred: {d} unresolved verification item(s) remain:\n{s}\nDropping or summarizing them does not satisfy completion. Finish the verification, or have the user change scope. If a newer user message already moved away from this work, set those items to status \"cancelled\" with todo_write (allowed only after a newer user message).", .{ unresolvedCount(agent), rendered }); + return try std.fmt.allocPrint(arena, "completion deferred: {d} unresolved verification item(s) remain:\n{s}\nDropping or summarizing them does not satisfy completion. Finish the verification, or have the user change scope. If the user already withdrew this work or handed it elsewhere (a newer message or an ask_user answer), set those items to status \"cancelled\" with todo_write, each with a short \"reason\" naming who withdrew it or where it went. A cancelled item stays on the list and is never counted as verified.", .{ unresolvedCount(agent), rendered }); } return goal_state.completionGate(arena, agent); } @@ -176,7 +176,7 @@ test "#1545 a verification item cannot be cancelled within the request that wrot )); // Same request, no user message since: the #844 obligation stands. const refused = try goal_todo.applyTodoWrite(&root, try todosArg(ar, - \\{"todos":[{"content":"run the tests","status":"cancelled"}]} + \\{"todos":[{"content":"run the tests","status":"cancelled","reason":"taking too long"}]} )); try std.testing.expect(std.mem.indexOf(u8, refused.reply(), "were not cancelled") != null); try std.testing.expectEqual(@as(usize, 1), unresolvedCount(&root)); @@ -198,7 +198,7 @@ test "#1545 after a newer user message, a cancelled verification item is parked, // The user changes scope; the old verification is no longer the task. try root.messages.append(try textMessage(ar, "user", "stop that, file an issue about it instead")); const res = try goal_todo.applyTodoWrite(&root, try todosArg(ar, - \\{"todos":[{"content":"run the tests","status":"cancelled"}, + \\{"todos":[{"content":"run the tests","status":"cancelled","reason":"user switched to filing an issue"}, \\ {"content":"file the issue","status":"completed"}]} )); try std.testing.expect(std.mem.indexOf(u8, res.reply(), "were not cancelled") == null); @@ -221,10 +221,80 @@ test "#1545 ordinary work can be cancelled any time; a brand-new verification it const textMessage = @import("messages.zig").textMessage; try root.messages.append(try textMessage(ar, "user", "ship it")); const res = try goal_todo.applyTodoWrite(&root, try todosArg(ar, - \\{"todos":[{"content":"sketch an alternative","status":"cancelled"}, - \\ {"content":"verify the fix","status":"cancelled"}]} + \\{"todos":[{"content":"sketch an alternative","status":"cancelled","reason":"not needed"}, + \\ {"content":"verify the fix","status":"cancelled","reason":"not needed"}]} )); try std.testing.expect(std.mem.indexOf(u8, res.text, "[-] sketch an alternative") != null); try std.testing.expect(std.mem.indexOf(u8, res.text, "[ ] verify the fix") != null); try std.testing.expectEqual(@as(usize, 1), unresolvedCount(&root)); } + +test "#1520 withdrawn verification: omission is restored, a reasonless cancel is rejected, a reasoned cancel lets completion through unverified" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const ar = arena_state.allocator(); + var root = todoRoot(ar); + root.goal = .{ .objective = "ship the fix", .epoch = 1 }; + const textMessage = @import("messages.zig").textMessage; + try root.messages.append(try textMessage(ar, "user", "fix it, run the tests, then publish and verify the release")); + _ = try goal_todo.applyTodoWrite(&root, try todosArg(ar, + \\{"todos":[{"content":"land the fix","status":"completed"}, + \\ {"content":"run the tests","status":"in_progress"}, + \\ {"content":"verify the published release","status":"pending"}]} + )); + // The user withdraws publication and hands it to another session. + try root.messages.append(try textMessage(ar, "user", "do not publish; the release session owns that now")); + // Leaving the item out still keeps it as an unresolved obligation. + _ = try goal_todo.applyTodoWrite(&root, try todosArg(ar, + \\{"todos":[{"content":"run the tests","status":"completed"}]} + )); + try std.testing.expectEqual(@as(usize, 1), unresolvedCount(&root)); + try std.testing.expect((try completionGate(ar, &root)) != null); + // Cancelling without a reason is rejected whole: nothing changes. + const bare = try goal_todo.applyTodoWrite(&root, try todosArg(ar, + \\{"todos":[{"content":"run the tests","status":"completed"}, + \\ {"content":"verify the published release","status":"cancelled"}]} + )); + try std.testing.expect(bare.rejected); + try std.testing.expect(std.mem.indexOf(u8, bare.text, "\"reason\"") != null); + try std.testing.expectEqual(@as(usize, 1), unresolvedCount(&root)); + // With a reason, the item is closed, stays on the list, and shows why. + const ok = try goal_todo.applyTodoWrite(&root, try todosArg(ar, + \\{"todos":[{"content":"run the tests","status":"completed"}, + \\ {"content":"verify the published release","status":"cancelled","reason":"delegated to the release session"}]} + )); + try std.testing.expect(!ok.rejected); + try std.testing.expect(std.mem.indexOf(u8, ok.text, "[-] verify the published release (cancelled: delegated to the release session)") != null); + try std.testing.expectEqual(@as(usize, 0), unresolvedCount(&root)); + try std.testing.expect((try completionGate(ar, &root)) == null); + try std.testing.expect(!taskVerified(&root)); // closed, never verified + // A later write that omits it keeps the record and its reason. + _ = try goal_todo.applyTodoWrite(&root, try todosArg(ar, + \\{"todos":[{"content":"run the tests","status":"completed"}]} + )); + try std.testing.expect(std.mem.indexOf(u8, goal_state.renderTodos(&root, 1), "(cancelled: delegated to the release session)") != null); + try std.testing.expectEqual(@as(usize, 0), unresolvedCount(&root)); +} + +test "#1520 an ask_user answer counts as the user's scope change for cancelling verification" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const ar = arena_state.allocator(); + var root = todoRoot(ar); + const answers = @import("ask_user_answers.zig"); + defer answers.reset(); + try root.messages.append(try @import("messages.zig").textMessage(ar, "user", "publish to the old target and verify it")); + _ = try goal_todo.applyTodoWrite(&root, try todosArg(ar, + \\{"todos":[{"content":"verify the old target","status":"pending"}]} + )); + const cancel = + \\{"todos":[{"content":"verify the old target","status":"cancelled","reason":"user replaced the target in a clarification"}]} + ; + const before = try goal_todo.applyTodoWrite(&root, try todosArg(ar, cancel)); + try std.testing.expect(std.mem.indexOf(u8, before.reply(), "were not cancelled") != null); + try std.testing.expectEqual(@as(usize, 1), unresolvedCount(&root)); + answers.record(answers.turnKey("publish to the old target and verify it"), "yes, use the new target instead"); + const after = try goal_todo.applyTodoWrite(&root, try todosArg(ar, cancel)); + try std.testing.expect(std.mem.indexOf(u8, after.reply(), "were not cancelled") == null); + try std.testing.expectEqual(@as(usize, 0), unresolvedCount(&root)); +} diff --git a/src/gui_background.zig b/src/gui_background.zig index e9a0fd55..ab689144 100644 --- a/src/gui_background.zig +++ b/src/gui_background.zig @@ -73,13 +73,13 @@ pub fn applyTo(m: *Map, os: std.Target.Os.Tag) std.mem.Allocator.Error!void { pub fn install(io: Io, home: []const u8, target: []const u8) void { var buf: [std.fs.max_path_bytes]u8 = undefined; const d = dir(&buf, builtin.os.tag, home, null) orelse return; - var text_buf: [512]u8 = undefined; + var text_buf: [2 * std.fs.max_path_bytes + 512]u8 = undefined; const text = script(&text_buf, target) catch return; const cwd = Io.Dir.cwd(); cwd.createDirPath(io, d) catch return; var shim_dir = cwd.openDir(io, d, .{}) catch return; defer shim_dir.close(io); - var have_buf: [512]u8 = undefined; + var have_buf: [text_buf.len]u8 = undefined; if (shim_dir.readFile(io, "open", &have_buf)) |have| { if (std.mem.eql(u8, have, text)) return; } else |_| {} diff --git a/src/harness_policy.zig b/src/harness_policy.zig index a09692dc..1eb22601 100644 --- a/src/harness_policy.zig +++ b/src/harness_policy.zig @@ -183,36 +183,54 @@ pub fn confinedPath(path: []const u8) bool { return true; } -/// Defense-in-depth on top of confinedPath: a confined path with no `..` and no -/// absolute prefix can still escape the cwd through a symlink that points -/// outside (e.g. `evil -> /etc`, then `read_file evil/passwd`). We refuse to -/// traverse symlinks at all: walk each path prefix and -/// reject if any component is a symlink (readLink succeeds). Conservative — a -/// symlink inside the repo becomes unreadable via the file tools — but it makes -/// confinement structural rather than lexical. bash (gated) is unaffected. -/// `base` is the agent's isolated worktree root (#276 P0-1) when its tool -/// calls are pinned there instead of the shared cwd — each checked prefix is -/// then resolved under `base` (an absolute path, so `Io.Dir.cwd()` below is -/// just the dir-fd the readLink call ignores for an absolute sub_path) rather -/// than the process's real cwd, which a worktree-isolated agent never enters. -/// `base == null` (the shared-cwd default) keeps the original behavior exactly. +fn beneath(path: []const u8, root: []const u8) bool { + if (!std.mem.startsWith(u8, path, root)) return false; + if (path.len == root.len) return true; + // A filesystem root already ends in a separator; otherwise require a + // component boundary so checkout-sibling is not a child of checkout. + return std.fs.path.isSep(root[root.len - 1]) or std.fs.path.isSep(path[root.len]); +} + +/// Defense-in-depth on top of lexical confinement. Permit symlinks only when +/// every linked prefix resolves beneath the canonical tool root. Check the +/// final component too, including for write targets. Dangling/looping links +/// fail closed; genuinely missing components remain legal for new files. +/// `base` pins relative paths to an agent worktree; null uses cwd. Absolute +/// paths must belong to an explicit --add-dir root, never to an implicit base. +/// Root ancestors may themselves be symlinked (e.g. a platform's temp dir). +/// This is a preflight check, not an atomic sandbox against concurrent renames. pub fn noSymlinkEscape(io: Io, path: []const u8, base: ?[]const u8) bool { + if (!confinedPath(path)) return false; + var root = base orelse "."; + var relative = path; + if (std.fs.path.isAbsolute(path)) { + root = ""; + for (@import("workspace_roots.zig").items()) |allowed_root| { + if (beneath(path, allowed_root) and allowed_root.len > root.len) root = allowed_root; + } + if (root.len == 0) return false; + relative = std.mem.trimStart(u8, path[root.len..], "/\\"); + } + var root_buf: [std.fs.max_path_bytes]u8 = undefined; + const root_len = Io.Dir.cwd().realPathFile(io, root, &root_buf) catch return false; + const canonical_root = root_buf[0..root_len]; var buf: [std.fs.max_path_bytes]u8 = undefined; var join_buf: [std.fs.max_path_bytes]u8 = undefined; var i: usize = 0; - while (i <= path.len) { - const slash = std.mem.indexOfScalarPos(u8, path, i, '/') orelse path.len; - const prefix = path[0..slash]; + while (i <= relative.len) { + const slash = std.mem.indexOfAnyPos(u8, relative, i, "/\\") orelse relative.len; + const prefix = relative[0..slash]; if (prefix.len > 0) { - const check_path = if (base) |b| - std.fmt.bufPrint(&join_buf, "{s}/{s}", .{ b, prefix }) catch return false - else - prefix; + const check_path = std.fmt.bufPrint(&join_buf, "{s}/{s}", .{ root, prefix }) catch return false; if (Io.Dir.cwd().readLink(io, check_path, &buf)) |_| { - return false; // this component is a symlink → refuse - } else |_| {} // not a symlink (or doesn't exist yet) → fine + const n = Io.Dir.cwd().realPathFile(io, check_path, &buf) catch return false; + if (!beneath(buf[0..n], canonical_root)) return false; + } else |err| switch (err) { + error.NotLink, error.FileNotFound, error.NotDir => {}, + else => return false, + } } - if (slash == path.len) break; + if (slash == relative.len) break; i = slash + 1; } return true; @@ -364,3 +382,112 @@ test "the settings file has one spelling, and it lives in the settings dir" { try std.testing.expect(std.mem.startsWith(u8, settings_path, settings_dir ++ "/")); try std.testing.expect(confinedPath(settings_path)); // never escapes the project } + +// Regression fixtures for in-checkout dependency links (#1748). +const builtin = @import("builtin"); +const test_roots = @import("workspace_roots.zig"); +const test_io = std.testing.io; +const test_allocator = std.testing.allocator; + +fn rootPath(tmp: *std.testing.TmpDir, buf: []u8) ![]const u8 { + return buf[0..try tmp.dir.realPath(test_io, buf)]; +} + +fn setup(tmp: *std.testing.TmpDir) !void { + if (builtin.os.tag == .windows) return error.SkipZigTest; + try tmp.dir.createDirPath(test_io, "checkout/node_modules/.store/pkg/docs"); + try tmp.dir.createDirPath(test_io, "checkout-sibling"); + try tmp.dir.writeFile(test_io, .{ .sub_path = "checkout/node_modules/.store/pkg/docs/guide.md", .data = "guide\n" }); + try tmp.dir.writeFile(test_io, .{ .sub_path = "checkout-sibling/secret.txt", .data = "outside\n" }); + try tmp.dir.symLink(test_io, ".store/pkg", "checkout/node_modules/pkg", .{ .is_directory = true }); +} + +test "symlink confinement #1748: relative dependency and final file links stay inside" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try setup(&tmp); + try tmp.dir.symLink(test_io, "node_modules/pkg/docs/guide.md", "checkout/guide.md", .{}); + try tmp.dir.symLink(test_io, "pkg", "checkout/node_modules/chained", .{ .is_directory = true }); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const root = try std.fmt.allocPrint(test_allocator, "{s}/checkout", .{try rootPath(&tmp, &buf)}); + defer test_allocator.free(root); + try std.testing.expect(noSymlinkEscape(test_io, "node_modules/pkg/docs/guide.md", root)); + try std.testing.expect(noSymlinkEscape(test_io, "node_modules/pkg", root)); + try std.testing.expect(noSymlinkEscape(test_io, "guide.md", root)); + try std.testing.expect(noSymlinkEscape(test_io, "node_modules/chained/docs/guide.md", root)); +} + +test "symlink confinement #1748: absolute in-root links and symlinked root ancestors" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try setup(&tmp); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const root = try std.fmt.allocPrint(test_allocator, "{s}/checkout", .{try rootPath(&tmp, &buf)}); + defer test_allocator.free(root); + const target = try std.fmt.allocPrint(test_allocator, "{s}/node_modules/.store/pkg", .{root}); + defer test_allocator.free(target); + try tmp.dir.symLink(test_io, target, "checkout/absolute", .{ .is_directory = true }); + try tmp.dir.symLink(test_io, ".", "alias", .{ .is_directory = true }); + const alias = try std.fmt.allocPrint(test_allocator, "{s}/../alias/checkout", .{root}); + defer test_allocator.free(alias); + try std.testing.expect(noSymlinkEscape(test_io, "absolute/docs/guide.md", alias)); + try std.testing.expect(noSymlinkEscape(test_io, "node_modules/pkg/docs/guide.md", alias)); +} + +test "symlink confinement #1748: sibling-prefix and final-component escapes are denied" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try setup(&tmp); + try tmp.dir.symLink(test_io, "../checkout-sibling", "checkout/outside", .{ .is_directory = true }); + try tmp.dir.symLink(test_io, "../checkout-sibling/secret.txt", "checkout/secret.txt", .{}); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const root = try std.fmt.allocPrint(test_allocator, "{s}/checkout", .{try rootPath(&tmp, &buf)}); + defer test_allocator.free(root); + try std.testing.expect(!noSymlinkEscape(test_io, "outside/secret.txt", root)); + try std.testing.expect(!noSymlinkEscape(test_io, "outside/new/note.txt", root)); + try std.testing.expect(!noSymlinkEscape(test_io, "secret.txt", root)); +} + +test "symlink confinement #1748: dangling and cyclic links fail closed but missing writes work" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try setup(&tmp); + try tmp.dir.symLink(test_io, "missing", "checkout/dangling", .{}); + try tmp.dir.symLink(test_io, "../checkout-sibling/missing", "checkout/dangling-outside", .{}); + try tmp.dir.symLink(test_io, "loop", "checkout/loop", .{}); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const root = try std.fmt.allocPrint(test_allocator, "{s}/checkout", .{try rootPath(&tmp, &buf)}); + defer test_allocator.free(root); + for ([_][]const u8{ "dangling", "dangling/child", "dangling-outside", "loop", "loop/child" }) |path| + try std.testing.expect(!noSymlinkEscape(test_io, path, root)); + try std.testing.expect(noSymlinkEscape(test_io, "new/nested/note.txt", root)); + try std.testing.expect(noSymlinkEscape(test_io, "node_modules/pkg/docs/new.txt", root)); + try std.testing.expect(!noSymlinkEscape(test_io, "node_modules/../guide.md", root)); + try std.testing.expect(!noSymlinkEscape(test_io, "/unregistered/guide.md", root)); +} + +test "symlink confinement #1748: default cwd and explicitly allowed absolute roots" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try setup(&tmp); + // tmpDir is relative to cwd, so exercise the null-base path without chdir. + const relative = try std.fmt.allocPrint(test_allocator, ".zig-cache/tmp/{s}/checkout/node_modules/pkg/docs/guide.md", .{tmp.sub_path}); + defer test_allocator.free(relative); + try std.testing.expect(noSymlinkEscape(test_io, relative, null)); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const root = try std.fmt.allocPrint(test_allocator, "{s}/checkout", .{try rootPath(&tmp, &buf)}); + defer test_allocator.free(root); + var arena = std.heap.ArenaAllocator.init(test_allocator); + defer arena.deinit(); + test_roots.resetForTest(); + defer test_roots.resetForTest(); + try test_roots.add(test_io, arena.allocator(), root); + const absolute = try std.fmt.allocPrint(test_allocator, "{s}/node_modules/pkg/docs/guide.md", .{root}); + defer test_allocator.free(absolute); + try std.testing.expect(confinedPath(absolute)); + try std.testing.expect(noSymlinkEscape(test_io, absolute, "/unused-base")); + try tmp.dir.symLink(test_io, "../checkout-sibling", "checkout/outside", .{ .is_directory = true }); + const escaped = try std.fmt.allocPrint(test_allocator, "{s}/outside/secret.txt", .{root}); + defer test_allocator.free(escaped); + try std.testing.expect(!noSymlinkEscape(test_io, escaped, null)); +} diff --git a/src/image_sniff.zig b/src/image_sniff.zig new file mode 100644 index 00000000..52a066f9 --- /dev/null +++ b/src/image_sniff.zig @@ -0,0 +1,77 @@ +//! An image's media type from its bytes, not its name (#1762). +//! +//! Providers check the declared media type against the pixels and reject a +//! mismatch, and the rejected block stays in the transcript, so every later +//! turn fails the same way. A WebP saved as `shot.png` used to go out as +//! `image/png` because the type came from the file extension. Every path that +//! holds the bytes asks here first; the four types below are the raster +//! formats every vision provider accepts. + +const std = @import("std"); + +/// The formats `sniff` recognizes, for error text. +pub const supported_names = "PNG, JPEG, GIF, or WebP"; + +/// The media type the bytes actually are, or null when they are none of the +/// four supported raster formats (BMP, TIFF, HEIC, SVG, text, truncated...). +pub fn sniff(bytes: []const u8) ?[]const u8 { + if (std.mem.startsWith(u8, bytes, "\x89PNG\r\n\x1a\n")) return "image/png"; + if (std.mem.startsWith(u8, bytes, "\xff\xd8\xff")) return "image/jpeg"; + if (std.mem.startsWith(u8, bytes, "GIF87a") or std.mem.startsWith(u8, bytes, "GIF89a")) return "image/gif"; + if (bytes.len >= 12 and std.mem.eql(u8, bytes[0..4], "RIFF") and std.mem.eql(u8, bytes[8..12], "WEBP")) return "image/webp"; + return null; +} + +/// `sniff` on base64 text, decoding only the 12-byte head every signature +/// fits in. Null for unrecognized bytes or base64 that does not decode. +pub fn sniffBase64(b64: []const u8) ?[]const u8 { + const dec = std.base64.standard.Decoder; + var head: [12]u8 = undefined; + // 16 base64 chars are exactly 12 bytes with no padding; shorter input + // decodes whole (its own padding included). + const text = b64[0..@min(b64.len, 16)]; + const n = dec.calcSizeForSlice(text) catch return null; + if (n > head.len) return null; + dec.decode(head[0..n], text) catch return null; + return sniff(head[0..n]); +} + +/// The type to send for pixels that arrived with a declared type (an MCP or +/// ACP `mimeType`, a request `media_type`): the bytes win when they are a +/// supported format, and the declaration stands only when they are not. +pub fn resolveDeclared(b64: []const u8, declared: []const u8) []const u8 { + return sniffBase64(b64) orelse declared; +} + +const webp_head = "RIFF\x24\x00\x00\x00WEBPVP8 "; + +test "sniff: each supported format by its signature, whatever the file is called" { + try std.testing.expectEqualStrings("image/png", sniff("\x89PNG\r\n\x1a\n\x00\x00").?); + try std.testing.expectEqualStrings("image/jpeg", sniff("\xff\xd8\xff\xe0\x00\x10JFIF").?); + try std.testing.expectEqualStrings("image/gif", sniff("GIF89a\x01\x00").?); + try std.testing.expectEqualStrings("image/gif", sniff("GIF87a\x01\x00").?); + try std.testing.expectEqualStrings("image/webp", sniff(webp_head).?); +} + +test "sniff: unsupported or truncated bytes are not guessed" { + try std.testing.expect(sniff("") == null); + try std.testing.expect(sniff("BM\x36\x00\x00\x00") == null); // BMP + try std.testing.expect(sniff("II*\x00\x08\x00\x00\x00") == null); // TIFF + try std.testing.expect(sniff("\x00\x00\x00\x18ftypheic") == null); // HEIC + try std.testing.expect(sniff(" 0) interaction_id = try arena.dupe(u8, str(ix.object, "id")); if (str(ix.object, "status").len > 0) status = try arena.dupe(u8, str(ix.object, "status")); if (ix.object.get("usage")) |u| usage = try @import("util.zig").dupeJsonValue(arena, u); diff --git a/src/kimi_catalog.zig b/src/kimi_catalog.zig index dde0245a..d2aac481 100644 --- a/src/kimi_catalog.zig +++ b/src/kimi_catalog.zig @@ -114,10 +114,17 @@ pub const Fetch = struct { pub fn fetch(io: Io, gpa: Allocator, arena: Allocator, access: []const u8) Fetch { if (access.len == 0) return .{ .source = "baked fallback — no Kimi login" }; + const body = fetchBody(io, gpa, arena, access) orelse return .{}; + const rows = parseModels(arena, body) orelse + return .{ .source = "baked fallback — invalid live catalog" }; + return .{ .rows = rows, .source = "live account catalog" }; +} + +fn fetchBody(io: Io, gpa: Allocator, arena: Allocator, access: []const u8) ?[]const u8 { var client: std.http.Client = .{ .allocator = gpa, .io = io }; defer client.deinit(); var aw: Io.Writer.Allocating = .init(arena); - const bearer = std.fmt.allocPrint(arena, "Bearer {s}", .{access}) catch return .{}; + const bearer = std.fmt.allocPrint(arena, "Bearer {s}", .{access}) catch return null; var extra: [8]std.http.Header = undefined; extra[0] = .{ .name = "Authorization", .value = bearer }; extra[1] = .{ .name = "Accept", .value = "application/json" }; @@ -128,11 +135,51 @@ pub fn fetch(io: Io, gpa: Allocator, arena: Allocator, access: []const u8) Fetch .response_writer = &aw.writer, .headers = .{ .user_agent = .{ .override = user_agent } }, .extra_headers = extra[0..sent], - }) catch return .{}; - if (@backingInt(response.status) != 200) return .{}; - const rows = parseModels(arena, aw.writer.buffered()) orelse - return .{ .source = "baked fallback — invalid live catalog" }; - return .{ .rows = rows, .source = "live account catalog" }; + }) catch return null; + if (@backingInt(response.status) != 200) return null; + return aw.writer.buffered(); +} + +/// The account catalog as last fetched, so boot activates it instead of +/// waiting on the network; `catalog_bounded` rewrites it in the background. +pub const cache_ttl_ms: i64 = 6 * 60 * 60 * 1000; + +fn cachePaths(arena: Allocator, home: []const u8) [2][]const u8 { + if (home.len == 0) return .{ "", "" }; + return .{ + std.fmt.allocPrint(arena, "{s}/.codegraff/kimi-models.json", .{home}) catch "", + std.fmt.allocPrint(arena, "{s}/.codegraff-kimi-models.json", .{home}) catch "", + }; +} + +/// Fetch the catalog and store the raw body; true when a valid catalog was +/// written. Runs on a `catalog_bounded` worker with its own Io and allocator. +pub fn fetchToCache(io: Io, gpa: Allocator, home: []const u8, access: []const u8) bool { + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const a = arena_state.allocator(); + const body = fetchBody(io, gpa, a, access) orelse return false; + if (parseModels(a, body) == null) return false; + for (cachePaths(a, home)) |path| { + if (path.len == 0) continue; + @import("credential_store.zig").replaceFile(io, Io.Dir.cwd(), path, body, .default_file) catch continue; + return true; + } + return false; +} + +pub const Cached = struct { rows: []const pricing.ModelInfo, fresh: bool }; + +pub fn cached(io: Io, arena: Allocator, home: []const u8) ?Cached { + for (cachePaths(arena, home)) |path| { + if (path.len == 0) continue; + const data = Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(1024 * 1024)) catch continue; + const rows = parseModels(arena, data) orelse continue; + const stat = Io.Dir.cwd().statFile(io, path, .{}) catch return .{ .rows = rows, .fresh = false }; + const age = util.unixMs(io) - @as(i64, @intCast(@divTrunc(stat.mtime.nanoseconds, std.time.ns_per_ms))); + return .{ .rows = rows, .fresh = age >= 0 and age <= cache_ttl_ms }; + } + return null; } fn retain(arena: Allocator, rows: []const pricing.ModelInfo) ?[]const pricing.ModelInfo { @@ -163,7 +210,7 @@ pub fn commit(arena: Allocator, rows: []const pricing.ModelInfo, source: []const catalog_source = source; return; }; - catalog_source = if (pricing.activateKimiModels(arena, kept)) "live account catalog" else source; + catalog_source = if (pricing.activateKimiModels(arena, kept)) source else "baked fallback — catalog rows rejected"; } /// Claim the once-per-process fetch. Startup's fan-out calls this before @@ -237,3 +284,24 @@ test "commit installs fetched Kimi rows into the active table" { try std.testing.expect(pricing.providerModelInTable("kimi", "k9-fanout")); try std.testing.expect(pricing.providerModelInTable("anthropic", "claude-opus-4-8")); } + +test "the cached account catalog activates at boot and reports its age" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer state.deinit(); + const a = state.allocator(); + const home = try tmp.dir.realPathFileAlloc(io, ".", a); + try std.testing.expect(cached(io, a, home) == null); + try tmp.dir.createDir(io, ".codegraff", .default_dir); + const path = try std.fmt.allocPrint(a, "{s}/.codegraff/kimi-models.json", .{home}); + try Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = + \\{"object":"list","data":[{"id":"k9-cached","context_length":262144}]} + }); + const hit = cached(io, a, home) orelse return error.TestUnexpectedResult; + try std.testing.expect(hit.fresh); + try std.testing.expectEqualStrings("k9-cached", hit.rows[0].name); + try Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = "not json" }); + try std.testing.expect(cached(io, a, home) == null); +} diff --git a/src/main.zig b/src/main.zig index 4fdf3ea3..53a9f39b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -53,7 +53,7 @@ test { _ = .{ @import("server_orphan.zig"), @import("term_sender.zig") }; _ = @import("repo_transaction.zig"); _ = .{ @import("pr_command.zig"), @import("pr_evidence.zig"), @import("artifact_repository.zig"), @import("artifact_claim_ledger.zig"), @import("artifact_claim_store.zig"), @import("artifact_claim_stale.zig"), @import("artifact_claim_unresolved.zig"), @import("artifact_claim_inventory.zig") }; - _ = .{ @import("argstream_citation_tests.zig"), @import("acp_reauth_tests.zig") }; // unit_tests' root is main.zig only, so reference every split-out module or its tests silently never run + _ = .{ @import("argstream_citation_tests.zig"), @import("acp_reauth_tests.zig"), @import("auth_recovery.zig") }; // unit_tests' root is main.zig only, so reference every split-out module or its tests silently never run _ = @import("codex_node_repl.zig"); _ = pricing; _ = models_cache; @@ -564,7 +564,7 @@ test { // ── Unit tests (`zig build test`): pull in tests from imported modu _ = .{ @import("prompt_astra.zig"), @import("prompt_guidance.zig"), @import("oauth_chatgpt.zig"), @import("chatgpt_reauth.zig"), @import("oauth_anthropic.zig"), @import("anthropic_oauth_wire.zig"), @import("anthropic_login_tests.zig") }; _ = .{ @import("agent_empty_completion.zig"), @import("agent_length_cutoff.zig"), @import("agent_model_loop.zig"), @import("publication_policy_tests.zig"), @import("jobs_completion_tests.zig"), @import("headless_result.zig"), @import("oneshot.zig"), @import("agent_length.zig"), @import("agent_length_tests.zig") }; _ = .{ @import("provider_routing_tests.zig"), @import("codegraff_messages_tests.zig"), @import("tool_result_empty_tests.zig"), @import("history_wire.zig"), @import("path_lookup.zig"), @import("agents_prepare.zig"), @import("catalog_price.zig"), @import("readline_paste_number_tests.zig"), @import("test_hooks.zig"), @import("peer_wake_loop.zig") }; - _ = .{ @import("list_dir_nearmiss.zig"), @import("tool_preview_tests.zig"), @import("stream_reconnect.zig") }; + _ = .{ @import("list_dir_nearmiss.zig"), @import("tool_preview_tests.zig"), @import("stream_reconnect.zig"), @import("oauth_kimi.zig"), @import("signin_link.zig") }; _ = .{ @import("repo_map.zig"), @import("cli_text.zig") }; _ = @import("agent_overflow_tests.zig"); // #414: and, through it, agent_overflow.zig's table tests _ = @import("agent_gateway_retry.zig"); // #1019: flake vs overflow classification must stay reachable @@ -577,18 +577,18 @@ test { // ── Unit tests (`zig build test`): pull in tests from imported modu _ = @import("agent_ws_steer.zig"); _ = @import("agent_ws_signal.zig"); _ = .{ @import("agent_ws_prewarm.zig"), @import("agent_ws_headers.zig") }; - _ = .{ @import("http2_pool.zig"), @import("agent_stream_h2.zig"), @import("agent_stream_h2_transport_test.zig") }; // h2 stream path, and graff's request/response contract over the pinned http-zig + _ = .{ @import("http2_pool.zig"), @import("agent_stream_h2.zig"), @import("agent_stream_h2_transport_test.zig"), @import("agent_stream_progress_test.zig"), @import("agent_stream_h2_progress_test.zig"), @import("stream_read_progress.zig") }; // transport contracts and partial-event byte progress _ = @import("acp_preauth.zig"); // credential-free ACP loop must stay in the test root _ = .{ @import("acp_protocol.zig"), @import("acp_v2.zig"), @import("acp_v2_prompt.zig"), @import("acp_session_load.zig"), @import("acp_session_fork.zig"), @import("session_fork.zig"), @import("acp_elicit.zig"), @import("hot_context.zig"), @import("history_translate.zig"), @import("mcp_notify.zig"), @import("mcp_wait.zig"), @import("mcp_pages.zig"), @import("mcp_mrtr.zig"), @import("ask_user_args.zig"), @import("mcp_add.zig"), @import("mcp_lazy.zig"), @import("mcp_share.zig"), @import("acp_room.zig"), @import("mcp_catalog.zig"), @import("mcp_watch.zig"), @import("acp_engine_test.zig"), @import("subagent_mimo_tests.zig"), @import("mimo_effort_tests.zig"), @import("gateway_picker_catalog.zig"), @import("ask_user_answers.zig"), @import("job_class.zig"), @import("startup_claim.zig") }; _ = @import("task_outcome.zig"); // goal-outcome telemetry events _ = @import("learn_delete.zig"); // #303: its tests were dead until listed here - _ = .{ @import("additional_tests.zig"), @import("req_stats.zig"), @import("exact_reply.zig"), @import("rlm_order_tests.zig"), @import("async_tool_policy.zig"), @import("agent_async_tools.zig"), @import("request_usage_attempts.zig"), @import("learn_formal.zig") }; + _ = .{ @import("additional_tests.zig"), @import("req_stats.zig"), @import("exact_reply.zig"), @import("rlm_order_tests.zig"), @import("async_tool_policy.zig"), @import("agent_async_tools.zig"), @import("request_usage_attempts.zig"), @import("learn_formal.zig"), @import("claude_agent_query.zig") }; _ = @import("goal_pacing_autonomous_test.zig"); _ = .{ @import("goal_state.zig"), @import("goal_state_tests.zig") }; _ = @import("goal_persist_tests.zig"); _ = @import("agent_render_cleanup.zig"); _ = .{ @import("goal_flow.zig"), @import("shell_identity.zig") }; - _ = @import("goal_todo.zig"); + _ = .{ @import("goal_todo.zig"), @import("todo_cancel.zig") }; _ = @import("goal_pacing.zig"); _ = .{ @import("presence_accord.zig"), @import("peer_idle.zig"), @import("acp_idle.zig"), @import("presence_chan.zig"), @import("presence_record.zig"), @import("daddy.zig"), @import("peer_inbox_content_test.zig"), @import("peer_inbox_failure_test.zig"), @import("peer_inbox_storage_test.zig"), @import("peer_inbox_sequence_test.zig"), @import("peer_inbox_snapshot_test.zig") }; _ = .{ @import("acp_agents.zig"), @import("repo_context_tests.zig") }; diff --git a/src/main_test.zig b/src/main_test.zig index 08713a79..5ef7f4e3 100644 --- a/src/main_test.zig +++ b/src/main_test.zig @@ -467,8 +467,12 @@ test "citation annotations stay out of reasoning and fold replay" { } try std.testing.expectEqualStrings(citation_clean, a.thinking_text.items); sink.emit(std.testing.io, .thinking_fold_toggle); + try std.testing.expect(a.thinking_folded); sink.emit(std.testing.io, .thinking_fold_toggle); + try std.testing.expect(!a.thinking_folded); const output = aw.writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, output, "Ctrl+T collapse") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "Ctrl+T expand") != null); try std.testing.expect(!@import("cite_markup.zig").contains(output)); try std.testing.expect(std.mem.indexOf(u8, output, "turn0") == null); try std.testing.expect(std.mem.indexOf(u8, output, citation_clean) != null); diff --git a/src/mainloop.zig b/src/mainloop.zig index 3f64a6d9..b2179877 100644 --- a/src/mainloop.zig +++ b/src/mainloop.zig @@ -498,6 +498,7 @@ pub fn run(ctx: *Ctx) !void { if (recovery_meter >= ctx.root.provider.compactAt()) { ctx.root.autocompact(recovery_meter); } + try @import("turn_dedup.zig").settleFailedTurn(ctx.root, ctx.arena, err); // #1729: a retry of this prompt is a new ask session.saveSession(ctx.root, ctx.arena, ctx.root.session_name) catch {}; continue; }, @@ -508,6 +509,7 @@ pub fn run(ctx: *Ctx) !void { } else { ctx.root.say("[turn aborted: {t}]\n", .{e}) catch {}; } + try @import("turn_dedup.zig").settleFailedTurn(ctx.root, ctx.arena, e); session.saveSession(ctx.root, ctx.arena, ctx.root.session_name) catch {}; continue; }, diff --git a/src/mcp_connect_notice.zig b/src/mcp_connect_notice.zig index 5c6bad3f..64bc2277 100644 --- a/src/mcp_connect_notice.zig +++ b/src/mcp_connect_notice.zig @@ -1,9 +1,8 @@ //! What a background MCP connect tells the session (ADR 0230). Handshakes no -//! longer hold up the start, so the session says when the first request goes -//! out while servers are still connecting, and the request that merges a -//! finished one names it: connected with its tool count, or did not connect. -//! The registry queues them under its lock; the root agent's request drains -//! them through the agent's own sink (`drain`), which is what a TUI shows. +//! longer hold up the start. The registry queues connection receipts under +//! its lock; the root request drains them through its sink only when startup +//! diagnostics are enabled. Normal sessions stay quiet; `/mcp` still exposes +//! connection state without waiting for a handshake. const std = @import("std"); const Io = std.Io; @@ -62,7 +61,7 @@ pub fn drain(reg: *mcp.Registry, sink: EngineSink, io: Io) void { const queued = reg.connect_notices; reg.connect_notices = .{}; reg.mutex.unlock(reg.io); - queued.emitTo(sink, io); + if (reg.show_diagnostics) queued.emitTo(sink, io); } /// Servers whose start is still running, for `/mcp`. Call after `joinReady` @@ -109,3 +108,39 @@ test "a finished start is named once it merges; an unfinished one stays connecti busy.store(true, .release); _ = boot.joinPending(®); } + +test "background MCP notices require diagnostics and drain only once" { + const Capture = struct { + count: usize = 0, + failed: bool = false, + fn emit(ctx: *anyopaque, event: @import("engine_sink.zig").Stamped) void { + const self: *@This() = @ptrCast(@alignCast(ctx)); + switch (event.event) { + .session_notice => |n| { + self.count += 1; + if (std.mem.indexOf(u8, n.text, "did not connect") != null) self.failed = true; + }, + else => {}, + } + } + const vt: @import("engine_sink.zig").VTable = .{ .emit = emit, .durable = false }; + }; + for ([_]bool{ false, true }) |debug| { + var reg = mcp.Registry.empty(std.testing.allocator, std.testing.io); + defer reg.deinit(); + reg.show_diagnostics = debug; + reg.connect_notices.still_connecting = true; + reg.connect_notices.n = 2; + reg.connect_notices.names[0] = "ready"; + reg.connect_notices.tools[0] = 3; + reg.connect_notices.names[1] = "unavailable"; + reg.connect_notices.tools[1] = null; + var capture: Capture = .{}; + const sink: EngineSink = .{ .ctx = &capture, .vt = &Capture.vt }; + drain(®, sink, std.testing.io); + try std.testing.expectEqual(@as(usize, if (debug) 3 else 0), capture.count); + try std.testing.expectEqual(debug, capture.failed); + drain(®, sink, std.testing.io); + try std.testing.expectEqual(@as(usize, if (debug) 3 else 0), capture.count); + } +} diff --git a/src/mcp_preload.zig b/src/mcp_preload.zig index 82ff3501..78e6f7af 100644 --- a/src/mcp_preload.zig +++ b/src/mcp_preload.zig @@ -46,9 +46,13 @@ pub fn namedServers(self: *Agent) bool { /// Folded natives a message asks for outright. A request to split work across /// subagents spawns them and waits on what they return, so both load with the -/// request instead of one load call each. +/// request instead of one load call each. A peer wake says "peer_message +/// action=inbox": the turn it starts reads the inbox first, and on Claude +/// Agent a call to the unloaded tool is refused by Claude Code itself +/// ("No such tool available"), which also costs a fresh session (ADR 0311). const named_natives = [_]struct { words: []const []const u8, tools: []const []const u8 }{ .{ .words = &.{ "subagent", "subagents", "sub-agent", "sub-agents" }, .tools = &.{ "subagent", "agent_output" } }, + .{ .words = &.{"peer_message"}, .tools = &.{"peer_message"} }, }; fn namedNatives(arena: std.mem.Allocator, text: []const u8, loaded: *std.ArrayList(u8)) void { @@ -124,6 +128,22 @@ test "asking for subagents loads subagent and agent_output with the request (ADR } } +test "a peer wake loads peer_message with the turn it starts (ADR 0311)" { + const fold = @import("native_fold.zig"); + fold.clearLoadedSession(); + defer fold.clearLoadedSession(); + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var loaded: std.ArrayList(u8) = .empty; + namedNatives(arena_state.allocator(), "Message the peer when you are done.", &loaded); + try std.testing.expectEqual(@as(usize, 0), loaded.items.len); + namedNatives(arena_state.allocator(), "[peer] 1 unread from lead \u{2014} parked; peer_message action=inbox when relevant", &loaded); + if (fold.enabled) { + try std.testing.expectEqualStrings("peer_message", loaded.items); + try std.testing.expect(fold.isLoaded("peer_message")); + } +} + test "a server is named only as a whole word, in any case" { try std.testing.expect(names("This workspace has a Linear-shaped MCP server named linear (stdio).", "linear")); try std.testing.expect(names("check LINEAR for open bugs", "linear")); diff --git a/src/no_local_tools.zig b/src/no_local_tools.zig index 0e8c95ca..09aa5a44 100644 --- a/src/no_local_tools.zig +++ b/src/no_local_tools.zig @@ -110,6 +110,11 @@ pub fn leanKeeps(name: []const u8) bool { for (lean_tools) |tool| { if (std.mem.eql(u8, name, tool)) return true; } + // A folded native the turn loaded (mcp_preload, load_tool_schemas) is on + // the wire: the load note says it is callable, so lean must not drop it. + // `agent_output` was the casualty: a subagent request loaded it, the + // catalog left it out, and the model polled it without a schema. + if (@import("native_fold.zig").isLoaded(name)) return true; // A workspace that ships skills keeps `skill` beside its catalog. return @import("skill_docs.zig").g_workspace_skills and std.mem.eql(u8, name, "skill"); } @@ -452,3 +457,16 @@ test "lean catalog drops overflow pager and empty load_tool_schemas" { try std.testing.expect(std.mem.indexOf(u8, json, "\"rlm\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"name\":\"read_file\"") != null); } + +test "lean keeps a folded native the turn loaded: the load note's agent_output is on the wire" { + const fold = @import("native_fold.zig"); + const saved = lean; + defer lean = saved; + lean = true; + fold.clearLoadedSession(); + defer fold.clearLoadedSession(); + try std.testing.expect(!leanKeeps("agent_output")); + fold.markLoaded("agent_output"); + try std.testing.expect(leanKeeps("agent_output")); + try std.testing.expect(!leanKeeps("workflow")); +} diff --git a/src/oauth.zig b/src/oauth.zig index 650acf06..b92a1674 100644 --- a/src/oauth.zig +++ b/src/oauth.zig @@ -13,7 +13,7 @@ const accountFromIdToken = helpers.accountFromIdToken; const writeCodexAuth = helpers.writeCodexAuth; const codex_login_page_html = helpers.codex_login_page_html; const queryParam = helpers.queryParam; -const openBrowser = helpers.openBrowser; +const signin_link = @import("signin_link.zig"); const callback = @import("oauth_callback.zig"); const ansi = @import("ansi.zig"); @@ -24,8 +24,6 @@ const util = @import("util.zig"); const strFieldObj = util.strFieldObj; const unixMs = util.unixMs; -const kimi_catalog = @import("kimi_catalog.zig"); -const pricing = @import("pricing.zig"); const codegraff = @import("oauth_codegraff.zig"); const zai = @import("oauth_zai.zig"); const credential_store = @import("credential_store.zig"); @@ -68,7 +66,7 @@ pub const takePersistError = helpers.takePersistError; // it — wider than the old 60s so a mid-session refresh has headroom before a // turn's request would otherwise cross the expiry and 401. kimi-code uses the // same "ensureFresh near expiry + force-refresh on 401" shape. -const oauth_refresh_margin_s: i64 = 300; +pub const oauth_refresh_margin_s: i64 = 300; // Serializes concurrent token refreshes (parallel subagents share the on-disk // token) so they don't race the single-use refresh_token. var oauth_refresh_mutex: Io.Mutex = .init; @@ -134,9 +132,11 @@ pub fn codexLogin(io: Io, gpa: Allocator, arena: Allocator, home: []const u8, re var server = try callback.listen(io, 1455); defer server.deinit(io); - try out.print("\nOpen this URL to authorize (browser should open automatically):\n\n{s}\n\nwaiting for the callback on {s} …\n", .{ url, codex_redirect }); + try out.print("\nOpen this URL to authorize (browser should open automatically):\n\n{s}\n", .{url}); + try signin_link.offer(io, out, url); + try out.print("\nwaiting for the callback on {s} …\n", .{codex_redirect}); try out.flush(); - openBrowser(io, url); + signin_link.open(io, url); // Only this attempt's redirect ends the wait (oauth_callback.zig). var rbuf: [16 * 1024]u8 = undefined; @@ -182,143 +182,16 @@ pub fn codexLogin(io: Io, gpa: Allocator, arena: Allocator, home: []const u8, re try out.flush(); } -// Kimi Code OAuth — device-code flow on the shared kimi-code OAuth client and -// endpoints (auth.kimi.com). Moonshot's own hosts share this client and tell -// themselves apart by `X-Msh-Platform`; graff reports `graff` and -// `graff/`, never the CLI's identity (#1297, ADR 0204). -// `graff login kimi` runs the flow and writes the token -// to ~/.kimi/credentials/graff-oauth.json; loadKimiOAuth reads it at startup -// and refreshes in place when near expiry. -const kimi_oauth_host = "https://auth.kimi.com"; -const kimi_device_auth_url = kimi_oauth_host ++ "/api/oauth/device_authorization"; -const kimi_token_url = kimi_oauth_host ++ "/api/oauth/token"; -const kimi_client_id = "17e5f671-d194-4dfb-9706-5516cb48c098"; - -/// POST a form body to a Kimi OAuth endpoint; return the parsed JSON object. -fn kimiOAuthPost(io: Io, gpa: Allocator, arena: Allocator, url: []const u8, body: []const u8) !std.json.ObjectMap { - var client: std.http.Client = .{ .allocator = gpa, .io = io }; - defer client.deinit(); - var aw: Io.Writer.Allocating = .init(arena); - var identity_buf: [5]std.http.Header = undefined; - const identity = kimi_catalog.identityHeaders(&identity_buf); - _ = try client.fetch(.{ - .location = .{ .url = url }, - .method = .POST, - .payload = body, - .response_writer = &aw.writer, - .headers = .{ - .content_type = .{ .override = "application/x-www-form-urlencoded" }, - .user_agent = .{ .override = kimi_catalog.user_agent }, - }, - .extra_headers = identity, - }); - const v = try std.json.parseFromSliceLeaky(Value, arena, aw.writer.buffered(), .{ .allocate = .alloc_always }); - if (v != .object) return error.BadOAuthResponse; - return v.object; -} - -fn kimiAuthPath(arena: Allocator, home: []const u8) []const u8 { - return credential_store.oauthPath(arena, home, ".kimi"); -} - -fn writeKimiAuth(io: Io, arena: Allocator, home: []const u8, access: []const u8, refresh: []const u8, expires_at: i64) !void { - return credential_store.writeOAuth(io, arena, home, ".kimi", access, refresh, expires_at); -} - -/// `graff login kimi`: Kimi Code device-code OAuth. Prints a verification URL + -/// user code, opens the browser, polls until the user authorizes, then stores -/// the access/refresh token. -pub fn kimiLogin(io: Io, gpa: Allocator, arena: Allocator, home: []const u8) !void { - var obuf: [4096]u8 = undefined; - var ow = Io.File.stdout().writer(io, &obuf); - const out = &ow.interface; - - const da_body = try std.fmt.allocPrint(arena, "client_id={s}", .{kimi_client_id}); - const da = kimiOAuthPost(io, gpa, arena, kimi_device_auth_url, da_body) catch |err| { - try out.print("✗ Kimi device authorization failed: {t}\n", .{err}); - try out.flush(); - return; - }; - if (da.get("error")) |e| { - try out.print("✗ {s}\n", .{if (e == .string) e.string else "device authorization error"}); - try out.flush(); - return; - } - const device_code = strFieldObj(da, "device_code") orelse return error.BadOAuthResponse; - const user_code = strFieldObj(da, "user_code") orelse ""; - const verify = strFieldObj(da, "verification_uri_complete") orelse strFieldObj(da, "verification_uri") orelse ""; - const interval: i64 = if (da.get("interval")) |iv| (if (iv == .integer) @max(iv.integer, 1) else 5) else 5; - - try out.print("\nTo log in to Kimi, open this URL (browser should open automatically):\n\n {s}\n\nand confirm the code: {s}\n\nwaiting for authorization…\n", .{ verify, user_code }); - try out.flush(); - openBrowser(io, verify); - - const poll_body = try std.fmt.allocPrint(arena, "client_id={s}&device_code={s}&grant_type=urn:ietf:params:oauth:grant-type:device_code", .{ kimi_client_id, device_code }); - var attempts: usize = 0; - while (attempts < 360) : (attempts += 1) { - io.sleep(Io.Duration.fromSeconds(interval), .awake) catch {}; - const resp = kimiOAuthPost(io, gpa, arena, kimi_token_url, poll_body) catch continue; - if (resp.get("access_token")) |a| if (a == .string and a.string.len > 0) { - const refresh = strFieldObj(resp, "refresh_token") orelse ""; - const expires_in: i64 = if (resp.get("expires_in")) |ei| (if (ei == .integer) ei.integer else 900) else 900; - try writeKimiAuth(io, arena, home, a.string, refresh, @divTrunc(unixMs(io), 1000) + expires_in); - _ = kimi_catalog.load(io, gpa, arena, a.string); - try out.print("✓ logged into Kimi — wrote {s}. /model kimi selects {s}\n", .{ - kimiAuthPath(arena, home), pricing.providerDefaultModel("kimi", "k3"), - }); - try out.flush(); - return; - }; - const msg = if (resp.get("error")) |e| (if (e == .string) e.string else "") else ""; - if (std.mem.eql(u8, msg, "authorization_pending") or std.mem.eql(u8, msg, "slow_down")) continue; - if (std.mem.eql(u8, msg, "expired_token")) { - try out.writeAll("✗ the code expired — run `graff login kimi` again\n"); - try out.flush(); - return; - } - if (msg.len > 0) { - try out.print("✗ {s}\n", .{msg}); - try out.flush(); - return; - } - } - try out.writeAll("✗ timed out waiting for authorization\n"); - try out.flush(); -} +// Kimi Code OAuth lives in oauth_kimi.zig (two sign-in servers, kimi.ai and kimi.com). +const kimi = @import("oauth_kimi.zig"); +pub const kimiLogin = kimi.login; +pub const loadKimiOAuth = kimi.load; /// #245 single-flight guard: has the token that just 401'd already been replaced /// on disk by another concurrent refresher? Defined in oauth_helpers.zig, where /// the silent codex refresh (#402) needs the same primitive. const supersededToken = helpers.supersededToken; -/// Reads the stored Kimi OAuth access token, refreshing it in place when within -/// 60s of expiry. Returns null if not logged in. Mirrors loadCodexAuth. -/// `stale` is the token that just 401'd (null when not recovering from one) — -/// see supersededToken. -pub fn loadKimiOAuth(io: Io, gpa: Allocator, arena: Allocator, home: []const u8, force: bool, stale: ?[]const u8) ?[]const u8 { - const data = Io.Dir.cwd().readFileAlloc(io, kimiAuthPath(arena, home), arena, .limited(64 * 1024)) catch return null; - const v = std.json.parseFromSliceLeaky(Value, arena, data, .{ .allocate = .alloc_always }) catch return null; - if (v != .object) return null; - const access = strFieldObj(v.object, "access_token") orelse return null; - const expires_at: i64 = if (v.object.get("expires_at")) |e| (if (e == .integer) e.integer else 0) else 0; - // #245: another concurrent refresher may already have replaced the token that - // 401'd; re-minting would rotate-kill theirs. Adopt what is on disk instead. - if (supersededToken(force, access, stale)) return access; - if (force or (expires_at != 0 and @divTrunc(unixMs(io), 1000) >= expires_at - oauth_refresh_margin_s)) { - if (strFieldObj(v.object, "refresh_token")) |refresh| { - const body = std.fmt.allocPrint(arena, "client_id={s}&grant_type=refresh_token&refresh_token={s}", .{ kimi_client_id, refresh }) catch return access; - const resp = kimiOAuthPost(io, gpa, arena, kimi_token_url, body) catch return access; - if (resp.get("access_token")) |a| if (a == .string and a.string.len > 0) { - const new_refresh = strFieldObj(resp, "refresh_token") orelse refresh; - const expires_in: i64 = if (resp.get("expires_in")) |ei| (if (ei == .integer) ei.integer else 900) else 900; - writeKimiAuth(io, arena, home, a.string, new_refresh, @divTrunc(unixMs(io), 1000) + expires_in) catch {}; - return a.string; - }; - } - } - return access; -} - /// #148: mid-session refresh for a login-sourced key. For the refreshable OAuth /// providers (kimi/xai/codex) returns the current access token, refreshed in /// place if near expiry or `force`d (e.g. after a 401). Returns null for @@ -439,9 +312,11 @@ pub fn xaiLogin(io: Io, gpa: Allocator, arena: Allocator, home: []const u8) !voi const verify = strFieldObj(da, "verification_uri_complete") orelse strFieldObj(da, "verification_uri") orelse ""; const interval: i64 = if (da.get("interval")) |iv| (if (iv == .integer) @max(iv.integer, 1) else 5) else 5; - try out.print("\nTo log in to Grok (xAI), open this URL (browser should open automatically):\n\n {s}\n\nand confirm the code: {s}\n\nwaiting for authorization…\n", .{ verify, user_code }); + try out.print("\nTo log in to Grok (xAI), open this URL (browser should open automatically):\n\n {s}\n", .{verify}); + try signin_link.offer(io, out, verify); + try out.print("\nand confirm the code: {s}\n\nwaiting for authorization…\n", .{user_code}); try out.flush(); - openBrowser(io, verify); + signin_link.open(io, verify); const poll_body = try std.fmt.allocPrint(arena, "client_id={s}&device_code={s}&grant_type=urn:ietf:params:oauth:grant-type:device_code", .{ xai_client_id, device_code }); var attempts: usize = 0; @@ -571,7 +446,7 @@ test "writeKimiAuth/writeXaiAuth: both real writers store 0600 in 0700 dirs, loa // Drive the two production writers, not a shared helper: xAI used to open its // token file with a bare createFile (0644, no 0700 dirs) while kimi hardened // its own, and only pinning both functions keeps that from coming back. - try writeKimiAuth(io, arena, home, "kimi-access", "kimi-refresh", far_future); + try kimi.writeAuth(io, arena, home, "kimi-access", "kimi-refresh", far_future, ""); try writeXaiAuth(io, arena, home, "xai-access", "xai-refresh", far_future); for ([_][]const u8{ ".kimi", ".xai" }) |provider_dir| { diff --git a/src/oauth_anthropic.zig b/src/oauth_anthropic.zig index 3c1ea8e6..cb3ca6d5 100644 --- a/src/oauth_anthropic.zig +++ b/src/oauth_anthropic.zig @@ -297,9 +297,11 @@ pub fn login(io: Io, gpa: Allocator, arena: Allocator, home: []const u8, refresh const verifier = helpers.b64url(arena, &random); if (verifier.len == 0) return error.OutOfMemory; const url = try authorizeUrl(arena, redirect, verifier); - try out.print("\nSign in with Anthropic (your browser should open it):\n\n{s}\n\nWaiting for sign-in…\n", .{url}); + try out.print("\nSign in with Anthropic (your browser should open it):\n\n{s}\n", .{url}); + try @import("signin_link.zig").offer(io, out, url); + try out.writeAll("\nWaiting for sign-in…\n"); try out.flush(); - helpers.openBrowser(io, url); + @import("signin_link.zig").open(io, url); while (true) { const stream = try server.accept(io); defer stream.close(io); diff --git a/src/oauth_chatgpt.zig b/src/oauth_chatgpt.zig index 76a5784a..06296776 100644 --- a/src/oauth_chatgpt.zig +++ b/src/oauth_chatgpt.zig @@ -399,10 +399,11 @@ pub fn login(io: Io, gpa: Allocator, arena: Allocator, home: []const u8) !void { .login_hint = if (returning) saved.?.email else "", }; const url = try authorizeUrl(arena, attempt); - try out.print("\nSign in with ChatGPT (your browser should open it):\n\n{s}\n\n", .{url}); - try out.print("waiting for the sign-in on {s} …\n", .{attempt.redirect}); + try out.print("\nSign in with ChatGPT (your browser should open it):\n\n{s}\n", .{url}); + try @import("signin_link.zig").offer(io, out, url); + try out.print("\nwaiting for the sign-in on {s} …\n", .{attempt.redirect}); try out.flush(); - helpers.openBrowser(io, url); + @import("signin_link.zig").open(io, url); // Only this attempt's redirect ends the wait; a tab left from an earlier // attempt gets a page saying so (oauth_callback.zig). var rbuf: [16 * 1024]u8 = undefined; diff --git a/src/oauth_codegraff.zig b/src/oauth_codegraff.zig index 09e80a3d..85c015b0 100644 --- a/src/oauth_codegraff.zig +++ b/src/oauth_codegraff.zig @@ -9,7 +9,7 @@ const style = &@import("ansi.zig").style; const util = @import("util.zig"); const strFieldObj = util.strFieldObj; const intFieldObj = util.intFieldObj; -const openBrowser = @import("oauth_helpers.zig").openBrowser; +const signin_link = @import("signin_link.zig"); const credential_store = @import("credential_store.zig"); const codegraff_device_base = @import("main.zig").codegraff_device_base; @@ -56,9 +56,11 @@ pub fn login(io: Io, gpa: Allocator, arena: Allocator, home: []const u8) !void { // Print the link that carries the code: a person who copies it (the // browser did not open, or opened elsewhere) lands on the approval itself, // not on a page with nowhere to type the code. - try out.print("\nTo authorize, open:\n\n {s}\n\nand check it shows the code {s}{s}{s}\n\nwaiting for approval …\n", .{ complete_uri, style.bold, user_code, style.reset }); + try out.print("\nTo authorize, open:\n\n {s}\n", .{complete_uri}); + try signin_link.offer(io, out, complete_uri); + try out.print("\nand check it shows the code {s}{s}{s}\n\nwaiting for approval …\n", .{ style.bold, user_code, style.reset }); try out.flush(); - openBrowser(io, complete_uri); + signin_link.open(io, complete_uri); const poll_body = try std.fmt.allocPrint(arena, "{{\"device_code\":\"{s}\"}}", .{device_code}); var waited: i64 = 0; diff --git a/src/oauth_kimi.zig b/src/oauth_kimi.zig new file mode 100644 index 00000000..5390c00a --- /dev/null +++ b/src/oauth_kimi.zig @@ -0,0 +1,320 @@ +//! Kimi Code OAuth — device-code flow on the shared kimi-code OAuth client. +//! Moonshot runs two sign-in servers: auth.kimi.ai for international +//! accounts and auth.kimi.com for mainland China. Hosts tell themselves +//! apart by `X-Msh-Platform`; graff reports `graff` and `graff/`, +//! never the CLI's identity (#1297, ADR 0204). +//! `graff login kimi` runs the flow and writes the token, plus the server +//! that issued it, to ~/.kimi/credentials/graff-oauth.json; `load` reads it at +//! startup and refreshes in place, on that same server, when near expiry. +//! Tokens from either server work on api.kimi.com, so the API host is shared. + +const std = @import("std"); +const Io = std.Io; +const Value = std.json.Value; +const Allocator = std.mem.Allocator; + +const util = @import("util.zig"); +const strFieldObj = util.strFieldObj; +const unixMs = util.unixMs; +const kimi_catalog = @import("kimi_catalog.zig"); +const credential_store = @import("credential_store.zig"); +const pricing = @import("pricing.zig"); +const signin_link = @import("signin_link.zig"); +const supersededToken = @import("oauth_helpers.zig").supersededToken; +const oauth_refresh_margin_s = @import("oauth.zig").oauth_refresh_margin_s; + +const client_id = "17e5f671-d194-4dfb-9706-5516cb48c098"; + +pub const Region = enum { + global, + cn, + + pub fn host(r: Region) []const u8 { + return switch (r) { + .global => "https://auth.kimi.ai", + .cn => "https://auth.kimi.com", + }; + } + + pub fn site(r: Region) []const u8 { + return switch (r) { + .global => "kimi.ai", + .cn => "kimi.com", + }; + } + + fn other(r: Region) Region { + return if (r == .global) .cn else .global; + } +}; + +/// A login saved before graff recorded the server came from auth.kimi.com. +const legacy_host = Region.cn.host(); + +/// `graff login kimi.ai` / `kimi.com` pick the account's site; plain `kimi` +/// returns null and lets `chooseRegion` decide. +pub fn regionFromLoginName(name: []const u8) ?Region { + for ([_][]const u8{ "kimi.ai", "kimi-global", "kimi-intl" }) |n| if (std.ascii.eqlIgnoreCase(name, n)) return .global; + for ([_][]const u8{ "kimi.com", "kimi-cn" }) |n| if (std.ascii.eqlIgnoreCase(name, n)) return .cn; + return null; +} + +pub fn isLoginName(name: []const u8) bool { + return std.ascii.eqlIgnoreCase(name, "kimi") or regionFromLoginName(name) != null; +} + +fn regionOfHost(h: []const u8) ?Region { + if (std.mem.eql(u8, h, Region.global.host())) return .global; + if (std.mem.eql(u8, h, Region.cn.host())) return .cn; + return null; +} + +/// Same order as kimi-code: the env override, then the server the saved +/// login recorded, then a mainland-China clock or locale; everyone else is +/// global. A login from before graff recorded the server doesn't choose. +fn chooseRegion(io: Io, arena: Allocator, home: []const u8) Region { + if (envHostRegion()) |r| return r; + if (recordedRegion(io, arena, home)) |r| return r; + var buf: [256]u8 = undefined; + const n = Io.Dir.readLinkAbsolute(io, "/etc/localtime", &buf) catch 0; + return if (inMainland(getenv("TZ"), getenv("LANG"), buf[0..n])) .cn else .global; +} + +fn envHostRegion() ?Region { + for ([_][*:0]const u8{ "KIMI_CODE_OAUTH_HOST", "KIMI_OAUTH_HOST" }) |name| { + const v = getenv(name) orelse continue; + if (std.mem.indexOf(u8, v, "kimi.ai") != null) return .global; + if (std.mem.indexOf(u8, v, "kimi.com") != null) return .cn; + } + return null; +} + +fn getenv(name: [*:0]const u8) ?[]const u8 { + const v = std.c.getenv(name) orelse return null; + return std.mem.span(v); +} + +fn inMainland(tz: ?[]const u8, lang: ?[]const u8, localtime: []const u8) bool { + const zones = [_][]const u8{ "Asia/Shanghai", "Asia/Urumqi", "Asia/Chongqing", "Asia/Chungking", "Asia/Harbin", "Asia/Kashgar", "PRC" }; + for (zones) |z| { + if (tz) |t| if (std.mem.endsWith(u8, t, z)) return true; + if (std.mem.endsWith(u8, localtime, z)) return true; + } + if (lang) |l| if (std.ascii.startsWithIgnoreCase(l, "zh_CN") or std.ascii.startsWithIgnoreCase(l, "zh-CN")) return true; + return false; +} + +/// POST a form body to a Kimi OAuth endpoint; return the parsed JSON object. +fn post(io: Io, gpa: Allocator, arena: Allocator, url: []const u8, body: []const u8) !std.json.ObjectMap { + var client: std.http.Client = .{ .allocator = gpa, .io = io }; + defer client.deinit(); + var aw: Io.Writer.Allocating = .init(arena); + var identity_buf: [5]std.http.Header = undefined; + const identity = kimi_catalog.identityHeaders(&identity_buf); + _ = try client.fetch(.{ + .location = .{ .url = url }, + .method = .POST, + .payload = body, + .response_writer = &aw.writer, + .headers = .{ + .content_type = .{ .override = "application/x-www-form-urlencoded" }, + .user_agent = .{ .override = kimi_catalog.user_agent }, + }, + .extra_headers = identity, + }); + const v = try std.json.parseFromSliceLeaky(Value, arena, aw.writer.buffered(), .{ .allocate = .alloc_always }); + if (v != .object) return error.BadOAuthResponse; + return v.object; +} + +fn endpoint(arena: Allocator, host: []const u8, path: []const u8) []const u8 { + return std.fmt.allocPrint(arena, "{s}{s}", .{ host, path }) catch ""; +} + +pub fn authPath(arena: Allocator, home: []const u8) []const u8 { + return credential_store.oauthPath(arena, home, ".kimi"); +} + +pub fn writeAuth(io: Io, arena: Allocator, home: []const u8, access: []const u8, refresh: []const u8, expires_at: i64, host: []const u8) !void { + return credential_store.writeOAuthFrom(io, arena, home, ".kimi", access, refresh, expires_at, host); +} + +fn readSaved(io: Io, arena: Allocator, home: []const u8) ?std.json.ObjectMap { + const data = Io.Dir.cwd().readFileAlloc(io, authPath(arena, home), arena, .limited(64 * 1024)) catch return null; + const v = std.json.parseFromSliceLeaky(Value, arena, data, .{ .allocate = .alloc_always }) catch return null; + return if (v == .object) v.object else null; +} + +/// The server that issued the saved login. Only a known Kimi server counts: +/// the refresh token is sent there. +fn savedHost(io: Io, arena: Allocator, home: []const u8) ?[]const u8 { + const saved = readSaved(io, arena, home) orelse return null; + const h = strFieldObj(saved, "oauth_host") orelse return legacy_host; + return if (regionOfHost(h) != null) h else legacy_host; +} + +fn recordedRegion(io: Io, arena: Allocator, home: []const u8) ?Region { + const saved = readSaved(io, arena, home) orelse return null; + return regionOfHost(strFieldObj(saved, "oauth_host") orelse return null); +} + +fn intField(obj: std.json.ObjectMap, name: []const u8, default: i64) i64 { + const v = obj.get(name) orelse return default; + return if (v == .integer) v.integer else default; +} + +/// `graff login kimi`: prints a verification URL + user code, copies the URL, +/// opens the browser, polls until the user authorizes, then stores the +/// access/refresh token. `region` null = choose (see chooseRegion). +pub fn login(io: Io, gpa: Allocator, arena: Allocator, home: []const u8, region: ?Region) !void { + var obuf: [4096]u8 = undefined; + var ow = Io.File.stdout().writer(io, &obuf); + const out = &ow.interface; + const r = region orelse chooseRegion(io, arena, home); + const host = r.host(); + + const da_body = try std.fmt.allocPrint(arena, "client_id={s}", .{client_id}); + const da = post(io, gpa, arena, endpoint(arena, host, "/api/oauth/device_authorization"), da_body) catch |err| { + try out.print("✗ Kimi device authorization failed: {t}\n", .{err}); + try out.flush(); + return; + }; + if (da.get("error")) |e| { + try out.print("✗ {s}\n", .{if (e == .string) e.string else "device authorization error"}); + try out.flush(); + return; + } + const device_code = strFieldObj(da, "device_code") orelse return error.BadOAuthResponse; + const user_code = strFieldObj(da, "user_code") orelse ""; + const verify = strFieldObj(da, "verification_uri_complete") orelse strFieldObj(da, "verification_uri") orelse ""; + var interval: i64 = @max(intField(da, "interval", 5), 1); + const lifetime: i64 = std.math.clamp(intField(da, "expires_in", 900), 60, 1800); + + const opens = signin_link.opensBrowser(); + try out.print("\nTo log in to Kimi on {s}, open this URL ({s}):\n\n {s}\n", .{ + r.site(), if (opens) "browser should open automatically" else "on any device", verify, + }); + try signin_link.offer(io, out, verify); + try out.print("\nand confirm the code: {s}\n\nwaiting for authorization… (the code expires in {d} min)\n", .{ user_code, @divTrunc(lifetime + 59, 60) }); + try out.print("Account on {s}? Run `graff login {s}` instead.\n", .{ r.other().site(), r.other().site() }); + try out.flush(); + signin_link.open(io, verify); + + const poll_body = try std.fmt.allocPrint(arena, "client_id={s}&device_code={s}&grant_type=urn:ietf:params:oauth:grant-type:device_code", .{ client_id, device_code }); + const token_url = endpoint(arena, host, "/api/oauth/token"); + const deadline = @divTrunc(unixMs(io), 1000) + lifetime; + var failures: usize = 0; + while (@divTrunc(unixMs(io), 1000) < deadline) { + io.sleep(Io.Duration.fromSeconds(interval), .awake) catch {}; + const resp = post(io, gpa, arena, token_url, poll_body) catch |err| { + failures += 1; + if (failures >= 6) { + try out.print("✗ can't reach {s}: {t}\n", .{ host, err }); + try out.flush(); + return; + } + if (failures == 1) { + try out.print(" can't reach {s} ({t}), still trying…\n", .{ host, err }); + try out.flush(); + } + continue; + }; + failures = 0; + if (resp.get("access_token")) |a| if (a == .string and a.string.len > 0) { + const refresh = strFieldObj(resp, "refresh_token") orelse ""; + const expires_in = intField(resp, "expires_in", 900); + try writeAuth(io, arena, home, a.string, refresh, @divTrunc(unixMs(io), 1000) + expires_in, host); + _ = kimi_catalog.load(io, gpa, arena, a.string); + try out.print("✓ logged into Kimi ({s}) — wrote {s}. /model kimi selects {s}\n", .{ + r.site(), authPath(arena, home), pricing.providerDefaultModel("kimi", "k3"), + }); + try out.flush(); + return; + }; + const msg = strFieldObj(resp, "error") orelse ""; + if (std.mem.eql(u8, msg, "authorization_pending")) continue; + // RFC 8628 §3.5: every slow_down adds 5 seconds for the rest of the poll. + if (std.mem.eql(u8, msg, "slow_down")) { + interval += 5; + continue; + } + if (std.mem.eql(u8, msg, "expired_token")) break; + if (std.mem.eql(u8, msg, "access_denied")) { + try out.print("✗ sign-in was declined on {s}\n", .{r.site()}); + try out.flush(); + return; + } + if (msg.len > 0) { + try out.print("✗ {s}\n", .{strFieldObj(resp, "error_description") orelse msg}); + try out.flush(); + return; + } + } + try out.writeAll("✗ the code expired — run `graff login kimi` again\n"); + try out.flush(); +} + +/// Reads the stored Kimi OAuth access token, refreshing it in place when within +/// the refresh margin of expiry. Returns null if not logged in. Mirrors +/// loadCodexAuth. `stale` is the token that just 401'd (null when not +/// recovering from one) — see supersededToken. +pub fn load(io: Io, gpa: Allocator, arena: Allocator, home: []const u8, force: bool, stale: ?[]const u8) ?[]const u8 { + const saved = readSaved(io, arena, home) orelse return null; + const access = strFieldObj(saved, "access_token") orelse return null; + const expires_at = intField(saved, "expires_at", 0); + // #245: another concurrent refresher may already have replaced the token that + // 401'd; re-minting would rotate-kill theirs. Adopt what is on disk instead. + if (supersededToken(force, access, stale)) return access; + if (force or (expires_at != 0 and @divTrunc(unixMs(io), 1000) >= expires_at - oauth_refresh_margin_s)) { + if (strFieldObj(saved, "refresh_token")) |refresh| { + const host = savedHost(io, arena, home) orelse legacy_host; + const body = std.fmt.allocPrint(arena, "client_id={s}&grant_type=refresh_token&refresh_token={s}", .{ client_id, refresh }) catch return access; + const resp = post(io, gpa, arena, endpoint(arena, host, "/api/oauth/token"), body) catch return access; + if (resp.get("access_token")) |a| if (a == .string and a.string.len > 0) { + const new_refresh = strFieldObj(resp, "refresh_token") orelse refresh; + const expires_in = intField(resp, "expires_in", 900); + writeAuth(io, arena, home, a.string, new_refresh, @divTrunc(unixMs(io), 1000) + expires_in, host) catch {}; + return a.string; + }; + } + } + return access; +} + +test "login names pick the Kimi site" { + try std.testing.expectEqual(Region.global, regionFromLoginName("kimi.ai").?); + try std.testing.expectEqual(Region.cn, regionFromLoginName("Kimi.com").?); + try std.testing.expect(regionFromLoginName("kimi") == null); + try std.testing.expect(isLoginName("kimi") and isLoginName("kimi.ai") and !isLoginName("kimix")); + try std.testing.expectEqualStrings("https://auth.kimi.ai", Region.global.host()); +} + +test "mainland clock or locale picks kimi.com; everyone else kimi.ai" { + try std.testing.expect(inMainland(null, null, "/var/db/timezone/zoneinfo/Asia/Shanghai")); + try std.testing.expect(inMainland("Asia/Urumqi", null, "")); + try std.testing.expect(inMainland(null, "zh_CN.UTF-8", "")); + try std.testing.expect(!inMainland(null, "zh_TW.UTF-8", "/usr/share/zoneinfo/Asia/Taipei")); + try std.testing.expect(!inMainland("Asia/Singapore", "en_US.UTF-8", "/usr/share/zoneinfo/Asia/Singapore")); +} + +test "a login keeps the server that issued it; old logins and unknown hosts mean kimi.com" { + const io = std.testing.io; + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const home = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{&tmp.sub_path}); + const far_future: i64 = 4102444800; + + try std.testing.expect(savedHost(io, arena, home) == null); + try writeAuth(io, arena, home, "a", "r", far_future, Region.global.host()); + try std.testing.expectEqualStrings("https://auth.kimi.ai", savedHost(io, arena, home).?); + try std.testing.expectEqual(Region.global, recordedRegion(io, arena, home).?); + try writeAuth(io, arena, home, "a", "r", far_future, ""); + try std.testing.expectEqualStrings(legacy_host, savedHost(io, arena, home).?); + try std.testing.expect(recordedRegion(io, arena, home) == null); + try writeAuth(io, arena, home, "a", "r", far_future, "https://evil.example"); + try std.testing.expectEqualStrings(legacy_host, savedHost(io, arena, home).?); + try std.testing.expectEqualStrings("a", load(io, std.testing.allocator, arena, home, false, null).?); +} diff --git a/src/oauth_zai.zig b/src/oauth_zai.zig index 48756736..a0aae5b4 100644 --- a/src/oauth_zai.zig +++ b/src/oauth_zai.zig @@ -14,7 +14,7 @@ const style = &@import("ansi.zig").style; const util = @import("util.zig"); const strFieldObj = util.strFieldObj; const intFieldObj = util.intFieldObj; -const openBrowser = @import("oauth_helpers.zig").openBrowser; +const signin_link = @import("signin_link.zig"); const credential_store = @import("credential_store.zig"); const provider_mod = @import("provider.zig"); const credential_failover = @import("credential_failover.zig"); @@ -352,9 +352,11 @@ pub fn login(io: Io, gpa: Allocator, arena: Allocator, home: []const u8) !void { return; }; - try out.print("\nTo log in to Z.AI Coding Plan, open this URL (browser should open automatically):\n\n {s}\n\nwaiting for authorization\xe2\x80\xa6\n", .{init.authorize_url}); + try out.print("\nTo log in to Z.AI Coding Plan, open this URL (browser should open automatically):\n\n {s}\n", .{init.authorize_url}); + try signin_link.offer(io, out, init.authorize_url); + try out.writeAll("\nwaiting for authorization\xe2\x80\xa6\n"); try out.flush(); - if (!main_mod.g_no_browser) openBrowser(io, init.authorize_url); + signin_link.open(io, init.authorize_url); const poll_url = try std.fmt.allocPrint(arena, "{s}/oauth/cli/poll/{s}", .{ cli_base, init.flow_id }); var ready: ?ReadyData = null; diff --git a/src/oneshot.zig b/src/oneshot.zig index 748dd47b..b75de72e 100644 --- a/src/oneshot.zig +++ b/src/oneshot.zig @@ -70,6 +70,7 @@ pub fn runOneshotPrompt(gpa: Allocator, io: Io, arena: Allocator, root: *agent_m if (telemetry.g_telem) |t| t.flush(); if (tracer.behavior) |behavior| behavior.finish(.failed); pricing.printUsageFooter(io); + @import("turn_dedup.zig").settleFailedTurn(root, arena, err) catch {}; // #1729: a resumed retry is a new ask const saved = save(root, arena); const detail = root.last_api_error orelse @errorName(err); const failure = result.classify(err, detail); diff --git a/src/opaque_state_persist.zig b/src/opaque_state_persist.zig new file mode 100644 index 00000000..c334829f --- /dev/null +++ b/src/opaque_state_persist.zig @@ -0,0 +1,78 @@ +//! #1686: a resumed session keeps the provider's count of its compaction state. +//! +//! The count a full request teaches (opaque_state_usage.zig) lives in +//! process-global slots, so a new process restoring the same history fell back +//! to bytes/16. An 8 MB state counted at ~80k then read as ~508k: the restored +//! meter showed 188% and the next prompt paid for a forced compaction the warm +//! process never needed. The count is saved with the session, scoped to the +//! provider, the model, and the exact state (size plus a digest of every blob), +//! and reapplied on load only when all of them still match. A changed state, +//! a different model, or a missing or malformed record leaves bytes/16 in place. + +const std = @import("std"); +const Value = std.json.Value; +const context_tokens = @import("context_tokens.zig"); +const Provider = @import("provider.zig").Provider; + +pub const field = "opaque_state_count"; + +/// Write the record when this process has counted the history's state. +pub fn write(s: *std.json.Stringify, provider: Provider, history: Value) !void { + const id = context_tokens.compactionStateIdentity(history); + const tokens = context_tokens.calibratedState(id.bytes) orelse return; + var digest_buf: [16]u8 = undefined; + try s.objectField(field); + try s.beginObject(); + try s.objectField("provider"); + try s.write(provider.id); + try s.objectField("model"); + try s.write(provider.model); + try s.objectField("state_bytes"); + try s.write(id.bytes); + // Hex: a u64 digest does not round-trip through JSON's signed integers. + try s.objectField("state_digest"); + try s.write(std.fmt.bufPrint(&digest_buf, "{x:0>16}", .{id.digest}) catch unreachable); + try s.objectField("tokens"); + try s.write(tokens); + try s.endObject(); +} + +/// Reapply a saved count to the restored history. Call after the history is +/// in its final restored form and the session's provider is active. +pub fn restore(provider: Provider, history: Value, session: std.json.ObjectMap) void { + const saved = recordFrom(session.get(field) orelse return) orelse return; + if (!std.mem.eql(u8, saved.provider, provider.id) or !std.mem.eql(u8, saved.model, provider.model)) return; + const id = context_tokens.compactionStateIdentity(history); + if (id.bytes != saved.state_bytes or id.digest != saved.state_digest) return; + context_tokens.calibrateState(saved.state_bytes, saved.tokens); +} + +const Record = struct { + provider: []const u8, + model: []const u8, + state_bytes: usize, + state_digest: u64, + tokens: u64, +}; + +fn recordFrom(value: Value) ?Record { + if (value != .object) return null; + const obj = value.object; + return .{ + .provider = str(obj.get("provider")) orelse return null, + .model = str(obj.get("model")) orelse return null, + .state_bytes = @intCast(positive(obj.get("state_bytes")) orelse return null), + .state_digest = std.fmt.parseInt(u64, str(obj.get("state_digest")) orelse return null, 16) catch return null, + .tokens = positive(obj.get("tokens")) orelse return null, + }; +} + +fn str(value: ?Value) ?[]const u8 { + const v = value orelse return null; + return if (v == .string) v.string else null; +} + +fn positive(value: ?Value) ?u64 { + const v = value orelse return null; + return if (v == .integer and v.integer > 0) @intCast(v.integer) else null; +} diff --git a/src/opaque_state_persist_tests.zig b/src/opaque_state_persist_tests.zig new file mode 100644 index 00000000..e5995160 --- /dev/null +++ b/src/opaque_state_persist_tests.zig @@ -0,0 +1,216 @@ +//! #1686: a resumed session keeps the provider's count of its compaction state. + +const std = @import("std"); +const Value = std.json.Value; +const Agent = @import("agent.zig").Agent; +const context_tokens = @import("context_tokens.zig"); +const persist = @import("opaque_state_persist.zig"); +const session = @import("session.zig"); + +const window: u64 = 270_000; +const state_len: usize = 8_000_000; + +fn history(a: std.mem.Allocator, fill: u8) !std.json.Array { + const head = "{\"type\":\"compaction\",\"encrypted_content\":\""; + const json = try a.alloc(u8, head.len + state_len + 2); + @memcpy(json[0..head.len], head); + @memset(json[head.len .. head.len + state_len], fill); + @memcpy(json[head.len + state_len ..], "\"}"); + var msgs = std.json.Array.init(a); + try msgs.append(try std.json.parseFromSliceLeaky(Value, a, json, .{})); + try msgs.append(try std.json.parseFromSliceLeaky(Value, a, "{\"role\":\"user\",\"content\":\"hi\"}", .{})); + return msgs; +} + +fn agent(msgs: std.json.Array) Agent { + var root: Agent = undefined; + root.provider = .{ .id = "codex", .kind = .responses, .auth = .bearer, .url = "", .api_key = "", .model = "gpt-5", .context = window }; + root.messages = msgs; + root.sub = false; + root.strict = false; + root.sys_normal = ""; + root.sys_strict = ""; + root.tools_responses = ""; + root.last_cache_read = 0; + return root; +} + +/// What a warm process saves after the provider counted the state at 80k: +/// the meter pair and the persisted count, as one session object. +const Saved = struct { context: u64, local: u64, obj: std.json.ObjectMap }; + +fn countAndSave(a: std.mem.Allocator, root: *Agent) !Saved { + context_tokens.learnState(state_len, 80_000, root.fullRequestEstimateTokens()); + const local = root.fullRequestEstimateTokens(); + var aw: std.Io.Writer.Allocating = .init(a); + var s: std.json.Stringify = .{ .writer = &aw.writer }; + try s.beginObject(); + try persist.write(&s, root.provider, .{ .array = root.messages }); + try s.endObject(); + const parsed = try std.json.parseFromSliceLeaky(Value, a, aw.writer.buffered(), .{}); + return .{ .context = 80_001, .local = local, .obj = parsed.object }; +} + +/// A new process: empty calibration, then loadSession's restore order. +fn reload(root: *Agent, saved: Saved) void { + context_tokens.resetStateCountsForTest(); + persist.restore(root.provider, .{ .array = root.messages }, saved.obj); + session.restoreContextMeter(root, saved.context, saved.local); +} + +test "a reloaded session keeps the provider's count of its compaction state (#1686)" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + context_tokens.resetStateCountsForTest(); + defer context_tokens.resetStateCountsForTest(); + + var root = agent(try history(a, 'A')); + const saved = try countAndSave(a, &root); + try std.testing.expect(saved.obj.get(persist.field) != null); + + // Without the record a new process reads the state at bytes/16 (the bug). + var cold = saved; + cold.obj = (try std.json.parseFromSliceLeaky(Value, a, "{}", .{})).object; + reload(&root, cold); + try std.testing.expect(root.effectiveContextTokens() > window); + try std.testing.expect(root.inputOverCompactThreshold()); + + // With it, the reload keeps the counted occupancy and needs no compaction. + reload(&root, saved); + try std.testing.expect(root.effectiveContextTokens() < 100_000); + try std.testing.expect(!root.inputOverCompactThreshold()); +} + +test "a saved state count applies only to the same state, provider, and model (#1686)" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + context_tokens.resetStateCountsForTest(); + defer context_tokens.resetStateCountsForTest(); + + var counted = agent(try history(a, 'A')); + const saved = try countAndSave(a, &counted); + + // Same length, different blob: the digest refuses it. + var other = agent(try history(a, 'B')); + reload(&other, saved); + try std.testing.expect(context_tokens.calibratedState(state_len) == null); + try std.testing.expect(other.inputOverCompactThreshold()); + + var moved = agent(counted.messages); + moved.provider.model = "gpt-6"; + reload(&moved, saved); + try std.testing.expect(context_tokens.calibratedState(state_len) == null); + moved.provider.model = "gpt-5"; + moved.provider.id = "openai"; + reload(&moved, saved); + try std.testing.expect(context_tokens.calibratedState(state_len) == null); + + const malformed = [_][]const u8{ + "{\"opaque_state_count\":{\"provider\":\"codex\",\"model\":\"gpt-5\",\"state_bytes\":8000000,\"state_digest\":\"zz\",\"tokens\":72000}}", + "{\"opaque_state_count\":{\"provider\":\"codex\",\"model\":\"gpt-5\",\"state_bytes\":8000000,\"state_digest\":\"0\",\"tokens\":-1}}", + "{\"opaque_state_count\":\"codex\"}", + "{}", + }; + for (malformed) |text| { + const parsed = try std.json.parseFromSliceLeaky(Value, a, text, .{}); + reload(&counted, .{ .context = saved.context, .local = saved.local, .obj = parsed.object }); + try std.testing.expect(context_tokens.calibratedState(state_len) == null); + } +} + +test "an uncounted compaction state writes no record (#1686)" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + context_tokens.resetStateCountsForTest(); + defer context_tokens.resetStateCountsForTest(); + + const root = agent(try history(a, 'A')); + var aw: std.Io.Writer.Allocating = .init(a); + var s: std.json.Stringify = .{ .writer = &aw.writer }; + try s.beginObject(); + try persist.write(&s, root.provider, .{ .array = root.messages }); + try s.endObject(); + try std.testing.expectEqualStrings("{}", aw.writer.buffered()); +} + +test "session save and load both carry the compaction-state count (#1686)" { + const src = @embedFile("session.zig"); + try std.testing.expect(std.mem.indexOf(u8, src, "opaque_state_persist.zig\").write(&s, root.provider, Value{ .array = saved_msgs })") != null); + // Restore runs before the meter is rebased, or the rebase reads bytes/16. + const restore_at = std.mem.indexOf(u8, src, "opaque_state_persist.zig\").restore(root.provider") orelse return error.TestUnexpectedResult; + const rebase_at = std.mem.indexOf(u8, src, " restoreContextMeter(root, saved_context_tokens, saved_local_tokens);") orelse return error.TestUnexpectedResult; + try std.testing.expect(restore_at < rebase_at); +} + +test "a session saved after a counted request loads without a forced compaction (#1686)" { + const writer = @import("session_writer.zig"); + const transcript = @import("session_transcript.zig"); + const gpa = std.testing.allocator; + const io = std.testing.io; + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const a = arena_state.allocator(); + writer.resetForTest(); + defer writer.resetForTest(); + transcript.resetForTest(); + defer transcript.resetForTest(); + context_tokens.resetStateCountsForTest(); + defer context_tokens.resetStateCountsForTest(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var client: std.http.Client = .{ .allocator = gpa, .io = io }; + defer client.deinit(); + var keys: @import("provider.zig").Keys = .{ .values = @splat("test-key") }; + var provider = try keys.providerById("openai", "gpt-6-sol"); + provider.context = window; + var root: Agent = .{ + .gpa = gpa, + .arena = a, + .io = io, + .client = &client, + .provider = provider, + .messages = try history(a, 'A'), + .sub = false, + .label = "root", + .out = null, + .home = try std.fmt.allocPrint(a, ".zig-cache/tmp/{s}", .{tmp.sub_path}), + }; + try root.ensureRootTools(root.provider.kind); + + // Warm process: the provider counted a full request carrying the state at 80k. + context_tokens.learnState(state_len, 80_000, root.fullRequestEstimateTokens()); + root.last_context_tokens = 80_001; + root.context_local_tokens = root.fullRequestEstimateTokens(); + try std.testing.expect(!root.inputOverCompactThreshold()); + try session.saveSessionTo(&root, a, tmp.dir, "reload-1686"); + session.flushSaves(); + + // New process: no calibration in memory, then the real load path. + context_tokens.resetStateCountsForTest(); + root.last_context_tokens = 0; + root.context_local_tokens = 0; + try session.loadSession(&root, &keys, a, "reload-1686"); + root.provider.context = window; + try std.testing.expect(root.effectiveContextTokens() < 100_000); + try std.testing.expect(!root.inputOverCompactThreshold()); +} + +test "a chained response teaches the compaction-state count (#1686)" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + context_tokens.resetStateCountsForTest(); + defer context_tokens.resetStateCountsForTest(); + + // previous_response_id still bills the whole chain as input (OpenAI), so a + // WebSocket delta's usage counts the state like a full request's does. + var root = agent(try history(a, 'A')); + root.codex_prev_id = "resp_1"; + const response = try std.json.parseFromSliceLeaky(Value, a, "{\"usage\":{\"input_tokens\":80000}}", .{}); + @import("opaque_state_usage.zig").learn(&root, response.object); + try std.testing.expect(context_tokens.calibratedState(state_len) != null); + try std.testing.expect(!root.inputOverCompactThreshold()); +} diff --git a/src/opaque_state_usage.zig b/src/opaque_state_usage.zig index c78f529f..fe9736bb 100644 --- a/src/opaque_state_usage.zig +++ b/src/opaque_state_usage.zig @@ -1,15 +1,20 @@ -//! #1686: calibrate compaction state from the provider's own count. A full -//! (unchained) Responses request carrying a compaction item reports its input -//! tokens; the state's share replaces the bytes/16 estimate for that state -//! (context_tokens.learnState). A chained request sends only a delta, so its -//! count says nothing about the state and is skipped. +//! #1686: calibrate compaction state from the provider's own count. A Responses +//! request carrying a compaction item reports its input tokens; the state's +//! share replaces the bytes/16 estimate for that state +//! (context_tokens.learnState). +//! +//! A chained request (previous_response_id, the WebSocket delta) counts too: +//! OpenAI bills every earlier input token in the chain as input, so its +//! input_tokens covers the whole context, the same reading the context meter +//! already takes from it, and Codex's own occupancy uses every response's usage +//! the same way. Skipping it left a WebSocket session (chatgpt-new's default) +//! with no count until a compaction forced a full resend. const std = @import("std"); const Agent = @import("agent.zig").Agent; const context_tokens = @import("context_tokens.zig"); pub fn learn(self: *Agent, response: std.json.ObjectMap) void { - if (self.codex_prev_id != null) return; const usage = response.get("usage") orelse return; if (usage != .object) return; const input = usage.object.get("input_tokens") orelse return; diff --git a/src/peer_idle.zig b/src/peer_idle.zig index 05eca26c..17f971c9 100644 --- a/src/peer_idle.zig +++ b/src/peer_idle.zig @@ -42,22 +42,31 @@ pub fn isBusy() bool { return turn_busy.load(.acquire); } -/// Accepted `attempt_completion` ends the task. Parked mail stays readable -/// but must not start another assistant/tool cycle (#1137). +/// Accepted `attempt_completion` ends the task the same way a final answer +/// does (ADR 0311): room mail stays parked for the next human prompt, and +/// only mail addressed to this session can start a turn. Which of the two a +/// model happens to end with must not decide whether a peer's follow-up is +/// heard. pub fn noteCompletion() void { - suppress_idle_wake = true; + settled = true; } /// A root turn ended with an ordinary final answer (#1137). The human task -/// is done just as after `attempt_completion`, but more softly: mail posted -/// to the folder's room is ambient coordination and no longer starts a turn -/// on its own. It stays parked and readable, and the next human prompt sees -/// it. Mail addressed to this session (a DM, or the user's own `/tell`) is an -/// explicit request and still wakes, so peers can still hand work over. +/// is done: mail posted to the folder's room is ambient coordination and no +/// longer starts a turn on its own. It stays parked and readable, and the +/// next human prompt sees it. Mail addressed to this session (a DM, or the +/// user's own `/tell`) is an explicit request and still wakes, so peers can +/// still hand work over. pub fn noteFinalAnswer() void { settled = true; } +/// The last task ended (final answer or attempt_completion) and no human +/// prompt has come since. +pub fn isSettled() bool { + return settled; +} + /// Whether the parked mail at or after `from` may start a turn by itself. fn mayStartTurn(from: usize) bool { if (settled) return peer_inbox.anyAddressedNeedsTurnFrom(from); @@ -182,7 +191,7 @@ test "#1001 idle wake fires once per new unread batch" { try std.testing.expect(takeIdleWake(io, &buf) == null); } -test "#1137 idle wake is silent after attempt_completion until a human prompt" { +test "ADR 0311: after attempt_completion only mail addressed to this session wakes" { peer_inbox.resetForTest(); resetForTest(); defer { @@ -190,6 +199,10 @@ test "#1137 idle wake is silent after attempt_completion until a human prompt" { resetForTest(); } var buf: [256]u8 = undefined; + noteCompletion(); + try std.testing.expect(isSettled() and !idleWakeSuppressed()); + _ = peer_inbox.parkHeard(&.{.{ .from_pid = 2, .from_start = 1, .from_session = "s-peer", .text = "room chatter" }}, &.{}); + try std.testing.expect(takeIdleWake(std.testing.io, &buf) == null); const msg: presence_chan.Message = .{ .from_pid = 2, .from_start = 1, @@ -198,10 +211,7 @@ test "#1137 idle wake is silent after attempt_completion until a human prompt" { .to = "s-me", }; _ = peer_inbox.parkHeard(&.{msg}, &.{}); - noteCompletion(); - try std.testing.expect(takeIdleWake(std.testing.io, &buf) == null); - noteHumanPrompt(); - const wake = takeIdleWake(std.testing.io, &buf) orelse return error.ExpectedWakeAfterHuman; + const wake = takeIdleWake(std.testing.io, &buf) orelse return error.ExpectedAddressedWake; try std.testing.expect(std.mem.indexOf(u8, wake, "[peer]") != null); } diff --git a/src/peer_wake_loop.zig b/src/peer_wake_loop.zig index 082a9bba..b5bcb9a4 100644 --- a/src/peer_wake_loop.zig +++ b/src/peer_wake_loop.zig @@ -175,7 +175,7 @@ test "#1137 peer arrival after attempt_completion does not auto-run" { } _ = peer_inbox.parkHeard(&.{.{ .from_pid = 2, .from_start = 1, .from_session = "s-peer", .text = "hold gui" }}, &.{}); peer_idle.noteCompletion(); - try std.testing.expect(peer_idle.idleWakeSuppressed()); + try std.testing.expect(peer_idle.isSettled()); try std.testing.expect(!shouldInjectAuthoritative(false, 1)); var buf: [256]u8 = undefined; try std.testing.expect(peer_idle.takeIdleWake(std.testing.io, &buf) == null); diff --git a/src/picker_auth.zig b/src/picker_auth.zig index 3e8d0e88..2979ea86 100644 --- a/src/picker_auth.zig +++ b/src/picker_auth.zig @@ -98,6 +98,12 @@ pub fn reloadLoginKey(root: *Agent, keys: *Keys, arena: Allocator, login_id: []c source.* = .login; reloaded = true; } + } else if (std.mem.eql(u8, provider_id, "claude-agent")) { + if (@import("claude_agent_login.zig").enabled(root.io, arena, @import("anthropic_profile.zig").ProcessEnv{}, home)) |exe| { + value.* = root.arena.dupe(u8, exe) catch exe; + source.* = .login; + reloaded = true; + } } else if (std.mem.eql(u8, provider_id, "chatgpt-new")) { if (@import("oauth_chatgpt.zig").loadChatgptOAuth(root.io, root.gpa, arena, home, false, null)) |k| { value.* = k; @@ -325,7 +331,7 @@ pub fn offerProviderAuth( return; }; } else if (std.mem.eql(u8, pid, "kimi")) { - oauth.kimiLogin(root.io, root.gpa, arena, home) catch |err| { + oauth.kimiLogin(root.io, root.gpa, arena, home, null) catch |err| { try out.print("\xe2\x9c\x97 kimi login failed: {t}\n", .{err}); try out.flush(); return; diff --git a/src/playbook_glue.zig b/src/playbook_glue.zig index 7859bdbd..4644c900 100644 --- a/src/playbook_glue.zig +++ b/src/playbook_glue.zig @@ -113,7 +113,7 @@ pub fn noteConstraint(agent: *Agent, input: std.json.Value) ExecResult { .is_error = true, }; if (text.len == 0) return .{ .text = "note_constraint needs non-empty exact text from the current user message", .is_error = true }; - if (text.len > playbook.max_text) return .{ .text = "constraint NOT recorded: exact text exceeds the 240-byte project-constraint limit; ask the user for a shorter standing rule", .is_error = true }; + if (text.len > playbook.max_text) return .{ .text = std.fmt.comptimePrint("constraint NOT recorded: exact text exceeds the {d}-byte project-constraint limit; ask the user for a shorter standing rule", .{playbook.max_text}), .is_error = true }; const current_user = currentUserText(agent); const answers = @import("ask_user_answers.zig"); const from_user = current_user.len > 0 and exactUserSegment(current_user, text); diff --git a/src/pricing.zig b/src/pricing.zig index 41ae97ab..a4ab6adc 100644 --- a/src/pricing.zig +++ b/src/pricing.zig @@ -311,6 +311,8 @@ pub var context_overlay: []const ModelInfo = &.{}; /// Context window for a model as served by a specific provider; falls back /// to any provider's row for the name, then to the conservative default. pub fn contextFor(provider_id: []const u8, model: []const u8) u64 { + // `[1m]` is Claude Code's spelling of an id's 1M window (claude-agent passes it through). + if (std.mem.endsWith(u8, model, "[1m]")) return @max(1_000_000, contextFor(provider_id, model[0 .. model.len - "[1m]".len])); // The ChatGPT plan route caps input like the Codex backend it shares. const is_codex = std.mem.eql(u8, provider_id, "codex") or std.mem.eql(u8, provider_id, "chatgpt-new"); const subscription_cap = if (std.mem.eql(u8, model, "gpt-6.1-sol")) sol_subscription_context_window else codex_context_window; diff --git a/src/pricing_models.zig b/src/pricing_models.zig index 1dd4cffa..b44420b3 100644 --- a/src/pricing_models.zig +++ b/src/pricing_models.zig @@ -32,6 +32,19 @@ pub const rows = [_]ModelInfo{ .{ .provider = "anthropic", .name = "claude-haiku-5-5", .context = 1_000_000 }, .{ .provider = "anthropic", .name = "claude-opus-4-5", .context = 200_000 }, .{ .provider = "anthropic", .name = "claude-sonnet-4-5", .context = 200_000 }, + // Claude Agent (ADR 0309): the models Claude Code's opus/fable/sonnet/ + // haiku aliases resolve to, under their real names, so the plan reads like + // any other. `claude-agent/` passes any id Claude Code accepts. Claude + // Code runs each of these at 1M natively (its result reports contextWindow + // 1000000 with or without a `[1m]` suffix), so the window matches Anthropic's. + .{ .provider = "claude-agent", .name = "claude-opus-5-5", .context = 1_000_000 }, + .{ .provider = "claude-agent", .name = "claude-fable-5-1", .context = 1_000_000 }, + .{ .provider = "claude-agent", .name = "claude-sonnet-5-5", .context = 1_000_000 }, + .{ .provider = "claude-agent", .name = "claude-haiku-5-5", .context = 1_000_000 }, + // The previous generation Claude Code still offers by name. + .{ .provider = "claude-agent", .name = "claude-opus-5", .context = 1_000_000 }, + .{ .provider = "claude-agent", .name = "claude-fable-5", .context = 1_000_000 }, + .{ .provider = "claude-agent", .name = "claude-sonnet-5", .context = 1_000_000 }, .{ .provider = "deepseek", .name = "deepseek-v4-pro", .context = 1_000_000 }, .{ .provider = "deepseek", .name = "deepseek-v4-flash", .context = 1_000_000 }, .{ .provider = "deepseek", .name = "deepseek-chat", .context = 1_000_000 }, diff --git a/src/pricing_tests.zig b/src/pricing_tests.zig index f8f1020e..8de24a60 100644 --- a/src/pricing_tests.zig +++ b/src/pricing_tests.zig @@ -187,6 +187,13 @@ test "contextFor known model and default fallback" { try std.testing.expectEqual(@as(u64, pricing.default_context), pricing.contextFor("nope", "unknown-xyz")); } +test "contextFor gives Claude Agent the 1M window Claude Code runs" { + try std.testing.expectEqual(@as(u64, 1_000_000), pricing.contextFor("claude-agent", "claude-opus-5-5")); + try std.testing.expectEqual(@as(u64, 1_000_000), pricing.contextFor("claude-agent", "claude-sonnet-5-5[1m]")); + try std.testing.expectEqual(@as(u64, 1_000_000), pricing.contextFor("claude-agent", "claude-opus-4-5[1m]")); + try std.testing.expectEqual(@as(u64, pricing.default_context), pricing.contextFor("nope", "unknown-xyz")); +} + test "Kimi discovery preserves Codex and chooses the newest pure generation" { const saved = pricing.active_model_table; defer pricing.active_model_table = saved; diff --git a/src/provider.zig b/src/provider.zig index 35600c16..68bd5db0 100644 --- a/src/provider.zig +++ b/src/provider.zig @@ -37,7 +37,7 @@ fn contextWindowFor(provider_id: []const u8, model: []const u8) u64 { /// provider_specs; one additional OpenAI-compatible router may be loaded from /// `.graff/.config.router` at startup. pub const ProviderSpec = struct { - pub const LoginKind = enum { api_key, codegraff_device, codex_device, kimi_device, xai_device, zai_cli, chatgpt_browser, anthropic_browser }; + pub const LoginKind = enum { api_key, codegraff_device, codex_device, kimi_device, xai_device, zai_cli, chatgpt_browser, anthropic_browser, claude_cli }; pub const CatalogKind = enum { baked, codex, kimi, openai, anthropic }; id: []const u8, @@ -131,6 +131,9 @@ pub const provider_specs = [_]ProviderSpec{ // (see loadCodexAuth), the same on-disk-credential trick used for the // codegraff gateway key in ~/forge/.credentials.json. .{ .id = "codex", .display_name = "Codex (ChatGPT)", .kind = .responses, .auth = .bearer, .url = "https://chatgpt.com/backend-api/codex/responses", .env_key = "CODEX_DISABLED", .default_model = "gpt-6-sol", .login = .codex_device, .sub_login = true, .catalog = .codex }, + // claude-agent (ADR 0309): Claude through the user's own `claude` and its login, opted into with + // `graff login claude-agent`. No URL: claude_agent.zig drives the child. The "key" is its path. + .{ .id = "claude-agent", .display_name = "Claude Agent", .kind = .anthropic, .auth = .bearer, .url = "stdio:claude", .env_key = "GRAFF_CLAUDE_AGENT", .default_model = "claude-opus-5-5", .login = .claude_cli, .sub_login = true, .takes_effort = true }, }; /// Optional workspace-local router loaded from `.graff/.config.router`. @@ -156,10 +159,11 @@ pub fn specFor(id: []const u8) ?ProviderSpec { pub const kimi_native_url = "https://api.kimi.com/coding/v1/chat/completions"; pub const kimi_anthropic_url = "https://api.kimi.com/coding/v1/messages?beta=true"; -/// Endpoint overrides (GRAFF_CODEX_URL / GRAFF_XAI_URL / GRAFF_ZAI_URL / -/// GRAFF_VERCEL_URL). Parsed before Keys.build so startup, /model, and -/// session restore share one URL. +/// Endpoint overrides (GRAFF_CODEX_URL / GRAFF_CHATGPT_URL / GRAFF_XAI_URL / +/// GRAFF_ZAI_URL / GRAFF_VERCEL_URL). Parsed before Keys.build so startup, +/// /model, and session restore share one URL. pub var g_codex_url_override: ?[]const u8 = null; +pub var g_chatgpt_url_override: ?[]const u8 = null; pub var g_xai_url_override: ?[]const u8 = null; pub var g_zai_url_override: ?[]const u8 = null; pub var g_vercel_url_override: ?[]const u8 = null; @@ -170,6 +174,7 @@ pub const vercel_v1_url = "https://ai-gateway.vercel.sh/v1/chat/completions"; fn resolvedUrl(spec: ProviderSpec, model: []const u8) []const u8 { if (std.mem.eql(u8, spec.id, "kimi") and pricing.kimiProtocol(model) == .anthropic) return kimi_anthropic_url; if (std.mem.eql(u8, spec.id, "codex")) return g_codex_url_override orelse spec.url; + if (std.mem.eql(u8, spec.id, "chatgpt-new")) return g_chatgpt_url_override orelse spec.url; if (std.mem.eql(u8, spec.id, "xai")) return g_xai_url_override orelse if (g_xai_responses) xai_responses_url else spec.url; if (std.mem.eql(u8, spec.id, "zai")) return g_zai_url_override orelse spec.url; if (std.mem.eql(u8, spec.id, "vercel")) return g_vercel_url_override orelse spec.url; @@ -416,6 +421,8 @@ pub const Keys = struct { // Moonshot's platform id is also `kimi-k3`; when the kimi login is // present it still wins. When only MOONSHOT_API_KEY is set, phase 0 // seats the moonshot row instead of falling through to the gateway. + // Claude Agent is the Claude plan once chosen (ADR 0309): its models, and its old aliases, route there first. + if (keys.get("claude-agent")) |exe| if (@import("claude_agent_wire.zig").routes(model)) return keys.build(specFor("claude-agent").?, exe, model); if (keys.get("kimi")) |kimi_key| { if (pricing.familyAliasEquals("kimi", "k3", model)) { const spec = specFor("kimi") orelse return error.MissingKey; diff --git a/src/providers.zig b/src/providers.zig index 699a3204..1ad90fe9 100644 --- a/src/providers.zig +++ b/src/providers.zig @@ -160,6 +160,9 @@ pub fn failoverEligible(provider_id: []const u8, detail: []const u8) bool { "authentication_error", "authentication token", "authentication failed", + "sign-in was rejected", // auth_recovery.failureText (ADR 0310) + "api key was rejected", + "console sign-in has expired", "invalid api key", "invalid_api_key", "invalid x-api-key", @@ -395,6 +398,9 @@ test "failoverEligible: accepts unavailable credentials/models, rejects transien try std.testing.expect(failoverEligible("codex", "codex api error: Unauthorized")); try std.testing.expect(failoverEligible("codex", "access token has expired")); try std.testing.expect(failoverEligible("codex", "model_not_found: rollout ended")); + try std.testing.expect(failoverEligible("kimi", "Kimi sign-in was rejected: sign in again (graff login kimi)")); + try std.testing.expect(failoverEligible("anthropic", "Anthropic plan is out of usage, and the fallback API key was rejected (Console profile): wait")); + try std.testing.expect(failoverEligible("anthropic", "Anthropic Console sign-in has expired: run `graff login console` again. Your sign-in is not affected.")); try std.testing.expect(failoverEligible("codegraff", "insufficient_quota")); try std.testing.expect(!failoverEligible("codex", "network error: HttpConnectionClosing")); try std.testing.expect(!failoverEligible("codex", "rate limited (429)")); diff --git a/src/read_file_schema_tests.zig b/src/read_file_schema_tests.zig index 2a4976de..036d364d 100644 --- a/src/read_file_schema_tests.zig +++ b/src/read_file_schema_tests.zig @@ -118,3 +118,85 @@ test "#1116: sequential missing reads under one prefix stop and point at list_di try std.testing.expect(!kept.is_error); try std.testing.expectEqualStrings("kept\n", kept.text); } + +fn nativeCall(cwd: []const u8, name: []const u8, json: []const u8) !tools.ToolOutput { + var parsed = try std.json.parseFromSlice(Value, std.testing.allocator, json, .{}); + defer parsed.deinit(); + var client: std.http.Client = undefined; + return exec.execTool(testCtx(&client, cwd, null), .{ + .id = "symlink-fixture", + .name = name, + .input = parsed.value, + }); +} + +fn dependencyFixture(tmp: *std.testing.TmpDir) !void { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const io = std.testing.io; + try tmp.dir.createDirPath(io, "checkout/node_modules/.store/pkg/docs"); + try tmp.dir.createDirPath(io, "outside"); + try tmp.dir.writeFile(io, .{ .sub_path = "checkout/node_modules/.store/pkg/docs/guide.md", .data = "dependency guide\n" }); + try tmp.dir.writeFile(io, .{ .sub_path = "outside/secret.md", .data = "outside\n" }); + try tmp.dir.symLink(io, ".store/pkg", "checkout/node_modules/pkg", .{ .is_directory = true }); + try tmp.dir.symLink(io, "../outside", "checkout/escape", .{ .is_directory = true }); + try tmp.dir.symLink(io, "../outside/secret.md", "checkout/secret.md", .{}); + try tmp.dir.symLink(io, "../outside/missing.md", "checkout/dangling.md", .{}); +} + +test "native tools #1748: read_file and codedb list_dir accept in-root dependency links only" { + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try dependencyFixture(&tmp); + const root = try tmp.dir.realPathFileAlloc(std.testing.io, "checkout", gpa); + defer gpa.free(root); + const read = try readFile(root, "{\"path\":\"node_modules/pkg/docs/guide.md\"}"); + defer gpa.free(read.text); + try std.testing.expect(!read.is_error); + try std.testing.expectEqualStrings("dependency guide\n", read.text); + const listing = try nativeCall(root, "codedb", "{\"command\":\"list_dir node_modules/pkg\"}"); + defer gpa.free(listing.text); + try std.testing.expect(!listing.is_error); + try std.testing.expect(std.mem.indexOf(u8, listing.text, "guide.md") != null); + const escaped_listing = try nativeCall(root, "codedb", "{\"command\":\"list_dir escape\"}"); + defer gpa.free(escaped_listing.text); + try std.testing.expect(escaped_listing.is_error); + try std.testing.expect(std.mem.indexOf(u8, escaped_listing.text, "outside the working directory") != null); + for ([_][]const u8{ "{\"path\":\"escape/secret.md\"}", "{\"path\":\"secret.md\"}" }) |json| { + const denied = try readFile(root, json); + defer gpa.free(denied.text); + try std.testing.expect(denied.is_error); + try std.testing.expect(std.mem.indexOf(u8, denied.text, "outside the working directory") != null); + } +} + +test "native tools #1748: new writes beneath internal links work without permitting escapes" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try dependencyFixture(&tmp); + const root = try tmp.dir.realPathFileAlloc(io, "checkout", gpa); + defer gpa.free(root); + const written = try nativeCall(root, "write_file", "{\"path\":\"node_modules/pkg/docs/new/note.md\",\"content\":\"new guide\\n\"}"); + defer gpa.free(written.text); + try std.testing.expect(!written.is_error); + const content = try tmp.dir.readFileAlloc(io, "checkout/node_modules/.store/pkg/docs/new/note.md", gpa, .limited(100)); + defer gpa.free(content); + try std.testing.expectEqualStrings("new guide\n", content); + for ([_][]const u8{ + "{\"path\":\"escape/new/note.md\",\"content\":\"denied\"}", + "{\"path\":\"secret.md\",\"content\":\"denied\",\"replace\":true}", + "{\"path\":\"dangling.md\",\"content\":\"denied\"}", + }) |json| { + const denied = try nativeCall(root, "write_file", json); + defer gpa.free(denied.text); + try std.testing.expect(denied.is_error); + try std.testing.expect(std.mem.indexOf(u8, denied.text, "outside the working directory") != null); + } + const outside = try tmp.dir.readFileAlloc(io, "outside/secret.md", gpa, .limited(100)); + defer gpa.free(outside); + try std.testing.expectEqualStrings("outside\n", outside); + try std.testing.expectError(error.FileNotFound, tmp.dir.statFile(io, "outside/missing.md", .{})); + try std.testing.expectError(error.FileNotFound, tmp.dir.statFile(io, "outside/new/note.md", .{})); +} diff --git a/src/read_image.zig b/src/read_image.zig index a1adff14..44c9fbee 100644 --- a/src/read_image.zig +++ b/src/read_image.zig @@ -15,6 +15,7 @@ const ToolCtx = tools.ToolCtx; const ToolOutput = tools.ToolOutput; const vision = @import("vision.zig"); const clip = @import("vision_clipboard.zig"); +const sniff = @import("image_sniff.zig"); /// Stage pixels after exec has confined the path. The tool-batch checkpoint /// delivers them to the next model request as a native image content block. @@ -29,21 +30,35 @@ pub fn pngDims(bytes: []const u8) ?[2]u32 { return .{ std.mem.readInt(u32, bytes[16..20], .big), std.mem.readInt(u32, bytes[20..24], .big) }; } -fn fileDims(io: Io, path: []const u8) ?[2]u32 { +/// The first bytes of `path`: enough for a PNG's dimensions and any +/// `image_sniff` signature. Null when the file cannot be read. +fn fileHead(io: Io, path: []const u8, buf: *[24]u8) ?[]const u8 { const file = Io.Dir.cwd().openFile(io, path, .{}) catch return null; defer file.close(io); - var head: [24]u8 = undefined; - const got = file.readPositionalAll(io, &head, 0) catch return null; - return pngDims(head[0..got]); + const got = file.readPositionalAll(io, buf, 0) catch return null; + return buf[0..got]; +} + +fn fileDims(io: Io, path: []const u8) ?[2]u32 { + var buf: [24]u8 = undefined; + return pngDims(fileHead(io, path, &buf) orelse return null); +} + +fn notRaster(gpa: Allocator, path: []const u8) !?ToolOutput { + return .{ .text = try std.fmt.allocPrint(gpa, "Image could not be attached: {s} is not a {s} image (its bytes match none of them, whatever its extension says), so it was not sent. Convert it to one of those formats first.", .{ path, sniff.supported_names }), .is_error = true }; } fn stageWith(gpa: Allocator, io: Io, ctx: ToolCtx, resolved: []const u8, path: []const u8, size: u64, resize: clip.Resizer) !?ToolOutput { const reg = ctx.registry orelse return .{ .text = try gpa.dupe(u8, "Image could not be attached: the image queue is unavailable."), .is_error = true }; if (size == 0) return .{ .text = try gpa.dupe(u8, "Image could not be attached: the file is empty."), .is_error = true }; - const media_type = vision.imageMediaType(path); + // #1762: the type comes from the bytes. The extension is only a label for + // a model that will not see the pixels anyway. + var head_buf: [24]u8 = undefined; + const sniffed = sniff.sniff(fileHead(io, resolved, &head_buf) orelse return null); if (!vision.visionCapable(ctx.provider)) return .{ - .text = try std.fmt.allocPrint(gpa, "[image: {s}, {d} bytes — the active model does not accept images, so it was not attached]", .{ media_type, size }), + .text = try std.fmt.allocPrint(gpa, "[image: {s}, {d} bytes — the active model does not accept images, so it was not attached]", .{ sniffed orelse vision.imageMediaType(path), size }), }; + const media_type = sniffed orelse return notRaster(gpa, path); const limit = clip.max_staged_image_bytes; const fit = switch (clip.planStage(io, gpa, resolved, limit, resize)) { .not_found => return null, @@ -58,9 +73,9 @@ fn stageWith(gpa: Allocator, io: Io, ctx: ToolCtx, resolved: []const u8, path: [ defer if (fit.temp) clip.discard(io, gpa, fit.path); const data = Io.Dir.cwd().readFileAlloc(io, fit.path, gpa, .limited(@intCast(limit))) catch return null; defer gpa.free(data); - // A downscale step is always PNG (sipsResize forces it, fitToBudget checks - // the magic), so the media type follows the file actually encoded. - const sent_type = if (fit.temp) vision.imageMediaType(fit.path) else media_type; + // The type of the bytes actually encoded: a downscale step re-encodes + // (sipsResize forces PNG), so it is sniffed again rather than assumed. + const sent_type = sniff.sniff(data) orelse return notRaster(gpa, path); const enc = std.base64.standard.Encoder; const b64 = try gpa.alloc(u8, enc.calcSize(data.len)); defer gpa.free(b64); @@ -135,6 +150,60 @@ test "native image read stages pixels and tool checkpoint sends them without a u try std.testing.expect(reg.pending_image == null); } +test "#1737: after workspace use, an image in the new tree attaches; one outside both trees is refused" { + if (builtin.os.tag == .windows) return error.SkipZigTest; // enterPath is POSIX-only + const a = std.testing.allocator; + const io = std.testing.io; + const policy = @import("harness_policy.zig"); + const switcher = @import("workspace_switch.zig"); + const main_mod = @import("main.zig"); + var launch = std.testing.tmpDir(.{}); + defer launch.cleanup(); + var other = std.testing.tmpDir(.{}); + defer other.cleanup(); + var outside = std.testing.tmpDir(.{}); + defer outside.cleanup(); + try other.dir.writeFile(io, .{ .sub_path = "shot.png", .data = tiny_png }); + try outside.dir.writeFile(io, .{ .sub_path = "shot.png", .data = tiny_png }); + var orig = try Io.Dir.cwd().openDir(io, ".", .{}); + defer orig.close(io); + defer _ = std.posix.system.fchdir(orig.handle); + const saved = .{ main_mod.g_cwd_display, main_mod.g_worktree_branch }; + defer { + main_mod.g_cwd_display = saved[0]; + main_mod.g_worktree_branch = saved[1]; + switcher.deinitDisplay(a); + @import("tool_spill.zig").resetForTest(); + } + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + var buf: [std.fs.max_path_bytes]u8 = undefined; + const other_abs = try arena.allocator().dupe(u8, buf[0..try other.dir.realPath(io, &buf)]); + const outside_abs = try arena.allocator().dupe(u8, buf[0..try outside.dir.realPath(io, &buf)]); + if (std.posix.system.fchdir(launch.dir.handle) != 0) return error.ChdirFailed; + _ = try switcher.enterPath(a, io, arena.allocator(), other_abs); + + // The native tools' own confinement now roots at the switched tree. + try std.testing.expect(policy.confinedPath("shot.png") and policy.noSymlinkEscape(io, "shot.png", null)); + const resolved = try @import("codedbpro_paths.zig").sessionAbs(a, io, null, "shot.png"); + defer a.free(resolved); + try std.testing.expect(std.mem.startsWith(u8, resolved, other_abs)); + var reg = @import("mcp.zig").Registry.empty(a, io); + defer reg.deinit(); + var ctx: ToolCtx = undefined; + ctx.registry = ® + ctx.provider = .{ .id = "test", .kind = .responses, .auth = .bearer, .url = "", .api_key = "", .model = "gpt-5", .context = 100000 }; + const out = (try stage(a, io, ctx, resolved, "shot.png", tiny_png.len)).?; + defer a.free(out.text); + try std.testing.expect(!out.is_error); + try std.testing.expect(reg.pending_image != null); + + // Outside both the launch tree and the switched tree: still refused. + const escaped = try std.fmt.allocPrint(arena.allocator(), "{s}/shot.png", .{outside_abs}); + try std.testing.expect(!policy.confinedPath(escaped) and !policy.noSymlinkEscape(io, escaped, null)); + try std.testing.expect(!policy.confinedPath("../outside/shot.png")); +} + /// 1x1 PNG a fake resizer "downscales" to. const tiny_png = "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01\x08\x04\x00\x00\x00\xb5\x1c\x0c\x02\x00\x00\x00\x0b\x49\x44\x41\x54\x78\xda\x63\xfc\xff\x1f\x00\x03\x03\x02\x00\xef\x9a\x0f\x5b\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82"; @@ -188,3 +257,37 @@ test "#1272: an oversize image is downscaled or refused, never queued as-is" { try std.testing.expectEqualStrings("image/png", pending.media_type); try std.testing.expect(std.mem.indexOf(u8, scaled.text, "downscaled from 4000x3000 to 1x1") != null); } + +test "#1762: read_image types a WebP named .png by its bytes, and refuses non-raster bytes" { + const a = std.testing.allocator; + const io = std.testing.io; + var reg = @import("mcp.zig").Registry.empty(a, io); + defer reg.deinit(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var ctx: ToolCtx = undefined; + ctx.registry = ® + ctx.provider = .{ .id = "test", .kind = .anthropic, .auth = .bearer, .url = "", .api_key = "", .model = "claude-x", .context = 100000 }; + const webp = "RIFF\x24\x00\x00\x00WEBPVP8 \x18\x00\x00\x00"; + try tmp.dir.writeFile(io, .{ .sub_path = "shot.png", .data = webp }); + const webp_path = try std.fmt.allocPrint(a, ".zig-cache/tmp/{s}/shot.png", .{tmp.sub_path}); + defer a.free(webp_path); + const out = (try stageWith(a, io, ctx, webp_path, "shot.png", webp.len, noResizer)).?; + defer a.free(out.text); + try std.testing.expect(!out.is_error); + try std.testing.expect(std.mem.indexOf(u8, out.text, "image/webp") != null); + const pending = reg.pending_image orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("image/webp", pending.media_type); + reg.pending_image = null; + + // Text saved as .png: a tool error, and no block that would wedge the chat. + const text = "not an image at all"; + try tmp.dir.writeFile(io, .{ .sub_path = "notes.png", .data = text }); + const text_path = try std.fmt.allocPrint(a, ".zig-cache/tmp/{s}/notes.png", .{tmp.sub_path}); + defer a.free(text_path); + const refused = (try stageWith(a, io, ctx, text_path, "notes.png", text.len, noResizer)).?; + defer a.free(refused.text); + try std.testing.expect(refused.is_error); + try std.testing.expect(std.mem.indexOf(u8, refused.text, sniff.supported_names) != null); + try std.testing.expect(reg.pending_image == null); +} diff --git a/src/readline_data_uri.zig b/src/readline_data_uri.zig index bd9059a8..98106fce 100644 --- a/src/readline_data_uri.zig +++ b/src/readline_data_uri.zig @@ -29,11 +29,8 @@ pub fn feedback(r: Result) []const u8 { } fn matchesMagic(mime: []const u8, bytes: []const u8) bool { - if (std.mem.eql(u8, mime, "image/png")) return std.mem.startsWith(u8, bytes, "\x89PNG\r\n\x1a\n"); - if (std.mem.eql(u8, mime, "image/jpeg")) return std.mem.startsWith(u8, bytes, "\xff\xd8\xff"); - if (std.mem.eql(u8, mime, "image/gif")) return std.mem.startsWith(u8, bytes, "GIF87a") or std.mem.startsWith(u8, bytes, "GIF89a"); - if (std.mem.eql(u8, mime, "image/webp")) return bytes.len >= 12 and std.mem.eql(u8, bytes[0..4], "RIFF") and std.mem.eql(u8, bytes[8..12], "WEBP"); - return false; + const actual = @import("image_sniff.zig").sniff(bytes) orelse return false; + return std.mem.eql(u8, mime, actual); } /// Called on the actual bracketed-paste path, before normal paste expansion. diff --git a/src/remote_images.zig b/src/remote_images.zig index 0d1d67dd..d13af346 100644 --- a/src/remote_images.zig +++ b/src/remote_images.zig @@ -66,14 +66,16 @@ pub fn stage(root: *Agent, request: std.json.ObjectMap) bool { return fail(root, "image_base64 data is not valid base64"); if (bytes == 0 or bytes > vision.max_staged_image_bytes) return fail(root, "image_base64 is empty or exceeds the staged-image limit"); - { + // #1762: the bytes, when recognized, decide the type sent. + const sent_type = blk: { const scratch = root.gpa.alloc(u8, bytes) catch return fail(root, "could not validate remote image data"); defer root.gpa.free(scratch); std.base64.standard.Decoder.decode(scratch, data) catch return fail(root, "image_base64 data is not valid base64"); - } - specs[i] = .{ .base64 = .{ .media_type = media_type, .data = data } }; + break :blk @import("image_sniff.zig").sniff(scratch) orelse media_type; + }; + specs[i] = .{ .base64 = .{ .media_type = sent_type, .data = data } }; continue; } return fail(root, "request image type must be image_url or image_base64"); diff --git a/src/rlm_query.zig b/src/rlm_query.zig index 8ccd1da2..64b390e8 100644 --- a/src/rlm_query.zig +++ b/src/rlm_query.zig @@ -5,6 +5,8 @@ //! `rlm_query`. Here the host function is that sub-call: one bounded //! completion, no tools, so spec_ptc can launch it the moment the prompt //! literal closes — overlapping generation of the rest of the script. +//! On Claude Agent (ADR 0309) the call goes to a throwaway `claude` process +//! instead of an HTTP endpoint (claude_agent_query.zig). const std = @import("std"); const Io = std.Io; @@ -44,6 +46,10 @@ pub fn run(ctx: ToolCtx, args_json: []const u8) ToolOutput { return .{ .text = ctx.gpa.dupe(u8, "rlm: llm_query could not build request") catch &.{}, .is_error = true }; }; defer ctx.gpa.free(body); + // Claude Agent has no HTTP endpoint (ADR 0309). The query runs on its own + // throwaway `claude` process, like a title; the chat's held session is + // keyed by its Agent and never taken here. + if (std.mem.eql(u8, ctx.provider.id, "claude-agent")) return viaClaudeAgent(ctx, body); const raw = http.postWatched(ctx.gpa, ctx.io, ctx.client, ctx.provider, body, null) catch |err| { return .{ .text = std.fmt.allocPrint(ctx.gpa, "rlm: llm_query failed: {s}", .{@errorName(err)}) catch &.{}, @@ -54,6 +60,25 @@ pub fn run(ctx: ToolCtx, args_json: []const u8) ToolOutput { return extract(ctx.gpa, ctx.provider.kind, raw); } +fn viaClaudeAgent(ctx: ToolCtx, body: []const u8) ToolOutput { + const reply = @import("claude_agent_query.zig").post(ctx.gpa, ctx.io, ctx.provider, body, .{ + .cwd = ctx.agent_cwd, + .effort = "low", + .tracer = ctx.tracer, + }) catch |err| return .{ + .text = std.fmt.allocPrint(ctx.gpa, "rlm: llm_query failed: {s}", .{@errorName(err)}) catch &.{}, + .is_error = true, + }; + defer reply.deinit(ctx.gpa); + return switch (reply) { + .message => |raw| extract(ctx.gpa, .anthropic, raw), + .problem => |text| .{ + .text = std.fmt.allocPrint(ctx.gpa, "rlm: llm_query failed: {s}", .{text}) catch &.{}, + .is_error = true, + }, + }; +} + pub fn buildBody(gpa: Allocator, provider: Provider, prompt: []const u8) ![]u8 { var aw: Io.Writer.Allocating = .init(gpa); errdefer aw.deinit(); diff --git a/src/route_report.zig b/src/route_report.zig index b85b3ad6..cfc0507e 100644 --- a/src/route_report.zig +++ b/src/route_report.zig @@ -52,6 +52,7 @@ pub fn loginOnDisk(io: Io, arena: Allocator, home: []const u8, spec: provider_mo .kimi_device => credential_store.oauthPath(arena, home, ".kimi"), .xai_device => credential_store.oauthPath(arena, home, ".xai"), .anthropic_browser => credential_store.oauthPath(arena, home, ".anthropic"), + .claude_cli => @import("claude_agent_login.zig").markerPath(arena, home), .zai_cli => credential_store.oauthPath(arena, home, ".zai"), .chatgpt_browser => return @import("oauth_chatgpt.zig").onDisk(io, arena, home), .codex_device => blk: { diff --git a/src/router_catalog.zig b/src/router_catalog.zig index db0d5abd..12246ea2 100644 --- a/src/router_catalog.zig +++ b/src/router_catalog.zig @@ -1,9 +1,10 @@ //! Model discovery for Codegraff, Anthropic, xAI, and the optional workspace router. //! -//! Workspace router cache: `.graff/.config.router`. xAI prefers a live GET but -//! boot activates any disk cache, waits at most 500ms to recache, else -//! refreshes in the background. `/models` is the blocking freshness moment. -//! Catalog failures never prevent startup. Baked pricing.zig rows are offline fallback. +//! Workspace router cache: `.graff/.config.router`. Boot activates any disk +//! cache now (fresh, or stale and refreshed behind it; xAI always refreshes) +//! and waits at most `catalog_bounded.startup_budget` only when there is none. +//! `/models` is the blocking freshness moment. Catalog failures never prevent +//! startup. Baked pricing.zig rows are offline fallback. const std = @import("std"); const Io = std.Io; @@ -13,6 +14,7 @@ const Value = std.json.Value; const root = @import("main.zig"); const catalog_selection = @import("catalog_selection.zig"); const credential_store = @import("credential_store.zig"); +const catalog_bounded = @import("catalog_bounded.zig"); const kimi_catalog = @import("kimi_catalog.zig"); const pricing = @import("pricing.zig"); const pricing_db = @import("pricing_db.zig"); // #557: LiteLLM price/context overlay, hydrated on the same beats as these catalogs @@ -27,43 +29,18 @@ const catalog_page_limit = 16 * 1024 * 1024; // Full provider lists may be much var attempted: [provider.provider_specs.len]bool = @splat(false); var additional_attempted = false; -/// xAI boot uses any disk cache; live GET is budgeted, else background. `/models` still blocks. -pub const live_budget_ms: i64 = 500; +/// xAI refreshes behind even a fresh cache; `/models` still blocks. pub fn alwaysLive(spec: provider.ProviderSpec) bool { return std.mem.eql(u8, spec.id, "xai"); } -var bg_refresh: Io.Future(void) = undefined; -var bg_refresh_live = false; -var bg_done: Io.Event = .unset; - -fn refreshCacheTask(io: Io, gpa: Allocator, home: []u8, spec: provider.ProviderSpec, key: []u8, source: provider.Keys.CredentialSource) void { - defer gpa.free(home); - defer gpa.free(key); - defer bg_done.set(io); +/// Fetch the live list into the disk cache; true when it was written. Runs +/// on a `catalog_bounded` worker with its own Io and allocator. +pub fn fetchToCache(io: Io, gpa: Allocator, home: []const u8, spec: provider.ProviderSpec, key: []const u8, source: provider.Keys.CredentialSource) bool { var arena_state = std.heap.ArenaAllocator.init(gpa); defer arena_state.deinit(); - const snapshot = fetch(io, gpa, arena_state.allocator(), spec, key, source) orelse return; - writeCache(io, arena_state.allocator(), home, spec, snapshot.models); -} - -fn startBackgroundRefresh(io: Io, gpa: Allocator, home: []const u8, spec: provider.ProviderSpec, key: []const u8, source: provider.Keys.CredentialSource) void { - if (bg_refresh_live) return; - const home_d = gpa.dupe(u8, home) catch return; - const key_d = gpa.dupe(u8, key) catch { - gpa.free(home_d); - return; - }; - bg_done.reset(); - const args = .{ io, gpa, home_d, spec, key_d, source }; - bg_refresh = io.concurrent(refreshCacheTask, args) catch io.async(refreshCacheTask, args); - bg_refresh_live = true; -} - -pub fn shutdown(io: Io) void { - if (!bg_refresh_live) return; - bg_refresh.await(io); - bg_refresh_live = false; + const snapshot = fetch(io, gpa, arena_state.allocator(), spec, key, source) orelse return false; + return writeCache(io, arena_state.allocator(), home, spec, snapshot.models); } pub const Snapshot = struct { @@ -109,7 +86,19 @@ pub fn dynamic(spec: provider.ProviderSpec) bool { } pub fn modelsUrl(spec: provider.ProviderSpec) []const u8 { - return if (dynamic(spec)) spec.models_url else ""; + if (!dynamic(spec)) return ""; + // GRAFF_CHATGPT_URL keeps discovery on the mock's origin, never the live API. + if (std.mem.eql(u8, spec.id, "chatgpt-new")) if (provider.g_chatgpt_url_override) |url| return overrideModelsUrl(url); + return spec.models_url; +} + +var override_models_buf: [256]u8 = undefined; + +/// scheme://host[:port] of `url` + /v1/models; `url` itself when unparsable. +fn overrideModelsUrl(url: []const u8) []const u8 { + const scheme_end = std.mem.indexOf(u8, url, "://") orelse return url; + const path_start = std.mem.indexOfScalarPos(u8, url, scheme_end + 3, '/') orelse url.len; + return std.fmt.bufPrint(&override_models_buf, "{s}/v1/models", .{url[0..path_start]}) catch url; } fn validModelId(id: []const u8) bool { @@ -323,13 +312,20 @@ pub fn cacheDocument(io: Io, arena: Allocator, spec: provider.ProviderSpec, mode return aw.writer.buffered(); } -fn writeCache(io: Io, arena: Allocator, home: []const u8, spec: provider.ProviderSpec, models: []const pricing.ModelInfo) void { - const document = cacheDocument(io, arena, spec, models) orelse return; +fn writeCache(io: Io, arena: Allocator, home: []const u8, spec: provider.ProviderSpec, models: []const pricing.ModelInfo) bool { + const document = cacheDocument(io, arena, spec, models) orelse return false; for ([_][]const u8{ dirPath(arena, home, spec), flatPath(arena, home, spec) }) |path| { if (path.len == 0) continue; credential_store.replaceFile(io, Io.Dir.cwd(), path, document, .default_file) catch continue; - return; + return true; } + return false; +} + +/// Activate the spec's disk cache, whatever its age. +pub fn activateCached(io: Io, arena: Allocator, home: []const u8, spec: provider.ProviderSpec) bool { + const snapshot = cachedSnapshot(io, arena, home, spec) orelse return false; + return activate(arena, spec, snapshot.models); } fn cachedSnapshot(io: Io, arena: Allocator, home: []const u8, spec: provider.ProviderSpec) ?Snapshot { @@ -362,7 +358,7 @@ fn loadSpec(io: Io, gpa: Allocator, arena: Allocator, home: []const u8, spec: pr }; if (fetch(io, gpa, arena, spec, key, source)) |snapshot| { if (activate(arena, spec, snapshot.models)) { - if (home.len != 0 or isAdditional(spec)) writeCache(io, arena, home, spec, snapshot.models); + if (home.len != 0 or isAdditional(spec)) _ = writeCache(io, arena, home, spec, snapshot.models); return true; } } @@ -437,7 +433,7 @@ fn finishFetch(io: Io, arena: Allocator, home: []const u8, outcome: FetchOutcome if (outcome.models.len > 0) { const kept = retainModels(arena, outcome.models) orelse return; if (activate(arena, outcome.spec, kept) and outcome.write_cache) - writeCache(io, arena, home, outcome.spec, kept); + _ = writeCache(io, arena, home, outcome.spec, kept); return; } if (cachedSnapshot(io, arena, home, outcome.spec)) |snapshot| @@ -451,20 +447,9 @@ fn cacheFresh(io: Io, arena: Allocator, home: []const u8, spec: provider.Provide return age >= 0 and age <= cache_ttl_ms and activate(arena, spec, snapshot.models); } -const KimiOutcome = struct { - fetch: kimi_catalog.Fetch = .{}, - arena_state: ?std.heap.ArenaAllocator = null, -}; - -fn kimiFetchTask(io: Io, gpa: Allocator, access: []const u8) KimiOutcome { - var arena_state = std.heap.ArenaAllocator.init(gpa); - return .{ - .fetch = kimi_catalog.fetch(io, gpa, arena_state.allocator(), access), - .arena_state = arena_state, - }; -} - -fn spawnKimi(io: Io, gpa: Allocator, arena: Allocator, keys: provider.Keys, model_flag: ?[]const u8, saved: ?serde.SavedModel) ?Io.Future(KimiOutcome) { +/// Kimi's account catalog: any cache activates now (a stale one refreshes +/// behind it); with none, a live fetch joins the boot batch. Returns its slot. +fn startKimi(io: Io, batch: *catalog_bounded.Batch, arena: Allocator, home: []const u8, keys: provider.Keys, model_flag: ?[]const u8, saved: ?serde.SavedModel) ?usize { if (!catalog_selection.startupMayUse(keys, "kimi", model_flag, saved)) return null; if (!kimi_catalog.claimAttempt()) return null; const access = keys.get("kimi") orelse ""; @@ -472,55 +457,65 @@ fn spawnKimi(io: Io, gpa: Allocator, arena: Allocator, keys: provider.Keys, mode kimi_catalog.commit(arena, &.{}, "baked fallback — no Kimi login"); return null; } - const args = .{ io, gpa, access }; - return io.concurrent(kimiFetchTask, args) catch io.async(kimiFetchTask, args); + const cache = kimi_catalog.cached(io, arena, home) orelse return batch.spawn(.kimi, home, access); + kimi_catalog.commit(arena, cache.rows, "cached account catalog"); + if (!cache.fresh) catalog_bounded.detach(.kimi, home, access); + return null; } -/// Startup catalog load. Fresh disk caches activate inline; live GETs run -/// concurrently so boot pays the slowest host, not the sum. xAI activates any -/// cache immediately, waits `live_budget_ms` only when there is none, and -/// otherwise refreshes the cache file in the background. +/// Startup catalog load. Never waits on a host that already has a disk +/// cache: fresh caches activate as they are, stale ones activate and refresh +/// behind. Hosts with no cache fetch concurrently under one budget, so boot +/// pays at most `startup_budget` however slow DNS or TLS is; a fetch that +/// misses it still caches its list for the next process. pub fn ensureForStartup(io: Io, gpa: Allocator, arena: Allocator, home: []const u8, keys: provider.Keys, model_flag: ?[]const u8, saved: ?serde.SavedModel) void { - var kimi_task = spawnKimi(io, gpa, arena, keys, model_flag, saved); - defer if (kimi_task) |*fut| { - var outcome = fut.await(io); - defer if (outcome.arena_state) |*ta| ta.deinit(); - kimi_catalog.commit(arena, outcome.fetch.rows, outcome.fetch.source); - }; - - var futures: [provider.provider_specs.len + 1]?Io.Future(FetchOutcome) = @splat(null); + _ = gpa; + var batch: catalog_bounded.Batch = .{}; + const kimi_slot = startKimi(io, &batch, arena, home, keys, model_flag, saved); + var slots: [provider.provider_specs.len + 1]?usize = @splat(null); for (provider.provider_specs, 0..) |spec, index| { if (!dynamic(spec) or !catalog_selection.startupMayUse(keys, spec.id, model_flag, saved)) continue; if (attempted[index]) continue; const key = keys.get(spec.id) orelse continue; if (key.len == 0) continue; attempted[index] = true; - const source = keys.source(spec.id); - if (cacheFresh(io, arena, home, spec)) { - if (alwaysLive(spec)) startBackgroundRefresh(io, gpa, home, spec, key, source); - continue; - } - if (alwaysLive(spec)) { - startBackgroundRefresh(io, gpa, home, spec, key, source); - bg_done.waitTimeout(io, .{ .duration = .{ .raw = .fromMilliseconds(live_budget_ms), .clock = .awake } }) catch {}; - if (cachedSnapshot(io, arena, home, spec)) |snapshot| _ = activate(arena, spec, snapshot.models); - continue; - } - futures[index] = spawnFetch(io, gpa, home, spec, key, source); + slots[index] = startSpec(io, &batch, arena, home, spec, key, keys.source(spec.id)); } if (provider.additional_router) |spec| { if (catalog_selection.startupMayUse(keys, spec.id, model_flag, saved) and !additional_attempted) { if (keys.get(spec.id)) |key| if (key.len != 0) { additional_attempted = true; - if (!cacheFresh(io, arena, home, spec)) - futures[provider.provider_specs.len] = spawnFetch(io, gpa, home, spec, key, keys.source(spec.id)); + slots[provider.provider_specs.len] = startSpec(io, &batch, arena, home, spec, key, keys.source(spec.id)); }; } } - for (&futures) |*maybe| { - const outcome = if (maybe.*) |*fut| fut.await(io) else continue; - finishFetch(io, arena, home, outcome); + if (batch.len == 0) return; + batch.wait(io, catalog_bounded.startup_budget); + if (batch.fetched(kimi_slot)) { + if (kimi_catalog.cached(io, arena, home)) |fresh| kimi_catalog.commit(arena, fresh.rows, "live account catalog"); + } else if (kimi_slot != null) kimi_catalog.commit(arena, &.{}, "baked fallback — live catalog still loading"); + for (provider.provider_specs, 0..) |spec, index| + if (batch.fetched(slots[index])) { + _ = activateCached(io, arena, home, spec); + }; + if (provider.additional_router) |spec| if (batch.fetched(slots[provider.provider_specs.len])) { + _ = activateCached(io, arena, home, spec); + }; +} + +fn startSpec(io: Io, batch: *catalog_bounded.Batch, arena: Allocator, home: []const u8, spec: provider.ProviderSpec, key: []const u8, source: provider.Keys.CredentialSource) ?usize { + const job: catalog_bounded.Job = .{ .router = .{ .spec = spec, .source = source } }; + // The workspace router caches under `.graff`, not home. + const cache_home = if (isAdditional(spec)) "." else home; + if (cacheFresh(io, arena, home, spec)) { + if (alwaysLive(spec)) catalog_bounded.detach(job, cache_home, key); + return null; + } + if (activateCached(io, arena, home, spec)) { + catalog_bounded.detach(job, cache_home, key); + return null; } + return batch.spawn(job, cache_home, key); } /// An explicit /models listing is the freshness moment: hit every keyed @@ -530,7 +525,6 @@ pub fn ensureForStartup(io: Io, gpa: Allocator, arena: Allocator, home: []const /// remains the offline fallback, and each live list is re-cached for the /// next boot. Returns how many providers were pinged. pub fn refreshForListing(io: Io, gpa: Allocator, arena: Allocator, home: []const u8, keys: provider.Keys) usize { - shutdown(io); pricing_db.ensure(io, gpa, arena, home, true); // the same freshness moment covers the price sheet, network and all var futures: [provider.provider_specs.len + 1]?Io.Future(FetchOutcome) = @splat(null); var pinged: usize = 0; diff --git a/src/router_catalog_tests.zig b/src/router_catalog_tests.zig index 03579ebd..00132898 100644 --- a/src/router_catalog_tests.zig +++ b/src/router_catalog_tests.zig @@ -282,7 +282,6 @@ test "alwaysLive is xAI-only so other OpenAI routers keep the disk TTL" { try std.testing.expect(!alwaysLive(provider.specFor("codegraff").?)); try std.testing.expect(!alwaysLive(provider.specFor("anthropic").?)); try std.testing.expect(!alwaysLive(provider.specFor("fireworks").?)); - try std.testing.expectEqual(@as(i64, 500), rc.live_budget_ms); } test "xAI /v1/models rows inherit baked context windows" { @@ -457,14 +456,16 @@ test "router discovery replaces only its provider slice" { try std.testing.expect(pricing.providerModelInTable("codex", "gpt-6-sol")); } -test "startup catalog loads fan out concurrently with Kimi" { +test "startup catalog loads fan out with Kimi under one bounded wait" { const src = @embedFile("router_catalog.zig"); - try std.testing.expect(std.mem.indexOf(u8, src, "io.concurrent(fetchSpecTask") != null); - try std.testing.expect(std.mem.indexOf(u8, src, "io.concurrent(kimiFetchTask") != null); - try std.testing.expect(std.mem.indexOf(u8, src, "fn spawnKimi") != null); - try std.testing.expect(std.mem.indexOf(u8, src, "startBackgroundRefresh") != null); - try std.testing.expect(std.mem.indexOf(u8, src, "live_budget_ms") != null); - try std.testing.expect(std.mem.indexOf(u8, src, "if (alwaysLive(spec)) return false") == null); + const start = std.mem.indexOf(u8, src, "pub fn ensureForStartup") orelse return error.TestUnexpectedResult; + const end = std.mem.indexOfPos(u8, src, start, "\nfn startSpec") orelse return error.TestUnexpectedResult; + const body = src[start..end]; + // Boot never awaits a catalog GET: one batch, one budget, then the cache. + try std.testing.expect(std.mem.indexOf(u8, body, "startKimi(io, &batch") != null); + try std.testing.expect(std.mem.indexOf(u8, body, "batch.wait(io, catalog_bounded.startup_budget)") != null); + try std.testing.expect(std.mem.indexOf(u8, body, ".await(io)") == null); + try std.testing.expect(std.mem.indexOf(u8, src, "if (alwaysLive(spec)) catalog_bounded.detach(job") != null); } // ── provider.zig routing tests parked here under its 600-line ceiling ────── @@ -537,3 +538,14 @@ test "xAI defaults to the Responses wire; GRAFF_XAI_WIRE=chat opts out (#502)" { try std.testing.expectEqual(Provider.Kind.responses, oai.kind); try std.testing.expect(std.mem.indexOf(u8, oai.url, "api.openai.com") != null); } + +test "GRAFF_CHATGPT_URL keeps chatgpt-new discovery on the override origin" { + const saved = provider.g_chatgpt_url_override; + defer provider.g_chatgpt_url_override = saved; + const spec = provider.specFor("chatgpt-new").?; + provider.g_chatgpt_url_override = null; + try std.testing.expectEqualStrings(spec.models_url, modelsUrl(spec)); + provider.g_chatgpt_url_override = "http://127.0.0.1:4321/v1/responses"; + try std.testing.expectEqualStrings("http://127.0.0.1:4321/v1/models", modelsUrl(spec)); + try std.testing.expectEqualStrings(provider.specFor("codex").?.models_url, modelsUrl(provider.specFor("codex").?)); +} diff --git a/src/schema.zig b/src/schema.zig index ea77d2bc..8e598d4d 100644 --- a/src/schema.zig +++ b/src/schema.zig @@ -8,6 +8,8 @@ const Allocator = std.mem.Allocator; const mcp = @import("mcp.zig"); const pricing = @import("pricing.zig"); const learn_store = @import("learn_store.zig"); +/// #1764: note_constraint advertises the byte ceiling playbook_glue enforces. +const playbook_max_text = std.fmt.comptimePrint("{d}", .{@import("playbook.zig").max_text}); const skill_docs = @import("skill_docs.zig"); // SKILL.md playbooks: the `skill` tool's name/desc/schema live there const peer_channel = @import("peer_channel.zig"); const workspace_switch = @import("workspace_switch.zig"); @@ -103,9 +105,9 @@ pub const base_specs = [_]ToolSpec{ const meta_specs = [_]ToolSpec{ .{ .name = "todo_write", - .desc = "Replace the task list for the current standing goal (items from a replaced, cleared or completed goal are parked - kept in the session, but not part of this list and not replaced by this call). An item you already marked completed is kept as history when you leave it out, for as long as the current request is being worked; list it again (with any status) to change or remove it that way. Once every item is completed, the next thing the user asks starts a clean list - finished work is not carried into it. Omitted pending/in_progress items are dropped. Every call needs at least one item: a call with none leaves the list untouched. Use to plan and track multi-step work; skip it for trivial single-step tasks. Each item has content and status (pending|in_progress|completed|cancelled). Use cancelled only for work the user has moved away from: it parks the item without counting it as done, and a verification item can be cancelled only after a newer user message changes scope. Write steps small enough to verify individually; keep exactly ONE item in_progress at a time — mark it in_progress when you start and completed as it lands, never a batch flipped at the end.", + .desc = "Replace the task list for the current standing goal (items from a replaced, cleared or completed goal are parked - kept in the session, but not part of this list and not replaced by this call). An item you already marked completed is kept as history when you leave it out, for as long as the current request is being worked; list it again (with any status) to change or remove it that way. Once every item is completed, the next thing the user asks starts a clean list - finished work is not carried into it. Omitted pending/in_progress items are dropped. Every call needs at least one item: a call with none leaves the list untouched. Use to plan and track multi-step work; skip it for trivial single-step tasks. Each item has content and status (pending|in_progress|completed|cancelled). Use cancelled only for work the user withdrew or handed to someone else, with a short reason (required: who withdrew it or where it went): the item stays on the list with its reason and stops blocking completion, but never counts as done or verified. A verification item can be cancelled only after a newer user message or an ask_user answer changes scope. Write steps small enough to verify individually; keep exactly ONE item in_progress at a time — mark it in_progress when you start and completed as it lands, never a batch flipped at the end.", .schema = - \\{"type": "object", "properties": {"todos": {"type": "array", "items": {"type": "object", "properties": {"content": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed", "cancelled"]}}, "required": ["content", "status"]}}}, "required": ["todos"]} + \\{"type": "object", "properties": {"todos": {"type": "array", "items": {"type": "object", "properties": {"content": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed", "cancelled"]}, "reason": {"type": "string", "description": "Required when status is cancelled: who withdrew the item or where it was delegated, in a few words."}}, "required": ["content", "status"]}}}, "required": ["todos"]} , }, .{ @@ -124,7 +126,7 @@ const meta_specs = [_]ToolSpec{ // by construction — there is no retire/edit/list operation here at all, // so a model that finds a constraint inconvenient has no mechanism to // widen it; only the user can, with /never rm (#638). - .{ .name = "note_constraint", .desc = "Record an explicit project-wide user rule in this project's playbook (.graff/playbook.jsonl). Do not call for temporary, task/session-limited, or ambiguous steering; follow that locally or ask before persisting. Durable intent must be clear. Pass scope=project and copy text verbatim from the current user message without widening qualifiers. The visible result includes scope, origin, and text plus /never removal guidance. Project records survive compaction and future sessions. Append-only: only the user can retire one with /never rm (REPL, TUI, or ACP) or an explicit override. Duplicates are harmless.", .schema = "{\"type\": \"object\", \"properties\": {\"text\": {\"type\": \"string\", \"description\": \"Exact verbatim project-wide constraint from the current user message\"}, \"scope\": {\"type\": \"string\", \"enum\": [\"project\"], \"description\": \"Durable scope; local steering must not be recorded\"}}, \"required\": [\"text\", \"scope\"]}" }, + .{ .name = "note_constraint", .desc = "Record an explicit project-wide user rule in this project's playbook (.graff/playbook.jsonl). Do not call for temporary, task/session-limited, or ambiguous steering; follow that locally or ask before persisting. Durable intent must be clear. Pass scope=project and copy text verbatim from the current user message without widening qualifiers; `text` is at most " ++ playbook_max_text ++ " bytes (UTF-8), so a longer rule cannot be recorded and must not be shortened by dropping qualifiers. The visible result includes scope, origin, and text plus /never removal guidance. Project records survive compaction and future sessions. Append-only: only the user can retire one with /never rm (REPL, TUI, or ACP) or an explicit override. Duplicates are harmless.", .schema = "{\"type\": \"object\", \"properties\": {\"text\": {\"type\": \"string\", \"description\": \"Exact verbatim project-wide constraint from the current user message, at most " ++ playbook_max_text ++ " bytes\"}, \"scope\": {\"type\": \"string\", \"enum\": [\"project\"], \"description\": \"Durable scope; local steering must not be recorded\"}}, \"required\": [\"text\", \"scope\"]}" }, .{ .name = "ask_user", .desc = "Ask the human a question and wait for typed text or an option selection. Do not request screenshots or images here unless the active interface explicitly supports attachments in question replies; backend vision support alone is not enough. If that support is absent or unknown and an image is essential, use yield_turn to explain the next-prompt attachment handoff and stop without calling ask_user. Do not ask for permission to pause. Keep the conversation open and do not claim the task is complete. For text questions, put choices in options: the harness renders a numbered picker and blocks for the answer. Ending the turn with a menu written in prose does neither.", diff --git a/src/session.zig b/src/session.zig index f89523f6..14d4479d 100644 --- a/src/session.zig +++ b/src/session.zig @@ -177,6 +177,7 @@ fn fingerprint(root: *Agent, name: []const u8) u64 { f.text(t.status); f.num(t.epoch); f.flag(t.retired); // #394: retiring a finished checklist is a real state change, so it must reach disk + f.text(t.reason); // #1520 } if (root.session_parent) |parent| f.text(parent) else f.flag(false); f.text(root.session_title orelse sessionTitle(root)); @@ -319,6 +320,10 @@ fn queueSave(root: *Agent, arena: Allocator, dir: Io.Dir, name: []const u8) !u64 try s.objectField("retired"); try s.write(true); } + if (t.reason.len > 0) { // #1520: why a cancelled item was withdrawn; absent otherwise + try s.objectField("reason"); + try s.write(t.reason); + } try s.endObject(); } try s.endArray(); @@ -350,6 +355,9 @@ fn queueSave(root: *Agent, arena: Allocator, dir: Io.Dir, name: []const u8) !u64 // this component with today's prompt/tool-schema estimate. try s.objectField("context_local_tokens"); try s.write(@min(context_estimate.local, @as(u64, std.math.maxInt(i64)))); + // #1686: the local component above uses this process's count of the + // compaction state; a resume needs that count, not bytes/16. + try @import("opaque_state_persist.zig").write(&s, root.provider, Value{ .array = saved_msgs }); try root.compaction_window.write(&s); // #330: the --json protocol sequence high-water mark. A REPLACEMENT graff // resuming this session continues the numbering from here instead of @@ -410,7 +418,8 @@ pub fn appendTodosFromValue(arena: Allocator, todos: *std.ArrayList(agent_mod.To // Absent in sessions written before #394: those items are live, and the // first new ask retires them if they turn out to be a finished list. const retired = if (item.object.get("retired")) |r| (r == .bool and r.bool) else false; - try todos.append(arena, .{ .content = content, .status = status, .epoch = epoch, .retired = retired }); + const reason = if (item.object.get("reason")) |r| (if (r == .string) r.string else "") else ""; + try todos.append(arena, .{ .content = content, .status = status, .epoch = epoch, .retired = retired, .reason = reason }); } } @@ -536,6 +545,9 @@ pub fn loadSession(root: *Agent, keys: *Keys, arena: Allocator, name: []const u8 root.goal_note_age = 0; root.session_parent = parent; root.session_title = title; + // #1686: reapply the saved count of this exact compaction state first, so + // the rebased meter does not fall back to bytes/16 in a new process. + @import("opaque_state_persist.zig").restore(root.provider, .{ .array = root.messages }, obj); // Rebase the saved server-only delta onto today's prompt/tool-schema input. restoreContextMeter(root, saved_context_tokens, saved_local_tokens); root.cap_new = false; // per-provider (like sox/effort below); relearn on rejection diff --git a/src/session_connect_tests.zig b/src/session_connect_tests.zig index 1bfd16df..29feb797 100644 --- a/src/session_connect_tests.zig +++ b/src/session_connect_tests.zig @@ -44,7 +44,7 @@ test "connectCompanion on interactive yolo queues instead of addServer" { try std.testing.expect(std.mem.indexOf(u8, src, "companion: {s} (background)") != null); } -test "connectCompanion yolo writes the companion receipt and does not addServer" { +test "connectCompanion yolo gates receipts on debug without addServer" { const main_mod = @import("main.zig"); const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); @@ -64,16 +64,19 @@ test "connectCompanion yolo writes the companion receipt and does not addServer" defer arena_state.deinit(); var env = std.process.Environ.Map.init(std.testing.allocator); defer env.deinit(); - var aw: Io.Writer.Allocating = .init(std.testing.allocator); - defer aw.deinit(); - var registry = mcp.Registry.empty(std.testing.allocator, io); - defer registry.deinit(); + for ([_]bool{ false, true }) |debug| { + if (debug) try env.put("GRAFF_REPL_DEBUG", "1"); + var aw: Io.Writer.Allocating = .init(std.testing.allocator); + defer aw.deinit(); + var registry = mcp.Registry.empty(std.testing.allocator, io); + defer registry.deinit(); - try session_start.connectCompanion(io, arena_state.allocator(), ®istry, .{ .yolo_flag = true }, &aw.writer, false, env); - const out = aw.writer.buffered(); - try std.testing.expect(std.mem.indexOf(u8, out, "companion: codedb-pro (background)") != null); - try std.testing.expectEqual(@as(usize, 0), registry.servers.len); - try std.testing.expectEqual(@as(usize, 0), registry.tools.len); + try session_start.connectCompanion(io, arena_state.allocator(), ®istry, .{ .yolo_flag = true }, &aw.writer, false, env); + const out = aw.writer.buffered(); + try std.testing.expectEqual(debug, std.mem.indexOf(u8, out, "companion: codedb-pro (background)") != null); + try std.testing.expectEqual(@as(usize, 0), registry.servers.len); + try std.testing.expectEqual(@as(usize, 0), registry.tools.len); + } } test "skipOptionalServer keeps deepwiki/mobbin out of the default catalog" { diff --git a/src/session_globals.zig b/src/session_globals.zig index 82e66aab..48f82fa9 100644 --- a/src/session_globals.zig +++ b/src/session_globals.zig @@ -13,7 +13,8 @@ pub fn release(gpa: Allocator, io: Io) void { presence.retire(io); @import("presence_accord.zig").stop(io); presence.deinit(gpa); - @import("router_catalog.zig").shutdown(io); // #1196: filled by the first successful MCP tool result. @import("mcp_shapes.zig").shutdown(io); + // ADR 0309: held `claude` children end with the run. + @import("claude_agent_session.zig").stopAll(io); } diff --git a/src/session_run.zig b/src/session_run.zig index f06d852d..de37b010 100644 --- a/src/session_run.zig +++ b/src/session_run.zig @@ -291,7 +291,10 @@ pub fn buildRootAgent( // Background the fleet-champion pull: a ~0.3s TLS round-trip that used to block // the first prompt. Spawn it now; joinElites() reaps it on the main thread at the // first turn, so the user's typing hides the fetch (prompt paints ~0.3s sooner). - fleet.g_elites_future = io.async(fleet.pullElites, .{ io, arena, client, telemetry.g_telem, telem_endpoint, scoring.providerClass(root.provider.model), arena.dupe(u8, pull_esh) catch pull_esh, fleet.g_agent_types }); + // `concurrent`, not `async`: async runs inline when the pool is busy, as it + // is at boot, and then boot waited up to the pull's 3s deadline. + const pull_args = .{ io, arena, client, telemetry.g_telem, telem_endpoint, scoring.providerClass(root.provider.model), arena.dupe(u8, pull_esh) catch pull_esh, fleet.g_agent_types }; + fleet.g_elites_future = io.concurrent(fleet.pullElites, pull_args) catch io.async(fleet.pullElites, pull_args); return root; } diff --git a/src/session_tests.zig b/src/session_tests.zig index b01aee4d..52698b23 100644 --- a/src/session_tests.zig +++ b/src/session_tests.zig @@ -47,6 +47,10 @@ test "todos round-trip: appendTodosFromValue parses content/status/epoch, skips try session.appendTodosFromValue(a, &todos, retired); try std.testing.expectEqual(@as(usize, 3), todos.items.len); try std.testing.expect(todos.items[2].retired); + // #1520: a cancelled item's reason survives resume; its absence reads as none. + try session.appendTodosFromValue(a, &todos, try std.json.parseFromSliceLeaky(Value, a, "[{\"content\":\"publish\",\"status\":\"cancelled\",\"reason\":\"delegated to the release session\"}]", .{})); + try std.testing.expectEqualStrings("delegated to the release session", todos.items[3].reason); + try std.testing.expectEqualStrings("", todos.items[0].reason); } test "goalFromValue: legacy string -> active; object round-trips; paused stays paused (#223)" { diff --git a/src/signin_link.zig b/src/signin_link.zig new file mode 100644 index 00000000..f4ab494a --- /dev/null +++ b/src/signin_link.zig @@ -0,0 +1,114 @@ +//! A sign-in link the user can paste anywhere. Device logins print the +//! link and open a browser; on a terminal graff also puts the link on the +//! clipboard, so signing in from another browser or machine is one paste. +//! Over SSH the clipboard that matters is the user's own, so the link goes +//! to the terminal (OSC 52) rather than the remote machine's pasteboard. +//! Piped output (Harness, scripts) is left alone: the caller shows the link. + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; + +pub const Copied = enum { clipboard, terminal, none }; + +/// Copy `url` when stdout is a terminal. Never fails the login. +pub fn copy(io: Io, url: []const u8) Copied { + if (url.len == 0 or url.len > 4096) return .none; + if (!(Io.File.stdout().isTty(io) catch false)) return .none; + if (overSsh()) return if (osc52(io, url)) .terminal else .none; + var buf: [3][]const u8 = undefined; + const argv = copierArgv(builtin.os.tag, hasEnv("WAYLAND_DISPLAY"), hasEnv("DISPLAY"), &buf) orelse return .none; + return if (pipeTo(io, argv, url)) .clipboard else .none; +} + +/// The line that follows the link, or "" when nothing was copied. No `✓`: +/// Harness reads a `✓` line as the finished login. +pub fn note(copied: Copied) []const u8 { + return switch (copied) { + .clipboard => " (copied to your clipboard)\n", + .terminal => " (sent to your terminal's clipboard)\n", + .none => "", + }; +} + +/// After the link line: copy it and say so. +pub fn offer(io: Io, out: *Io.Writer, url: []const u8) !void { + try out.flush(); + try out.writeAll(note(copy(io, url))); + try out.flush(); +} + +/// Open the browser unless the user can't see it (GRAFF_NO_BROWSER, SSH). +pub fn open(io: Io, url: []const u8) void { + if (opensBrowser()) @import("oauth_helpers.zig").openBrowser(io, url); +} + +pub fn opensBrowser() bool { + return !@import("main.zig").g_no_browser and !overSsh(); +} + +/// Is graff running on a machine the user reached over SSH? A browser +/// opened there is one the user can't see. +pub fn overSsh() bool { + return hasEnv("SSH_TTY") or hasEnv("SSH_CONNECTION"); +} + +fn hasEnv(name: [*:0]const u8) bool { + const v = std.c.getenv(name) orelse return false; + return std.mem.span(v).len > 0; +} + +fn copierArgv(os: std.Target.Os.Tag, wayland: bool, x11: bool, buf: *[3][]const u8) ?[]const []const u8 { + const parts: []const []const u8 = switch (os) { + .macos => &.{"pbcopy"}, + .windows => &.{"clip.exe"}, + else => if (wayland) &.{"wl-copy"} else if (x11) &.{ "xclip", "-selection", "clipboard" } else return null, + }; + @memcpy(buf[0..parts.len], parts); + return buf[0..parts.len]; +} + +fn pipeTo(io: Io, argv: []const []const u8, text: []const u8) bool { + var child = std.process.spawn(io, .{ .argv = argv, .stdin = .pipe, .stdout = .ignore, .stderr = .ignore }) catch return false; + { + var wbuf: [512]u8 = undefined; + var w = child.stdin.?.writerStreaming(io, &wbuf); + w.interface.writeAll(text) catch {}; + w.interface.flush() catch {}; + child.stdin.?.close(io); + child.stdin = null; + } + const term = child.wait(io) catch return false; + return switch (term) { + .exited => |code| code == 0, + else => false, + }; +} + +fn osc52(io: Io, url: []const u8) bool { + var enc: [std.base64.standard.Encoder.calcSize(4096)]u8 = undefined; + var obuf: [64]u8 = undefined; + var ow = Io.File.stdout().writer(io, &obuf); + const w = &ow.interface; + w.writeAll("\x1b]52;c;") catch return false; + w.writeAll(std.base64.standard.Encoder.encode(&enc, url)) catch return false; + w.writeAll("\x07") catch return false; + w.flush() catch return false; + return true; +} + +test "copierArgv picks the platform's clipboard tool" { + var buf: [3][]const u8 = undefined; + try std.testing.expectEqualDeep(@as([]const []const u8, &.{"pbcopy"}), copierArgv(.macos, false, false, &buf).?); + try std.testing.expectEqualDeep(@as([]const []const u8, &.{"clip.exe"}), copierArgv(.windows, false, false, &buf).?); + try std.testing.expectEqualDeep(@as([]const []const u8, &.{"wl-copy"}), copierArgv(.linux, true, true, &buf).?); + try std.testing.expectEqualDeep(@as([]const []const u8, &.{ "xclip", "-selection", "clipboard" }), copierArgv(.linux, false, true, &buf).?); + try std.testing.expect(copierArgv(.linux, false, false, &buf) == null); +} + +test "note never claims a finished login" { + for ([_]Copied{ .clipboard, .terminal, .none }) |c| { + try std.testing.expect(std.mem.indexOf(u8, note(c), "\xe2\x9c\x93") == null); + } + try std.testing.expectEqualStrings("", note(.none)); +} diff --git a/src/spec_provider_conformance.zig b/src/spec_provider_conformance.zig index 07f0ea37..767b4ac0 100644 --- a/src/spec_provider_conformance.zig +++ b/src/spec_provider_conformance.zig @@ -35,6 +35,7 @@ fn loginName(l: ProviderSpec.LoginKind) []const u8 { .zai_cli => "zai_cli", .chatgpt_browser => "chatgpt_browser", .anthropic_browser => "anthropic_browser", + .claude_cli => "claude_cli", }; } diff --git a/src/sse_error_event.zig b/src/sse_error_event.zig new file mode 100644 index 00000000..693acf21 --- /dev/null +++ b/src/sse_error_event.zig @@ -0,0 +1,191 @@ +//! Explicit error events inside a streamed body (#748). +//! +//! A provider that refuses a streamed request often answers with an SSE body +//! that holds nothing but an error event: `event: error` plus +//! `data: {"type":"error","error":{…}}` on Anthropic Messages, a +//! `{"error":{…}}` chunk on chat-completions, Google's +//! `{"error":{"code":400,"status":"INVALID_ARGUMENT",…}}` on Interactions, or a +//! bare `{"message":…}` frame after `event: error` from a proxy. Each stream +//! assembler asks `envelope` about every payload it parses and hands a match +//! back as the `{"type":"error","error":{…}}` root that request() already +//! reports, so the provider's type and message reach the api-error path and +//! its retry policy instead of the body failing reassembly and looking like a +//! truncated response. + +const std = @import("std"); +const Value = std.json.Value; +const ObjectMap = std.json.ObjectMap; + +const util = @import("util.zig"); + +/// Track the SSE `event:` name for the data lines that follow it. Call with +/// every line the assembler's `ssePayload` rejected; returns the new flag. +/// `event: error` sets it, any other event name clears it, other lines keep it. +pub fn namesError(raw_line: []const u8, prev: bool) bool { + const line = std.mem.trim(u8, raw_line, " \t\r"); + if (!std.mem.startsWith(u8, line, "event:")) return prev; + return std.mem.eql(u8, std.mem.trim(u8, line["event:".len..], " \t"), "error"); +} + +fn isError(v: ?Value) bool { + const t = v orelse return false; + return t == .string and std.mem.eql(u8, t.string, "error"); +} + +/// The `error` member when it carries anything (`"error": null` is a normal +/// chat-completions chunk on some providers). +fn errorField(obj: ObjectMap) ?Value { + const e = obj.get("error") orelse return null; + return switch (e) { + .object, .string => e, + else => null, + }; +} + +/// True when this parsed payload is an explicit error event: it is typed +/// `error` (`type` or Interactions' `event_type`), carries a non-null `error` +/// member, or arrived under an `event: error` name. +pub fn isErrorEvent(obj: ObjectMap, named_error: bool) bool { + return named_error or isError(obj.get("type")) or isError(obj.get("event_type")) or errorField(obj) != null; +} + +/// The `{"type":"error","error":{type,message,code,…}}` envelope for an error +/// event, deep-copied onto `arena`, or null when `obj` is an ordinary event. +/// Nested `error` fields win; top-level `message`/`code` (Responses-style and +/// bare proxy frames) fill gaps; Google's `status` stands in for a missing +/// `type` so deterministic classes like INVALID_ARGUMENT stay recognizable. +pub fn envelope(arena: std.mem.Allocator, obj: ObjectMap, named_error: bool) !?ObjectMap { + if (!isErrorEvent(obj, named_error)) return null; + var inner: ObjectMap = .empty; + if (errorField(obj)) |e| switch (e) { + .object => inner = (try util.dupeJsonValue(arena, e)).object, + .string => |s| try inner.put(arena, "message", .{ .string = try arena.dupe(u8, s) }), + else => {}, + }; + for ([_][]const u8{ "message", "code" }) |k| if (inner.get(k) == null) if (obj.get(k)) |v| switch (v) { + .string, .integer => try inner.put(arena, k, try util.dupeJsonValue(arena, v)), + else => {}, + }; + if (inner.get("type") == null) if (inner.get("status")) |s| if (s == .string) try inner.put(arena, "type", s); + var env: ObjectMap = .empty; + try env.put(arena, "type", .{ .string = "error" }); + try env.put(arena, "error", .{ .object = inner }); + if (obj.get("request_id")) |r| if (r == .string) try env.put(arena, "request_id", .{ .string = try arena.dupe(u8, r.string) }); + return env; +} + +/// `error.type` from an envelope, or "" — the class name request() and the +/// retry classifiers read. +pub fn errorType(env: ObjectMap) []const u8 { + const e = env.get("error") orelse return ""; + if (e != .object) return ""; + const t = e.object.get("type") orelse return ""; + return if (t == .string) t.string else ""; +} + +fn parse(arena: std.mem.Allocator, payload: []const u8) !ObjectMap { + return (try std.json.parseFromSliceLeaky(Value, arena, payload, .{})).object; +} + +test "#748: envelope recognizes each error event shape and preserves type and message" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + + const anth = (try envelope(a, try parse(a, "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"bad tool schema\"},\"request_id\":\"req_1\"}"), false)).?; + try std.testing.expectEqualStrings("invalid_request_error", errorType(anth)); + try std.testing.expectEqualStrings("bad tool schema", anth.get("error").?.object.get("message").?.string); + try std.testing.expectEqualStrings("req_1", anth.get("request_id").?.string); + + const google = (try envelope(a, try parse(a, "{\"error\":{\"code\":400,\"message\":\"Request contains an invalid argument.\",\"status\":\"INVALID_ARGUMENT\"}}"), false)).?; + try std.testing.expectEqualStrings("INVALID_ARGUMENT", errorType(google)); + + const string_err = (try envelope(a, try parse(a, "{\"error\":\"model not allowed\"}"), false)).?; + try std.testing.expectEqualStrings("model not allowed", string_err.get("error").?.object.get("message").?.string); + + // A bare frame is an error only because the SSE event named it one. + const bare = "{\"message\":\"Overloaded\",\"code\":\"overloaded\"}"; + try std.testing.expect((try envelope(a, try parse(a, bare), false)) == null); + const named = (try envelope(a, try parse(a, bare), true)).?; + try std.testing.expectEqualStrings("Overloaded", named.get("error").?.object.get("message").?.string); + try std.testing.expectEqualStrings("overloaded", named.get("error").?.object.get("code").?.string); + + // Ordinary events stay ordinary. + try std.testing.expect((try envelope(a, try parse(a, "{\"choices\":[],\"error\":null}"), false)) == null); + try std.testing.expect((try envelope(a, try parse(a, "{\"type\":\"message_start\",\"message\":{}}"), false)) == null); +} + +test "#748: namesError follows the SSE event name" { + try std.testing.expect(namesError("event: error\r", false)); + try std.testing.expect(namesError("event:error", false)); + try std.testing.expect(!namesError("event: message_start", true)); + try std.testing.expect(namesError(": keep-alive", true)); + try std.testing.expect(!namesError("id: 7", false)); +} + +const Agent = @import("agent.zig").Agent; + +fn testAgent(arena: std.mem.Allocator) Agent { + return .{ + .gpa = std.testing.allocator, + .arena = arena, + .io = undefined, + .client = undefined, + .provider = undefined, + .messages = undefined, + .sub = false, + .label = "test", + .out = null, + }; +} + +test "#748: error-only Anthropic SSE becomes an error root on every frame shape" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var agent = testAgent(arena_state.allocator()); + const assemble = @import("anthropic_assemble.zig").assembleAnthropic; + const typed = (try assemble(&agent, "event: error\n" ++ + "data: {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"tools.0: name too long\"}}\n\n")).?; + try std.testing.expectEqualStrings("invalid_request_error", errorType(typed)); + try std.testing.expectEqualStrings("tools.0: name too long", typed.get("error").?.object.get("message").?.string); + + // A compatible proxy that drops `type` used to reassemble to null and fall + // through to the unparseable/truncated-body path. + const untyped = (try assemble(&agent, "event: error\n" ++ + "data: {\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}\n\n")).?; + try std.testing.expectEqualStrings("error", untyped.get("type").?.string); + try std.testing.expectEqualStrings("overloaded_error", errorType(untyped)); + + const bare = (try assemble(&agent, "event: error\ndata: {\"message\":\"upstream rejected the request\"}\n\n")).?; + try std.testing.expectEqualStrings("upstream rejected the request", bare.get("error").?.object.get("message").?.string); +} + +test "#748: error-only Interactions SSE is an error root, not an empty interaction" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var agent = testAgent(arena_state.allocator()); + const assemble = @import("interactions_stream.zig").assemble; + const root = (try assemble(&agent, "event: error\n" ++ + "data: {\"event_type\":\"error\",\"error\":{\"code\":400,\"message\":\"Request contains an invalid argument.\",\"status\":\"INVALID_ARGUMENT\"}}\n\n" ++ + "data: [DONE]\n")).?; + try std.testing.expectEqualStrings("error", root.get("type").?.string); + try std.testing.expectEqualStrings("INVALID_ARGUMENT", errorType(root)); + try std.testing.expectEqualStrings("Request contains an invalid argument.", root.get("error").?.object.get("message").?.string); + try std.testing.expect(root.get("steps") == null); + + // A failed interaction carries its error inside the interaction object. + const failed = (try assemble(&agent, "event: interaction.completed\n" ++ + "data: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"v1_x\",\"status\":\"failed\",\"error\":{\"code\":503,\"message\":\"The model is overloaded.\",\"status\":\"UNAVAILABLE\"}}}\n\n")).?; + try std.testing.expectEqualStrings("UNAVAILABLE", errorType(failed)); + try std.testing.expectEqualStrings("The model is overloaded.", failed.get("error").?.object.get("message").?.string); +} + +test "#748: error-only chat-completions SSE keeps a string error and a bare named frame" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var agent = testAgent(arena_state.allocator()); + const str_err = (try agent.assembleOpenAI("data: {\"error\":\"tool_choice must be auto\"}\n\ndata: [DONE]\n")).?; + try std.testing.expectEqualStrings("tool_choice must be auto", str_err.get("error").?.object.get("message").?.string); + const named = (try agent.assembleOpenAI("event: error\ndata: {\"message\":\"Provider returned error\",\"code\":502}\n\n")).?; + try std.testing.expectEqualStrings("Provider returned error", named.get("error").?.object.get("message").?.string); +} diff --git a/src/startup.zig b/src/startup.zig index b6492fb7..28a4cc1d 100644 --- a/src/startup.zig +++ b/src/startup.zig @@ -340,7 +340,7 @@ pub fn runSubcommand(io: Io, gpa: Allocator, arena: Allocator, init: std.process // args.loginTarget owns the names. if (flags.login_flag) { const home = keys_cli.homeEnv(init.environ_map) orelse std.process.fatal("no HOME/USERPROFILE", .{}); - if (flags.anthropic_console_login) try @import("anthropic_profile.zig").login(io, arena, init.environ_map, home) else if (flags.anthropic_login) try @import("oauth_anthropic.zig").login(io, gpa, arena, home, flags.refresh_flag) else if (flags.chatgpt_login) try @import("oauth_chatgpt.zig").login(io, gpa, arena, home) else if (flags.xai_login) try oauth.xaiLogin(io, gpa, arena, home) else if (flags.kimi_login) try oauth.kimiLogin(io, gpa, arena, home) else if (flags.zai_login) try oauth.zaiLogin(io, gpa, arena, home) else if (flags.codex_login or flags.refresh_flag) try oauth.codexLogin(io, gpa, arena, home, flags.refresh_flag) else try oauth.codegraffLogin(io, gpa, arena, home); + if (flags.claude_agent_login) @import("claude_agent_login.zig").login(io, arena, init.environ_map, home) catch std.process.exit(1) else if (flags.anthropic_console_login) try @import("anthropic_profile.zig").login(io, arena, init.environ_map, home) else if (flags.anthropic_login) try @import("oauth_anthropic.zig").login(io, gpa, arena, home, flags.refresh_flag) else if (flags.chatgpt_login) try @import("oauth_chatgpt.zig").login(io, gpa, arena, home) else if (flags.xai_login) try oauth.xaiLogin(io, gpa, arena, home) else if (flags.kimi_login) try oauth.kimiLogin(io, gpa, arena, home, flags.kimi_region) else if (flags.zai_login) try oauth.zaiLogin(io, gpa, arena, home) else if (flags.codex_login or flags.refresh_flag) try oauth.codexLogin(io, gpa, arena, home, flags.refresh_flag) else try oauth.codegraffLogin(io, gpa, arena, home); return true; } @@ -348,8 +348,12 @@ pub fn runSubcommand(io: Io, gpa: Allocator, arena: Allocator, init: std.process if (flags.positionals.items.len > 0 and std.mem.eql(u8, flags.positionals.items[0], "logout")) { const home = keys_cli.homeEnv(init.environ_map) orelse std.process.fatal("no HOME/USERPROFILE", .{}); const target = if (flags.positionals.items.len > 1) flags.positionals.items[1] else ""; + if (std.mem.eql(u8, @import("args.zig").loginTarget(target) orelse "", "claude-agent")) { + try @import("claude_agent_login.zig").logout(io, arena, home); + return true; + } const plan = std.mem.eql(u8, @import("args.zig").loginTarget(target) orelse "", "chatgpt-new"); - if (!plan) std.process.fatal("graff logout supports: chatgpt", .{}); + if (!plan) std.process.fatal("graff logout supports: chatgpt, claude-agent", .{}); try @import("oauth_chatgpt.zig").logout(io, gpa, arena, home); return true; } @@ -562,7 +566,7 @@ pub fn runSubcommand(io: Io, gpa: Allocator, arena: Allocator, init: std.process return true; } - if (!main_mod.json_mode and flags.oneshot_prompt == null) + if (!main_mod.json_mode and flags.oneshot_prompt == null and init.environ_map.get("GRAFF_REPL_DEBUG") != null) @import("server_diagnostics.zig").warnStartup(arena, io); return false; diff --git a/src/startup_keys.zig b/src/startup_keys.zig index f1e756be..b84056ff 100644 --- a/src/startup_keys.zig +++ b/src/startup_keys.zig @@ -256,6 +256,7 @@ fn missingCredentials() noreturn { \\ graff login anthropic Claude subscription browser sign-in (also: ant) \\ graff key set store a key (macOS Keychain, else 0600 file) \\ graff login console Anthropic Console account (its workspace's API credits) + \\ graff login claude-agent your own Claude Code login, through `claude` (Claude Agent) \\ export ANTHROPIC_API_KEY=sk-ant-… or CODEGRAFF/DEEPSEEK/OPENAI/MINIMAX/XIAOMI/KIMI/MOONSHOT/XAI/ZAI _API_KEY \\a Codex CLI login (~/.codex/auth.json) is also picked up automatically. , .{}); @@ -265,6 +266,11 @@ fn missingCredentials() noreturn { /// failures without manufacturing a provider. All successful resolution and /// model-selection behavior is shared with resolveKeys above. pub fn resolveKeysOptional(io: Io, gpa: Allocator, arena: Allocator, environ_map: anytype, model_flag: ?[]const u8, worker_provider_hint: ?[]const u8) !?ResolvedKeys { + // GRAFF_CHATGPT_URL: chatgpt-new's Responses endpoint for localhost mocks, + // read before Keys.build and the startup catalog load so neither goes live. + if (environ_map.get("GRAFF_CHATGPT_URL")) |v| { + if (v.len > 0) provider_mod.g_chatgpt_url_override = v; + } var keys: provider_mod.Keys = .{ .values = undefined }; for (provider_mod.provider_specs, &keys.values, &keys.sources) |spec, *value, *source| { value.* = environ_map.get(spec.env_key); @@ -303,6 +309,16 @@ pub fn resolveKeysOptional(io: Io, gpa: Allocator, arena: Allocator, environ_map codex_account = auth.account; } } + // Claude Agent (ADR 0309) is seated only once chosen: `graff login + // claude-agent` or GRAFF_CLAUDE_AGENT=1. Its "key" is the `claude` to run. + { + const exe = @import("claude_agent_login.zig").enabled(io, arena, environ_map, keys_cli.homeEnv(environ_map) orelse ""); + for (provider_mod.provider_specs, &keys.values, &keys.sources) |spec, *value, *source| { + if (!std.mem.eql(u8, spec.id, "claude-agent")) continue; + value.* = exe; + source.* = if (exe != null) .login else .none; + } + } // #471: the kimi/xAI device logins outrank their env keys, and the metered // key is parked rather than dropped — credential_failover.preferPlan owns // that policy. #274: the gate stays here because only startup knows what diff --git a/src/startup_tests.zig b/src/startup_tests.zig index d3398a87..9e605bbd 100644 --- a/src/startup_tests.zig +++ b/src/startup_tests.zig @@ -42,7 +42,6 @@ test "resolveKeys does not load Kimi/xAI OAuth credentials for an explicit unrel const saved_models = pricing.active_model_table; defer pricing.active_model_table = saved_models; defer @import("http2_pool.zig").shutdown(io); - defer @import("router_catalog.zig").shutdown(io); // This test checks credential scope, so seed a fresh local catalog. It // must not turn a synthetic key into a real provider GET during tier 1. diff --git a/src/startup_timing.zig b/src/startup_timing.zig index aca0f580..a148e57b 100644 --- a/src/startup_timing.zig +++ b/src/startup_timing.zig @@ -53,10 +53,9 @@ pub const Tracker = struct { self.phases_len = 0; } - /// `GRAFF_BOOT_DEBUG` prints every phase. Interactive boots also name - /// anything ≥80ms so a hang after `plugins:` is visible without an env var. - pub fn shouldPrint(debug: bool, phase_ms: i64) bool { - return debug or phase_ms >= 80; + /// Phase receipts always reach the trace; stderr is an explicit debug view. + pub fn shouldPrint(debug: bool, _: i64) bool { + return debug; } fn emit(tracer: *trace.Tracer, phase: Phase) void { @@ -85,9 +84,10 @@ test "startup phases retain order and non-negative deltas before attach" { try std.testing.expect(tracker.phases[1].cumulative_ms >= tracker.phases[0].cumulative_ms); } -test "slow boot phases print without GRAFF_BOOT_DEBUG" { +test "boot phase diagnostics require GRAFF_BOOT_DEBUG even for slow phases" { try std.testing.expect(!Tracker.shouldPrint(false, 0)); try std.testing.expect(!Tracker.shouldPrint(false, 79)); - try std.testing.expect(Tracker.shouldPrint(false, 80)); + try std.testing.expect(!Tracker.shouldPrint(false, 80)); + try std.testing.expect(!Tracker.shouldPrint(false, 80_000)); try std.testing.expect(Tracker.shouldPrint(true, 0)); } diff --git a/src/stream_read_progress.zig b/src/stream_read_progress.zig new file mode 100644 index 00000000..76efba7c --- /dev/null +++ b/src/stream_read_progress.zig @@ -0,0 +1,174 @@ +//! SSE inactivity is lack of body-byte progress, not lack of a complete line. +//! The read task owns all buffers; its watchdog observes only an atomic clock. +const std = @import("std"); +const Io = std.Io; +const http = @import("http.zig"); +const Agent = @import("agent.zig").Agent; +const cancel_source = @import("cancel_source.zig"); + +/// Largest single streamed event either transport assembles. A Responses +/// compaction item carries its opaque state whole; real ones exceed 60 MB. +pub const max_event_bytes: usize = 128 * 1024 * 1024; + +pub const Limits = struct { + idle_ms: u64, + // A peer dripping bytes cannot hold an unfinished event open forever. + total_ms: u64 = 15 * 60 * 1000, + max_bytes: usize = max_event_bytes, +}; + +pub fn expired(start_ms: i64, last_ms: i64, now_ms: i64, limits: Limits) bool { + const idle: u64 = @intCast(@max(now_ms - last_ms, 0)); + const total: u64 = @intCast(@max(now_ms - start_ms, 0)); + return idle >= limits.idle_ms or total >= @max(limits.total_ms, limits.idle_ms); +} + +fn nowMs(io: Io) i64 { + return @intCast(@divTrunc(Io.Timestamp.now(io, .awake).nanoseconds, std.time.ns_per_ms)); +} + +pub const Progress = struct { + io: Io, + limits: Limits, + started_ms: i64, + last_ms: std.atomic.Value(i64), + assembly_expired: bool = false, // read only after the watchdog has joined + + pub fn init(io: Io, limits: Limits) Progress { + const now = nowMs(io); + return .{ .io = io, .limits = limits, .started_ms = now, .last_ms = .init(now) }; + } + + pub fn note(self: *Progress) void { + self.last_ms.store(nowMs(self.io), .monotonic); + } + + pub fn watch(self: *Progress, poll_stdin: bool) http.WatchdogFired { + while (!expired(self.started_ms, self.last_ms.load(.monotonic), nowMs(self.io), self.limits)) { + self.io.sleep(.fromMilliseconds(10), .awake) catch return .deadline; + if (poll_stdin and Agent.drainSteerStdin(true)) { + cancel_source.cancelFromStdin(); + return .esc; + } + if (Agent.esc_cancel.load(.acquire)) return .esc; + } + const elapsed: u64 = @intCast(@max(nowMs(self.io) - self.started_ms, 0)); + self.assembly_expired = elapsed >= @max(self.limits.total_ms, self.limits.idle_ms); + return .deadline; + } + + pub fn deadlineError(self: *const Progress) anyerror { + return if (self.assembly_expired) error.StreamEventDeadline else error.StreamStalled; + } +}; + +/// An unbuffered forwarding writer observes each fragment the delimiter reader +/// consumes, including fragments much smaller than a normal writer buffer. +const Writer = struct { + out: *Io.Writer, + progress: *Progress, + bytes: usize = 0, + too_long: bool = false, + writer: Io.Writer = .{ .vtable = &.{ .drain = drain }, .buffer = &.{}, .end = 0 }, + + fn write(self: *Writer, bytes: []const u8) Io.Writer.Error!void { + if (bytes.len > self.progress.limits.max_bytes - self.bytes) { + self.too_long = true; + return error.WriteFailed; + } + try self.out.writeAll(bytes); + self.bytes += bytes.len; + if (bytes.len > 0) self.progress.note(); + } + + fn drain(w: *Io.Writer, data: []const []const u8, splat: usize) Io.Writer.Error!usize { + const self: *Writer = @alignCast(@fieldParentPtr("writer", w)); + var written: usize = 0; + for (data[0 .. data.len - 1]) |part| { + try self.write(part); + written += part.len; + } + const pattern = data[data.len - 1]; + for (0..splat) |_| try self.write(pattern); + return written + pattern.len * splat; + } +}; + +pub fn lineTask(reader: *Io.Reader, out: *Io.Writer, progress: *Progress) !usize { + var writer: Writer = .{ .out = out, .progress = progress }; + return reader.streamDelimiterEnding(&writer.writer, '\n') catch |err| { + if (writer.too_long) return error.StreamTooLong; + return err; + }; +} + +/// http-zig's readLine hides DATA progress until LF. Consume its body chunks +/// instead, retaining any bytes beyond LF across successive line reads. Control +/// frames and DATA for other streams do not count as this response's progress. +pub const H2Lines = struct { + source: *@import("http_zig").LineStream, + buffer: [8192]u8 = undefined, + start: usize = 0, + end: usize = 0, + + pub fn readLine(self: *H2Lines, dest: *std.ArrayList(u8), gpa: std.mem.Allocator, progress: *Progress) !bool { + dest.clearRetainingCapacity(); + while (true) { + if (self.start == self.end) { + self.start = 0; + self.end = try self.source.readChunk(&self.buffer); + if (self.end == 0) return dest.items.len > 0; + progress.note(); + } + const bytes = self.buffer[self.start..self.end]; + const end = std.mem.indexOfScalar(u8, bytes, '\n'); + const n = end orelse bytes.len; + if (n > progress.limits.max_bytes - dest.items.len) return error.StreamTooLong; + try dest.appendSlice(gpa, bytes[0..n]); + self.start += n; + if (end != null) { + self.start += 1; + return true; + } + } + } +}; + +test "SSE byte progress resets idle time but not the unfinished-event deadline" { + const limits: Limits = .{ .idle_ms = 100, .total_ms = 500 }; + try std.testing.expect(!expired(0, 90, 100, limits)); + try std.testing.expect(expired(0, 90, 190, limits)); + try std.testing.expect(expired(0, 490, 500, limits)); + try std.testing.expect(!expired(0, 490, 499, limits)); + // An explicitly longer silence allowance is not shortened by the cap. + try std.testing.expect(!expired(0, 590, 600, .{ .idle_ms = 700, .total_ms = 500 })); +} + +test "SSE byte progress bounds an oversized unfinished event before appending it" { + var reader: Io.Reader = .fixed("abcdefgh\n"); + var out: Io.Writer.Allocating = .init(std.testing.allocator); + defer out.deinit(); + var progress = Progress.init(std.testing.io, .{ .idle_ms = 100, .max_bytes = 4 }); + try std.testing.expectError(error.StreamTooLong, lineTask(&reader, &out.writer, &progress)); + try std.testing.expectEqual(@as(usize, 0), out.written().len); +} + +test "SSE byte progress still ends a peer that drips an event forever" { + const Drip = struct { + fn run(progress: *Progress, done: *std.atomic.Value(bool)) void { + while (!done.load(.acquire)) { + progress.note(); + progress.io.sleep(.fromMilliseconds(20), .awake) catch return; + } + } + }; + var progress = Progress.init(std.testing.io, .{ .idle_ms = 150, .total_ms = 300 }); + var done: std.atomic.Value(bool) = .init(false); + var peer = std.testing.io.async(Drip.run, .{ &progress, &done }); + defer peer.await(std.testing.io); + defer done.store(true, .release); + try std.testing.expectEqual(http.WatchdogFired.deadline, progress.watch(false)); + const elapsed = nowMs(std.testing.io) - progress.started_ms; + try std.testing.expect(elapsed >= 300 and elapsed < 2000); + try std.testing.expectEqual(error.StreamEventDeadline, progress.deadlineError()); +} diff --git a/src/subagent_resume.zig b/src/subagent_resume.zig index 37daa397..cf3c9e43 100644 --- a/src/subagent_resume.zig +++ b/src/subagent_resume.zig @@ -28,7 +28,7 @@ pub fn refuse(ctx: tools.ToolCtx, reason: []const u8) !tools.ToolOutput { return .{ .text = try std.fmt.allocPrint(ctx.gpa, "Worker follow-up refused: {s}", .{reason}), .is_error = true }; } pub fn queued(ctx: tools.ToolCtx, id: []const u8, message: []const u8) !tools.ToolOutput { - const state = retained.load(ctx, id) catch |err| return refuse(ctx, @errorName(err)); + const state = retained.load(ctx, id) catch |err| return refuse(ctx, retained.loadFailureText(err)); defer state.deinit(ctx.gpa, ctx.io); const ack = try ctx.gpa.dupe(u8, "Feedback retained without starting a turn. Use subagent_resume with this task_id to continue."); errdefer ctx.gpa.free(ack); @@ -45,7 +45,7 @@ pub fn exec(ctx: tools.ToolCtx, input: std.json.Value) !tools.ToolOutput { const id = tools.strField(input, "task_id") orelse return tools.missingArg(ctx.gpa, "task_id"); const action = tools.strField(input, "action") orelse "resume"; if (!std.mem.eql(u8, action, "resume") and !std.mem.eql(u8, action, "forget")) return refuse(ctx, "unknown action"); - const state = retained.load(ctx, id) catch |err| return refuse(ctx, @errorName(err)); + const state = retained.load(ctx, id) catch |err| return refuse(ctx, retained.loadFailureText(err)); var transferred = false; defer if (!transferred) state.deinit(ctx.gpa, ctx.io); if (std.mem.eql(u8, action, "forget")) { diff --git a/src/subagent_retained.zig b/src/subagent_retained.zig index 81034bc2..4182f103 100644 --- a/src/subagent_retained.zig +++ b/src/subagent_retained.zig @@ -34,6 +34,8 @@ pub const State = struct { arena: std.heap.ArenaAllocator, record: Record, path: []const u8, + /// The folder `path` was derived from (see `remember`). + root: []const u8 = "", lock: ?Io.File = null, persisted: bool = false, @@ -63,12 +65,66 @@ fn pathFor(a: std.mem.Allocator, root: []const u8, owner: []const u8, id: []cons const hex = std.fmt.bytesToHex(digest, .lower); return std.fmt.allocPrint(a, "{s}/.graff/sessions/{s}/workers/{s}.json", .{ root, hex, id }); } +/// The folder each checkpoint this process created lives under. The path was +/// rebuilt from the process cwd on every load, so any chdir between spawn and +/// resume (`workspace use`, ACP cwd adoption, session/load re-entering a +/// tree) looked in the wrong tree and refused with a bare FileNotFound +/// (#1724). A small ring: an evicted or foreign id falls back to the cwd. +const known_cap = 64; +var known_ids: [known_cap][32]u8 = undefined; +var known_roots: [known_cap][std.fs.max_path_bytes]u8 = undefined; +var known_lens: [known_cap]usize = @splat(0); +var known_next: usize = 0; +var known_lock = std.atomic.Value(bool).init(false); + +fn lockKnown() void { + while (known_lock.cmpxchgWeak(false, true, .acquire, .monotonic) != null) std.atomic.spinLoopHint(); +} +fn remember(id: []const u8, root: []const u8) void { + if (id.len != 32 or root.len > std.fs.max_path_bytes) return; + lockKnown(); + defer known_lock.store(false, .release); + const slot = known_next % known_cap; + known_next += 1; + @memcpy(&known_ids[slot], id); + @memcpy(known_roots[slot][0..root.len], root); + known_lens[slot] = root.len; +} +fn knownRoot(a: std.mem.Allocator, id: []const u8) !?[]const u8 { + if (id.len != 32) return null; + lockKnown(); + defer known_lock.store(false, .release); + for (0..known_cap) |slot| { + if (known_lens[slot] == 0 or !std.mem.eql(u8, &known_ids[slot], id)) continue; + return try a.dupe(u8, known_roots[slot][0..known_lens[slot]]); + } + return null; +} +pub fn resetKnownForTest() void { + known_lens = @splat(0); + known_next = 0; +} + +/// Why a task_id could not be continued, phrased for the parent model: what +/// happened and what to do next, instead of a bare error name (#1724). +pub fn loadFailureText(err: anyerror) []const u8 { + return switch (err) { + error.InvalidWorkerId => "task_id is not a retained worker id. Pass the 32-character hex task_id from the worker's start receipt or report, not the numeric agent id.", + error.FileNotFound => "no saved checkpoint for this task_id in this session's workspace. The worker was not retained, its state was never saved, or it was created by an earlier process in another folder. Its conversation cannot be continued; brief a new worker with the context it needs.", + error.WorkerOwnershipMismatch => "this task_id belongs to another parent session, so it cannot be continued here. Brief a new worker with the context it needs.", + error.WorkerForgotten => "this worker was forgotten and its history cleared. Brief a new worker with the context it needs.", + error.WouldBlock => "this worker is still running or another follow-up holds it. Wait for its report (agent_output) before resuming.", + else => "the worker's saved checkpoint could not be read. Brief a new worker with the context it needs.", + }; +} + fn allocate(ctx: tools.ToolCtx, id: []const u8, root_override: ?[]const u8) !*State { const state = try ctx.gpa.create(State); state.* = .{ .arena = .init(ctx.gpa), .record = undefined, .path = undefined }; errdefer state.deinit(ctx.gpa, ctx.io); const a = state.arena.allocator(); - const root = root_override orelse try Io.Dir.cwd().realPathFileAlloc(ctx.io, ".", a); + const root = root_override orelse try knownRoot(a, id) orelse try Io.Dir.cwd().realPathFileAlloc(ctx.io, ".", a); + state.root = root; state.path = try pathFor(a, root, family(ctx), id); try Io.Dir.cwd().createDirPath(ctx.io, std.fs.path.dirname(state.path).?); const lock_path = try std.fmt.allocPrint(a, "{s}.lock", .{state.path}); @@ -86,6 +142,7 @@ pub fn create(ctx: tools.ToolCtx, label: []const u8) !*State { const id = std.fmt.bytesToHex(random, .lower); const state = try allocate(ctx, &id, null); errdefer state.deinit(ctx.gpa, ctx.io); + remember(&id, state.root); const a = state.arena.allocator(); state.record = .{ .id = try a.dupe(u8, &id), @@ -226,6 +283,57 @@ test "retained worker history round trips without credentials and rejects parall try std.testing.expectEqual(@as(usize, 2), agent.messages.items.len); try std.testing.expectEqualStrings(root, agent.agent_cwd.?); } +test "#1724: a retained checkpoint survives a parent workspace switch; a missing one is explained" { + if (@import("builtin").os.tag == .windows) return error.SkipZigTest; // fchdir + const ctx = testContext(); + var launch = std.testing.tmpDir(.{}); + defer launch.cleanup(); + var other = std.testing.tmpDir(.{}); + defer other.cleanup(); + var orig = try Io.Dir.cwd().openDir(ctx.io, ".", .{}); + defer orig.close(ctx.io); + defer _ = std.posix.system.fchdir(orig.handle); + defer resetKnownForTest(); + const launch_root = try launch.dir.realPathFileAlloc(ctx.io, ".", ctx.gpa); + defer ctx.gpa.free(launch_root); + + // Spawn: the checkpoint is created and saved under the launch tree. + if (std.posix.system.fchdir(launch.dir.handle) != 0) return error.ChdirFailed; + var id: [32]u8 = undefined; + { + const state = try create(ctx, "worker"); + defer state.deinit(ctx.gpa, ctx.io); + @memcpy(&id, state.record.id); + try write(ctx.io, state); + } + + // The parent moves (`workspace use`); the follow-up still finds it. + if (std.posix.system.fchdir(other.dir.handle) != 0) return error.ChdirFailed; + const loaded = try load(ctx, &id); + try std.testing.expect(std.mem.startsWith(u8, loaded.path, launch_root)); + try std.testing.expectEqualStrings(&id, loaded.record.id); + loaded.deinit(ctx.gpa, ctx.io); + + // A well-formed id with no checkpoint and a malformed id get distinct, + // actionable refusals instead of a bare error name. + const resume_tool = @import("subagent_resume.zig"); + var arena: std.heap.ArenaAllocator = .init(ctx.gpa); + defer arena.deinit(); + const missing = try std.json.parseFromSliceLeaky(std.json.Value, arena.allocator(), "{\"task_id\":\"" ++ test_id ++ "\",\"message\":\"continue\"}", .{}); + const gone = try resume_tool.exec(ctx, missing); + defer ctx.gpa.free(gone.text); + try std.testing.expect(gone.is_error); + try std.testing.expect(std.mem.startsWith(u8, gone.text, "Worker follow-up refused: no saved checkpoint")); + try std.testing.expect(std.mem.indexOf(u8, gone.text, "brief a new worker") != null); + try std.testing.expect(std.mem.indexOf(u8, gone.text, "FileNotFound") == null); + const bad = try std.json.parseFromSliceLeaky(std.json.Value, arena.allocator(), "{\"task_id\":\"3\",\"message\":\"continue\"}", .{}); + const wrong = try resume_tool.exec(ctx, bad); + defer ctx.gpa.free(wrong.text); + try std.testing.expect(wrong.is_error); + try std.testing.expect(std.mem.indexOf(u8, wrong.text, "not a retained worker id") != null); + try std.testing.expect(!std.mem.eql(u8, loadFailureText(error.FileNotFound), loadFailureText(error.InvalidWorkerId))); +} + test "retained worker ownership and path validation fail closed" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); diff --git a/src/subagent_run.zig b/src/subagent_run.zig index f309f119..7e90b4f1 100644 --- a/src/subagent_run.zig +++ b/src/subagent_run.zig @@ -118,7 +118,7 @@ pub fn classifyFailure(err: anyerror, detail: ?[]const u8) FailKind { if (containsAnyCI(msg, &.{ "500", "502", "503", "504", "service unavailable", "temporarily unavailable", "bad gateway", "gateway timeout", "internal server error", "overloaded_error" })) return .transport; if (containsAnyCI(msg, &.{ "network error", "stream stalled", "stream dropped", "timed out", "connection reset", "connection closed" })) return .transport; if (containsAnyCI(msg, &.{ "rate limit", "rate_limit", "quota", "429", "overloaded", "out of credits", "credits", "billing", "too many requests" })) return .quota; - if (containsAnyCI(msg, &.{ "api key", "unauthorized", "expired", "authentication", "invalid_api_key", "401" })) return .auth; + if (containsAnyCI(msg, &.{ "api key", "unauthorized", "expired", "authentication", "invalid_api_key", "401", "sign-in was rejected" })) return .auth; // "unavailable" must stay qualified: bare, it swallows every 5xx above. if (containsAnyCI(msg, &.{ "does not exist", "not found", "no such model", "decommission", "no longer", "model_not_found", "model unavailable", "model is unavailable", "404" })) return .model; if (containsAnyCI(msg, &.{ "context length", "context window", "invalid", "unsupported", "not supported", "must be", "too long", "400", "parameter", "max_tokens", "bad request" })) return .invalid; diff --git a/src/test_hooks.zig b/src/test_hooks.zig index c89c1ba9..1611424b 100644 --- a/src/test_hooks.zig +++ b/src/test_hooks.zig @@ -338,6 +338,9 @@ test { _ = @import("worker_jev.zig"); // ADR 0232: a child asks Jev for its own effort mid-run _ = @import("piped_paste.zig"); // ADR 0231 _ = @import("mcp_connect_notice.zig"); // ADR 0230 + _ = @import("image_sniff.zig"); // #1762: image media type from the bytes + _ = @import("catalog_bounded.zig"); // model discovery: catalog GETs never hold boot past a budget + _ = @import("opaque_state_persist_tests.zig"); // #1686: a resumed session keeps its compaction-state count _ = @import("oauth_callback.zig"); _ = @import("cache_key_tests.zig"); // ADR 0223: moved off http_headers.zig (600-line cap) _ = @import("router_headers_tests.zig"); // moved off http_headers.zig (600-line cap) @@ -378,6 +381,7 @@ test { _ = @import("commands_resume.zig"); // #697: --resume SOURCE --branch DEST parser _ = @import("session_list.zig"); // #712: device-wide saved-session listing _ = @import("turn_dedup.zig"); // #714: identical user turns do not replay + _ = @import("turn_dedup_tests.zig"); // #1729: a failed turn's retry is a new ask _ = @import("net_efficiency_test.zig"); _ = @import("codex_tool_search.zig"); // hosted tool_search on gpt-5.4+ Codex _ = @import("acp_tool_view.zig"); // #1287/#1289: ACP locations and edit diffs diff --git a/src/todo_cancel.zig b/src/todo_cancel.zig new file mode 100644 index 00000000..3d214762 --- /dev/null +++ b/src/todo_cancel.zig @@ -0,0 +1,144 @@ +//! todo_write's "cancelled" status (#1545, #1520): work the user withdrew or +//! handed to someone else. A cancelled item stays on the list as a record and +//! no longer blocks completion, but it never counts as verified +//! (goal_verify.taskVerified), so cancelling is not a quiet way to finish. +//! +//! Two rules keep it honest: +//! - Every cancelled item carries a short reason (who withdrew the work, or +//! where it went). A write that cancels without one is rejected whole, so +//! the reason is always on the record and the UI never shows a cancel that +//! did not happen. +//! - A verification item is the #844 obligation, so only the user can lift +//! it: a newer user message, or an ask_user answer, must have arrived since +//! the item was first written. + +const std = @import("std"); +const Value = std.json.Value; +const Agent = @import("agent.zig").Agent; +const TodoItem = @import("agent.zig").TodoItem; +const goal_verify_kind = @import("goal_verify_kind.zig"); +const util = @import("util.zig"); + +pub const status = "cancelled"; +/// Longest reason kept, in bytes. A reason is a label, not a report. +pub const max_reason: usize = 160; + +pub fn isCancel(s: []const u8) bool { + return std.mem.eql(u8, s, status); +} + +pub fn contentOf(item: Value) ?[]const u8 { + if (item != .object) return null; + const c = item.object.get("content") orelse return null; + if (c != .string) return null; + return c.string; +} + +pub fn statusOf(item: Value) []const u8 { + const st = item.object.get("status") orelse return "pending"; + return if (st == .string) st.string else "pending"; +} + +/// The item's reason: its first line, trimmed and capped. A re-listed item +/// that was already cancelled keeps its recorded reason when it sends none. +pub fn reasonOf(item: Value, prior: ?TodoItem) []const u8 { + const raw = if (item.object.get("reason")) |r| (if (r == .string) r.string else "") else ""; + const line = raw[0 .. std.mem.indexOfAny(u8, raw, "\r\n") orelse raw.len]; + const got = util.utf8Prefix(std.mem.trim(u8, line, " \t"), max_reason); + if (got.len > 0) return got; + const p = prior orelse return ""; + return if (isCancel(p.status)) p.reason else ""; +} + +/// The rejection for a write that cancels an item without a reason, or null +/// when every cancelled item has one. `priors` is aligned with the usable +/// items (those with a content string), as applyTodoWrite builds it. An item +/// already cancelled before reasons existed (a resumed session) is not a new +/// cancel, so re-listing it unchanged never rejects the write. +pub fn missingReason(arena: std.mem.Allocator, items: []const Value, priors: []const ?TodoItem) !?[]const u8 { + var k: usize = 0; + for (items) |item| { + const content = contentOf(item) orelse continue; + const prior = priors[k]; + k += 1; + if (!isCancel(statusOf(item)) or reasonOf(item, prior).len > 0) continue; + if (prior) |p| if (isCancel(p.status)) continue; + return try std.fmt.allocPrint(arena, "todo_write rejected; the list is unchanged. The cancelled item \"{s}\" needs a short \"reason\" saying who withdrew the work or where it went (for example \"user withdrew publication\" or \"delegated to the release session\"). Resend the full list with a reason on every cancelled item.", .{content}); + } + return null; +} + +/// Cancelling ordinary work is always allowed (omitting it drops it anyway). +/// A verification item needs a user scope change since it was first written: +/// its request fingerprint must differ from the current one. A brand-new item +/// has nothing to cancel. +pub fn allowed(s: []const u8, content: []const u8, prior: ?TodoItem, ask_now: u64) bool { + if (!isCancel(s)) return true; + if (!goal_verify_kind.isVerification(content)) return true; + const p = prior orelse return false; + return p.ask_fp != ask_now; +} + +/// Fingerprint of the user's latest word on the task: the latest real user +/// request (notices excluded) plus how many ask_user answers have arrived. +/// Compaction keeps the current turn's opening message verbatim, so it is +/// stable within a request and changes when the user sends a new message, +/// steers mid-run, or answers a question (#1520). +pub fn askFingerprint(root: *const Agent) u64 { + const t = @import("messages.zig").latestUserText(root.messages.items); + const answers = @import("ask_user_answers.zig").answeredCount(); + return std.hash.Wyhash.hash(0x1545 ^ answers, t) | 1; // never 0: 0 marks a legacy item +} + +fn parse(arena: std.mem.Allocator, json: []const u8) !Value { + return std.json.parseFromSliceLeaky(Value, arena, json, .{ .allocate = .alloc_always }); +} + +test "#1520 a cancel reason is one trimmed, capped line; a re-list keeps the recorded one" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const ar = arena_state.allocator(); + const given = try parse(ar, "{\"content\":\"publish\",\"status\":\"cancelled\",\"reason\":\" delegated to the release session \\nmore\"}"); + try std.testing.expectEqualStrings("delegated to the release session", reasonOf(given, null)); + const long_reason: [400]u8 = @splat('a'); + const long = try parse(ar, try std.fmt.allocPrint(ar, "{{\"content\":\"x\",\"status\":\"cancelled\",\"reason\":\"{s}\"}}", .{&long_reason})); + try std.testing.expectEqual(max_reason, reasonOf(long, null).len); + const bare = try parse(ar, "{\"content\":\"publish\",\"status\":\"cancelled\"}"); + try std.testing.expectEqualStrings("", reasonOf(bare, null)); + const kept: TodoItem = .{ .content = "publish", .status = "cancelled", .reason = "user withdrew it" }; + try std.testing.expectEqualStrings("user withdrew it", reasonOf(bare, kept)); + const reopened: TodoItem = .{ .content = "publish", .status = "pending", .reason = "stale" }; + try std.testing.expectEqualStrings("", reasonOf(bare, reopened)); +} + +test "#1520 missingReason names the first cancelled item without a reason" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const ar = arena_state.allocator(); + const list = try parse(ar, + \\[{"content":"done","status":"completed"},{"status":"pending"}, + \\ {"content":"publish","status":"cancelled","reason":"delegated"}, + \\ {"content":"tag the release","status":"cancelled","reason":" "}] + ); + const priors = [_]?TodoItem{ null, null, null }; + const msg = (try missingReason(ar, list.array.items, &priors)).?; + try std.testing.expect(std.mem.indexOf(u8, msg, "\"tag the release\"") != null); + try std.testing.expect(std.mem.indexOf(u8, msg, "unchanged") != null); + try std.testing.expect((try missingReason(ar, list.array.items[0..3], priors[0..2])) == null); + // Cancelled before reasons existed (a resumed session): re-listing it is not a new cancel. + const legacy = [_]?TodoItem{ null, .{ .content = "tag the release", .status = "cancelled" } }; // aligned with usable items + try std.testing.expect((try missingReason(ar, &.{ list.array.items[0], list.array.items[1], list.array.items[3] }, &legacy)) == null); +} + +test "#1520 an ask_user answer changes the request fingerprint" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + var root: Agent = undefined; + root.messages = std.json.Array.init(arena_state.allocator()); + const answers = @import("ask_user_answers.zig"); + defer answers.reset(); + const before = askFingerprint(&root); + try std.testing.expectEqual(before, askFingerprint(&root)); // stable while nothing new arrives + answers.record(0, "yes, the other session publishes"); + try std.testing.expect(before != askFingerprint(&root)); +} diff --git a/src/todo_reply.zig b/src/todo_reply.zig index 811ed830..5b5c153a 100644 --- a/src/todo_reply.zig +++ b/src/todo_reply.zig @@ -42,6 +42,7 @@ pub fn text(arena: Allocator, todos: []const TodoItem, epoch: u64, incoming: []c if (!kept_header) try w.writeAll("\nKept from before (not in your list):"); kept_header = true; try w.print("\n{s} {s}", .{ mark(t.status), t.content }); + if (t.reason.len > 0) try w.print(" (cancelled: {s})", .{t.reason}); // #1520 } if (kept_verify > 0) try w.print("\n({d} verification item(s) you left out were kept as unresolved acceptance requirements)", .{kept_verify}); if (dropped_open > 0) try w.print("\n({d} open item(s) you left out were dropped; re-list one to keep it)", .{dropped_open}); @@ -100,7 +101,7 @@ test "#1545 todo reply distinguishes cancelled and dropped records from pending defer arena_state.deinit(); const todos = [_]TodoItem{ .{ .content = "finished", .status = "completed", .epoch = 1 }, - .{ .content = "scope changed", .status = "cancelled", .epoch = 1 }, + .{ .content = "scope changed", .status = "cancelled", .epoch = 1, .reason = "delegated to another session" }, .{ .content = "abandoned", .status = "dropped", .epoch = 1 }, .{ .content = "remaining", .status = "pending", .epoch = 1 }, }; @@ -108,7 +109,7 @@ test "#1545 todo reply distinguishes cancelled and dropped records from pending try std.testing.expectEqualStrings( \\Todo list saved: 1 done, 0 in progress, 1 pending, 1 cancelled. 1 dropped (abandoned by user order; no longer blocks completion). \\Kept from before (not in your list): - \\[-] scope changed + \\[-] scope changed (cancelled: delegated to another session) \\[-] abandoned , got); } diff --git a/src/tool_repeat.zig b/src/tool_repeat.zig index 6e1f4a8a..695a8445 100644 --- a/src/tool_repeat.zig +++ b/src/tool_repeat.zig @@ -16,8 +16,11 @@ //! code) breaks this run: quiet commands in a row are ordinary work. //! //! A batch still waiting on work breaks both runs: a pending or cancelled -//! result, or a shell job poll that reports the job is still running -//! (`[job N: running…]`), which is how a model waits on CI or a build. Reset +//! result, a shell job poll that reports the job is still running +//! (`[job N: running…]`), which is how a model waits on CI or a build, or an +//! `agent_output` snapshot of a subagent still running (`[agent N: running]`). +//! `clock_sleep` is not waiting by itself: it keeps no rail bypass (#225), and +//! a sleep between re-checks puts a different batch between them anyway. Reset //! at `runTurn`, except when a worker's transient-failure re-ask resumes the //! same history (`carryOver`): a fresh count there would let a looping worker //! repeat up to `stop_at - 1` more batches per ask. @@ -103,6 +106,25 @@ fn jobRunning(text: []const u8) bool { return std.mem.indexOf(u8, head, ": running") != null; } +/// An `agent_output` snapshot of a subagent that has not finished +/// (`subagent.agentStatusText`). With `ids`, one report per agent is joined +/// by blank lines, so any line of that exact shape means one is still out. +fn agentRunning(text: []const u8) bool { + var lines = std.mem.splitScalar(u8, text, '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, "[agent ") or !std.mem.endsWith(u8, line, ": running]")) continue; + const id = line["[agent ".len .. line.len - ": running]".len]; + if (id.len > 0 and std.mem.indexOfNone(u8, id, "0123456789") == null) return true; + } + return false; +} + +/// The result says the work it reports on is still going: polling it again +/// and reading the same line is waiting, not repeating. +fn stillRunning(text: []const u8) bool { + return jobRunning(text) or agentRunning(text); +} + /// Inputs that only bound or label a call. A model that varies them, or the /// spacing inside a command, to dodge the guard is still repeating itself: /// one live run polled `cat status.txt` as `cat status.txt`, then with @@ -161,7 +183,7 @@ fn writeInput(w: *std.Io.Writer, v: std.json.Value, top: bool) !void { pub fn fingerprint(keys: []const []const u8, results: []const tools.ExecResult) ?u64 { var h = std.hash.Wyhash.init(0x1706); for (keys, results) |key, r| { - if (r.pending or r.cancelled or jobRunning(r.text)) return null; + if (r.pending or r.cancelled or stillRunning(r.text)) return null; h.update(key); h.update(&.{ 0, @intFromBool(r.is_error) }); h.update(r.text); @@ -279,6 +301,25 @@ test "polling a job that is still running never counts, a finished job's output try std.testing.expect(t.stopText() != null); } +test "polling a subagent that is still running never counts, its finished report does" { + var t: Tracker = .{}; + const keys = [_][]const u8{"agent_output\n{\"id\":7}"}; + const running = [_]tools.ExecResult{.{ .text = "[agent 7: running]", .is_error = false }}; + const many = [_]tools.ExecResult{.{ .text = "[agent 6: completed in 900ms · 2 tool call(s) · ~10 context token(s) · 0 cache-read token(s)]\n\nreport\n\n[agent 7: running]", .is_error = false }}; + const done = [_]tools.ExecResult{.{ .text = "[agent 7: completed in 1200ms · 3 tool call(s) · ~4500 context token(s) · 100 cache-read token(s)]\n\nreport", .is_error = false }}; + const lookalike = [_]tools.ExecResult{.{ .text = "[agent x: running]", .is_error = false }}; + var i: u8 = 0; + while (i < 2 * stop_at) : (i += 1) { + try std.testing.expectEqual(@as(u8, 0), t.calls.note(fingerprint(&keys, &running))); + try std.testing.expectEqual(@as(u8, 0), t.calls.note(fingerprint(&keys, &many))); + } + try std.testing.expect(t.stopText() == null); + try std.testing.expect(fingerprint(&keys, &lookalike) != null); + i = 1; + while (i <= stop_at) : (i += 1) _ = t.calls.note(fingerprint(&keys, &done)); + try std.testing.expectEqual(@as(?[]const u8, stop_text), t.stopText()); +} + test "an empty-argument error loop still stops" { var t: Tracker = .{}; const keys = [_][]const u8{"read_file\n{}"}; diff --git a/src/tool_schema_tests.zig b/src/tool_schema_tests.zig index 8eb606b2..c827a7b7 100644 --- a/src/tool_schema_tests.zig +++ b/src/tool_schema_tests.zig @@ -390,6 +390,12 @@ test "note_constraint (#789): root-only, project-explicit, append-only schema" { try std.testing.expect(std.mem.indexOf(u8, t.desc, "Do not call for temporary") != null); try std.testing.expect(std.mem.indexOf(u8, t.desc, "copy text verbatim") != null); try std.testing.expect(std.mem.indexOf(u8, t.desc, "survive compaction") != null); + // #1764: the byte ceiling noteConstraint enforces is advertised, from + // the same constant, so a model never learns it from a rejection. + const ceiling = std.fmt.comptimePrint("at most {d} bytes", .{@import("playbook.zig").max_text}); + try std.testing.expect(std.mem.indexOf(u8, t.desc, ceiling) != null); + const text_desc = properties.get("text").?.object.get("description").?.string; + try std.testing.expect(std.mem.indexOf(u8, text_desc, ceiling) != null); }; try std.testing.expect(found); // A subagent must never be handed it: a child never saw the rejection, so diff --git a/src/turn_dedup.zig b/src/turn_dedup.zig index 8f40401c..b880b0d6 100644 --- a/src/turn_dedup.zig +++ b/src/turn_dedup.zig @@ -79,6 +79,24 @@ pub fn enqueueOrSkip(root: *Agent, arena: Allocator, out: ?*Io.Writer, text: []c return try enqueue(root, arena, out, text) != .started; } +/// #1729: a turn that ended in an error before any reply left its prompt as +/// the last row of history, so resending the same words read as the host +/// re-submitting a pending row and was skipped without a request. Close the +/// failed turn with an assistant row that says what streamed (if anything) +/// and why no reply came. It is persisted with the session, so the boundary +/// survives a checkpoint reload, and the model sees the request failed. A +/// no-op when the turn already left a row after the prompt (a cancel or +/// stream-end marker, a tool result), so those paths keep their own text. +pub fn settleFailedTurn(root: *Agent, arena: Allocator, err: anyerror) !void { + const items = root.messages.items; + if (items.len == 0 or messages.userPromptText(items[items.len - 1]) == null) return; + const why = root.last_api_error orelse @errorName(err); + const marker = try std.fmt.allocPrint(arena, "[request failed: {s}; no reply was received]", .{why}); + const partial = std.mem.trim(u8, root.partial_text.items, " \t\r\n"); + const text = if (partial.len == 0) marker else try std.fmt.allocPrint(arena, "{s}\n\n{s}", .{ partial, marker }); + try root.messages.append(try messages.textMessage(arena, "assistant", text)); +} + test "#714: the first copy of a prompt proceeds" { resetForTest(); defer resetForTest(); diff --git a/src/turn_dedup_tests.zig b/src/turn_dedup_tests.zig new file mode 100644 index 00000000..8dccb655 --- /dev/null +++ b/src/turn_dedup_tests.zig @@ -0,0 +1,143 @@ +//! #1729: a failed turn must not turn its own retry into a "duplicate". +//! +//! turn_dedup skips a prompt only when history already ends on that exact +//! user row (#714: a host re-submitting a pending row). A turn that failed +//! before any reply used to leave its prompt in that position, so resending +//! the same words after the endpoint recovered finished without a request. +//! `settleFailedTurn` closes the failed turn with an assistant row; these +//! tests pin that the retry starts, that the boundary survives a checkpoint +//! round trip on every wire, and that the #714 guard still holds. + +const std = @import("std"); +const Io = std.Io; +const Value = std.json.Value; +const Allocator = std.mem.Allocator; + +const Agent = @import("agent.zig").Agent; +const messages = @import("messages.zig"); +const dedup = @import("turn_dedup.zig"); +const session = @import("session.zig"); +const session_writer = @import("session_writer.zig"); +const session_transcript = @import("session_transcript.zig"); + +fn testAgent(a: Allocator, client: ?*std.http.Client) Agent { + return .{ + .gpa = std.testing.allocator, + .arena = a, + .io = std.testing.io, + .client = if (client) |c| c else undefined, + .provider = .{ .id = "xai", .kind = .openai, .auth = .bearer, .url = "", .api_key = "k", .model = "grok-4.6", .context = 100_000 }, + .messages = .init(a), + .sub = false, + .label = "test", + .out = null, + .session_title = "retry test", + }; +} + +fn lastText(agent: *Agent) []const u8 { + const last = agent.messages.items[agent.messages.items.len - 1]; + return last.object.get("content").?.string; +} + +test "#1729: an identical prompt after a failed turn starts a new request" { + dedup.resetForTest(); + defer dedup.resetForTest(); + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + var agent = testAgent(a, null); + defer agent.partial_text.deinit(std.testing.allocator); + try std.testing.expectEqual(dedup.Outcome.started, try dedup.enqueue(&agent, a, null, "summarize the README")); + agent.last_api_error = "network error: ConnectionRefused (gave up after 6 attempts)"; + try dedup.settleFailedTurn(&agent, a, error.ApiError); + try std.testing.expectEqual(@as(usize, 2), agent.messages.items.len); + try std.testing.expectEqualStrings( + "[request failed: network error: ConnectionRefused (gave up after 6 attempts); no reply was received]", + lastText(&agent), + ); + // The endpoint is back and the user sends the same words: a new ask, not + // a host replay. Repeating it after each failure never trips the stuck + // latch either, because every attempt was a real request. + for (0..dedup.max_replays + 1) |_| { + try std.testing.expectEqual(dedup.Outcome.started, try dedup.enqueue(&agent, a, null, "summarize the README")); + try dedup.settleFailedTurn(&agent, a, error.ConnectionRefused); + } + try std.testing.expectEqualStrings("[request failed: network error: ConnectionRefused (gave up after 6 attempts); no reply was received]", lastText(&agent)); +} + +test "#1729: the boundary keeps what streamed and falls back to the error name" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + var agent = testAgent(a, null); + defer agent.partial_text.deinit(std.testing.allocator); + try agent.messages.append(try messages.textMessage(a, "user", "go")); + try agent.partial_text.appendSlice(std.testing.allocator, " half an answer \n"); + try dedup.settleFailedTurn(&agent, a, error.FallbackConsentRequired); + try std.testing.expectEqualStrings("half an answer\n\n[request failed: FallbackConsentRequired; no reply was received]", lastText(&agent)); +} + +test "#1729: a turn that already left a row after its prompt is not settled twice" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + var agent = testAgent(a, null); + defer agent.partial_text.deinit(std.testing.allocator); + try dedup.settleFailedTurn(&agent, a, error.ApiError); // empty history + try std.testing.expectEqual(@as(usize, 0), agent.messages.items.len); + try agent.messages.append(try messages.textMessage(a, "user", "go")); + // A cancel or stream-end marker already closed the turn. + try agent.messages.append(try messages.textMessage(a, "assistant", "[response ended early: connection dropped]")); + try dedup.settleFailedTurn(&agent, a, error.StreamDropped); + try std.testing.expectEqual(@as(usize, 2), agent.messages.items.len); +} + +test "#1729: a host re-submitting a pending prompt is still skipped (#714)" { + dedup.resetForTest(); + defer dedup.resetForTest(); + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + var agent = testAgent(a, null); + try std.testing.expectEqual(dedup.Outcome.started, try dedup.enqueue(&agent, a, null, "Read SPEC.md")); + // No failure was recorded: the row is still pending, so the copy is a replay. + try std.testing.expectEqual(dedup.Outcome.skipped, try dedup.enqueue(&agent, a, null, "Read SPEC.md")); + try std.testing.expectEqual(dedup.Outcome.skipped, try dedup.enqueue(&agent, a, null, "Read SPEC.md")); + try std.testing.expectEqual(dedup.Outcome.stuck, try dedup.enqueue(&agent, a, null, "Read SPEC.md")); + try std.testing.expectEqual(@as(usize, 1), agent.messages.items.len); +} + +test "#1729: the failed-request boundary survives a checkpoint reload on every wire" { + const gpa = std.testing.allocator; + dedup.resetForTest(); + defer dedup.resetForTest(); + session_writer.resetForTest(); + defer session_writer.resetForTest(); + session_transcript.resetForTest(); + defer session_transcript.resetForTest(); + var arena_state = std.heap.ArenaAllocator.init(gpa); + defer arena_state.deinit(); + const a = arena_state.allocator(); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var client: std.http.Client = .{ .allocator = gpa, .io = std.testing.io }; + var agent = testAgent(a, &client); + defer agent.partial_text.deinit(gpa); + try std.testing.expectEqual(dedup.Outcome.started, try dedup.enqueue(&agent, a, null, "summarize the README")); + try dedup.settleFailedTurn(&agent, a, error.ApiError); + try session.saveSessionTo(&agent, a, tmp.dir, "retry"); + session.flushSaves(); + const bytes = try tmp.dir.readFileAlloc(std.testing.io, ".graff/sessions/retry.session.json", gpa, .limited(64 * 1024)); + defer gpa.free(bytes); + // Load applies history_wire.prepare for the session's wire; the boundary + // must stay the last row whichever wire the session resumes on. + for ([_]@import("provider.zig").Provider.Kind{ .openai, .responses, .anthropic, .interactions }) |kind| { + const saved = try std.json.parseFromSliceLeaky(Value, a, bytes, .{ .allocate = .alloc_always }); + var reloaded = testAgent(a, null); + reloaded.messages = saved.object.get("messages").?.array; + @import("history_wire.zig").prepare(a, kind, &reloaded.messages); + try std.testing.expect(!messages.trailingUserIs(reloaded.messages.items, "summarize the README")); + try std.testing.expectEqual(dedup.Outcome.started, try dedup.enqueue(&reloaded, a, null, "summarize the README")); + } +} diff --git a/src/vision.zig b/src/vision.zig index 06e370b9..250c3183 100644 --- a/src/vision.zig +++ b/src/vision.zig @@ -89,7 +89,8 @@ pub fn visionCapable(p: Provider) bool { return visionModel(p.model) or std.mem.eql(u8, p.id, "openai") or std.mem.eql(u8, p.id, "codex"); } -/// Guess an image media type from a file extension (default image/png). +/// Guess an image media type from a file extension (default image/png). A +/// label only: anything sent to a provider is typed by `image_sniff` (#1762). pub fn imageMediaType(path: []const u8) []const u8 { if (std.ascii.endsWithIgnoreCase(path, ".jpg") or std.ascii.endsWithIgnoreCase(path, ".jpeg")) return "image/jpeg"; if (std.ascii.endsWithIgnoreCase(path, ".gif")) return "image/gif"; @@ -167,6 +168,8 @@ pub const StageResult = union(enum) { not_found, /// It was there and the right size, but the read or the encode failed. read_error, + /// The bytes are not PNG, JPEG, GIF or WebP, whatever the name says (#1762). + unsupported, pub fn isOk(self: StageResult) bool { return std.meta.activeTag(self) == .ok; @@ -201,15 +204,13 @@ pub fn stageImagePath(root: *Agent, path: []const u8) StageResult { const arena = root.arena; const data = readStagedBytes(io, arena, fit.path) orelse return .read_error; + // Media type from the bytes we actually encode, never the name (#1762): a + // WebP saved as .png must go out as image/webp, and a downscale temp is + // whatever `sips` wrote. Labelled with the path the USER named. + const media = @import("image_sniff.zig").sniff(data) orelse return .unsupported; const enc = std.base64.standard.Encoder; const b64 = arena.alloc(u8, enc.calcSize(data.len)) catch return .read_error; _ = enc.encode(b64, data); - // Media type from the file we actually encoded, but labelled with the path - // the USER named — a temp file's random name means nothing to them. A - // downscale step is PNG because `sipsResize` forces `-s format png` AND - // `fitToBudget` re-checks the magic bytes; without both, `sips -Z` would - // keep the source encoding and we'd send JPEG bytes as `image/png`. - const media = imageMediaType(fit.path); @import("vision_queue.zig").stage(root, .{ .media_type = media, .b64 = b64, .label = arena.dupe(u8, path) catch path }); return .{ .ok = .{ .bytes = fit.bytes, .media_type = media } }; } @@ -229,6 +230,7 @@ pub fn stageMessage(buf: []u8, r: StageResult, what: []const u8) []const u8 { ) catch "that image is too large to send", .not_found => std.fmt.bufPrint(buf, "can't find {s}", .{what}) catch "can't find that image", .read_error => std.fmt.bufPrint(buf, "couldn't read {s}", .{what}) catch "couldn't read that image", + .unsupported => std.fmt.bufPrint(buf, "{s} is not a PNG, JPEG, GIF or WebP image; convert it first", .{what}) catch "that file is not a supported image", }; } @@ -323,10 +325,13 @@ pub fn mcpImageBlock(block: Value) ?McpImageBlock { // mimeType is required by the spec; default rather than drop an otherwise // usable image, but refuse a mime that is not an image at all. const mime = if (block.object.get("mimeType")) |m| (if (m == .string) m.string else "") else ""; - const media_type = if (mime.len == 0) "image/png" else mime; - if (!std.mem.startsWith(u8, media_type, "image/")) return null; + const declared = if (mime.len == 0) "image/png" else mime; + if (!std.mem.startsWith(u8, declared, "image/")) return null; const bytes = std.base64.standard.Decoder.calcSizeForSlice(data.string) catch return null; if (bytes == 0 or bytes > max_mcp_image_bytes) return null; + // #1762: a server (or ACP client) that mislabels a WebP as image/png + // would otherwise wedge the chat; the bytes win when we recognize them. + const media_type = @import("image_sniff.zig").resolveDeclared(data.string, declared); return .{ .media_type = media_type, .b64 = data.string, .bytes = bytes }; } diff --git a/src/vision_queue.zig b/src/vision_queue.zig index fc0c2fb6..ac175e0a 100644 --- a/src/vision_queue.zig +++ b/src/vision_queue.zig @@ -348,6 +348,13 @@ test "remote image validation rejects malformed data before staging (#615)" { , .{}); try std.testing.expect(!@import("remote_images.zig").stage(&root, bad.object)); try std.testing.expectEqual(@as(u8, 0), root.pending_image_len); + // #1762: a recognized format overrides the declared media_type. + const webp = try std.json.parseFromSliceLeaky(Value, arena, + \\{"type":"user","text":"read it","images":[ + \\ {"type":"image_base64","media_type":"image/png","data":"UklGRiQAAABXRUJQVlA4IA=="}]} + , .{}); + try std.testing.expect(@import("remote_images.zig").stage(&root, webp.object)); + try std.testing.expectEqualStrings("image/webp", root.pending_image.?.media_type); } test "placeholder-only ask_user reply gets an explicit fallback" { diff --git a/src/ws.zig b/src/ws.zig index 265726b3..0eb9108b 100644 --- a/src/ws.zig +++ b/src/ws.zig @@ -62,7 +62,9 @@ const sock_buf_cap = 64 * 1024; // Plaintext TLS buffers. const tls_buf_cap = 64 * 1024; /// Hard cap on one reassembled inbound message (a single Responses event). -const message_cap = 4 * 1024 * 1024; +/// The SSE cap too: 4 MiB refused a compaction item's opaque state, so the +/// turn was retried on a fresh socket and then SSE, each re-running it. +const message_cap = @import("stream_read_progress.zig").max_event_bytes; comptime { if (sock_buf_cap < tls.max_ciphertext_record_len) @compileError("sock_buf_cap < tls min_buffer_len"); @@ -362,3 +364,30 @@ test "counted forced WS failures exhaust exactly" { )); try std.testing.expectEqual(@as(u8, 0), g_force_connect_failure_count); } + +test "an inbound message may carry a compaction item's multi-megabyte state" { + const gpa = std.testing.allocator; + const len: usize = 5 * 1024 * 1024; // over the old 4 MiB cap + const frame = try gpa.alloc(u8, 10 + len); + defer gpa.free(frame); + frame[0] = 0x81; // FIN | text + frame[1] = 127; + std.mem.writeInt(u64, frame[2..10], len, .big); + @memset(frame[10..], 'x'); + var reader: Io.Reader = .fixed(frame); + var client: WsClient = undefined; + client.r = &reader; + var out: std.ArrayList(u8) = .empty; + defer out.deinit(gpa); + try std.testing.expectEqual(Opcode.text, try client.readMessage(gpa, &out)); + try std.testing.expectEqual(len, out.items.len); + + // A header claiming more than the cap is refused before any allocation. + var oversized: [10]u8 = undefined; + oversized[0] = 0x81; + oversized[1] = 127; + std.mem.writeInt(u64, oversized[2..10], message_cap + 1, .big); + var over_reader: Io.Reader = .fixed(&oversized); + client.r = &over_reader; + try std.testing.expectError(error.MessageTooLong, client.readMessage(gpa, &out)); +} diff --git a/src/xai_hosted.zig b/src/xai_hosted.zig index d7897b91..d403f35f 100644 --- a/src/xai_hosted.zig +++ b/src/xai_hosted.zig @@ -74,6 +74,35 @@ pub fn isServerSideCall(itype: []const u8, name: []const u8) bool { std.mem.eql(u8, name, "x_thread_fetch"); } +/// True when these output items carry a search the provider ran itself. Those +/// calls never count as local tool calls, so a reply built on them would +/// otherwise look like a bare text answer. +pub fn ranServerSideSearch(items: []const Value) bool { + for (items) |item| { + if (item != .object) continue; + const itype = if (item.object.get("type")) |t| (if (t == .string) t.string else "") else ""; + const name = if (item.object.get("name")) |n| (if (n == .string) n.string else "") else ""; + if (isServerSideCall(itype, name)) return true; + } + return false; +} + +test "ranServerSideSearch finds hosted search items, not local calls or text" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + for ([_][]const u8{ + "[{\"type\":\"x_search_call\"},{\"type\":\"message\"}]", + "[{\"type\":\"web_search_call\"}]", + "[{\"type\":\"custom_tool_call\",\"name\":\"x_keyword_search\"}]", + }) |raw| { + const v = try std.json.parseFromSliceLeaky(Value, a, raw, .{}); + try std.testing.expect(ranServerSideSearch(v.array.items)); + } + const local = try std.json.parseFromSliceLeaky(Value, a, "[{\"type\":\"function_call\",\"name\":\"shell\"},{\"type\":\"message\"}]", .{}); + try std.testing.expect(!ranServerSideSearch(local.array.items)); +} + test "active only for xAI Responses while enabled" { const saved = enabled; defer enabled = saved;