diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 47a684b8..37ba72ce 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -14,7 +14,6 @@ "name": "claude-agent-ruby", "source": "./plugins/claude-agent-ruby", "description": "Build AI agents in Ruby using the claude-agent-sdk gem. Covers query() one-shot calls, Client sessions, hooks, MCP servers, streaming, structured output, sandbox settings, session management, and Rails integration.", - "version": "0.13.1", "author": { "name": "ya-luotao" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7f3c012..a2e8fe91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,20 +6,26 @@ on: pull_request: branches: [main] +# Every job has a time limit. This suite is built on subprocesses, pipes and +# a reactor, and the regressions it guards against tend to hang rather than +# fail; without a limit a stuck example would hold its job for GitHub's +# default of six hours and name nothing. A healthy job finishes in about a +# minute, so 15 leaves ample room for a cold bundle install. jobs: # Main suite + lint on every supported Ruby (Linux). The Ruby 3.4 Linux leg # also collects coverage (simplecov, COVERAGE=1): the total goes to the job # summary and the HTML report is uploaded as an artifact. No threshold gate. - # One macOS leg runs the suite on the newest Ruby: the transport depends on + # One macOS leg runs the suite on Ruby 3.4: the transport depends on # signals, pipes and TERM/KILL escalation, whose behavior differs per OS. test: name: test (${{ matrix.os }}, Ruby ${{ matrix.ruby-version }}) runs-on: ${{ matrix.os }} + timeout-minutes: 15 strategy: fail-fast: false matrix: os: [ubuntu-latest] - ruby-version: ['3.2', '3.3', '3.4'] + ruby-version: ['3.2', '3.3', '3.4', '4.0'] include: - os: ubuntu-latest ruby-version: '3.4' @@ -80,6 +86,7 @@ jobs: rbs-test: name: rbs runtime type check (Ruby 3.4) runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 @@ -94,19 +101,22 @@ jobs: run: bundle exec rake rbs:test # Dependency resolutions the main Gemfile can't reach: its RuboCop pin holds - # json at 2.x. `latest` forces the newest allowed majors (json 3.x, mcp 1.x); - # `floor` pins the oldest supported runtime dependencies, on the oldest and - # newest supported Ruby. rspec only — lint runs on the main leg. See - # gemfiles/*.gemfile. + # json at 2.x. `latest` forces the newest allowed majors (json 3.x, mcp 1.x), + # on Ruby 3.4 and on Ruby 4.0; `floor` pins the oldest supported runtime + # dependencies, on Ruby 3.2 and 3.4. rspec only — lint runs on the main leg. + # See gemfiles/*.gemfile. deps: name: deps (${{ matrix.gemfile }}, Ruby ${{ matrix.ruby-version }}) runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: include: - gemfile: latest ruby-version: '3.4' + - gemfile: latest + ruby-version: '4.0' - gemfile: floor ruby-version: '3.2' - gemfile: floor @@ -138,6 +148,7 @@ jobs: rails: name: rails (${{ matrix.gemfile }}, Ruby ${{ matrix.ruby-version }}) runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 7088c2f8..17cb6490 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -1,16 +1,24 @@ -# Real-CLI integration run: installs the CLI pinned by -# CLIInstaller::PINNED_CLI_VERSION and runs spec/integration against it with -# live, budget-capped API calls (each example sets max_budget_usd). +# Real-CLI checks. Each job installs the CLI pinned by +# CLIInstaller::PINNED_CLI_VERSION (rake claude_agent_sdk:install_cli) and +# drives it from the SDK: # -# Triggers: weekly, on demand, and on PRs that touch the installer (which is -# where cli-pin-bump.yml moves the pin), so a CLI bump is checked against the -# real binary before it merges. +# - keyless-smoke needs no secret and runs on every trigger: the initialize +# handshake, and a prompt the CLI cannot send for lack of credentials, +# which must come back as a typed ResultError rather than a hang +# (spec/integration/keyless_smoke_spec.rb). It checks the installer, the +# transport, the control protocol, the message parser and the error path +# against the real binary. It reaches no model. +# - real-cli runs spec/integration with live, budget-capped API calls (each +# example sets max_budget_usd). It needs an ANTHROPIC_API_KEY repository +# secret: the gate job checks for it, and without one real-cli shows as +# skipped, not as passed. Fork and Dependabot PRs never receive secrets, +# so for them it is always skipped. # -# Requires an ANTHROPIC_API_KEY repository secret. Without it the job skips -# with a notice instead of failing. Fork and Dependabot PRs never receive -# secrets, so they always take the skip path; so do pin-bump PRs opened with -# the default GITHUB_TOKEN, which don't trigger workflows at all (see -# cli-pin-bump.yml; dispatch this workflow on that branch instead). +# Triggers: weekly, on demand, and on PRs that touch the installer (which is +# where cli-pin-bump.yml moves the pin), the transport, Query or the message +# parser, so a CLI bump or a wire-protocol change meets the real binary +# before it merges. A pin-bump PR opened with the default GITHUB_TOKEN gets +# its runs only once someone approves them (see cli-pin-bump.yml). name: Integration (real CLI) on: @@ -23,6 +31,9 @@ on: branches: [main] paths: - lib/claude_agent_sdk/cli_installer.rb + - lib/claude_agent_sdk/subprocess_cli_transport.rb + - lib/claude_agent_sdk/query.rb + - lib/claude_agent_sdk/message_parser.rb - spec/integration/** - .github/workflows/integration.yml @@ -34,31 +45,76 @@ concurrency: cancel-in-progress: true jobs: - real-cli: - name: real CLI (pinned version, Ruby 3.4) + keyless-smoke: + name: keyless smoke (pinned CLI, no credentials, Ruby 3.4) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby 3.4 + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + + # As in real-cli below: the vendored binary, put on PATH. + - name: Install pinned Claude Code CLI + run: | + bundle exec rake claude_agent_sdk:install_cli + echo "$GITHUB_WORKSPACE/vendor/claude" >> "$GITHUB_PATH" + + - name: Show CLI version + run: claude --version + + - name: Run the keyless smoke + env: + RUN_KEYLESS_SMOKE: '1' + run: bundle exec rspec spec/integration/keyless_smoke_spec.rb --format json --out "$RUNNER_TEMP/keyless-smoke.json" + + # Without RUN_KEYLESS_SMOKE the spec filters its examples out, and rspec + # passes a run that executed none. Make sure this one ran them. + - name: Check that the smoke ran + run: >- + ruby -rjson -e 'summary = JSON.parse(File.read(ARGV[0])).fetch("summary"); + abort("the keyless smoke did not run its examples: #{summary}") unless + summary["example_count"] >= 2 && summary["pending_count"].zero? && summary["failure_count"].zero?' + "$RUNNER_TEMP/keyless-smoke.json" + + # secrets.* can't appear in an `if:`, so this job checks for the key and + # real-cli keys off its output. + gate: + name: ANTHROPIC_API_KEY available + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + run: ${{ steps.check.outputs.run }} steps: - # secrets.* can't appear in an `if:`, so a step checks for the key and - # the rest of the job keys off its output. - name: Check for ANTHROPIC_API_KEY - id: gate + id: check env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | if [ -z "$ANTHROPIC_API_KEY" ]; then - echo "::notice title=Integration run skipped::The ANTHROPIC_API_KEY secret is not available (not configured, or a fork/Dependabot PR). Add it as a repository secret to enable this run." + echo "::notice title=Keyed integration run skipped::The ANTHROPIC_API_KEY secret is not available (not configured, or a fork/Dependabot PR), so the real-cli job is skipped. Add it as a repository secret to enable that run; keyless-smoke runs either way." echo "run=false" >> "$GITHUB_OUTPUT" else echo "run=true" >> "$GITHUB_OUTPUT" fi + real-cli: + name: real CLI (pinned version, Ruby 3.4) + needs: gate + if: needs.gate.outputs.run == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: - uses: actions/checkout@v4 - if: steps.gate.outputs.run == 'true' - name: Set up Ruby 3.4 - if: steps.gate.outputs.run == 'true' uses: ruby/setup-ruby@v1 with: ruby-version: '3.4' @@ -69,17 +125,14 @@ jobs: # the CLI up on PATH and skip without it, so put it there and fail # loudly here if it doesn't run. - name: Install pinned Claude Code CLI - if: steps.gate.outputs.run == 'true' run: | bundle exec rake claude_agent_sdk:install_cli echo "$GITHUB_WORKSPACE/vendor/claude" >> "$GITHUB_PATH" - name: Show CLI version - if: steps.gate.outputs.run == 'true' run: claude --version - name: Run integration specs - if: steps.gate.outputs.run == 'true' env: RUN_INTEGRATION: '1' ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f4fe62..6717f904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`docs/options.md`**: a reference for every `ClaudeAgentOptions` attribute — its type, its default, and the CLI flag, `initialize` field or environment variable it becomes — plus what an option reads when it is left out, passed as `nil` or given a configured default, and the environment variables the SDK reads. It documents options that had no entry anywhere before (`extra_args`, `include_partial_messages`, `include_hook_events`, `task_budget`, `strict_mcp_config`) and the `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` ceiling from 1.1.0. A spec checks every row against the code. `docs/client.md` documents the `transport:` argument of `query` / `ask`. ([#155](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/155)) +- `Pathname` where a path goes: `settings:` (a settings file; used to send no `--settings` at all), `SystemPromptFile#path`, the `path` of a `{ type: 'file' }` system prompt Hash, and a plugin path, typed or Hash (these failed at connect with `no implicit conversion of Pathname into String`). `tools:` is signed `String` as well; the RBS signatures are widened accordingly. ([#160](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/160)) + ### Changed - `docs/cli-installer.md` now says when `CLIInstaller::PINNED_CLI_VERSION` moves: pin-only changes ship with the next gem release rather than one release per CLI bump. It also covers how a gem patch can move your CLI and how to take a newer CLI before a release pins it. The pin-bump workflow keeps a single `[Unreleased]` entry for the pin. - `CLIInstaller::PINNED_CLI_VERSION` moves from 2.1.285 to **2.1.287**, following the CLI the Python SDK bundles. `CLIInstaller.install_pinned` installs it. See the [Claude Code changelog](https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md). +- **The Rails guide, both Rails examples and the generated initializer turn off the CLI's per-project auto-memory** (`env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }`; commented out in the initializer). Every SDK session reads that memory and a `claude_code`-preset session writes it without a permission check, so in a multi-user app one user's "remember …" would reach every other user's session. If you copied those examples, add the line; the value must be `'1'`. ([#158](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/158)) +- **`docs/configuration.md` has a "Session Isolation" section.** Claude Code's per-project auto-memory is on in SDK sessions: every session loads the project's `MEMORY.md` index into its context (`setting_sources: []` does not prevent it), and a session on the `claude_code` preset also writes memory files, which the CLI allows without any permission setup. Servers and multi-tenant hosts should set `env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }` (or `settings: { autoMemoryEnabled: false }`); a custom transport has to pass either one through to its CLI itself; `Client#context_usage[:memoryFiles]` shows what a session loaded. ([#155](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/155)) +- **A requested sandbox that is not active now produces a warning.** `sandbox: { enabled: true }` does not guarantee a sandbox: when the CLI cannot start one (a Linux container without `bubblewrap` and `socat`, for example) it prints `⚠ Sandbox disabled: …` to its stderr and runs commands unsandboxed, which a host only saw through a `stderr:` callback. The SDK now repeats that line as a Ruby warning, prefixed `[claude-agent-sdk]`, once per session, when the `sandbox:` option enabled the sandbox. The default is unchanged; set `fail_if_unavailable: true` to make the CLI exit instead. ([#156](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/156)) +- **`OTelObserver` now leaves a span for a session that fails before its first `InitMessage`.** A CLI that could not be found or started, or an `initialize` that failed or timed out, raised to the caller but exported no span at all (0.18.0's "crashed sessions now produce OTel traces with error status" only held once an `InitMessage` had arrived). `on_error` now emits a `claude_agent.session` span with the observer's default attributes, the prompt if one had been sent, the exception event and error status, and ends it at once. The span has no model or `session.id`. Nothing changes once a trace has started. If you count `claude_agent.session` spans, failed starts are now among them. ([#148](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/148)) +- `docs/observability.md` no longer promises "standard `gen_ai.*` semantic conventions … any OTel backend". The attributes follow the Langfuse and OpenInference conventions plus a subset of OTel `gen_ai.*`; the page now lists every attribute, event and status `OTelObserver` sets, and how the `gen_ai.*` names differ from the OTel GenAI semantic conventions — notably that `gen_ai.usage.input_tokens` leaves cache tokens out, and that the inclusive count is `llm.token_count.prompt` on the session span. No attribute changed. ([#148](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/148)) +- **`docs/rails.md` no longer says callbacks inherit the request's state.** A callback runs on a thread or fiber Rails never set up: `Current` attributes, `Time.zone`, log tags, the error context and the `connected_to` role / shard read as their defaults there (a write under `connected_to(shard:)` lands in the default shard), with or without `Railtie.callback_wrapper`; `I18n.locale` depends on the i18n version. The guide now has the table, a per-call recipe that carries the state across, the rule for composing your own wrapper with the Rails one, a section on transactions and the connection pool (a callback is outside the caller's transaction and uses its own connection), and a note that in development an agent run holds the reload lock. ([#158](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/158)) +- `docs/configuration.md` says what the CLI does with an invalid sandbox value: it discards the whole `--settings` value, sandbox and `permissions` rules alike. The SDK does not surface this — `connect` succeeds and nothing appears on stderr; only the CLI's `get_settings` control response lists the error. That holds for every form, the typed classes included. ([#160](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/160)) +- Disk listings (`list_sessions`, `get_session_info`) scan each transcript's head and tail windows as bytes; with non-ASCII text in a window this takes a fraction of the CPU time it did. Results are unchanged. ([#157](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/157)) +- Development only: the gemspec's development dependency on Bundler is `>= 2.0` (was `~> 2.0`), so `bundle install` works in a checkout on Ruby 4.0, which ships Bundler 4. CI now runs the suite and RuboCop on Ruby 4.0, and a keyless smoke runs the pinned CLI on PRs that touch the installer, the transport, `Query` or the message parser; the API-key integration job shows as skipped instead of passed when the key is absent. Runtime dependencies are unchanged. ([#153](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/153)) + +### Fixed +- **A hook callback's Hash return value now means what the equivalent typed output means, whatever its key style.** Only five top-level keys were renamed before, and only as Symbols, so `{ hook_specific_output: { hook_event_name: 'PreToolUse', permission_decision: 'deny' } }` reached the CLI under keys it does not read: the decision was ignored and the tool ran. A String-keyed Hash (e.g. after `JSON.parse`) and a `SyncHookJSONOutput` holding a snake_case Hash failed the same way. The fields the typed output classes model are now accepted in snake_case or camelCase, as Symbols or Strings, at the top level and inside `hook_specific_output`; any other key is sent as written, and the keys inside `updated_input`, the tool outputs and a `PermissionRequest` `decision` are not renamed. **If a hook of yours returns snake_case keys, they now take effect.** When one Hash spells a field both ways the CLI's spelling is sent, and a Symbol and a String spelling the same key are written once (json 3.x used to fail the hook with `detected duplicate key`). ([#149](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/149)) +- **A `sandbox:` Hash written in Ruby spelling is now applied.** The typed sandbox classes take snake_case attributes and write the camelCase keys the CLI reads, but a Hash was forwarded as written: `sandbox: { enabled: true, network: { denied_domains: ['evil.example'] }, filesystem: { deny_read: ['~/.aws'] } }` reached the CLI under keys it does not know, and the CLI ignored them without an error — the session ran with `network: {}` and `filesystem: {}`, no deny rule. `SandboxSettings.new(network: { denied_domains: [...] })` lost its rules the same way. The fields of `SandboxSettings`, `SandboxNetworkConfig` and `SandboxFilesystemConfig` are now accepted in snake_case or camelCase, as Symbols or Strings, at the top level and inside `network` / `filesystem`; any other key is sent as written. **If a sandbox Hash of yours has snake_case keys, they now take effect.** A snake_case key is renamed only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, a port number from 0 to 65535); with any other value it is sent as written and stays without effect, as before — so the rename can never make the CLI discard your settings. When one Hash spells a field both ways the camelCase one is sent; a field holding `nil` is left out (the CLI rejects `null` there); a Symbol and a String spelling the same key are written once. A typed `SandboxNetworkConfig` / `SandboxFilesystemConfig` inside a `sandbox:` Hash is written as the Hash it stands for (it reached the CLI as its `#inspect` text, which made the CLI drop the sandbox). ([#160](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/160)) +- **"Always allow" answers from `can_use_tool` now stick for whole-tool rules.** `PermissionUpdate#to_h` wrote `"ruleContent": null` for a rule without content and omitted a nil `destination`; the CLI accepts neither and drops the **whole** `updatedPermissions` array when one entry is malformed, so `PermissionResultAllow.new(updated_permissions: context.suggestions)` silently persisted nothing for the suggestions the CLI builds for `WebSearch` or MCP tools, and the callback was asked again on every call. `ruleContent` is now omitted when nil, and an update that has a `type` but no `destination` is sent to `'session'` (this run only; no settings file is written). ([#149](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/149)) +- **`tools: 'Read,Grep'` is no longer ignored.** A String had no branch in the command builder, so no `--tools` flag was sent and the session got every built-in tool. It is now passed to `--tools` as written (the CLI's own comma-separated form). **If you pass `tools:` as a String, the session is now restricted to the tools it names.** ([#160](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/160)) +- **`system_prompt`, `tools`, `output_format` and `plugins` Hashes accept a Symbol `type`**, like `thinking` and the MCP server configs already did. `tools: { type: :preset, preset: :claude_code }` sent the Hash's JSON text as a tool name and the session started with **no tools**; `system_prompt: { type: :custom, prompt: '…' }` (and `:file`, `:preset`) sent no flag, so the prompt and its options were dropped; `output_format: { type: :json_schema, schema: {…} }` made the CLI exit; `plugins: [{ type: :local, … }]` raised `Unsupported plugin type`. An `output_format` Hash with `type` and `schema` under different key styles silently turned structured output off; `schema` is now found under either. A Symbol-typed Hash means what the String-typed one means, including its check: `{ type: :custom }` without a `:prompt` String raises the same `ArgumentError`. ([#160](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/160)) +- **Directory-scoped session APIs could read and modify another project's sessions.** For project paths over 200 characters, `list_sessions(directory: B)`, `get_session_messages` and `rename_session` / `tag_session` / `delete_session` / `fork_session` fell back to ANY project directory sharing the first 200 characters, so they could return or change the sessions of a sibling path A. In a directory found by that fallback, only sessions whose own transcript records the requested path as its top-level `cwd` are listed, read or modified (worktrees whose paths share such a directory are each listed). ([#157](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/157)) +- **A relative CLI path could run a different binary than the one the SDK checked.** The version probe runs in the process's working directory but the CLI is spawned in `cwd:`, so `cli_path: 'bin/claude'`, or a `claude` found through a relative `PATH` entry (`bin`, `.`, an empty entry), was checked in one directory and executed in the other — whatever file sat at that path inside the directory the agent was pointed at. The transport now settles one absolute path and uses it for both: a relative `cli_path` is resolved against the process's working directory (a leading `~` is expanded), and a bare name is looked up on `PATH` by the SDK, with relative `PATH` entries resolved against the process's working directory. Discovery no longer runs `which`: the SDK searches the process's `PATH` for `claude` itself, so a `which` found through a relative `PATH` entry can neither run nor redirect the lookup (a host without a `which` program now finds `claude` on `PATH`). If you relied on a relative `cli_path` resolving inside `cwd:`, pass an absolute path. ([#156](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/156)) +- **`#inspect` no longer prints credentials held outside `env` and `headers`.** `ClaudeAgentOptions#inspect` (and `#to_s`, `pp`, string interpolation) now filters `settings` and `extra_args` the way it filters `env`: a Hash keeps its keys with `"[FILTERED]"` values, and a String `settings` (JSON or a file path) prints as `"[FILTERED]"`. `mcp_servers` given as a String (JSON or a file path) prints as `"[FILTERED]"` too. `McpStdioServerConfig#args` is filtered. The `url` of `McpHttpServerConfig` / `McpSSEServerConfig` prints as scheme, host and port only (`"https://mcp.example.com/[FILTERED]"`), since a token can sit in its userinfo, path or query. Raw Hash server configs are filtered the same way (`type`, `command` and the redacted `url` are shown; `args`, `env`, `headers` and any other key print as `"[FILTERED]"`): inside `mcp_servers`, where they used to rely on the nesting limit, and in `McpServerStatus#config`, where the CLI echoes each server's `headers`, `url` and `args` back in `mcp_status`. Display only: the objects are not modified and nothing sent to the CLI changes. ([#151](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/151)) +- **A materialized resume directory preserved after a mirror failure now holds only `projects/`.** The cleanup removed four known files and left whatever else the CLI had written — including `backups/.claude.json.backup.`, a full copy of the seeded `.claude.json`, which can hold MCP header secrets. Every entry except `projects/` is now deleted. To do that safely the SDK first moves the directory into a fresh private one next to it (`claude-preserved-resume-*`), so **the preserved transcript is no longer at the temp dir's old path: the warning names the new one.** If what it moved is not the directory it created (the path had been replaced by a symlink or another directory), or it cannot be moved, nothing is deleted and the warning says the scrub was skipped and why; symlinks are never followed, permissions are repaired only on the directory that was checked, an entry that cannot be removed is named in the warning, and an interrupted scrub says which directory to remove once the transcript is imported. Calling `disconnect` again after a teardown that was interrupted while it preserved the directory no longer deletes it. ([#159](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/159)) +- **`ClaudeAgentSDK::Railtie.callback_wrapper` no longer reports to `Rails.error`.** In production it ran callbacks inside `executor.wrap`, which reports whatever passes through it as an unhandled error: a message-block exception was reported from the callback thread with an empty context (and then skipped by the request / job layer as already reported), exceptions the SDK handles itself (hook, `can_use_tool`, SDK MCP tool, observer) were reported although the run continued, and — on Rails 7.2.3, 8.0.2 and later — so were the SDK's own cancellations under `callback_scheduling: :inline`. The wrapper now enters the executor with `run!` / `complete!` (same hooks, no report), so an exception that escapes a callback is reported once, by the layer that called the SDK, with its controller / job context. If you relied on those reports to see hook or tool failures, report them inside the callback (`Rails.error.report`). ([#158](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/158)) +- **A callback that fails with `NotImplementedError`, `LoadError`, `SystemStackError` or `SecurityError` is answered like any other failing callback.** These are not `StandardError`s, so nothing rescued them when a hook, a `can_use_tool` callback, an SDK MCP tool, resource or prompt handler, or the `callback_wrapper` raised one: the CLI never got a response for that request, and the exception stopped the whole Async reactor, taking every other session and task on it down (`query()` raised the raw exception). Now hooks and `can_use_tool` get the error response, a tool handler's failure reaches the model as an `isError: true` result, and a resource or prompt handler gets the JSON-RPC `-32603` error, exactly as for a `RuntimeError`; the session carries on. `SdkMcpServer#call_tool` called directly returns the `isError` result for these exceptions instead of raising. `exit`, `Interrupt`, signals and cancellation behave as before, and `NoMemoryError` is still fatal. ([#150](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/150)) +- **A callback failure whose message is not valid UTF-8 is answered.** When a hook or `can_use_tool` callback raised an exception whose message held invalid bytes (a multibyte character cut by `byteslice`, binary subprocess or HTTP output), building the error response raised `JSON::GeneratorError` and the CLI was never answered. On the process-exit path the same error replaced the `SystemExit` the SDK promises to re-raise: under `:inline` scheduling `exit` did not end the process. An `exit` or signal exception whose message was in an encoding that is not ASCII-compatible (UTF-16) failed the same way, with `Encoding::CompatibilityError`. The error text is now normalized first — invalid bytes become U+FFFD, text in another valid encoding is transcoded — and a response that still cannot be serialized is replaced by a fixed message, so the request is always answered and the original exception is the one re-raised. ([#150](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/150)) +- **`query()` with a streamed (Enumerator) prompt no longer closes stdin right after writing a message.** With hooks, `can_use_tool` or SDK MCP servers, stdin could close before that message's turn had produced a frame, so the turn's hook, permission and SDK MCP requests failed with "Stream closed". It happened when the CLI's `idle` for the previous run and the next message became ready in the same reactor pass: `Query#end_run` stopped the between-turns ceiling task (which yields) before it marked the run ended, and the message joined the run that was about to end. The run is now marked ended first. ([#154](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/154)) +- **A `Client` control method called from a fiber on another thread's reactor no longer breaks the session.** `interrupt`, `set_model`, `set_permission_mode`, `mcp_status`, … called through `Sync { client.interrupt }` on a request thread while the session runs on a background reactor, or from a `Sync` block inside a `:thread`-mode callback: on async 2.10–2.28 the response raised `FiberError: fiber called across threads` in the session's read loop, which ended the session and left the caller waiting for the control-request timeout (20 minutes by default); on async ≥ 2.29 the response could be missed, with the same wait. Such callers now wait the way plain threads already did. ([#154](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/154)) +- **`receive_response` / `receive_messages` no longer hang after the CLI is gone.** After the CLI exited or crashed while a `Client` was connected, the first call raised the stream error (or returned the last turn), the second returned without messages, and every later call hung forever with no error and no timeout (from plain Ruby after the reactor had finished, async 2.10 raised `NoMethodError` instead). Every call after the end of the stream now returns at once; the stream error is still raised once. ([#154](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/154)) +- **A deadline around a `Client` call is no longer swallowed while an observer runs.** With observers configured, a deadline around `Client#receive_response`, `#receive_messages`, `#query` or `#disconnect` (`task.with_timeout`, or `Timeout.timeout` inside a reactor) was silently dropped when it expired while an observer callback was still running: nothing was raised and the turn carried on, calling the message block after the deadline. Only what the observer (or its `callback_wrapper`) raises is contained now. With `callback_scheduling: :inline` the observer runs on the caller's own fiber, where such a deadline cannot be told from the observer's own failure, and it is still contained. ([#154](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/154)) +- **`Client#disconnect`, and the cleanup of a failing `Client#connect`, always tear the session down.** When the call was interrupted while an observer's `on_close` / `on_error` was running — the caller's task stopped (`Async::Stop`), or an inline hook's cooperative timeout fired — the CLI process, the session's read loop and the materialized resume directory (with its credentials copy) were left behind. The teardown now always runs, and the interruption propagates after it. ([#154](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/154)) +- **A `Client#disconnect` from another thread completes the CLI's graceful shutdown.** From an application thread, or from a tool handler, hook or `can_use_tool` callback under the default `callback_scheduling: :thread`, the close was handed to a transient task on the session's reactor; when the session had nothing else left to do the reactor wound down and stopped that task mid-teardown, so the CLI got SIGTERM instead of stdin EOF and its 5-second grace period (the time it has to finish its last session write), and `disconnect` returned before the child was reaped. The close now runs on a task the reactor waits for: such a `disconnect` takes as long as the teardown, like one made on the reactor. ([#161](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/161)) +- **A callback's thread hop no longer returns before the callback has finished.** When an exception (`Async::Stop`, a timeout) had been raised into the waiting fiber just as the previous hop's thread ended, the stale wakeup resumed the next hop, which returned `nil` as its result; with a `timeout:` (session-store adapter calls) the same wakeup was reported as `timed out` although no time had passed. `FiberBoundary.invoke` now waits until the thread has really finished or the deadline has really passed. ([#154](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/154)) +- An error raised by `ClaudeAgentSDK.query` / `.ask` from synchronous code (CLI not found, `ProcessError`, `ResultError`, an exception in your own block) was also logged by Async as `Task may have ended with unhandled exception.`, with the message and a backtrace, even when your code rescued it. The error is now only raised. Inside a reactor the same applied to failures before the first suspension, such as a transport that cannot connect. ([#154](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/154)) +- **A `stderr:` callback that raised `NotImplementedError`, `LoadError` or `SystemStackError` could hang the session.** Only `StandardError` was contained, so those ended the thread that reads the CLI's stderr; once the CLI had written another 64 KiB of stderr it blocked for good. They are now handled like any other callback error: dropped, and the callback is called again for the next line. ([#156](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/156)) +- **Store-backed resume now authenticates on macOS with a custom `CLAUDE_CONFIG_DIR`.** There the CLI keeps its OAuth credentials in the Keychain under `Claude Code-credentials-` and writes no `.credentials.json`; the SDK only bridged the default config dir's entry, so the resumed subprocess reported "Not logged in" (and the failed attempt was mirrored into the store). The Keychain is now consulted for a custom config dir too — only when that directory has no `.credentials.json`, and never under `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN`. The seeded copy still has the refresh token removed and mode `0600`. ([#159](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/159)) +- **A mirror flush cancelled while it waited behind an in-flight append no longer loses its batch.** The batcher detached the pending entries before queueing on its lock, so a `#flush` whose task was stopped there (a timeout around the caller, `Task#stop`) dropped them: they never reached the store and no `MirrorErrorMessage` was produced. The buffer is now detached inside the lock, so those entries go out with the next flush; a frame that arrives while the final flush is still waiting is delivered rather than counted as dropped (Python [#1289](https://github.com/anthropics/claude-agent-sdk-python/pull/1289)). The mirror's retry backoff uses `Kernel#sleep` instead of the deprecated `Async::Task#sleep`. ([#159](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/159)) +- **`get_session_messages` and `get_subagent_messages` (disk and `session_store:`) no longer drop the results of parallel tool calls.** The CLI parents each `tool_result` on the entry holding its own `tool_use`, so with several tool calls in one assistant turn only the last-written result lay on the path the readers walk; the others were silently missing, leaving `tool_use` blocks without a `tool_result`. They are now returned, after the turn's `tool_use` messages and before the result the conversation continued from; the chain's own result wins, so a call answered again after a rewind is not returned twice. Affected sessions return more messages than before, so `offset:`/`limit:` pages over them shift. ([#157](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/157)) +- **Session reads, listings and mutations — other fixes** ([#157](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/157)): + - `get_session_messages` (disk and store) no longer returns `[]` for a session whose last entry is an unanswered meta injection (a slash-command or skill body, a stop-hook message, a system reminder written just before the session was closed). + - The message readers no longer raise `Encoding::CompatibilityError` under a non-UTF-8 locale (`LANG=C` / `LC_ALL=C`), nor on a transcript whose last line was cut inside a multibyte character or that holds a raw non-UTF-8 byte (a torn line is skipped; invalid bytes come back as U+FFFD); `import_session_to_store` no longer stops half-way on such a line. Under `LC_ALL=C` a non-ASCII `CLAUDE_CONFIG_DIR`, `directory: Dir.pwd` and a non-ASCII worktree path no longer raise or hide every worktree. + - `list_sessions(directory:)` no longer leaves out the directory's own sessions when it is a subdirectory of a repository with several git worktrees; worktree paths git reports in decomposed Unicode form are NFC-normalized. + - Project paths containing a character outside the BMP (an emoji, a CJK Extension B ideograph) map to the project directory the CLI creates (two hyphens per such character). **`project_key_for_directory` changes for affected paths**: store entries written under the old key by `import_session_to_store` or the store mutations no longer match (the transcript mirror never used that key). + - `rename_session`, `tag_session`, `fork_session` and `delete_session` no longer raise `Errno::ENOENT` when `directory:` names a removed project directory (a deleted worktree); a missing path is resolved as far as it exists, following symlinks whose target is gone and applying a `..` after a link to the link's target, as `os.path.realpath` does. + - `list_sessions` no longer returns `[]` when the config directory path contains `[` or `{`. + - Disk listings find a first prompt beyond the first 64 KiB of the transcript (a bounded scan of up to 1 MiB) and take `created_at` only from a top-level `timestamp`, as the store path does; `created_at` is no longer 1 ms low for about one ISO timestamp in eight. `docs/sessions.md` lists what the disk path still does not see. + - `fork_session` without `title:` names the fork after the title the listing shows for the source, on disk and with `session_store:` alike. + - `get_session_info(session_store:)` reports the adapter's `mtime` as `last_modified`, as `list_sessions(session_store:)` does; the store subagent readers skip non-String `list_subkeys` values; `rename_session` / `tag_session` raise `ArgumentError` for a title or tag that is not a usable String; `list_subagents` on disk returns each id once. + - An optional `SessionStore` method that raises `NotImplementedError` when called (a stub behind a delegating wrapper, an adapter declining at run time) is treated as not implemented — the documented fallback is taken and `run_session_store_conformance` skips its contracts — while a required `load` that raises still fails the resume. + - `docs/sessions.md`: rename / tag / fork are safe while the session's CLI runs; delete only sessions whose CLI has exited. +- **`CLIInstaller.install` no longer deadlocks when two fibers of one `Async` reactor install into the same directory.** The second fiber blocked the reactor thread in `flock` while the first, which held the lock, was waiting on the network inside the critical section, so neither returned; with the default `version: 'stable'` this happened even when nothing needed installing. A waiting installer now polls the lock (every 50 ms) instead of blocking its thread. Threads and separate processes were never affected, and mutual exclusion is unchanged. ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- **`CLIInstaller.install` / `install_pinned` no longer fail in a read-only install directory when the requested version is already installed.** In an image built as root and run as another user, or on a read-only root filesystem, a boot-time `install_pinned` raised `CLIInstallError` (`EACCES`) because the lock file could not be opened, although the binary was intact. When the lock file cannot be opened (`EACCES`, `EROFS`, `EPERM`), a concrete version that is installed and matches its recorded checksum and platform is now returned without the lock. A dist-tag (`'stable'` / `'latest'`), a different version or a damaged binary still raises there. ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- **`CLIInstaller` honors `HTTPS_PROXY` / `https_proxy`.** `Net::HTTP` only looks at `http_proxy` unless it is told otherwise, even for a TLS connection, so an environment that exported only `HTTPS_PROXY` was bypassed, and where direct egress is blocked the install timed out with no mention of the proxy. The proxy for each request (redirect hops included) is now resolved from the `https` URL and passed explicitly, with its credentials; `NO_PROXY` / `no_proxy` apply as before, and an environment that only sets `http_proxy` keeps working. `ALL_PROXY` is not read, and only `http://` proxy URLs are used. `docs/cli-installer.md` has a new "Proxies and custom CAs" section (`HTTPS_PROXY`, `NO_PROXY`, `SSL_CERT_FILE`). ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- **`CLIInstaller` fsyncs what it publishes.** The downloaded binary and `VERSION` are flushed to disk before they are renamed into place, and the directory after it (best-effort). Previously a power loss shortly after an install could leave an empty or partial executable `claude` under the published name, which discovery kept selecting until the next `install`. ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- `CLIInstaller.install` with no `dir:` names the default directory (`vendor/claude`) in its error when the process's working directory no longer exists, instead of an empty path. `docs/cli-installer.md` states the locked sequence in the order the code uses (record `VERSION`, then place the binary), and that a failed first install never publishes a binary — it can leave the directory, its lock file and, after a failed final rename, a recorded `VERSION`. ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- **Shorthand tool schemas with `Array` / `Hash` parameters are advertised correctly.** `{ order_id: Integer, tags: Array }` told the model `tags` was a string, so it sent `"fragile,gift"` and the handler got a String; a model that did send an array had the call rejected. `Array` / `:array` now map to `array` and `Hash` / `:object` to `object`, in `tools/list` and in `tools/call` argument validation alike. Other shorthand values are unchanged. `docs/mcp-servers.md` lists the shorthand types. ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- **A tool handler that returns a non-Array `:content` gets a clear error.** `{ content: 'text' }`, a single block Hash, or a present `false` or `nil`, was forwarded unchanged; the CLI rejected it and told the model the server "returned a malformed result", so the text never arrived. It is now an in-band `isError` result naming the tool: `Tool 'x' must return :content as an Array of content blocks (got String)`. A result without a `:content` key keeps the existing message. Return the String itself, or `content: [{ type: 'text', text: ... }]`. ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- **`create_tool(annotations: { maxResultSizeChars: … }, meta: { … })` keeps the size hint.** An explicit `meta:` replaced the `_meta['anthropic/maxResultSizeChars']` entry derived from the annotation, so large results were truncated to a preview again. The two are now merged; a size key set in `meta:` wins. ([#152](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/152)) +- **`OTelObserver` no longer counts an API response's token usage once per content block.** The CLI sends one `AssistantMessage` per content block, and each repeats the response's `message_id` and `usage`. The observer put that usage on every `claude_agent.generation` span, so a response with a thinking block and a tool call reported its input, cache-read and cache-creation tokens twice (N times for N blocks). The `gen_ai.usage.*` token attributes are now set on the first generation span of each `message_id` only. Span count, names and parentage are unchanged, and the session span was already correct. **Sums of `gen_ai.usage.*` over generation spans drop by the duplication factor.** `gen_ai.usage.output_tokens` on a generation span is still the snapshot taken when the response started; `docs/observability.md` now says so, and that the session span carries the authoritative totals. ([#148](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/148)) +- `examples/advanced_hooks_example.rb` and `examples/lifecycle_hooks_example.rb` returned a `*HookSpecificOutput#to_h` as the whole hook output, which leaves `permissionDecision`, `additionalContext`, `retry` and `watchPaths` at the top level where the CLI does not read them — the example's "deny" did not deny. They now wrap the typed output in `SyncHookJSONOutput`. If you copied that pattern, wrap yours the same way (or return `{ hook_specific_output: … }`). ([#149](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/149)) +- `agents:` accepts a Hash for an agent, as its signature says: `agents: { reviewer: { description: '…', prompt: '…' } }` used to raise `NoMethodError` at connect. The Hash is read like `AgentDefinition.new(hash)`, so a misspelled key raises the same `ArgumentError`. ([#149](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/149)) +- `allowed_tools`, `disallowed_tools`, `add_dirs`, `extra_args`, `env` and `load_timeout_ms` can be set back to `nil` after construction (`options.dup_with(allowed_tools: nil)`, `options.extra_args = nil`), as their signatures say. The command builder, the transport and store-backed resume used to raise an anonymous `NoMethodError` at connect; they now read `nil` as the default. ([#149](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/149), [#156](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/156), [#159](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/159)) +- The `sandbox:` option now replaces a `sandbox` section already present in `settings:` instead of being added beside it. Settings given as a JSON String, a file path or a String-keyed Hash produced a `--settings` value with the key twice: under json 3.x connect raised a raw `JSON::GeneratorError: detected duplicate key "sandbox"`, which made the documented `sandbox: false` override unusable; under json 2.x it only worked as long as the CLI kept the last duplicate. ([#149](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/149)) +- Hooks registered for one event under both a String and a Symbol key (`{ 'PreToolUse' => [a], PreToolUse: [b] }`) are all registered, in the order written. The second key used to replace the first silently, so `a` never ran. ([#149](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/149)) +- **`#[]` returns `nil` for a writer's name instead of raising.** `msg[:session_id=]`, `msg['sessionId=']` and `options[:env=]` called the setter without an argument (`ArgumentError: wrong number of arguments`); they now read as `nil`, like any other name that is not an attribute. ([#151](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/151)) +- **`ClaudeAgentOptions` reports `'[]'` as an unknown option.** `ClaudeAgentOptions.new('[]' => 1)` raised `wrong number of arguments (given 1, expected 2)`; it now raises the usual `unknown ClaudeAgentOptions option: "[]"`. The keys `'='`, `'!'` and `'=='` used to resolve to `#==`, `#!=` and `#===` and were dropped without an error; they now raise the same `ArgumentError`. Setters you define on your own `ClaudeAgentOptions` subclass are still accepted. ([#151](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/151)) +- **`Type#inspect` no longer raises on a value it cannot render.** A `BasicObject` attribute raised `NoMethodError` (`nil?`), and a String, Hash or Array subclass whose `#length` / `#size` raises took `#inspect` down with it — inside a logger or `puts`. Such a value now shows as `#` (`#` for a `BasicObject`) and the rest of the object still prints. `Interrupt`, `SystemExit` and other non-`StandardError` exceptions are not rescued. ([#151](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/151)) +- **`cli_path:` given as a `Pathname` failed at connect.** The signature has always allowed it, but the spawn raised `no implicit conversion of Pathname into String`. The transport now converts it once, before the version check and the spawn. ([#156](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/156)) +- No more `Async::Task#sleep is deprecated` warnings under `ruby -w` from the transport: while it waited for the CLI to exit, or for the version check, it printed one every 50 ms. It now uses `Kernel#sleep`, which parks only the calling fiber on a reactor. ([#156](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/156)) +- The Postgres reference adapter (`examples/session_stores/`) stores entries in a `json` column instead of `jsonb`, which rejects the JSON escape for U+0000: one transcript entry with a NUL character (binary tool output) failed the whole `INSERT` and the mirror dropped that batch. An existing table is not changed by `create_schema`; the README gives the migration (`ALTER TABLE … ALTER COLUMN entry TYPE json USING entry::json`). ([#159](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/159)) +- **`.yardopts` is packaged in the gem.** rubydoc.info builds the API reference from the gem's own files; without it YARD ran with its defaults, so the reference listed every `@api private` internal (`Query`, `FiberBoundary`, `MessageParser`, …) next to the public API and did not render `CHANGELOG.md` or `UPGRADING-1.0.md`. Takes effect on rubydoc.info from this release on. ([#155](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/155)) +- **Documentation that contradicted the code** ([#155](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/155)): + - `docs/types.md` lists every message and content-block class the SDK can yield (`ToolProgressMessage`, `AuthStatusMessage`, `ToolUseSummaryMessage`, `PromptSuggestionMessage`, `ServerToolUseBlock` and `ServerToolResultBlock` were missing), with a table of the system and progress messages and what `#data` holds; the `SandboxIgnoreViolations` row (removed in 0.12.0) is gone. `docs/errors.md` documents `CLIInstallError`, and that a control request the CLI rejects raises a plain `StandardError`, not a `ClaudeSDKError`. + - Hooks, custom tools and permission callbacks do not require `Client` (`docs/client.md`, the README and the bundled skill said they did); `query()` and `ask` have run all three for many releases. + - The custom-transport contract is stated once: `connect`, `write`, `read_messages`, `end_input` and `close` are required, `ready?` is optional. `docs/client.md` listed six methods and the bundled skill four (a transport written from the skill's list made `query()` hang). The YARD of `ClaudeAgentSDK::Transport` no longer calls it an internal API. + - README: links are absolute GitHub URLs (relative ones were 404 on rubydoc.info, and the banner was missing from the installed gem); the CLI install snippet uses `CLIInstaller.install_pinned` instead of the literal `2.1.220`; a new Authentication section says how the CLI authenticates and what a missing login looks like; the comparison table's Python column is corrected. + - The bundled skill's configure examples no longer put `ENV.fetch('ANTHROPIC_API_KEY')` into the default options (the CLI inherits the environment anyway, and the line made every boot without the key raise `KeyError`); the skill and `docs/options.md` say that a `CLAUDE_CLI_PATH` naming a missing file is skipped and discovery continues. + - Four clarifications, each checked against Claude Code 2.1.287: `sandbox: { enabled: true }` does not make the sandbox a requirement (set `fail_if_unavailable: true`; otherwise the CLI warns on stderr and runs unsandboxed); `setting_sources: []` does not stop the MCP servers of the logged-in claude.ai account (`strict_mcp_config: true` does); `Client#query(prompt, session_id:)` does not start a separate conversation; an explicit `tools:` list must include `'Skill'` for `skills:` to work. Sandbox settings travel inside `--settings`, not a `--sandbox` flag. + - The `Client` examples in the guides and the `Client` YARD use `Client.open` (the `connect` … `disconnect` shape they showed leaves the session open when the block raises). + - `.claude-plugin/marketplace.json` no longer declares a version for the plugin (it said `0.13.1`); `SECURITY.md` states the supported versions for 1.x; `examples/message_types_example.rb` handles all 28 typed message classes. + - In the pages other PRs of this batch own: the documented Gemfile lines for the OpenTelemetry setup list the `base64` gem, which is no longer a default gem on Ruby 3.4 (`docs/observability.md`, [#148](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/148)); `docs/cli-installer.md` says an unusable `CLAUDE_CLI_PATH` is skipped without a warning and discovery continues, instead of "no discovery at all" ([#156](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/156)); `docs/rails.md` says hooks and custom tools work with `query` too ([#158](https://github.com/ya-luotao/claude-agent-sdk-ruby/pull/158)). ## [1.1.0] - 2026-09-30 diff --git a/CLAUDE.md b/CLAUDE.md index 0b433ffd..dcac12f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,9 @@ User code ### CLI Discovery / Installer -`SubprocessCLITransport#find_cli` probes, in order: `CLAUDE_CLI_PATH` (absolutized against the process cwd before use — the CLI is later spawned with `chdir: options.cwd`, where a relative path would name a different file) → `CLIInstaller.installed_path` (project-local vendored binary, deliberately ahead of `PATH` so a pinned install is hermetic) → `which claude` → common install locations. +`SubprocessCLITransport#find_cli` probes, in order: `CLAUDE_CLI_PATH` (absolutized against the process cwd before use — the CLI is later spawned with `chdir: options.cwd`, where a relative path would name a different file) → `CLIInstaller.installed_path` (project-local vendored binary, deliberately ahead of `PATH` so a pinned install is hermetic) → `claude` on the process `PATH`, searched by the SDK itself (`#executable_on_path`; no `which` — it would be found through `PATH` too, relative entries included, and its answer would have to be believed) → common install locations. + +**One absolute path, settled in `#initialize` and again at the top of `#connect`** (`#settle_cli_path`, from `@given_cli_path` — the lookup happens when spawn used to do it): the version probe runs in the process cwd and the spawn in `options.cwd`, so both must be handed the same absolute path. The input is `options.cli_path || find_cli` (any falsy value means discovery), `.to_s` (it may be a `Pathname`). A path with a separator is anchored to the process cwd — an absolute one is kept as given, a leading `~` becomes the home directory — and never normalized: `link/..` must resolve through the filesystem, so no `File.expand_path` (it collapses `..` lexically, which names another file when `link` is a symlink; `CLAUDE_CLI_PATH`'s branch still uses it). A bare name is searched by the SDK itself (`#executable_on_path`: an executable regular file; relative entries, `.` and the empty entry are anchored to the process cwd, not skipped; a `~` or `~/x` entry is the home directory, as in Ruby's own command lookup, so deliberately not one of them — `#path_entry_dir`). Each lookup uses the `PATH` of the mechanism it replaced: discovery the process's (what `which claude` saw), an explicit bare `cli_path` the one the child will get (`#spawn_search_path`: `options.env`'s, else the process's, else Ruby's built-in default — what spawn searched). A path that cannot be settled is kept as given and the SDK never executes it: `#connect` skips the probe and, when that path is the program of the argv, raises `CLINotFoundError` through the same `Errno::ENOENT` mapping as a missing file (the argv of a subclass whose `#build_command` wraps the CLI in another program is spawned as built). `CLIInstaller` (`lib/claude_agent_sdk/cli_installer.rb`) downloads a pinned CLI into `vendor/claude` (relative to `CLIInstaller.root` when set — the Railtie sets it to `Rails.root` — else to `Dir.pwd`, resolved at call time; `root` is a plain frozen-String ivar, absolutized on set, read lock-free) for Docker/CI deploys: dist-tag → concrete version, manifest SHA-256 + declared size, streamed download (bounded by that size) to a random-named sibling temp file (`O_EXCL`), verify, `chmod 0755`, atomically write `VERSION` = version + verified checksum, and **finally** rename the temp file into place. Stdlib only; every failure (filesystem errors included) is wrapped in `CLIInstallError` with `cause` preserved. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53144e54..4aa5d3b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,11 +47,11 @@ bundle exec rake claude_agent_sdk:install_cli # optional: the pinned CLI, into PATH="$PWD/vendor/claude:$PATH" RUN_INTEGRATION=1 ANTHROPIC_API_KEY=... bundle exec rspec spec/integration ``` -Run them when you change anything on the CLI wire protocol (the transport, `Query`, the control protocol, `CommandBuilder`). CI also runs them weekly, and on PRs that touch `cli_installer.rb`, against the pinned CLI version. +Run them when you change anything on the CLI wire protocol (the transport, `Query`, the control protocol, `CommandBuilder`). CI also runs them weekly, and on PRs that touch the installer, the transport, `Query` or the message parser, against the pinned CLI version, when the repository has an `ANTHROPIC_API_KEY` secret; without it that job shows as skipped. On the same triggers CI runs a keyless smoke that needs no secret (`spec/integration/keyless_smoke_spec.rb`): it installs the pinned CLI, completes the `initialize` handshake and checks that a prompt without credentials fails with a `ResultError`. **TLA+ models** (`formal/tla/`) model-check the CLI installer's publish/lock ordering and the outbound control-request protocol. They need Java 11+; `run.sh` fetches the pinned TLA+ tools itself. Run `formal/tla/run.sh` when you change `CLIInstaller.install` / `#publish` or `Query#send_control_request` / `#await_control_response`, and update the model if the design changed. `formal/tla/README.md` explains what each model covers. -CI runs the unit suite and RuboCop on Ruby 3.2, 3.3 and 3.4 (Linux), the unit suite on macOS, the dependency floor and latest legs, and the Rails specs on Rails 7.1 and 8. The Ruby 3.4 Linux leg also runs `rake rbs:validate`, and a separate job runs `rake rbs:test`. See [`.github/workflows/`](.github/workflows/). +CI runs the unit suite and RuboCop on Ruby 3.2, 3.3, 3.4 and 4.0 (Linux), the unit suite on macOS, the dependency floor and latest legs (the latest one on Ruby 3.4 and on Ruby 4.0), and the Rails specs on Rails 7.1 and 8. The Ruby 3.4 Linux leg also runs `rake rbs:validate`, and a separate job runs `rake rbs:test`. See [`.github/workflows/`](.github/workflows/). ## Pull requests @@ -88,6 +88,7 @@ The gem ships [RBS](https://github.com/ruby/rbs) signatures for exactly that pub - **Duck types are interfaces.** Transports (`_Transport`), session stores (`_SessionStore`, with the optional methods listed in its comment), and every user callback (`_CanUseTool`, `_HookCallback`, `_ToolHandler`, `_CallbackWrapper`, ...) are interfaces, so any object with the right methods fits, including a `Method` or a custom class. A proc type (`^(...) -> ...`) would accept only a `Proc`. - **Follow the Hash-key rule** in [docs/types.md](docs/types.md#hash-keys): use `wire_hash` (`Hash[Symbol, untyped]`) for a Hash passed through from the CLI stream and `transcript_hash` (`Hash[String, untyped]`) for transcript and store data. - **Type attributes are nilable.** A `Type` can be built empty, and the SDK parses CLI output leniently, so an attribute reads `nil` whenever the CLI did not send it. +- **Constructor keywords are documented, not checked.** `ClaudeAgentOptions` and the value types sign their constructor with two overloads: `(?name: T, ...) -> void | (Hash[Symbol | String, untyped]? attributes) -> void`. The keyword overload documents the accepted keys and their types, but no type checker enforces it: `Klass.new(typo: 1)` is also a Symbol-keyed Hash, which the second overload accepts whatever its keys and values. Steep reports neither a misspelled keyword nor a wrong value type, and `rbs:test` never reports a constructor mismatch (it only checks the attribute setters the constructor calls). The enforcement is the `ArgumentError` the constructor raises at runtime for an unknown key. Keep the keyword overload in step with the attributes all the same: it is what a reader and an editor's completion see. - **Use `void` for a return value the docs don't promise.** It keeps the return value out of the contract, and the runtime checker skips it. - **Deprecated methods keep their signature** until they are removed, marked with a `# @deprecated` comment. diff --git a/README.md b/README.md index 7307cdb0..422eb5f7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Claude Agent SDK for Ruby — a ruby connecting code to terminal, tools, and messages](assets/readme-banner.webp) +![Claude Agent SDK for Ruby — a ruby connecting code to terminal, tools, and messages](https://raw.githubusercontent.com/ya-luotao/claude-agent-sdk-ruby/main/assets/readme-banner.webp) # Claude Agent SDK for Ruby @@ -6,17 +6,17 @@ [![CI](https://github.com/ya-luotao/claude-agent-sdk-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/ya-luotao/claude-agent-sdk-ruby/actions/workflows/ci.yml) [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D)](https://www.ruby-lang.org/) [![Docs](https://img.shields.io/badge/docs-rubydoc.info-blue)](https://rubydoc.info/gems/claude-agent-sdk) -[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![License: MIT](https://img.shields.io/badge/license-MIT-green)](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/LICENSE) A Ruby SDK for the [Claude Code](https://docs.claude.com/en/docs/claude-code-overview) agent runtime, built for running agents in production Ruby and Rails apps. It has the same capabilities as the official [TypeScript](https://github.com/anthropics/claude-agent-sdk-typescript) and [Python](https://github.com/anthropics/claude-agent-sdk-python) SDKs, plus what a Rails deploy needs around them: a generator and CLI-vendoring rake task, callbacks that are safe to touch ActiveRecord from, a pinned CLI binary, built-in OpenTelemetry tracing, and transcript mirroring to your own storage. -> **Unofficial and community-maintained.** This project is not affiliated with or supported by Anthropic. It tracks the official SDKs release by release; see the [CHANGELOG](CHANGELOG.md) for the currently synced version. +> **Unofficial and community-maintained.** This project is not affiliated with or supported by Anthropic. It tracks the official SDKs release by release; see the [CHANGELOG](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CHANGELOG.md) for the currently synced version. -> **Upgrading from 0.x?** 1.0 raises on unknown keys, limits `#[]` to attributes and adds `SessionStoreError`. [UPGRADING-1.0.md](UPGRADING-1.0.md) has the checklist. +> **Upgrading from 0.x?** 1.0 raises on unknown keys, limits `#[]` to attributes and adds `SessionStoreError`. [UPGRADING-1.0.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/UPGRADING-1.0.md) has the checklist. ## Highlights -- **Rails integration.** `bin/rails generate claude_agent_sdk:install` writes the initializer and `bin/rails claude_agent_sdk:install_cli` vendors the CLI; [docs/rails.md](docs/rails.md) covers jobs, ActionCable streaming, session resumption, and solid_queue fiber workers (`callback_scheduling: :inline`). +- **Rails integration.** `bin/rails generate claude_agent_sdk:install` writes the initializer and `bin/rails claude_agent_sdk:install_cli` vendors the CLI; [docs/rails.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/rails.md) covers jobs, ActionCable streaming, session resumption, and solid_queue fiber workers (`callback_scheduling: :inline`). - **Callbacks that are safe around ActiveRecord.** Tool handlers, hooks, permission callbacks, and message blocks run on a plain thread by default, outside the SDK's fiber scheduler, so thread-keyed libraries (ActiveRecord, `pg`, per-thread caches) behave as they do everywhere else in your app. `ClaudeAgentSDK::Railtie.callback_wrapper` runs them in the Rails executor so connections go back to the pool, without deadlocking development code reloading. - **Hermetic deploys.** `CLIInstaller` vendors a checksum-verified CLI binary, pinned to the version each gem release is tested with, so production never depends on a global `npm install`. - **Built-in OpenTelemetry observer** with Langfuse support; no third-party instrumentation library required. @@ -39,14 +39,25 @@ Then `bundle install`, or install directly with `gem install claude-agent-sdk`. **Prerequisites** - Ruby 3.2 or newer +- Credentials for Claude Code: `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, or a login the CLI has already stored (see [Authentication](#authentication)) - Claude Code CLI 2.0.0 or newer, either installed globally (`npm install -g @anthropic-ai/claude-code`) or vendored with `CLIInstaller`: ```ruby -# bin/setup or a cached Docker layer — pin a concrete version in production -ClaudeAgentSDK::CLIInstaller.install(version: '2.1.220') # => "/app/vendor/claude/claude" +# bin/setup or a cached Docker layer: the CLI version this gem release was tested with +ClaudeAgentSDK::CLIInstaller.install_pinned # => "/app/vendor/claude/claude" ``` -The vendored binary is found ahead of `PATH`, installs are idempotent and concurrency-safe, and a failed upgrade never breaks a working install. See [docs/cli-installer.md](docs/cli-installer.md) for the full behaviour, supported platforms, and the CLI discovery order. +`install_pinned` installs `CLIInstaller::PINNED_CLI_VERSION`, so upgrading the gem carries the CLI forward with it; `CLIInstaller.install(version: 'x.y.z')` pins a version of your own. The vendored binary is found ahead of `PATH`, installs are idempotent and concurrency-safe, and a failed upgrade never breaks a working install. See [docs/cli-installer.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/cli-installer.md) for the full behaviour, supported platforms, and the CLI discovery order. + +### Authentication + +The SDK holds no credentials of its own. The `claude` process it starts authenticates the way Claude Code does, with one of: + +- `ANTHROPIC_API_KEY`, in the environment of your Ruby process (the CLI inherits it) or per session with `ClaudeAgentOptions.new(env: { 'ANTHROPIC_API_KEY' => key })` +- `CLAUDE_CODE_OAUTH_TOKEN`, a long-lived token for a Claude subscription (`claude setup-token` creates one), set the same way +- a login the CLI has already stored for the user your process runs as (`claude auth login`) + +A machine with none of them, such as a fresh container or a CI runner, does not fail at startup. The first prompt comes back as an `AssistantMessage` whose `error` is `'authentication_failed'` (its text is "Not logged in · Please run /login", a command an SDK host cannot run), followed by a `ResultMessage` with `is_error` set. `query()` and `ask` then raise `ResultError` with `terminal_reason == 'api_error'`; a `Client` session stays open, so check the result's `is_error` there. A key or token the API rejects ends the same way, with `api_error_status` 401, but only after the CLI has retried: watch for `APIRetryMessage` (ten of them over about three minutes when tested) rather than waiting for the error. [docs/errors.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/errors.md) shows how to handle both. ### Rails in a minute @@ -70,7 +81,7 @@ class SummarizeTicketJob < ApplicationJob end ``` -The block runs on a plain thread, so ActiveRecord calls inside it just work. [docs/rails.md](docs/rails.md) continues with multi-turn sessions, ActionCable streaming, and fiber workers. +The block runs on a plain thread, so ActiveRecord calls inside it just work. [docs/rails.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/rails.md) continues with multi-turn sessions, ActionCable streaming, and fiber workers. ## Quick Start @@ -89,7 +100,7 @@ end puts result ``` -It raises the same errors as `query()` (see [docs/errors.md](docs/errors.md)), plus `CLIConnectionError` if the stream ends without a result. +It raises the same errors as `query()` (see [docs/errors.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/errors.md)), plus `CLIConnectionError` if the stream ends without a result. ### `query()` — one-shot and streaming @@ -121,7 +132,7 @@ end ### `Client` — bidirectional sessions -`Client` keeps a session open so you can send follow-up queries, interrupt, switch models, and use hooks, permission callbacks, and custom tools. `Client.open` connects, yields the client, and always disconnects when the block exits, even on an exception. It returns the block's value. +`Client` keeps a session open so you can send follow-up queries, interrupt, and switch the model or the permission mode mid-session. (Hooks, permission callbacks and custom tools are not a reason to choose it: they work with `query()` and `ask` too.) `Client.open` connects, yields the client, and always disconnects when the block exits, even on an exception. It returns the block's value. ```ruby require 'claude_agent_sdk' @@ -148,7 +159,7 @@ ensure end ``` -See [docs/client.md](docs/client.md) for `interrupt`, mid-session model and permission switching, MCP status, and custom transports. +See [docs/client.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/client.md) for `interrupt`, mid-session model and permission switching, MCP status, and custom transports. ### Custom tools (SDK MCP servers) @@ -167,7 +178,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ) ``` -A String return is sent to Claude as a single text block. Return a Hash instead (`{ content: [...], is_error: true }`) to flag an error, attach `structured_content:`, or send several content blocks or images. Arguments are validated against the tool's JSON Schema before your handler runs, and handler exceptions are reported back to the model in-band so it can self-correct. See [docs/mcp-servers.md](docs/mcp-servers.md) for resources, prompts, mixed SDK + external servers, and schema details. +A String return is sent to Claude as a single text block. Return a Hash instead (`{ content: [...], is_error: true }`) to flag an error, attach `structured_content:`, or send several content blocks or images. Arguments are validated against the tool's JSON Schema before your handler runs, and handler exceptions are reported back to the model in-band so it can self-correct. See [docs/mcp-servers.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/mcp-servers.md) for resources, prompts, mixed SDK + external servers, and schema details. ### Hooks and permission callbacks @@ -180,23 +191,24 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ) ``` -See [docs/hooks-and-permissions.md](docs/hooks-and-permissions.md) for the full event list and worked examples. +See [docs/hooks-and-permissions.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/hooks-and-permissions.md) for the full event list and worked examples. ## Documentation | Topic | Guide | |-------|-------| -| `Client` advanced features and custom transports | [docs/client.md](docs/client.md) | -| SDK MCP servers: tools, resources, prompts, schema compatibility | [docs/mcp-servers.md](docs/mcp-servers.md) | -| All hook events, typed inputs, permission callbacks | [docs/hooks-and-permissions.md](docs/hooks-and-permissions.md) | -| Structured output, thinking, budget, fallback and advisor models, sandbox, bare mode, checkpointing | [docs/configuration.md](docs/configuration.md) | -| Session listing, reading, renaming, tagging, forking, resume-at-message | [docs/sessions.md](docs/sessions.md) | -| Subagent capabilities, event contracts, and minimal example | [docs/subagents.md](docs/subagents.md) | -| OpenTelemetry tracing, Langfuse, custom observers | [docs/observability.md](docs/observability.md) | -| Rails: generator, `install_cli` task, callback wrapper, fiber safety, solid_queue fiber workers, ActionCable, jobs | [docs/rails.md](docs/rails.md) | -| Vendoring a pinned CLI binary and CLI discovery order | [docs/cli-installer.md](docs/cli-installer.md) | -| Hash-key rule, attribute access, and the message, content block, and configuration type reference | [docs/types.md](docs/types.md) | -| Error handling, exception hierarchy, timeouts | [docs/errors.md](docs/errors.md) | +| `Client` advanced features and custom transports | [docs/client.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/client.md) | +| SDK MCP servers: tools, resources, prompts, schema compatibility | [docs/mcp-servers.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/mcp-servers.md) | +| All hook events, typed inputs, permission callbacks | [docs/hooks-and-permissions.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/hooks-and-permissions.md) | +| Structured output, thinking, budget, fallback and advisor models, sandbox, bare mode, session isolation, checkpointing | [docs/configuration.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/configuration.md) | +| Every `ClaudeAgentOptions` attribute: type, default, and the CLI flag or protocol field it becomes; environment variables | [docs/options.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/options.md) | +| Session listing, reading, renaming, tagging, forking, resume-at-message | [docs/sessions.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/sessions.md) | +| Subagent capabilities, event contracts, and minimal example | [docs/subagents.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/subagents.md) | +| OpenTelemetry tracing, Langfuse, custom observers | [docs/observability.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/observability.md) | +| Rails: generator, `install_cli` task, callback wrapper, fiber safety, solid_queue fiber workers, ActionCable, jobs | [docs/rails.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/rails.md) | +| Vendoring a pinned CLI binary and CLI discovery order | [docs/cli-installer.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/cli-installer.md) | +| Hash-key rule, attribute access, and the message, content block, and configuration type reference | [docs/types.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/types.md) | +| Error handling, exception hierarchy, timeouts | [docs/errors.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/docs/errors.md) | API reference: [rubydoc.info/gems/claude-agent-sdk](https://rubydoc.info/gems/claude-agent-sdk). Available built-in tools: [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code/settings#tools-available-to-claude). @@ -223,18 +235,18 @@ All three SDKs drive the same CLI over the same protocol, so capabilities line u | Bidirectional `Client` | ✅ | ✅ | ✅ | | Streaming input | `AsyncIterable` | `AsyncIterable` | `Enumerator` | | Custom tools (SDK MCP servers) | `tool()` | `@tool` decorator | `create_tool` block | -| Hooks (all 27 events) | ✅ | ✅ | ✅ | +| Hooks (all 27 events) | ✅ | 10 typed, the rest by name | ✅ | | Permission callbacks | ✅ | ✅ | ✅ | | Structured output | ✅ | ✅ | ✅ | | All 28 message types | ✅ | partial | ✅ | | [Sandbox](https://github.com/anthropic-experimental/sandbox-runtime) settings | ✅ | partial | ✅ | -| Bare mode (`--bare`) | ✅ | ✅ | ✅ | +| Bare mode (`--bare`) | ✅ | via `extra_args` | ✅ | | File checkpointing & rewind | ✅ | ✅ | ✅ | | Session browsing & mutations | ✅ | ✅ | ✅ | | Programmatic subagents | ✅ | ✅ | ✅ | | CLI binary | bundled | bundled | vendored on demand (`CLIInstaller`) | | Observability (OTel / Langfuse) | via [Arize](https://github.com/Arize-ai/openinference) | — | ✅ built-in | -| Custom transport (pluggable I/O) | — | — | ✅ | +| Custom transport (pluggable I/O) | — | ✅ | ✅ | | Rails integration | — | — | ✅ | Types are plain Ruby classes with `attr_accessor` and keyword arguments, mirroring the field names of the TypeScript Zod schemas and Python dataclasses; there is no runtime type checking. @@ -260,12 +272,12 @@ RUN_INTEGRATION=1 bundle exec rspec # also run the real-CLI integration suite ( BUNDLE_GEMFILE=gemfiles/rails_8.gemfile bundle exec rspec --options spec/rails/.rspec # Rails integration specs ``` -CI runs the suite and RuboCop on Ruby 3.2, 3.3, and 3.4 on Linux, the suite on macOS, and the Rails specs against Rails 7.1 and 8, validates the RBS signatures and runs the suite under RBS runtime type checking; a weekly job runs the integration suite against the pinned CLI. The gem ships RBS signatures for its public API in `sig/`, which Steep and other RBS tools pick up through `rbs collection`. See [CONTRIBUTING.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CONTRIBUTING.md) for the development setup and [spec/README.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/spec/README.md) for the test layout. +CI runs the suite and RuboCop on Ruby 3.2, 3.3, 3.4 and 4.0 on Linux, the suite on macOS, and the Rails specs against Rails 7.1 and 8, validates the RBS signatures and runs the suite under RBS runtime type checking. Weekly, and on PRs that touch the installer, the transport, `Query` or the message parser, it runs a keyless smoke test against the pinned CLI, and the integration suite as well when the repository has an API key. The gem ships RBS signatures for its public API in `sig/`, which Steep and other RBS tools pick up through `rbs collection`. See [CONTRIBUTING.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CONTRIBUTING.md) for the development setup and [spec/README.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/spec/README.md) for the test layout. ## Contributing -Bug reports and pull requests are welcome on [GitHub](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues). Please include a failing spec with bug reports where possible, and keep pull requests focused on one change; [CONTRIBUTING.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CONTRIBUTING.md) has the details. Report security vulnerabilities privately, as described in [SECURITY.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/SECURITY.md). Releases follow [Semantic Versioning](https://semver.org/) and are recorded in the [CHANGELOG](CHANGELOG.md). +Bug reports and pull requests are welcome on [GitHub](https://github.com/ya-luotao/claude-agent-sdk-ruby/issues). Please include a failing spec with bug reports where possible, and keep pull requests focused on one change; [CONTRIBUTING.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CONTRIBUTING.md) has the details. Report security vulnerabilities privately, as described in [SECURITY.md](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/SECURITY.md). Releases follow [Semantic Versioning](https://semver.org/) and are recorded in the [CHANGELOG](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/CHANGELOG.md). ## License -Released under the [MIT License](LICENSE). +Released under the [MIT License](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 6a0eba91..d8c06117 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,4 +26,4 @@ If you aren't sure which side an issue is on, report it here and we'll help rout ## Supported versions -The gem is pre-1.0. Security fixes go into the latest minor release only; upgrade to receive them. +Security fixes are released as a patch on the latest 1.x minor release only. Earlier minors and the 0.x line get no backports, so upgrade to the latest release to receive a fix. The gem follows [Semantic Versioning](https://semver.org/): an upgrade within 1.x does not break the public API. diff --git a/claude-agent-sdk.gemspec b/claude-agent-sdk.gemspec index 229afbb2..fee68549 100644 --- a/claude-agent-sdk.gemspec +++ b/claude-agent-sdk.gemspec @@ -34,8 +34,13 @@ Gem::Specification.new do |spec| # spec/unit/gemspec_spec.rb asserts both select the same set. Not a hard # failure: Bundler evaluates this file for path/git-sourced consumers, and # raising here would break their bundle, not just a release build. + # + # .yardopts ships because rubydoc.info builds the API docs from the gem's + # own files: without it YARD runs with its defaults, which list everything + # tagged `@api private` (the tag is the SemVer boundary) and skip the + # CHANGELOG / UPGRADING pages. tracked = begin - IO.popen(%w[git ls-files -z lib sig docs README.md LICENSE CHANGELOG.md UPGRADING-1.0.md], + IO.popen(%w[git ls-files -z lib sig docs README.md LICENSE CHANGELOG.md UPGRADING-1.0.md .yardopts], chdir: __dir__, err: File::NULL, &:read).split("\x0") rescue SystemCallError [] @@ -45,7 +50,7 @@ Gem::Specification.new do |spec| # .rake: the Railtie's tasks; .tt: the Rails generator's templates; # sig/: the RBS signatures of the public API (+ manifest.yaml). Dir.glob(['lib/**/*.{rb,rake,tt}', 'sig/**/*.{rbs,yaml}', 'docs/**/*.md', 'README.md', 'LICENSE', - 'CHANGELOG.md', 'UPGRADING-1.0.md'], + 'CHANGELOG.md', 'UPGRADING-1.0.md', '.yardopts'], base: __dir__) else tracked @@ -74,7 +79,7 @@ Gem::Specification.new do |spec| spec.add_dependency 'mcp', '>= 0.22', '< 2' # Development dependencies - spec.add_development_dependency 'bundler', '~> 2.0' + spec.add_development_dependency 'bundler', '>= 2.0' spec.add_development_dependency 'rake', '~> 13.0' spec.add_development_dependency 'rspec', '~> 3.0' # Pinned to a single minor so local and CI resolve the same RuboCop (Gemfile.lock diff --git a/docs/cli-installer.md b/docs/cli-installer.md index e4ec42b4..90321556 100644 --- a/docs/cli-installer.md +++ b/docs/cli-installer.md @@ -27,11 +27,12 @@ ClaudeAgentSDK::CLIInstaller.installed_path `install` is idempotent and safe to run concurrently, so it fits `bin/setup`, a cached Docker layer, and every process of a multi-process boot: - The install directory's `VERSION` file records the installed version, verified SHA-256, and target platform (OS, architecture, libc). The shortcut re-hashes the vendored binary (~0.1s for the real 245MB binary) and only skips the download when all three match — a truncated binary or a cache copied from another platform is reinstalled instead of trusted. It makes **no network request**, so same-platform repeat boots work offline — with a pinned concrete version; `'stable'`/`'latest'` must always re-resolve through the endpoint, which is one more reason to pin in production. Older one- or two-line metadata lacks a platform and requires one online reinstall to migrate; subsequent pinned installs work offline again. -- An exclusive `flock` on `/.install.lock` covers the whole check → download → place → record sequence, so parallel installs into one directory don't race; the loser simply observes the finished install. +- An exclusive `flock` on `/.install.lock` covers the whole check → download → record → place sequence, so parallel installs into one directory don't race — whether they come from separate processes, threads, or fibers of one `Async` reactor (a waiting installer polls the lock rather than blocking its thread on it); the loser simply observes the finished install. +- The shortcut also works where the running process cannot write the install directory: an image built as root and run as another user, or a read-only root filesystem. `install` cannot open its lock file there (`EACCES`, `EROFS` or `EPERM`), and still returns the binary when the request is a concrete version that is already installed and intact — the same `VERSION` and SHA-256 check, without the lock. Anything that would need a write raises `CLIInstallError` as before: a dist-tag (`'stable'` / `'latest'`, which has to be resolved and may have to be installed), a different version, a damaged binary, an empty directory. Failures (unsupported platform, invalid version, HTTP error, response-size cap, oversized download, checksum mismatch, filesystem errors) raise `ClaudeAgentSDK::CLIInstallError`. -**A failed install never breaks a working one.** The new binary is downloaded to a temp file, checksum-verified and recorded, and only then renamed into place — the rename is the last step, and nothing can fail after it. So a failed upgrade leaves the previously installed binary intact and runnable (the SDK keeps working), and the next `install` redoes it cleanly. A first install that fails leaves nothing behind at all. +**A failed install never breaks a working one.** The new binary is downloaded to a temp file, checksum-verified and recorded, and only then renamed into place — the rename is the last step, and nothing can fail after it. So a failed upgrade leaves the previously installed binary intact and runnable (the SDK keeps working), and the next `install` redoes it cleanly. The binary and `VERSION` are also flushed to disk (`fsync`) before they are renamed into place, so a machine that loses power right after an install comes back with a complete binary or with the previous state, not with a truncated file under the published name. A first install that fails never publishes a binary. It can leave the install directory and its `.install.lock` behind and — when the step that failed is the last one, the rename — a `VERSION` that already records the version. Nothing trusts that file on its own: discovery finds no binary there and moves on, and the next `install` redoes it cleanly. ## When the pin moves @@ -96,6 +97,17 @@ RUN bin/rails claude_agent_sdk:install_cli The variable is deliberately not rake's conventional `VERSION`, which Rails' `db:migrate` uses and build environments often export for an app version or git SHA. An empty `CLAUDE_CLI_VERSION` means the gem's pin. +## Proxies and custom CAs + +The installer downloads over HTTPS with Ruby's `Net::HTTP` and reads the usual environment variables: + +- **`HTTPS_PROXY`** (or `https_proxy`) names the proxy to download through, as an `http://` URL: `HTTPS_PROXY=http://proxy.corp.example:3128`, with `user:password@` in front of the host for an authenticating proxy (percent-encode special characters). The download is tunnelled through it with `CONNECT`, so TLS still ends at the release endpoint. +- **`NO_PROXY`** (or `no_proxy`) lists the hosts, domain suffixes and IP ranges to reach directly, separated by commas. +- With neither spelling of `HTTPS_PROXY` set, `Net::HTTP`'s own default applies: it goes through `http_proxy` when that is set. +- **`SSL_CERT_FILE`** points OpenSSL at another CA bundle, which is what a TLS-inspecting proxy needs. Set it in the environment the process starts with. The server certificate is always verified; there is no switch to turn that off. + +This is not everything `curl` understands. `ALL_PROXY` is not read, and only an `http://` proxy URL is used: a value without a scheme (`proxy.corp.example:3128`), a `socks5://` proxy or an `https://` one is ignored. + ## Supported platforms `darwin-arm64`, `darwin-x64` (Rosetta 2 gets the arm64 build), `linux-x64`, `linux-arm64`, and the `-musl` variants. Windows is not supported. @@ -104,7 +116,9 @@ The variable is deliberately not rake's conventional `VERSION`, which Rails' `db With no explicit `cli_path:` in `ClaudeAgentOptions`, the transport probes in this order: -1. `CLAUDE_CLI_PATH` — an explicit path to an executable, no discovery at all (a relative value is resolved against the process's working directory, not `cwd:`) +1. `CLAUDE_CLI_PATH` — a path to the CLI, used when it names an executable regular file (a relative value is resolved against the process's working directory, not `cwd:`). A value that names anything else — a missing file, a directory, a file that is not executable — is skipped without a warning, and discovery continues with the steps below 2. The vendored binary (`CLIInstaller.installed_path`, i.e. `vendor/claude` under `CLIInstaller.root` or the working directory — see [above](#where-vendorclaude-is)) — deliberately ahead of `PATH`, so a pinned install beats whatever is installed globally -3. `which claude` +3. `claude` on the process's `PATH` — the first executable regular file of that name. The SDK searches `PATH` itself and does not run `which`. A `PATH` passed in `env:` belongs to the session and is not searched here 4. Common install locations (`~/.claude/local/claude`, `/usr/local/bin/claude`, …) — only an executable regular file counts, and the `~` ones are skipped when there is no usable home directory (HOME unset with no passwd entry, or a non-absolute HOME) + +However the CLI is named, the transport settles on one absolute path before it runs anything, and uses that path for both the version check and the session. Anything relative is resolved against the process's working directory, never against `cwd:` — a relative `cli_path:` (a leading `~` expands to the home directory), and a `PATH` hit that came from a relative `PATH` entry (`bin`, `.`, an empty entry). A `PATH` entry that is `~` or starts with `~/` is not one of those: it is expanded against the home directory, as Ruby's own command lookup does. A bare `cli_path: 'claude'` is looked up by the SDK in the same way, but on the `PATH` the session will get: the one in `env:` when you set one there, otherwise the process's. A `cli_path:` that names no file raises `CLINotFoundError` from `connect`. diff --git a/docs/client.md b/docs/client.md index 9d371b2c..ede54813 100644 --- a/docs/client.md +++ b/docs/client.md @@ -1,6 +1,8 @@ # Client & Custom Transport -`ClaudeAgentSDK::Client` supports bidirectional, interactive conversations with Claude Code. Unlike `query()`, `Client` enables **custom tools**, **hooks**, and **permission callbacks**, all of which can be defined as Ruby procs/lambdas. The Client class automatically uses streaming mode for bidirectional communication, allowing you to send multiple queries dynamically during a single session without closing the connection. +`ClaudeAgentSDK::Client` keeps one Claude Code session open for a conversation you drive. What it adds over `query()` is lifecycle: you can send follow-up queries in the same session, and you can call the CLI while the session runs (`interrupt`, switch the model or the permission mode, inspect and reconnect MCP servers, rewind files, stop or background a task). + +**Custom tools**, **hooks** and **permission callbacks** are not part of that difference. `query()` and `ask` speak the same control protocol as `Client`, so all three run them; each is a Ruby proc or lambda you pass in `ClaudeAgentOptions`. For a single question you don't need a session: `ClaudeAgentSDK.ask(prompt, options:)` runs `query()` to completion and returns the final `ResultMessage` (`#result` is the answer text), optionally yielding each message to a block on the way. See the README's [Quick Start](../README.md#quick-start). @@ -27,6 +29,8 @@ end Called outside a reactor, `break` inside the `Client.open` block raises `LocalJumpError` (the client still disconnects), so return a value from the block instead. `break` inside `receive_response` / `receive_messages` is fine. It stops the iteration. +Every `client.query` on one client continues the same conversation. The `session_id:` keyword of `Client#query` does not change that: it is a label on the message the SDK sends, and the CLI keeps a single session per client. Every `ResultMessage#session_id` is the CLI's own id, whatever you passed, and all the turns land in one transcript. For separate contexts, one per user for instance, use one `Client` (or one `query()`) each. + If your code already runs inside an `Async` reactor and you want to manage the connection yourself, call `connect` and `disconnect` directly: ```ruby @@ -43,10 +47,7 @@ end ## Advanced Features ```ruby -Async do - client = ClaudeAgentSDK::Client.new - client.connect - +ClaudeAgentSDK::Client.open do |client| client.interrupt # Send interrupt signal client.permission_mode = 'acceptEdits' # Change permission mode mid-conversation client.model = 'claude-sonnet-5' # Switch model mid-conversation (nil = default) @@ -60,9 +61,7 @@ Async do client.background_tasks(tool_use_id: 'toolu_01') # Only the task spawned by that tool_use block # => { backgrounded: true } | { backgrounded: false } (definitive miss) # '' or a non-String raises ArgumentError; nil is the all-tasks form - - client.disconnect -end.wait +end ``` The Ruby-style names above sit next to the Python SDK's spellings, and both work, so code ported from the Python docs runs unchanged: @@ -106,7 +105,7 @@ end By default, `Client` uses `SubprocessCLITransport` to spawn the Claude Code CLI locally. You can provide a custom transport class to connect via other channels (e.g., remote SSH, WebSocket, or a sandbox VM). -A transport must implement six methods: +A transport must implement five methods. It can subclass `ClaudeAgentSDK::Transport`, whose methods raise `NotImplementedError` until you override them, or be any object that has them: | Method | Purpose | |---|---| @@ -114,8 +113,9 @@ A transport must implement six methods: | `write(data)` | Send raw JSON-line bytes to stdin | | `read_messages { \|hash\| ... }` | Yield each stdout line as a Hash parsed with `JSON.parse(line, symbolize_names: true)` (the SDK reads Symbol keys; see [Hash keys](types.md#hash-keys)); block until the stream closes | | `end_input` | Signal EOF on stdin | -| `close` | Terminate and clean up | -| `ready?` | Report whether the transport can accept I/O | +| `close` | Terminate and clean up. Must be safe to call more than once | + +`ready?` (report whether the transport can accept I/O) is on the `Transport` base class as well, but the SDK never calls it, so it is optional. `end_input` is not: a one-shot `query()` calls it when the run is over, and `Client` calls it when a streamed prompt is exhausted. **Environment your transport should give the CLI.** `SubprocessCLITransport` sets a few variables that a custom transport has to set itself. The one that @@ -138,6 +138,24 @@ client = ClaudeAgentSDK::Client.new( ) ``` +### One-shot queries over a custom transport + +`ClaudeAgentSDK.query` and `ClaudeAgentSDK.ask` take a transport as well: a ready-made instance in `transport:`, not a class. + +```ruby +transport = MyTransport.new(options, foo: 'bar') +ClaudeAgentSDK.query(prompt: 'Hello', options: options, transport: transport) { |message| puts message } + +result = ClaudeAgentSDK.ask('Hello', options: options, transport: MyTransport.new(options, foo: 'bar')) +``` + +- The SDK connects the transport, runs the query over it and closes it, so one instance serves one query. `close` is called even when `connect` raised, and it must be idempotent. +- `options` still drives everything the SDK does on its own side: hooks, SDK MCP servers, agents, observers, callback scheduling. The command line and the environment are whatever your transport gives the CLI. `query` does not rebuild them from `options`, so build the transport from the same options. +- `can_use_tool` needs one more step. The CLI asks the callback only when it is started with `--permission-prompt-tool stdio`. The SDK adds that to the options of a transport it builds (a `transport_class:` transport receives options with `permission_prompt_tool_name: 'stdio'`), but `query` cannot add it to a transport you built. Build that transport from `options.dup_with(permission_prompt_tool_name: 'stdio')` and still pass the original `options` to `query`; passing the copy raises `ArgumentError`, because `can_use_tool` and `permission_prompt_tool_name` cannot be combined there. +- Anything that does not respond to `connect` raises `ArgumentError`. + +Resuming from a `session_store` is not available over a custom transport, through `query(transport:)` or `transport_class:`: the SDK prepares the transcript only for a CLI it starts with `SubprocessCLITransport` (or a subclass of it). + ### Reference: running `claude` inside an E2B sandbox [`examples/e2b_transport_example.rb`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/e2b_transport_example.rb) is a working transport that runs the Claude Code CLI inside an [E2B](https://e2b.dev) Firecracker microVM instead of on your host. The wire protocol stays identical — only the I/O layer changes: diff --git a/docs/configuration.md b/docs/configuration.md index 66f988f8..120e5e6b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,6 +35,8 @@ ClaudeAgentSDK.query(prompt: "Create a profile for a software engineer", options end ``` +In the `output_format` Hash, `type` may be a String or a Symbol (`type: :json_schema`), and the keys Symbols or Strings. + See [examples/structured_output_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/structured_output_example.rb). ## Thinking Configuration @@ -96,6 +98,8 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ) ``` +In a Hash form, `type` may be a String or a Symbol (`type: :preset`). + `snapshot` is sent on the control-protocol `initialize` request (never as a CLI flag), so it applies to both `query()` and `Client`. When omitted it acts as `true`, except in bare mode (`bare: true`), where it acts as `false`. A `SystemPromptFile` has no `snapshot`. Requires Claude Code CLI 2.1.257 or later. Before 2.1.265, a session with an `append` or custom prompt recorded it only when `snapshot` was `true`. See [Modifying system prompts](https://code.claude.com/docs/en/agent-sdk/modifying-system-prompts#change-the-prompt-of-an-existing-session) for details. @@ -177,10 +181,18 @@ Available beta features are listed in the `SDK_BETAS` constant. # Array of tool names options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: ['Read', 'Edit', 'Bash']) +# The same names as one String, in the comma-separated form of the CLI's --tools flag +options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: 'Read,Edit,Bash') + # Preset options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: ClaudeAgentSDK::ToolsPreset.new(preset: 'claude_code')) + +# The preset as a Hash +options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: { type: 'preset', preset: 'claude_code' }) ``` +A String is passed to the CLI as written. In the Hash form, `type` may be a String or a Symbol (`type: :preset`). + ## Skills `skills` is the single place to enable skills for the main session — it auto-allows the `Skill` tool and defaults `setting_sources` to `['user', 'project']` (when unset) so skill files are discovered: @@ -195,9 +207,15 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(skills: %w[pdf docx]) Semantics: `nil` (default) leaves CLI defaults untouched; `[]` hides every skill from the listing; an Array adds `Skill(name)` allow-rules per entry (use `plugin:skill` for plugin-qualified names). An explicitly set `setting_sources` (including `[]`) is never overridden. This is a context filter, not a sandbox — skill files remain readable on disk. +If you also give `tools` as a list of names, put `'Skill'` in it. `skills` adds the `Skill` tool to the *allowed* tools, while `tools` decides which tools the session has at all: with `tools: ['Read'], skills: 'all'` there is no `Skill` tool, and no skill can run. Leaving `tools` unset keeps it. + +```ruby +options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: %w[Read Skill], skills: %w[pdf docx]) +``` + ## Sandbox Settings -Configure [sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) restrictions (network policy, filesystem access) via the CLI's `--sandbox` flag. The CLI handles OS-level process isolation using `srt`. +Configure [sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) restrictions (network policy, filesystem access) with the `sandbox` option. The SDK sends it to the CLI as the `sandbox` key of the `--settings` argument, merged into your `settings:` when you pass both; there is no separate sandbox flag. The CLI handles OS-level process isolation using `srt`. ```ruby sandbox = ClaudeAgentSDK::SandboxSettings.new( @@ -212,8 +230,40 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ) ``` +`sandbox` also takes a Hash, and so do `network` and `filesystem`, inside that Hash or inside a `SandboxSettings`. A Hash may spell the fields of `SandboxSettings`, `SandboxNetworkConfig` and `SandboxFilesystemConfig` as the classes do (`denied_domains`) or as the CLI does (`deniedDomains`), with Symbol or String keys: + +```ruby +options = ClaudeAgentSDK::ClaudeAgentOptions.new( + sandbox: { + enabled: true, + network: { denied_domains: ['evil.example'] }, + filesystem: { deny_read: ['~/.ssh'] } + } +) +``` + +- Any other key is sent as written. That is how to pass a sandbox setting the classes have no attribute for (`allowAppleEvents`, `strictAllowlist` inside `network`): spell it as the CLI does. +- A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for that key: `true` or `false` for a switch, an Array of Strings for a list, a port number (an Integer from 0 to 65535) for a proxy port, a Hash of String Arrays for `ignore_violations`. With a value of any other shape (`denied_domains: 'evil.example'`, a String where an Array belongs) the key is sent as written, and the CLI ignores a key it does not know. +- A field holding `nil` is left out, in either spelling, as a `nil` attribute of the typed classes is. + +The second rule exists because of how the CLI validates these settings (observed with CLI 2.1.287). When one sandbox value fails its settings schema, the CLI discards the **whole** `--settings` value: the sandbox, and everything you passed in `settings:` next to it, `permissions` rules included. The CLI reports the failure in the `errors` of its `get_settings` control response, and the SDK does not surface that: `connect` succeeds, nothing appears on stderr, and the session runs unsandboxed. That applies to a value written under the CLI's own name (`excludedCommands: 'docker'`) and to the typed classes, which send each value as you gave it: `SandboxSettings.new(excluded_commands: 'docker')` leaves the session without a sandbox. + +`enabled: true` asks for a sandbox. It does not make one a requirement. When the sandbox cannot start on the host (missing dependencies, an unsupported platform), the CLI carries on without it. Its settings schema describes the setting that decides this, `failIfUnavailable`, as follows (CLI 2.1.287): + +> Exit with an error at startup if sandbox.enabled is true but the sandbox cannot start (missing dependencies or unsupported platform). When false (default), a warning is shown and commands run unsandboxed. + +The SDK sends your sandbox settings as you wrote them and does not add this one. If commands must never run unsandboxed, set it yourself: + +```ruby +sandbox = ClaudeAgentSDK::SandboxSettings.new(enabled: true, fail_if_unavailable: true) +``` + +Without it, the sign is the CLI's warning on stderr ("Sandbox disabled: ... Commands will run WITHOUT sandboxing. Network and filesystem restrictions will NOT be enforced."), and stderr reaches your code only through the `stderr` (or `debug_stderr`) option. This is the CLI's own description of its behavior: the fallback has not been reproduced in this SDK's testing, where the sandbox was always available. + See [examples/sandbox_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/sandbox_example.rb). +When the `sandbox:` option enabled the sandbox and the CLI reports `Sandbox disabled: …` on its stderr, the SDK repeats that line as a Ruby warning prefixed `[claude-agent-sdk]`, once per session, whether or not you set `stderr:`. + ## Bare Mode Bare mode (`--bare`) is a minimal startup mode that skips hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. It sets `CLAUDE_CODE_SIMPLE=1` internally. Useful for scripted/programmatic usage where you want fast startup and full control over what's loaded. @@ -287,6 +337,119 @@ and `Client#query`. Matches the Python SDK's `verbatim_prompts`. +## Session Isolation + +Claude Code keeps an **auto-memory** for each project: Markdown notes and an +index file, `/projects//memory/MEMORY.md`. It is a +CLI feature, it is on by default, and SDK sessions take part in it. If one +process runs sessions for more than one user or tenant, three things follow: + +- **Every session reads the index.** The CLI puts `MEMORY.md` into the context + of every session of that project, under the same heading as `CLAUDE.md` (in + CLI 2.1.287: "IMPORTANT: These instructions OVERRIDE any default behavior + and you MUST follow them exactly as written"). That holds with the SDK's + default (empty) system prompt and with `tools: []`. `setting_sources: []` + does not change it either: that option selects the user, project and local + sources (their settings files and their `CLAUDE.md` files), and the + auto-memory is not one of them. +- **A session on the `claude_code` preset also writes it.** The preset system + prompt includes instructions for keeping memories, so a message such as + "remember that ..." makes the model save a note and update the index with + its file tools. The CLI allows those writes by itself: in testing (CLI + 2.1.286) they succeeded in the default permission mode with no + `can_use_tool` callback, no hook and no allow rule. Do not count on your + permission setup to stop them. With the SDK's default system prompt the + sessions tested only read the index: asked to remember something, they + wrote nothing (an observation, not a guarantee). +- **The memory belongs to the project, not to the session.** The project key + is the root of the git repository that contains the working directory, so + every subdirectory and every git worktree of one repository shares one + memory directory. Outside a repository the key is the working directory + itself. + +So on a server that runs every user's session from one checkout and one config +directory with the `claude_code` preset, what one user asks the agent to +remember can be saved without a permission check and then reach every later +session as an instruction. With the default system prompt the exposure is the +read side: whatever memory already exists for that project and config +directory, a developer's own for instance, is in every session's context. + +### Turning auto-memory off + +Servers and multi-tenant hosts should switch it off. Both forms work with +`query()` and `Client`: + +```ruby +# An environment variable for the CLI process +options = ClaudeAgentSDK::ClaudeAgentOptions.new( + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } +) + +# Or the CLI setting +options = ClaudeAgentSDK::ClaudeAgentOptions.new( + settings: { autoMemoryEnabled: false } +) +``` + +To apply it to every session, set it as a default (a per-call `env` Hash is +merged into the configured one): + +```ruby +ClaudeAgentSDK.configure do |config| + config.default_options = { env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } } +end +``` + +- The variable must be `'1'`. `'0'` and `'false'` do not mean "the default": + they force auto-memory **on** and override `autoMemoryEnabled: false`. +- `bare: true` turns auto-memory off as well, but bare mode never reads an + OAuth login or the keychain: it authenticates with `ANTHROPIC_API_KEY` (or + an `apiKeyHelper` setting) only. See [Bare Mode](#bare-mode). +- Both switches reach the CLI that `query()` and `Client` start themselves, + through the default `SubprocessCLITransport`: it puts `env` into the CLI's + environment and `settings` on its command line (`--settings`). +- A [custom transport](client.md#custom-transport) starts the CLI its own way, + so neither switch reaches that CLI unless the transport passes it through: + `env` into the environment it gives the CLI, `settings` onto the command + line (a transport that builds its command line with `CommandBuilder` gets + `--settings` from it; one that does not, has to add it). Until then the + session is not isolated. + +### What does not isolate sessions + +- **A different `cwd`** separates the memory only when the two directories + are not in the same git repository. +- **A different `CLAUDE_CONFIG_DIR`** separates it only when the two config + directories do not share `projects/` (a `projects/` that is a symlink to + another config directory's is shared). A new config directory also has no + login, so give the CLI `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` + (in the process environment or in `env`). +- **`setting_sources: []`** keeps the user, project and local settings and + `CLAUDE.md` files out of a session. The auto-memory index still loads. So + do the MCP servers connected to the claude.ai account the CLI is logged in + with: `strict_mcp_config: true` is what limits a session to the servers in + `mcp_servers` (see the [options reference](options.md#strict_mcp_config)). + +"One working directory per tenant" is therefore not enough on its own. Use the +switch. + +### Checking what a session loaded + +`Client#context_usage` reports the files in a session's context without a +model call. The auto-memory index is the entry whose `:type` is `"AutoMem"`: + +```ruby +ClaudeAgentSDK::Client.open(options: options) do |client| + client.context_usage.fetch(:memoryFiles, []).each do |file| + puts "#{file[:type]} #{file[:path]} (#{file[:tokens]} tokens)" + end +end +# AutoMem /home/app/.claude/projects/-srv-app/memory/MEMORY.md (27 tokens) +``` + +With auto-memory off the list has no `AutoMem` entry. `query()` has no +equivalent; run the check through a `Client` with the same options. + ## Forwarding Subagent Text By default only `tool_use` / `tool_result` blocks from subagents (spawned via diff --git a/docs/errors.md b/docs/errors.md index 30b50284..51760bc6 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -87,6 +87,30 @@ initial handshake that was still in flight when the CLI exited. Match on `Resume rejected by --resume-drops-turn:` in the message and treat it as deterministic: clear the fork target and resume plainly rather than retrying. +## Errors from Client Control Methods + +`Client#interrupt`, `#set_model`, `#set_permission_mode`, `#rewind_files`, `#reconnect_mcp_server`, `#toggle_mcp_server`, `#stop_task` and the other control methods (the Ruby-style `model=` and `permission_mode=` included) send a request to the CLI and wait for its answer. They can fail in three ways: + +| What happened | Raised | +|---------------|--------| +| The client is not connected, or the connection ended while the request was waiting | `CLIConnectionError` | +| The CLI did not answer in time | `ControlRequestTimeoutError` | +| The CLI answered with an error: an MCP server name it does not know, a rewind without `enable_file_checkpointing`, a permission mode it does not accept | a plain `StandardError` whose message is the CLI's text | + +The third is **not** a `ClaudeSDKError`, so `rescue ClaudeAgentSDK::ClaudeSDKError` does not catch it: + +```ruby +begin + client.reconnect_mcp_server('my-server') +rescue ClaudeAgentSDK::ClaudeSDKError => e + puts "Connection lost or timed out: #{e.message}" +rescue StandardError => e + puts "The CLI refused: #{e.message}" # e.g. "Server not found: my-server" +end +``` + +Rescue `StandardError` for it, and do not test for the exact class. + ## Configuring Timeout The control request timeout defaults to **1200 seconds** (20 minutes) to accommodate long-running agent sessions. Override it via environment variable: @@ -98,7 +122,7 @@ export CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS=300 # 5 minutes ## Error Type Reference ```ruby -# Base exception class for all SDK errors +# Base class of the SDK's error classes (all the ones below) class ClaudeSDKError < StandardError; end # Raised when connection to Claude Code fails @@ -113,6 +137,11 @@ class CLINotFoundError < CLIConnectionError # @param cli_path [String, nil] Optional path to the CLI that was not found end +# Raised by CLIInstaller.install / .install_pinned (and the +# claude_agent_sdk:install_cli rake task) when the CLI cannot be installed. +# Never raised by query, ask or Client +class CLIInstallError < ClaudeSDKError; end + # Raised by the local-disk session APIs when CLAUDE_CONFIG_DIR is unset and # no usable home directory exists for the default ~/.claude class ConfigDirError < ClaudeSDKError; end @@ -155,10 +184,11 @@ end | Error | Description | |-------|-------------| -| `ClaudeSDKError` | Base error for all SDK errors | +| `ClaudeSDKError` | Base class of every error class in this table. One failure is not a `ClaudeSDKError`: a control request the CLI rejects, see [Errors from Client Control Methods](#errors-from-client-control-methods) | | `CLIConnectionError` | Connection issues — including every write after a stdin write was cancelled mid-frame (the connection is unusable from then on — reconnect), and `ClaudeAgentSDK.ask` when the stream ends without a `ResultMessage` | | `ControlRequestTimeoutError` | Control protocol timeout (configurable via env var) | | `CLINotFoundError` | Claude Code not installed | +| `CLIInstallError` | `CLIInstaller.install` / `install_pinned` (or the `claude_agent_sdk:install_cli` rake task) could not install the CLI: unsupported platform, invalid version, HTTP error, a response or download over its size limit, checksum mismatch, or a filesystem error. A wrapped failure keeps the original exception in `#cause`. This is the error a deploy or `bin/setup` script rescues; a failed install leaves a previously installed binary in place. See [CLI installer](cli-installer.md) | | `ConfigDirError` | A local-disk session API (`list_sessions`, `get_session_*`, `rename_session`, ...) could not locate the Claude config directory: `CLAUDE_CONFIG_DIR` is unset and there is no usable home directory (`HOME` unset with no passwd entry, as under `docker --user` in a minimal image, or an empty/relative `HOME`). Set `CLAUDE_CONFIG_DIR` | | `SessionStoreError` | Resuming from `session_store:` failed: a store call (`#load`, `#list_sessions`, `#list_subkeys`) raised or exceeded `load_timeout_ms` while the SDK materialized the transcript, before the CLI started. The message names the call; `#cause` is the adapter's exception. Before 1.0 this was a bare `RuntimeError` — see [Sessions](sessions.md#mirroring-to-a-sessionstore) | | `ProcessError` | Process failed (includes `exit_code` and `stderr`) — also raised when the CLI is still running 5s after closing stdout and the SDK had to terminate it | diff --git a/docs/hooks-and-permissions.md b/docs/hooks-and-permissions.md index 20e4034b..f0fc3067 100644 --- a/docs/hooks-and-permissions.md +++ b/docs/hooks-and-permissions.md @@ -69,16 +69,34 @@ Async do } ) - client = ClaudeAgentSDK::Client.new(options: options) - client.connect - client.query("Run the bash command: ./foo.sh --help") - client.receive_response { |msg| puts msg } - client.disconnect + ClaudeAgentSDK::Client.open(options: options) do |client| + client.query("Run the bash command: ./foo.sh --help") + client.receive_response { |msg| puts msg } + end end.wait ``` See [examples/hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/hooks_example.rb), [examples/advanced_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/advanced_hooks_example.rb), and [examples/lifecycle_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/lifecycle_hooks_example.rb). +### Hook output + +A callback returns a typed output (`AsyncHookJSONOutput`, or `SyncHookJSONOutput`, which can hold a `*HookSpecificOutput`), a Hash, or `nil` (the same as `{}`). A Hash stands for the typed output with the same fields. Its keys may be Symbols or Strings, and each field may be spelled as the typed class's attribute (`permission_decision`) or as the CLI reads it (`permissionDecision`), at the top level and inside `hook_specific_output`. The deny in the example above can also be written as: + +```ruby +{ + hook_specific_output: { + hook_event_name: 'PreToolUse', # a typed output sets this itself; a Hash has to carry it + permission_decision: 'deny', + permission_decision_reason: "Command contains forbidden pattern: #{pattern}" + } +} +``` + +- `continue_` and `async_` stand for `continue` and `async`. +- A key the typed classes do not have is sent as written, so a CLI field the SDK does not model has to be spelled the way the CLI spells it. +- The keys inside `updated_input`, `updated_tool_output`, `updated_mcp_tool_output` and a `PermissionRequest` `decision` are not renamed: those are the tool's own payloads (for `decision`, the CLI's), so spell them as the tool or the CLI does. +- If one Hash spells the same field both ways, the CLI's spelling is the one sent (`permissionDecision` over `permission_decision`, `continue` over `continue_`). + ### Hook cancellation Dispatched hooks receive `HookContext#request_id` and `HookContext#signal`, using @@ -123,11 +141,10 @@ Async do can_use_tool: permission_callback ) - client = ClaudeAgentSDK::Client.new(options: options) - client.connect - client.query("Create a file called test.txt with content 'Hello'") - client.receive_response { |msg| puts msg } - client.disconnect + ClaudeAgentSDK::Client.open(options: options) do |client| + client.query("Create a file called test.txt with content 'Hello'") + client.receive_response { |msg| puts msg } + end end.wait ``` @@ -202,6 +219,9 @@ An exception raised inside a hook or a `can_use_tool` callback fails that control request: the CLI receives an error response carrying the exception message, and the session carries on with later requests. The request's cancellation signal is invalidated, as for any other callback failure. +`NotImplementedError`, `LoadError`, `SystemStackError` and `SecurityError` are +answered the same way, although they are not `StandardError`s. Bytes of the +message that are not valid UTF-8 are replaced with U+FFFD. `exit`, `Interrupt` and other signal exceptions are never swallowed. If one is raised while a callback runs (by the callback itself, or a real Ctrl-C / diff --git a/docs/mcp-servers.md b/docs/mcp-servers.md index 296cbb6b..bc3d6f34 100644 --- a/docs/mcp-servers.md +++ b/docs/mcp-servers.md @@ -8,7 +8,6 @@ A **custom tool** is a Ruby proc/lambda that you can offer to Claude, for Claude ```ruby require 'claude_agent_sdk' -require 'async' greet_tool = ClaudeAgentSDK.create_tool( 'greet', 'Greet a user', { name: :string }, @@ -28,13 +27,10 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( allowed_tools: ['mcp__tools__greet'] ) -Async do - client = ClaudeAgentSDK::Client.new(options: options) - client.connect +ClaudeAgentSDK::Client.open(options: options) do |client| client.query("Greet Alice") client.receive_response { |msg| puts msg } - client.disconnect -end.wait +end ``` ## Handler Return Values @@ -56,7 +52,28 @@ ClaudeAgentSDK.create_tool('lookup_order', 'Look up an order', { id: :string }) end ``` -Any other return value (`nil`, an Integer, an Array, ...) is reported to Claude as an `isError: true` result saying the tool must return a hash with a `:content` key. +Any other return value (`nil`, an Integer, an Array, ...) is reported to Claude as an `isError: true` result saying the tool must return a hash with a `:content` key. So is a Hash whose `:content` is not an Array — `{ content: 'text' }`, or a single block Hash — with a message saying `:content` must be an Array of content blocks; return the String itself, or wrap the block in an Array. + +## Shorthand Input Schemas + +`{ name: :string }` is shorthand for a JSON Schema object in which every listed parameter is required. Each value names a type, as a Ruby class or as a Symbol: + +| Shorthand | JSON Schema type | The handler receives | +| --- | --- | --- | +| `String`, `:string` | `string` | a String | +| `Integer`, `:integer` | `integer` | an Integer | +| `Float`, `:float`, `:number` | `number` | an Integer or a Float | +| `TrueClass`, `FalseClass`, `:boolean` | `boolean` | `true` or `false` | +| `Array`, `:array` | `array` | an Array, with elements of any type | +| `Hash`, `:object` | `object` | a Hash with Symbol keys | + +```ruby +ClaudeAgentSDK.create_tool('tag_order', 'Tag an order', { order_id: Integer, tags: Array }) do |args| + "Tagged order #{args[:order_id]} with #{args[:tags].join(', ')}" +end +``` + +Arguments are validated against these types before the handler runs. For an optional parameter, the type of an Array's elements, an enum or a per-parameter description, pass a full JSON Schema instead (next section). ## Pre-built JSON Schemas @@ -125,12 +142,14 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( An exception raised inside a handler is returned to the model as an `isError: true` result carrying the exception message, so it can self-correct. +That holds for `NotImplementedError`, `LoadError`, `SystemStackError` and +`SecurityError` as well, although they are not `StandardError`s. `exit`, `Interrupt` and other signal exceptions are never swallowed: the CLI first gets an `isError` result naming the exception class (`"SystemExit: exit"`), so it is not left waiting on the tool call, and then the exception propagates as Ruby normally would (`exit` ends the process, Ctrl-C interrupts it). Called directly, without a session, -`SdkMcpServer#call_tool` simply lets such exceptions propagate. Cancellation of the tool call itself still propagates. +`SdkMcpServer#call_tool` simply lets `exit` and signal exceptions propagate. Cancellation of the tool call itself still propagates. ## Mixed Server Support @@ -196,7 +215,9 @@ server = ClaudeAgentSDK.create_sdk_mcp_server( ``` An exception raised inside a resource reader or prompt generator is answered -with a JSON-RPC internal error (`-32603`) carrying the exception message. For +with a JSON-RPC internal error (`-32603`) carrying the exception message; +`NotImplementedError`, `LoadError`, `SystemStackError` and `SecurityError` +are covered here too. For `exit`, `Interrupt` and other signal exceptions the CLI gets that error first, naming the exception class, and then the exception propagates as Ruby normally would. diff --git a/docs/observability.md b/docs/observability.md index d0320424..121c1262 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -1,6 +1,6 @@ # Observability (OpenTelemetry / Langfuse) -The SDK includes a built-in **observer interface** and an **OpenTelemetry observer** for tracing agent sessions. Traces are emitted using standard `gen_ai.*` semantic conventions, compatible with Langfuse, Jaeger, Datadog, and any OTel backend. +The SDK includes a built-in **observer interface** and an **OpenTelemetry observer** for tracing agent sessions. Span attributes follow the Langfuse and OpenInference conventions, plus a subset of the OTel `gen_ai.*` attributes. Any OTel backend (Jaeger, Datadog, ...) can store and display the spans; one that interprets attributes by the OTel GenAI semantic conventions reads only part of them, as [Span Attributes](#span-attributes) explains. ## Distributed Trace Context (W3C) @@ -18,13 +18,15 @@ In `Client` mode, call `disconnect` (ideally in an `ensure` block) so `on_close` ``` claude_agent.session (root span — one per query/session) -├── claude_agent.generation (per AssistantMessage, with model + token usage) +├── claude_agent.generation (per AssistantMessage, with model; token usage once per API response) ├── claude_agent.tool.Bash (per tool call, open on ToolUseBlock, close on ToolResultBlock) ├── claude_agent.tool.Read ├── claude_agent.generation └── ... ``` +A session that fails before its first `InitMessage` (the CLI cannot be found or started, or the `initialize` handshake fails or times out) still leaves a span. `OTelObserver` emits a `claude_agent.session` span that records the exception and has error status, and ends it at once, so it is exported even when no `on_close` follows: a `Client#connect` that fails before the handshake never calls it. The span carries the observer's default attributes and, if a prompt had already been sent, `input.value`. It has no model, `session.id` or `claude_code.*` attributes, because the CLI never reported them. Once a trace has started, an error is recorded on its session span instead, and an error that arrives after the trace ended adds no span: the usual one is the `ResultError` raised when the CLI exits non-zero after an error result, which that trace already reports. + ## Setup with Langfuse **1. Install the OTel gems** (not bundled with the SDK — you choose your exporter): @@ -38,6 +40,7 @@ Or add to your Gemfile: ```ruby gem 'opentelemetry-sdk', '~> 1.4' gem 'opentelemetry-exporter-otlp', '~> 0.28' +gem 'base64' # the snippet below requires it; under Bundler, Ruby 3.4+ needs it listed ``` **2. Configure the OTel SDK** to export to your Langfuse instance: @@ -110,19 +113,67 @@ See [docs/rails.md](rails.md) for the Rails-specific pattern. ## Span Attributes -The OTel observer sets attributes using both `gen_ai.*` (OTel GenAI) and OpenInference conventions for maximum backend compatibility: - -| Span | Type | Key Attributes | -|------|------|----------------| -| `claude_agent.session` | `agent` | `gen_ai.system`, `gen_ai.request.model`, `session.id`, `input.value`, `output.value`, `gen_ai.usage.cost`, `llm.cost.total`, `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.cache_read_input_tokens`, `llm.token_count.prompt_details.cache_read`, `llm.token_count.prompt_details.cache_write` | -| `claude_agent.generation` | `generation` | `gen_ai.response.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.cache_read_input_tokens`, `output.value` | -| `claude_agent.tool.*` | `tool` | `tool.name`, `input.value`, `output.value` | +Attribute names follow the Langfuse and OpenInference conventions, plus a subset of the OTel `gen_ai.*` attributes. The tables below list every attribute the observer sets. An attribute whose value the CLI did not report is left out, and `input.value`, `output.value` and `gen_ai.completion` are cut at 4,096 characters. + +**`claude_agent.session`** + +| Attribute | Value | +|-----------|-------| +| `gen_ai.system` | `anthropic` | +| `gen_ai.request.model`, `llm.model_name` | The model named by the `InitMessage` | +| `session.id` | The CLI session ID | +| `openinference.span.kind` | `AGENT` | +| `langfuse.observation.type` | `agent` | +| `input.mime_type`, `output.mime_type` | `text/plain` | +| `claude_code.version`, `claude_code.cwd`, `claude_code.permission_mode` | From the `InitMessage` | +| Your default attributes | Whatever you passed to `OTelObserver.new`. They are set on this span only | +| `input.value` | The prompt of the trace | +| `output.value` | `ResultMessage#result`, or the last assistant text when the result has none | +| `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.cache_read_input_tokens` | The four counts of `ResultMessage#usage` as the API reports them, so `input_tokens` leaves cache tokens out | +| `llm.token_count.prompt` | Input, cache-creation and cache-read tokens added up | +| `llm.token_count.completion` | Output tokens | +| `llm.token_count.total` | `llm.token_count.prompt` plus `llm.token_count.completion` | +| `llm.token_count.prompt_details.cache_read`, `llm.token_count.prompt_details.cache_write` | Cache-read and cache-creation tokens | +| `gen_ai.usage.cost`, `llm.cost.total` | The cost increase since the previous result (see below) | +| `claude_agent.duration_ms`, `claude_agent.duration_api_ms`, `claude_agent.num_turns`, `claude_agent.stop_reason` | From the `ResultMessage` | + +The span status is error when the `ResultMessage` has `is_error` (the description is its stop reason) or when `on_error` recorded an exception, which also adds an `exception` event. Three more events are recorded on this span: `api_retry` (`attempt`, `max_retries`, `retry_delay_ms`, `error_status`, `error`), `rate_limit` (`status`, `rate_limit_type`) and `tool_progress` (`tool_name`, `tool_use_id`, `elapsed_time_seconds`). + +**`claude_agent.generation`** + +| Attribute | Value | +|-----------|-------| +| `openinference.span.kind` | `LLM` | +| `langfuse.observation.type` | `generation` | +| `gen_ai.response.model`, `llm.model_name` | The model named by the `AssistantMessage` | +| `gen_ai.completion`, `output.value` | The text blocks of the message joined by newlines; an empty string when it has none (a thinking or tool-call message) | +| `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.cache_read_input_tokens` | The usage of the API response, on the first span of each `message_id` only (see below) | + +**`claude_agent.tool.`** + +| Attribute | Value | +|-----------|-------| +| `openinference.span.kind` | `TOOL` | +| `langfuse.observation.type` | `tool` | +| `tool.name` | The name of the tool | +| `input.value`, `input.mime_type` | The tool input as JSON, and `application/json` | +| `output.value`, `output.mime_type` | The tool result: a String as it is (`text/plain`), structured content as JSON (`application/json`). Both are left out when the result has no content | + +The span status is error when the tool result has `is_error`. + +**Token usage on generation spans.** The CLI sends one `AssistantMessage` per content block, so an API response with a thinking block and a tool call produces two `claude_agent.generation` spans, and both messages repeat the response's `message_id` and `usage`. The observer sets the four `gen_ai.usage.*` token attributes on the first generation span of each `message_id` and on none of that response's other spans, so a sum over generation spans counts every response once. A message without a `message_id` keeps its usage. That usage is the snapshot taken when the response started: the input, cache-read and cache-creation counts are final, but `gen_ai.usage.output_tokens` holds only the few tokens generated by then, and the response's final output count never reaches the message stream. For authoritative totals, output tokens included, read the session span, which takes them from `ResultMessage#usage`. `gen_ai.usage.cost` and `llm.cost.total` record the **increase** since the last observed `ResultMessage.total_cost_usd` in the connected session, rather than repeating that cumulative total on every turn's span. The baseline survives per-turn span resets and resets on close, a changed session ID (such as `/clear`), or a decreased counter. The original `ResultMessage` is unchanged. Missing costs omit both attributes without discarding the last known total; the next reported increment can therefore include an unreported or interrupted turn. The first observation uses the CLI's reported total. If a newer CLI restores historical spend on resume, that first span also includes it: a fresh observer cannot separate spend it never observed. These are CLI cost estimates, not billing records. -Events (`api_retry`, `rate_limit`, `tool_progress`) are recorded on the root span. +**How this compares with the OTel GenAI semantic conventions.** Nine of the attributes above are `gen_ai.*` names. Three are used as the conventions define them: `gen_ai.request.model`, `gen_ai.response.model`, and `gen_ai.usage.output_tokens` on the session span. The other six are deprecated, removed, not defined or defined differently there, and the observer keeps them for the Langfuse mappings they were added for: + +- `gen_ai.system` is deprecated in favor of `gen_ai.provider.name`, and `gen_ai.completion` has been removed. +- `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.cache_read_input_tokens` and `gen_ai.usage.cost` are not names the conventions define. The two cache names are the Anthropic API's field names. +- `gen_ai.usage.input_tokens` is the API's `input_tokens`, which leaves cache tokens out, while the conventions say the value should include them. A backend that follows the conventions therefore sees only part of the input of a cached turn: 18 tokens for a recorded turn that consumed 49,974. The inclusive count is `llm.token_count.prompt` on the session span. + +The observer sets neither `gen_ai.operation.name` nor `gen_ai.provider.name`. This comparison was made against `opentelemetry-semantic_conventions` 1.43.0; the GenAI conventions are still marked as in development. The `langfuse.observation.type` attribute is set on each span (`agent`/`generation`/`tool`) to enable Langfuse's **trace flow diagram** (DAG graph visualization). diff --git a/docs/options.md b/docs/options.md new file mode 100644 index 00000000..5c7d9ad0 --- /dev/null +++ b/docs/options.md @@ -0,0 +1,232 @@ +# Options Reference + +Every attribute of `ClaudeAgentSDK::ClaudeAgentOptions`, with its type, its default, and what it turns into: a flag on the `claude` command line, a field of the control protocol's `initialize` request, a variable in the CLI's environment, or something the SDK handles on its own side. The topic guides explain how to use the larger features; this page is the complete list. + +```ruby +options = ClaudeAgentSDK::ClaudeAgentOptions.new(model: 'claude-sonnet-5', max_turns: 5) +ClaudeAgentSDK.query(prompt: 'Hello', options: options) { |message| puts message } +``` + +- An unknown option name raises `ArgumentError`, in `.new`, in `#[]=` and in `#dup_with`. Names may be Symbols or Strings, snake_case or camelCase. +- `options.dup_with(max_turns: 1)` returns a changed copy and leaves the original alone. +- `ClaudeAgentSDK.configure { |config| config.default_options = { ... } }` sets defaults for every `ClaudeAgentOptions` built afterwards. A value you pass replaces the configured one, with two exceptions: `nil` keeps the configured value, and a Hash is merged into a configured Hash (that holds for every Hash-valued option, `env`, `mcp_servers`, `settings` and a Hash `system_prompt` or `output_format` included). + +## Reading the tables + +- **Type** is what `.new` accepts for the option: the type of the constructor's keyword in the gem's RBS signatures (`sig/claude_agent_sdk/types/options.rbs`). Every option also accepts `nil`. `bool` is `true` or `false`. A name that starts with an underscore is an interface, so any object with the right method fits: `call` for the callbacks, `puts` for `_Puts`, `append` and `load` for `_SessionStore`. +- **Default** is what the option reads after a `ClaudeAgentOptions.new` that leaves it out, with no configured defaults. [Leaving an option out, and passing `nil`](#leaving-an-option-out-and-passing-nil) has the other cases. +- **Sent to the CLI as** names the flag, the `initialize` field (`initialize.hooks` is the `hooks` field of that request) or the environment variable. "Nothing (SDK only)" marks an option that changes only what the SDK does in your process. + +### Leaving an option out, and passing `nil` + +What an option reads after `.new` depends on what you pass for it, and on whether `ClaudeAgentSDK.configure` has set a default for it: + +| You pass | No default is configured for the option | A default is configured for it | +|----------|------------------------------------------|--------------------------------| +| nothing | the Default column | the configured value | +| `nil` | the Default column, except where it says "explicit `nil` stays `nil`" | the configured value | +| another value | that value | that value; a Hash is merged into a configured Hash | + +The exception is every option whose default is `false`. With no default configured for it, `ClaudeAgentOptions.new(fork_session: nil).fork_session` is `nil`, not `false`. The option still acts as `false`: it adds nothing to what the SDK sends to the CLI, and its predicate (`fork_session?`) returns `false`. Only code that compares the reader's value with `false` sees the difference. + +Three types in the tables are aliases: + +| Alias | Stands for | +|-------|------------| +| `system_prompt_config` | `String`, `SystemPromptPreset`, `SystemPromptCustom`, `SystemPromptFile`, or the equivalent Hash (`{ type: 'preset' \| 'custom' \| 'file', ... }`) | +| `thinking_config` | `ThinkingConfigAdaptive`, `ThinkingConfigEnabled`, `ThinkingConfigDisabled`, or the equivalent Hash | +| `mcp_server_config` | `McpStdioServerConfig`, `McpSSEServerConfig`, `McpHttpServerConfig`, `McpSdkServerConfig`, or the equivalent Hash | + +## Prompt, model and limits + +Guides: [Structured Output](configuration.md#structured-output), [Thinking Configuration](configuration.md#thinking-configuration), [Budget Control](configuration.md#budget-control), [Fallback Model](configuration.md#fallback-model), [Advisor Model](configuration.md#advisor-model), [Beta Features](configuration.md#beta-features). + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `system_prompt` | `system_prompt_config` | `nil` | `--system-prompt`, `--system-prompt-file` or `--append-system-prompt`, and the fields `initialize.excludeDynamicSections` and `initialize.systemPromptSnapshot`. See [System prompt forms](#system-prompt-forms) | +| `model` | `String` | `nil` | `--model` | +| `fallback_model` | `String` | `nil` | `--fallback-model` | +| `advisor_model` | `String` | `nil` | `--advisor` | +| `max_turns` | `Integer` | `nil` | `--max-turns` | +| `max_budget_usd` | `Numeric` | `nil` | `--max-budget-usd` | +| `task_budget` | `TaskBudget \| Hash[Symbol \| String, untyped]` | `nil` | `--task-budget `. See [`task_budget`](#task_budget) | +| `thinking` | `thinking_config` | `nil` | `--thinking adaptive`, `--thinking disabled` or `--max-thinking-tokens `; `display:` adds `--thinking-display` | +| `effort` | `String \| Symbol \| Integer` | `nil` | `--effort` | +| `max_thinking_tokens` | `Integer` | `nil` | `--max-thinking-tokens`. Deprecated: used only when `thinking` is unset | +| `betas` | `Array[String]` | `nil` | `--betas` (comma-joined) | +| `output_format` | `Hash[Symbol \| String, untyped] \| String` | `nil` | `--json-schema ` | + +`effort` takes one of `ClaudeAgentSDK::EFFORT_LEVELS`; which levels a model supports is in [Thinking Configuration](configuration.md#thinking-configuration). + +### System prompt forms + +| `system_prompt` | Command line | +|-----------------|--------------| +| `nil` (the default) | `--system-prompt ""`: an empty prompt, not Claude Code's default one | +| a String, `SystemPromptCustom`, or `{ type: 'custom', prompt: '...' }` | `--system-prompt ` | +| `SystemPromptFile`, or `{ type: 'file', path: '...' }` (the path a String or a `Pathname`) | `--system-prompt-file ` | +| `SystemPromptPreset`, or `{ type: 'preset', preset: 'claude_code' }` | no system prompt flag, so the CLI uses its default prompt; `append:` adds `--append-system-prompt ` | + +`exclude_dynamic_sections` on a preset is sent as `initialize.excludeDynamicSections` ([Cross-User Prompt Caching](configuration.md#cross-user-prompt-caching)), and `snapshot` on a preset or a custom prompt as `initialize.systemPromptSnapshot` ([System Prompt Snapshot](configuration.md#system-prompt-snapshot)). Both are left out when unset. + +### `task_budget` + +A token budget for the task, counted on the API side: the model is told how much of it is left, so it can pace its tool use and wrap up before the limit. Pass `ClaudeAgentSDK::TaskBudget.new(total: 50_000)` or `{ total: 50_000 }`. It is not `max_budget_usd`, which is a spending limit in dollars. + +## Tools and permissions + +Guides: [Tools Configuration](configuration.md#tools-configuration), [Skills](configuration.md#skills), [Sandbox Settings](configuration.md#sandbox-settings), [Hooks & Permission Callbacks](hooks-and-permissions.md). + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `tools` | `Array[String] \| ToolsPreset \| Hash[Symbol \| String, untyped] \| String` | `nil` | `--tools `; a String is sent as written, in the CLI's own comma-separated form. `[]` sends `--tools ""` (no built-in tools); a `ToolsPreset`, or `{ type: 'preset' }`, sends `--tools default` | +| `allowed_tools` | `Array[String]` | `[]` | `--allowedTools` (comma-joined; no flag when empty) | +| `disallowed_tools` | `Array[String]` | `[]` | `--disallowedTools` (comma-joined; no flag when empty) | +| `permission_mode` | `String` | `nil` | `--permission-mode` | +| `can_use_tool` | `_CanUseTool` | `nil` | `--permission-prompt-tool stdio`: the CLI asks over the control protocol and the SDK calls the callback | +| `permission_prompt_tool_name` | `String` | `nil` | `--permission-prompt-tool `. Combining it with `can_use_tool` raises `ArgumentError` | +| `hooks` | `Hash[String \| Symbol, Array[HookMatcher]?]` | `nil` | `initialize.hooks` (matchers, callback ids, timeouts). The callbacks run in your process | +| `skills` | `String \| Array[String]` | `nil` | `Skill` or `Skill(name)` entries in `--allowedTools`; an Array is also sent as `initialize.skills`; `--setting-sources user,project` when `setting_sources` is `nil` | +| `sandbox` | `SandboxSettings \| Hash[Symbol \| String, untyped] \| bool` | `nil` | The `sandbox` key of `--settings` | + +`permission_mode` takes one of `ClaudeAgentSDK::PERMISSION_MODES`. + +## MCP servers, agents and plugins + +Guides: [Custom Tools (SDK MCP Servers)](mcp-servers.md), [Subagent capabilities](subagents.md). + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `mcp_servers` | `Hash[String \| Symbol, mcp_server_config] \| String` | `{}` | `--mcp-config`: a Hash as `{"mcpServers": {...}}` JSON, a String (a file path or JSON) as given. An SDK server contributes only its `type` and `name`; its tools are served in your process over the control protocol | +| `strict_mcp_config` | `bool` | `false`; explicit `nil` stays `nil` | `--strict-mcp-config`. See [`strict_mcp_config`](#strict_mcp_config) | +| `agents` | `Hash[String \| Symbol, AgentDefinition \| Hash[Symbol \| String, untyped]]` | `nil` | `initialize.agents` | +| `plugins` | `Array[SdkPluginConfig \| Hash[Symbol \| String, untyped]]` | `nil` | `--plugin-dir `, once per plugin. The path may be a String or a `Pathname` | + +### `strict_mcp_config` + +`true` makes the CLI use only the servers in `mcp_servers` and ignore every other MCP configuration (the CLI's own description of `--strict-mcp-config`: "Only use MCP servers from --mcp-config, ignoring all other MCP configurations"). + +"Every other" includes the MCP servers connected to the claude.ai account the CLI is logged in with. A session loads those by default, and `setting_sources: []` does not keep them out, because they do not come from a settings file. `client.mcp_status` shows them: their `:config` has `type: 'claudeai-proxy'`. A host that runs sessions for other people under its own login should set `strict_mcp_config: true`. + +## Settings and context + +Guides: [Bare Mode](configuration.md#bare-mode), [Verbatim Prompts](configuration.md#verbatim-prompts), [Session Isolation](configuration.md#session-isolation). + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `settings` | `String \| Pathname \| Hash[Symbol \| String, untyped]` | `nil` | `--settings`: a Hash as JSON, a String (a file path or JSON) as given, a `Pathname` as the path of a settings file. With `sandbox` set as well, one merged JSON value | +| `setting_sources` | `Array[String]` | `nil` | `--setting-sources` (comma-joined). `[]` sends `--setting-sources ""`; `nil` sends no flag | +| `add_dirs` | `Array[String \| Pathname]` | `[]` | `--add-dir`, once per directory | +| `bare` | `bool` | `nil` | `--bare` | +| `verbatim_prompts` | `bool` | `false`; explicit `nil` stays `nil` | `client_composed: true` on every user message the SDK writes | + +`setting_sources` takes entries of `ClaudeAgentSDK::SETTING_SOURCES` (`user`, `project`, `local`). With `nil`, the default, the CLI decides, and it loads the user's and the project's settings and `CLAUDE.md` files. `[]` loads none of the three sources. Neither value affects the auto-memory: see [Session Isolation](configuration.md#session-isolation). + +## Sessions + +Guide: [Session Browsing & Mutations](sessions.md), which also covers `session_store`. [File Checkpointing & Rewind](configuration.md#file-checkpointing--rewind) is in the configuration guide. + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `resume` | `String` | `nil` | `--resume=` | +| `continue_conversation` | `bool` | `false`; explicit `nil` stays `nil` | `--continue`. Combining it with `resume` raises `ArgumentError` | +| `fork_session` | `bool` | `false`; explicit `nil` stays `nil` | `--fork-session` | +| `session_id` | `String` | `nil` | `--session-id=` | +| `resume_session_at` | `String` | `nil` | `--resume-session-at=`. Without `resume` it raises `ArgumentError` | +| `resume_drops_turn` | `String` | `nil` | `--resume-drops-turn=` | +| `enable_file_checkpointing` | `bool` | `false`; explicit `nil` stays `nil` | `CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=true` in the CLI's environment | +| `session_store` | `_SessionStore` | `nil` | `--session-mirror`. The SDK appends the mirrored transcript to the store and can resume from it | +| `session_store_flush` | `String \| Symbol` | `"batched"` | Nothing (SDK only): `'batched'` or `'eager'` | +| `load_timeout_ms` | `Numeric` | `60000` | Nothing (SDK only): the limit, in milliseconds, for each store call while a resume loads the transcript | + +To find the session to resume, `ClaudeAgentSDK.list_sessions` lists sessions and `ClaudeAgentSDK.get_session_info(session_id:, directory: nil, session_store: nil)` reads the metadata of one session without listing the others. It returns an `SDKSessionInfo`, or `nil` when there is no session to report under that id. Both are plain functions that need neither a `Client` nor the CLI; the sessions guide has the rest of them. + +## Message stream + +Guides: [Forwarding Subagent Text](configuration.md#forwarding-subagent-text), [Subagent Progress Summaries](configuration.md#subagent-progress-summaries). The message classes are in the [types reference](types.md#message-types). + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `include_partial_messages` | `bool` | `false`; explicit `nil` stays `nil` | `--include-partial-messages`. See [`include_partial_messages`](#include_partial_messages) | +| `include_hook_events` | `bool` | `false`; explicit `nil` stays `nil` | `--include-hook-events`. See [`include_hook_events`](#include_hook_events) | +| `forward_subagent_text` | `bool` | `false`; explicit `nil` stays `nil` | `initialize.forwardSubagentText`, sent only when `true` | +| `agent_progress_summaries` | `bool` | `nil` | `initialize.agentProgressSummaries`, left out when `nil` | + +### `include_partial_messages` + +Asks the CLI for partial message chunks while the model is still producing a message. Each chunk arrives as a `StreamEvent` whose `event` is the raw API stream event, a Hash with Symbol keys; the complete `AssistantMessage` still follows. Without the option the CLI sends no `StreamEvent`. + +### `include_hook_events` + +Asks the CLI to put all hook lifecycle events into the message stream, as `HookStartedMessage`, `HookProgressMessage` and `HookResponseMessage` (see [System and progress messages](types.md#system-and-progress-messages)). It only adds messages: the callbacks you register with `hooks` are called whether or not it is set. + +## The CLI process + +Guides: [Vendoring the CLI](cli-installer.md), [Custom Transport](client.md#custom-transport). + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `cli_path` | `String \| Pathname` | `nil` | The executable the SDK starts. `nil`: the [discovery order](cli-installer.md#cli-discovery-order) | +| `cwd` | `String \| Pathname` | `nil` | The working directory of the CLI process (and its `PWD`) | +| `env` | `Hash[String \| Symbol, String?]` | `{}` | Merged over the environment the CLI process inherits. A `nil` value unsets the variable | +| `user` | `String \| Integer` | `nil` | The user the CLI process runs as (name or uid; Unix, and the SDK process needs the privilege to switch) | +| `extra_args` | `Hash[String \| Symbol, untyped]` | `{}` | `-- ` per pair, `--` for a `nil` value. See [`extra_args`](#extra_args) | +| `stderr` | `_StderrCallback` | `nil` | Nothing (SDK only): called with each line the CLI writes to stderr | +| `debug_stderr` | `_Puts \| String` | `nil` | Nothing (SDK only): an IO-like object (`puts`) or a file path that receives each CLI stderr line | +| `max_buffer_size` | `Integer` | `nil` | Nothing (SDK only): the largest single message, in bytes, accepted from the CLI. `nil` means 1 MiB | + +These options describe the process the SDK starts itself. A [custom transport](client.md#custom-transport) starts the CLI its own way and decides what to do with them. + +### `extra_args` + +The way to pass a CLI flag the SDK has no option for. A key is the flag's name without the leading dashes; the value follows it, and `nil` means a flag without a value: + +```ruby +options = ClaudeAgentSDK::ClaudeAgentOptions.new( + extra_args: { + 'no-session-persistence' => nil, # --no-session-persistence + 'agent' => 'reviewer' # --agent reviewer + } +) +``` + +- A flag name may contain only lowercase letters, digits and hyphens. Anything else raises `ArgumentError` when the command line is built, before the CLI starts. +- Values are converted with `to_s`. One that starts with `-` is sent as `--flag=value`, so the CLI cannot read it as another flag. +- The flags go at the end of the command line, after everything the options above produce. The SDK does not check that the CLI knows them: an unknown flag makes the CLI exit at startup, which raises `ProcessError` (`error: unknown option '--...'`). +- Use an option when there is one. A flag given here as well as through its option reaches the CLI twice. + +## Callbacks and observers + +Guides: [Observability](observability.md), [Rails Integration](rails.md). + +| Option | Type | Default | Sent to the CLI as | +|--------|------|---------|--------------------| +| `observers` | `Array[untyped]` | `[]` | Nothing (SDK only): observer instances, or callables that return a fresh one per query or session | +| `callback_scheduling` | `:thread \| :inline \| "thread" \| "inline"` | `:thread` | Nothing (SDK only): `:thread` runs each callback on a plain thread, `:inline` on the reactor fiber. A String is stored as its Symbol (`callback_scheduling: 'inline'` reads back as `:inline`). A value that does not convert (through `to_sym`) to `:thread` or `:inline` raises `ArgumentError`; `nil` means the default | +| `callback_wrapper` | `_CallbackWrapper` | `nil` | Nothing (SDK only): a callable wrapped around every callback dispatch | + +## Environment variables + +Variables that change what the SDK or a session does. "SDK process" means your Ruby process's own environment; `env` means `ClaudeAgentOptions#env`, which reaches only the CLI. + +| Variable | Set it in | Effect | +|----------|-----------|--------| +| `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN` | SDK process (the CLI inherits it) or `env` | Credentials the CLI can authenticate with | +| `CLAUDE_CLI_PATH` | SDK process | The CLI executable, ahead of the vendored binary and `PATH`, when `cli_path` is `nil`. A relative path is resolved against the SDK process's working directory. A value that does not name an executable file is skipped without a warning, and discovery goes on to the vendored binary, `PATH` and the common install locations | +| `CLAUDE_CONFIG_DIR` | SDK process, and `env` for the CLI | Where Claude Code keeps its configuration and transcripts, instead of `~/.claude`. The session functions read it from the SDK process ([Sessions](sessions.md)) | +| `CLAUDE_AGENT_SDK_CONTROL_REQUEST_TIMEOUT_SECONDS` | SDK process | How long the SDK waits for the CLI's answer to a control request. Default 1200 ([Error Handling](errors.md#configuring-timeout)) | +| `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` | `env`, or the SDK process | See below | +| `CLAUDE_CODE_DISABLE_AUTO_MEMORY` | `env` | `'1'` turns the CLI's auto-memory off ([Session Isolation](configuration.md#session-isolation)) | +| `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS` | `env` | `'1'` makes `SessionStateChangedMessage` reach your message block | +| `CLAUDE_CODE_DISABLE_ADVISOR_TOOL` | `env` | `'1'` disables the advisor tool ([Advisor Model](configuration.md#advisor-model)) | + +### `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` + +A one-shot `query()` or `ask` that has hooks, a `can_use_tool` callback or SDK MCP servers keeps the CLI's stdin open after a turn's result, because background work can still wake the session for another turn whose hook, permission and tool requests are answered over stdin. It closes stdin when the CLI reports that it is idle. This variable bounds that wait: if the CLI still reports work this many milliseconds after a result and no new turn has started, the run ends anyway. + +- The default is 600000 (10 minutes). `0` means no limit. +- It is read from `env` first and from the SDK process's environment otherwise. Only a plain non-negative integer counts; anything else gives the default. +- The clock runs only between turns. A turn in progress, a request the SDK is still answering and a background agent that is still running stop it. +- The CLI reads the same variable for its own wait for background work once stdin is closed. +- A CLI that reports no session state (2.1.282 and earlier) is not waited for: stdin closes at the first result with no tracked background task still running. +- A `Client` session driven with `Client#query` is not affected: its stdin stays open until `disconnect`. diff --git a/docs/rails.md b/docs/rails.md index 04518dad..23af8641 100644 --- a/docs/rails.md +++ b/docs/rails.md @@ -16,7 +16,7 @@ The gem ships a Railtie, an install generator and a rake task for vendoring the bin/rails generate claude_agent_sdk:install ``` - This writes `config/initializers/claude_agent_sdk.rb` — a `ClaudeAgentSDK.configure` block with commented defaults (model, permission mode, CLI path, OpenTelemetry) and the Rails callback wrapper [described below](#rails-executor-around-callbacks-callback_wrapper) switched on — and adds `/vendor/claude/` to `.gitignore`. + This writes `config/initializers/claude_agent_sdk.rb` — a `ClaudeAgentSDK.configure` block with commented defaults (model, permission mode, CLI path, [per-user isolation](#per-user-isolation), OpenTelemetry) and the Rails callback wrapper [described below](#rails-executor-around-callbacks-callback_wrapper) switched on — and adds `/vendor/claude/` to `.gitignore`. 3. Vendor the Claude Code CLI: @@ -33,7 +33,10 @@ The gem ships a Railtie, an install generator and a rake task for vendoring the # app/jobs/summarize_ticket_job.rb class SummarizeTicketJob < ApplicationJob def perform(ticket) - options = ClaudeAgentSDK::ClaudeAgentOptions.new(tools: [], max_turns: 1) # text only, no built-in tools + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + tools: [], max_turns: 1, # text only, no built-in tools + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # see "Per-user isolation" + ) prompt = "Summarize this support ticket in two sentences:\n\n#{ticket.body}" ClaudeAgentSDK.query(prompt: prompt, options: options) do |message| @@ -43,24 +46,26 @@ The gem ships a Railtie, an install generator and a rake task for vendoring the end ``` - The block runs on a plain thread (see the next section), so ActiveRecord calls inside it just work. For multi-turn sessions, hooks, custom tools and interrupts use `ClaudeAgentSDK::Client.open` — see [ActionCable streaming](#actioncable-streaming) below. + The block runs on a plain thread (see the next section), so ActiveRecord calls inside it are safe. It is a thread of its own, though: the job's `Current` attributes, time zone, log tags and database role or shard are not set there — see [Request state does not follow into callbacks](#request-state-does-not-follow-into-callbacks). For multi-turn sessions and mid-session control (interrupt, model switching) use `ClaudeAgentSDK::Client.open` — see [ActionCable streaming](#actioncable-streaming) below. Hooks and custom tools work with `ClaudeAgentSDK.query` too. ## Thread-keyed libraries are safe inside SDK callbacks The SDK depends on [`async`](https://github.com/socketry/async), which installs a Fiber scheduler that multiplexes fibers onto a single OS thread and intercepts IO so blocking calls yield to siblings. Most mature Ruby libraries are thread-safe but not fiber-safe — they key state (checked-out DB connections, per-thread caches, request stores) on `Thread.current`. When the scheduler interleaves two fibers on one thread, those fibers share the same state slot, and interleaved IO on a shared connection silently corrupts wire protocols. This affects every DB driver keyed by thread (`pg`, `mysql2`, `sqlite3`), ActiveRecord's connection pool, and HTTP/cache clients pooled per thread. -You do **not** need to think about this. By default (`callback_scheduling: :thread`; see the fiber-workers section below for the opt-in alternative) the SDK hops to a plain thread at every user-callback boundary — message blocks given to `query` / `Client`, SDK MCP tool handlers, hooks, permission callbacks, and observer methods — so your code runs with no Fiber scheduler active and inherits the ordinary thread-keyed assumptions every Rails / Sidekiq / Kamal app already makes: +The SDK keeps your callbacks out of this. By default (`callback_scheduling: :thread`; see the fiber-workers section below for the opt-in alternative) it hops to a plain thread at every user-callback boundary — message blocks given to `query` / `Client`, SDK MCP tool handlers, hooks, permission callbacks, and observer methods — so your code runs with no Fiber scheduler active, and thread-keyed libraries behave there as they do on any other thread: ```ruby tool = ClaudeAgentSDK.create_tool('lookup_user', 'Look up a user', { id: Integer }) do |args| - User.find(args[:id]).name # just works + User.find(args[:id]).name # a connection of this thread's own: safe end ClaudeAgentSDK.query(prompt: '...') do |message| - Message.create!(role: 'assistant', body: message.to_s) # just works + Message.create!(role: 'assistant', body: message.to_s) # likewise end ``` +That thread is a new one, not the thread that called the SDK. The connection pool does not mind; everything Rails keeps for the current request or job does — `Current` attributes, `Time.zone`, log tags, the database role and shard are all back at their defaults inside a callback, and the callback is outside the caller's transaction. Read [Request state does not follow into callbacks](#request-state-does-not-follow-into-callbacks) and [Transactions and the connection pool](#transactions-and-the-connection-pool) before a callback touches anything scoped to the request. + The trade-off: because callbacks run on a plain thread rather than inside an `Async::Task`, fiber-specific primitives aren't available to them — `Async::Task.current` will raise "No async task available". If a callback wants cooperative concurrency it should open its own `Async { }` block. In practice, callbacks typically do some Ruby work, call external services, and return — so this rarely matters. If you wrap your own call site in an outer `Async { }` block, the scheduler is visible to your code again; you've opted in, and whatever fiber-safety rules your app uses apply there. ### Rails executor around callbacks: `callback_wrapper` @@ -75,20 +80,233 @@ ClaudeAgentSDK.configure do |config| end ``` -It runs each callback inside `Rails.application.executor.wrap` — except where that would deadlock, which is why it replaces the bare `->(invocation) { Rails.application.executor.wrap { invocation.call } }` this guide used to recommend: +It runs each callback inside the Rails executor — except where that would deadlock, which is why it replaces the bare `->(invocation) { Rails.application.executor.wrap { invocation.call } }` this guide used to recommend: - **Development (code reloading enabled).** Every executor then holds a share of the code-reload interlock. The request or job calling the SDK is already inside the executor, and in `:thread` mode it waits for the callback's thread. If a reload is requested meanwhile (say, another request arrives after the agent edited an app file), the reloader queues for the exclusive unload lock, and a callback thread entering `executor.wrap` queues behind it for a fresh share — which the reloader can never let through while the waiting caller holds its own. Everything hangs. The helper instead runs the callback without entering the executor (the caller's share still keeps code from being unloaded under it) and returns the thread's ActiveRecord connections to the pool when the callback finishes. The executor's other per-run hooks (query cache, `CurrentAttributes` reset) do not run for callbacks in this case. - **`config.allow_concurrency = false`.** The executor holds a process-wide monitor that the calling thread already owns, so a callback thread's `executor.wrap` would block every time; same treatment. -- **Already inside the executor** (`:inline` scheduling on a job's own fiber): the callback runs straight through, leaving cleanup to the enclosing executor. +- **Already inside the executor** — a callback that runs on the caller's own fiber, which under `:inline` scheduling is a `Client`'s message block and observers: it runs straight through, leaving cleanup to the enclosing executor. The other `:inline` callbacks run on fibers of their own, where under fiber isolation the executor is not active; they take one of the other paths. + +Everywhere else — production, with no reloading — the callback runs inside the executor: its run hooks before the callback and its complete hooks after it, also when the callback raises. The configuration is read per call, so one initializer is correct in every environment. + +**Development: an agent run still holds the reload lock.** The helper removes the deadlock, not the wait. A request or in-process job that runs an agent stays inside the executor, with its share of the interlock, until the run returns. Once a file changes — an agent editing your app does that — the next request asks to reload, the reloader waits for that share, and every other request waits behind the reloader. Rails treats any long request this way; it is not specific to the SDK. In development, run agent jobs in a separate process (`bin/jobs`, Sidekiq) rather than in a controller action or the in-process `:async` adapter, and point an agent that edits code at a different checkout than the one the server runs from. To see who is waiting for whom, add `config.middleware.insert_before Rack::Sendfile, ActionDispatch::DebugLocks` and open `/rails/locks`. + +**Errors and `Rails.error`.** The wrapper reports nothing to `Rails.error` itself, which is the one difference from `executor.wrap` (that reports whatever passes through it as an unhandled error). What your error tracker sees is therefore decided by where an exception ends up: + +- An exception that escapes a callback — one raised in a message block, say — comes out of `query` / `receive_response` on the thread that called the SDK. It is reported there, once, with that thread's context, by whatever runs the code inside the executor: the request middleware for a controller action, ActiveJob for a job a queue worker runs (`perform_later`). A `perform_now` called outside any executor — from a script or a console — raises it to its caller and reports nothing by itself. +- A failure the SDK handles is not reported: an exception in a hook or `can_use_tool` is answered to the CLI as an error response, one in an SDK MCP tool handler becomes an error result the model sees, one in an observer is swallowed, and a timed-out or cancelled callback is cancellation, not an error. To track these, report them where they happen: `rescue` inside the callback, call `Rails.error.report(e, handled: true)`, and re-raise. + +Writing your own wrapper: it is a callable receiving a zero-arg `invocation`; it must call it and return its value. It runs on the **same execution context as the callback** — inside the worker thread in `:thread` mode, which is the whole point: the executor runs on the thread that touches ActiveRecord, so connections check back in when the callback ends. Exceptions from the callback propagate through the wrapper unchanged (don't rescue them); `ensure`-based wrappers are safe, including around a `break` from a message block. -Everywhere else — production, with no reloading — it is exactly `executor.wrap`. The configuration is read per call, so one initializer is correct in every environment. +Beyond the executor, this is a generic hook: APM spans, logging context, per-request state. To combine a wrapper of your own with the Rails one, call the Rails one from yours — and mind which side of that call your code is on. In production `rails.call` enters the Rails executor, and the executor starts every execution from a clean slate: on the way in it resets `CurrentAttributes` and the error context (`Rails.error.set_context`). + +```ruby +rails = ClaudeAgentSDK::Railtie.callback_wrapper + +# What only has to surround the callback can stay outside: +->(invocation) { MyApm.trace('agent.callback') { rails.call(invocation) } } + +# State that Rails resets per execution is set inside: +->(invocation) { rails.call(-> { Current.set(account: account) { invocation.call } }) } + +# Not around it. This loses Current.account in every callback, in production only: +->(invocation) { Current.set(account: account) { rails.call(invocation) } } +``` -Writing your own wrapper: it is a callable receiving a zero-arg `invocation`; it must call it and return its value. It runs on the **same execution context as the callback** — inside the worker thread in `:thread` mode, which is the whole point: `executor.wrap` runs on the thread that touches ActiveRecord, so connections check back in when the callback ends. Exceptions from the callback propagate through the wrapper unchanged (don't rescue them); `ensure`-based wrappers like `executor.wrap` are safe, including around a `break` from a message block. Beyond the executor, this is a generic hook for APM span propagation, `CurrentAttributes`/logging context, etc. — to combine one with the Rails wrapper, call it from yours: `rails = ClaudeAgentSDK::Railtie.callback_wrapper` then `->(inv) { MyApm.trace { rails.call(inv) } }`. +The last form is a trap because it works in development, where the Rails wrapper stays out of the executor (see above), and because the loss looks selective: `Time.zone`, log tags and `connected_to` are not reset by the executor and survive on either side. `Current.set` does not carry the error context either; that needs `ActiveSupport::ExecutionContext.set`. The [recipe below](#carrying-the-callers-state-into-callbacks) puts all of it in the right place, and `spec/rails/callback_wrapper_composition_spec.rb` pins this rule against the executor hooks of both supported Rails versions. The wrapper also composes around every timeout-bounded `SessionStore` adapter call (mirror-batcher appends, resume-materialization loads and listings), inside the timeout bound — so an ActiveRecord-backed store adapter gets the same connection hygiene as your callbacks. When do you want this vs `callback_scheduling: :inline`? `callback_wrapper` + default `:thread` mode is the right choice for ordinary threaded hosts (Puma, threaded Sidekiq/solid_queue): it fixes connection hygiene without any fiber-isolation precondition. `:inline` is only for hosts that are fiber-isolated end to end (solid_queue fiber workers with `isolation_level = :fiber`); there the wrapper still applies — it simply runs in place on the reactor fiber. +## Request state does not follow into callbacks + +A callback is written inside your controller action or job, a few lines below its `Current.set` or `connected_to` block — but it does not run there. It runs on a thread the SDK starts for it (the default `:thread` scheduling) or on a fiber of the SDK's reactor (`:inline`), and Rails keeps what belongs to the current request or job per thread or per fiber. A thread or fiber Rails never set up reads all of it as the default: + +| Set on the caller | What a callback sees | +| --- | --- | +| `Current.user = alice` (any `ActiveSupport::CurrentAttributes`) | `nil` | +| `Time.zone = "Tokyo"`, `Time.use_zone` | the application's default zone (`UTC` unless configured) | +| log tags: `Rails.logger.tagged("req-123")`, the request id, ActiveJob's job id | none | +| `connected_to(role: :reading)` | `:writing` | +| `connected_to(shard: :tenant_b)` | `:default` | +| `connected_to(prevent_writes: true)` | writes allowed | +| the error context: `Rails.error.set_context`, the controller or job Rails records | empty | +| `I18n.locale = :de` | depends on the i18n version — see below | +| the OpenTelemetry context | kept — the SDK carries it across | + +This is the same in a message block, an observer, a hook, `can_use_tool` and an SDK MCP tool handler; with and without `Railtie.callback_wrapper`; in development and in production. It is the same under `callback_scheduling: :inline` with fiber isolation, with one exception: there a `Client`'s message block and observers run on the fiber that called the SDK, and see its state. (`ClaudeAgentSDK.query` runs every callback on another fiber, and hooks, `can_use_tool` and tool handlers always do.) + +What a callback gets of `I18n.locale` depends on where i18n keeps its configuration: + +| i18n | Callback on a thread of its own (`:thread` scheduling) | Callback on another fiber of the caller's thread (`:inline`) | +| --- | --- | --- | +| 1.14.7 and earlier: a fiber-local | the default locale | the default locale | +| 1.14.8: a thread variable | the default locale | `:de` — every fiber of the thread shares one locale | +| 1.15 and later: fiber storage | `:de` — new threads and fibers inherit it | `:de` | + +Nothing raises. The consequences are silent: + +- A job inside `connected_to(shard: :tenant_b)` writes its own records to `tenant_b`; the same `create!` in a tool handler or in the message block writes to the **default** shard. Inside `connected_to(role: :reading)` the caller's own write raises `ActiveRecord::ReadOnlyError`; the same write in a callback succeeds, on the primary. +- A scope or policy that reads `Current.account` runs unscoped. Log lines written by callbacks carry no request id, errors reported from them no controller or job, and times are formatted in the default zone. + +### Carrying the caller's state into callbacks + +The SDK has no option for this yet. The supported way today is a wrapper of your own that captures the state **on the caller** and restores it **inside** the Rails wrapper, on the callback's thread or fiber: + + +```ruby +# app/lib/agent_context.rb +module AgentContext + # A callback_wrapper that runs SDK callbacks under the state of whoever + # calls this method. Call it from the request or job, once per SDK call. + def self.callback_wrapper + rails = ClaudeAgentSDK::Railtie.callback_wrapper + origin = Fiber.current + restore = capture + + lambda do |invocation| + # The callback is running on the caller itself: the state is there. + next rails.call(invocation) if Fiber.current.equal?(origin) + + # Restore inside the Rails wrapper: the executor it enters starts + # every execution from a clean slate. + rails.call(-> { restore.call(invocation) }) + end + end + + # Snapshots the caller. Returns a lambda that runs an invocation under + # that snapshot, on whatever thread or fiber it is called on. + def self.capture + attributes = Current.attributes.dup + context = ActiveSupport::ExecutionContext.to_h + locale = I18n.locale + zone = Time.zone + tags = log_tags + + lambda do |invocation| + Current.set(attributes) do + ActiveSupport::ExecutionContext.set(**context) do + I18n.with_locale(locale) do + Time.use_zone(zone) do + with_log_tags(tags) { invocation.call } + end + end + end + end + end + end + + # The tags of the first tagged logger Rails.logger writes to. Not + # Rails.logger.formatter: in a broadcast that can be a plain logger's. + def self.log_tags + loggers = Rails.logger.respond_to?(:broadcasts) ? Rails.logger.broadcasts : [Rails.logger] + formatter = loggers.map { |logger| logger.formatter if logger.respond_to?(:formatter) } + .find { |candidate| candidate.respond_to?(:current_tags) } + formatter ? formatter.current_tags.dup : [] + end + + # push / pop, not `Rails.logger.tagged(*tags) { ... }`: a BroadcastLogger + # runs that block once for every tagged logger it broadcasts to. + def self.with_log_tags(tags) + logger = Rails.logger + return yield if tags.empty? || !logger.respond_to?(:push_tags) + + logger.push_tags(*tags) + begin + yield + ensure + logger.pop_tags(tags.size) + end + end +end +``` + +Build the wrapper where the state is set, and pass it in that call's options: + +```ruby +class SummarizeTicketJob < ApplicationJob + def perform(ticket) + Current.account = ticket.account + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + callback_wrapper: AgentContext.callback_wrapper, # replaces the configured Rails wrapper, and calls it + tools: [], max_turns: 1, + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } + ) + + ClaudeAgentSDK.query(prompt: "Summarize:\n\n#{ticket.body}", options: options) do |message| + # Current.account, the locale, Time.zone, the job's log tags and error context are set here + ticket.update!(summary: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage) + end + end +end +``` + +What the recipe depends on: + +- **One wrapper per call, built on the caller.** `AgentContext.callback_wrapper` snapshots whoever calls it. Call it from the request or job, after its state is set and before `query` / `Client.open` — not inside their blocks, which may already run on another fiber. It cannot go into the initializer: a process-wide wrapper only ever runs at the destination and has no caller to look at. And it must not outlive the call: a wrapper is fixed for the lifetime of the session it was passed to, so one built when a long-lived `Client` connects makes every later turn run its callbacks as the **first** caller — another user's turn would read and write under the first user's account and shard. Open a session per request or job and resume it by id, as the examples below do, and the capture happens once per call. +- **The restore happens inside `rails.call`.** In production the Rails wrapper enters the executor, and the executor starts every execution from a clean slate: it resets `Current` and the error context. State set around `rails.call` is wiped on the way in; state set inside it survives. +- **No restore on the fiber that captured the state.** Under `:inline` scheduling a `Client`'s message block and observers run on the caller itself. The state is already there, and restoring it again would add the log tags a second time. +- **Log tags are pushed and popped.** `Rails.logger` is an `ActiveSupport::BroadcastLogger`, and `Rails.logger.tagged(*tags) { ... }` runs its block once for every tagged logger in the broadcast, returning an array: with two tagged loggers the callback would run twice. `push_tags` / `pop_tags` reach every tagged logger and run nothing. The tags are read from the first logger in the broadcast that keeps tags rather than from `Rails.logger.formatter`, which is the first logger's on Rails 8.1, tagged or not, and `nil` on 7.1 for a broadcast you built yourself. With no tagged logger at all the recipe carries no tags and still runs. +- **`ActiveSupport::ExecutionContext` is restored explicitly.** It is where `Rails.error.set_context` and Rails' own controller and job entries live; `Current.set` does not bring it back. Rails has no public reader for it, so recheck this line when you upgrade Rails. +- **The locale is restored rather than assumed**, because only i18n 1.15 and later hand it to every callback by themselves. The restore is safe wherever a callback has a thread of its own, which is `:thread` scheduling, with any i18n. With i18n 1.14.8 it is only safe there: under `:inline` all fibers of the reactor thread share one locale, so a callback's `I18n.with_locale` changes the locale of every job on that worker while the callback runs, and another job that sets its own locale meanwhile changes the callback's. On a fiber-isolated host, keeping jobs' locales apart takes i18n 1.15 or later: on 1.14.8 the jobs of one worker share a locale among themselves, with or without the SDK. `:thread` scheduling takes the recipe's restore off that shared thread; it does not separate the jobs. +- **Values travel, containers do not.** The recipe rebuilds the caller's state from values. The objects themselves — `Current.user`, say — are shared with the caller, so treat them as read-only in callbacks. Do not go further and copy thread-local variables wholesale, hand the caller's ActiveRecord connection to a callback, or pass `ActiveRecord::Base.connected_to_stack` across: those are mutable and belong to one thread. + +An application with replicas or shards also carries the role, the shard and `prevent_writes`. Capture them in `capture`: + +```ruby + role, shard = ApplicationRecord.current_role, ApplicationRecord.current_shard + prevent_writes = ApplicationRecord.current_preventing_writes +``` + +and re-enter them innermost, in place of `with_log_tags(tags) { invocation.call }`: + +```ruby + with_log_tags(tags) do + ApplicationRecord.connected_to(role: role, shard: shard, prevent_writes: prevent_writes) { invocation.call } + end +``` + +`connected_to` on `ApplicationRecord` switches the models that inherit from it; an application with several connection classes captures and re-enters each of them. + +What is checked: `spec/rails/request_state_spec.rb` boots a Rails application for each combination of production / development, `:thread` / `:inline` scheduling and `ClaudeAgentSDK.query` / `Client.open`. It pins the `Current`, `Time.zone`, log tag and error context rows of the table, the `I18n.locale` cases for whichever i18n the bundle resolves (CI's resolve 1.15 or later; the 1.14.7 and 1.14.8 rows were run against those releases), and runs the `AgentContext` block exactly as printed above through all five kinds of callback — also with two tagged loggers in the `Rails.logger` broadcast, and with a plain logger ahead of the tagged one. It does not cover the three `connected_to` rows or the role / shard lines — the gem's Rails test bundles carry no ActiveRecord; those were measured in a Rails 8.1 application with ActiveRecord and SQLite. + +## Transactions and the connection pool + +For the same reason — another thread — a callback is outside the caller's database transaction, on a connection of its own. Wrapping an SDK call in `transaction` or `with_lock` does not do what it looks like: + +```ruby +ticket.with_lock do # the job's connection holds the row lock + ClaudeAgentSDK.query(prompt: prompt) do |message| + next unless message.is_a?(ClaudeAgentSDK::ResultMessage) + + ticket.update!(summary: message.result) # another connection: waits for that lock, + end # while the job waits for this block +end +``` + +- A callback does not see rows the caller has not committed. +- What a callback writes is committed on its own connection. Rolling the caller's transaction back does not undo it. +- A callback that needs a lock the caller's transaction holds waits for the caller, which is waiting for the callback. SQLite gives up after its busy timeout (`database is locked`); a server database keeps the callback waiting for as long as its lock timeout allows, which for PostgreSQL is forever by default. No database can detect the cycle, because half of it is in your process. + +So finish the transaction **before** you call the SDK, and hand the callbacks what they need explicitly — ids rather than records in an unsaved state: + +```ruby +ticket.update!(state: 'summarizing') # committed before the agent starts +ticket_id = ticket.id + +ClaudeAgentSDK.query(prompt: prompt) do |message| + next unless message.is_a?(ClaudeAgentSDK::ResultMessage) + + Ticket.find(ticket_id).update!(summary: message.result, state: 'summarized') # commits here, independently +end +``` + +**Pool size.** A callback that uses the database needs a connection while the caller may still be holding one: inside a transaction, under an explicit lease, and for the whole request or job on Rails 7.1, where a connection stays with its thread until the request ends. And one session can have several callbacks in the database at the same moment — the SDK answers the CLI's hook, permission and tool requests concurrently, so two parallel tool calls mean two hooks running at once. Size the pool for the caller's connection **plus the peak number of callback invocations that use the database at the same time**, summed over the sessions a process runs concurrently — not for one extra connection per session. With a pool of one and the caller inside a transaction, the first query in a callback raises `ActiveRecord::ConnectionTimeoutError`. + +Under `callback_scheduling: :inline` with fiber isolation all of this applies to every callback that runs on a fiber other than the caller's: hooks, permission callbacks, tool handlers, and everything under `ClaudeAgentSDK.query`. Only a `Client`'s message block and observers share the caller's connection and transaction there. + +(Measured with ActiveRecord 8.1 on SQLite; the PostgreSQL and MySQL lock waits follow from how those databases wait for row locks and were not measured.) + ## Fiber workers (solid_queue) and `callback_scheduling: :inline` [solid_queue 728](https://github.com/rails/solid_queue/pull/728) added a fiber-based worker mode: workers configured with `fibers: N` run claimed jobs as fibers on one async reactor thread — built for exactly the long-lived, I/O-bound "LLM streaming" jobs this SDK produces. It requires the app to be fiber-isolated end to end: @@ -111,9 +329,13 @@ ClaudeAgentSDK.configure do |config| end ``` -With `:inline`, every user callback — message blocks, hooks, permission callbacks, SDK MCP handlers, observers — runs in place on the reactor fiber of the job. This is the same execution model as the Python SDK (async callbacks run natively on the event loop). Concretely: +With `:inline`, no user callback leaves the job's reactor: message blocks, hooks, permission callbacks, SDK MCP handlers and observers all run in place, on a fiber of that reactor. This is the same execution model as the Python SDK (async callbacks run natively on the event loop). -- `Fiber.scheduler` is live inside callbacks; reactor primitives work directly. DB access goes through the Rails 7.2+ fiber-aware pool under the same assumptions as the rest of your fiber-worker jobs. +In place on the reactor is not the same as on the job's own fiber, and under fiber isolation the fiber is what Rails keys the job's state on. A `Client`'s message block and observers run on the fiber that called the SDK. Hooks, permission callbacks and SDK MCP handlers run on child fibers of the session's read task, and `ClaudeAgentSDK.query` runs its whole body, message block included, on a fiber of its own. On those fibers `Current`, `Time.zone`, the log tags and the database role and shard start from their defaults, exactly as on a callback thread — see [Request state does not follow into callbacks](#request-state-does-not-follow-into-callbacks). + +Concretely: + +- `Fiber.scheduler` is live inside callbacks; reactor primitives work directly. DB access goes through the Rails 7.2+ fiber-aware pool, as in the rest of your fiber-worker jobs. - No per-call threads exist, so nothing can strand an AR connection. - The whole SDK session can live directly on the job fiber — no bridge threads. `Client#connect` already requires an Async context, and the transport's pipe I/O is scheduler-aware. - Hook timeouts become **cooperative**: a timed-out inline hook is cancelled at its next suspension point (its `ensure` blocks run), instead of being abandoned on a worker thread. A CPU-stuck hook cannot be timed out. @@ -148,7 +370,8 @@ class ChatAgentJob < ApplicationJob def perform(chat_id, message_content) options = ClaudeAgentSDK::ClaudeAgentOptions.new( system_prompt: { type: 'preset', preset: 'claude_code' }, - permission_mode: 'bypassPermissions' + permission_mode: 'bypassPermissions', + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # one job class serves every chat: see "Per-user isolation" ) ClaudeAgentSDK::Client.open(options: options) do |client| @@ -173,6 +396,18 @@ end `Client.open` connects, yields the client, and always disconnects — also when the block raises, so the job's error handling sees the original exception. It runs inside an existing reactor or starts its own, so a job needs no `Async { }.wait` wrapper. Its return value is the block's; to leave the block early use `next`, not `break` (outside an `Async` block, `break` raises `LocalJumpError`, though the session is still torn down). `break` inside `receive_response` itself is fine. +### Per-user isolation + +One job class serves every chat here, from one working directory — and the CLI keeps an auto-memory per *project directory*, not per session or per user: + +- Every SDK session reads that project's memory index. The CLI injects it next to the `CLAUDE.md` instructions, under the same "these instructions override default behavior" header. +- A session on the `claude_code` preset also **writes** it when a user asks it to remember something, and that write passes no permission check: no `permission_mode`, `can_use_tool` callback or hook is consulted. +- `setting_sources: []` isolates settings files. It does not turn this off. + +So in a multi-user app one user's "remember that…" becomes part of every other user's context. `env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }` turns auto-memory off for the session. Every set of options built on this page carries it (`spec/rails/session_isolation_spec.rb` checks each one). Set it for every session a multi-user app runs — per call, as here, or once for the whole process by uncommenting the line the generated initializer carries; defaults merge per key, so a per-call `env:` keeps it, and the short snippets on this page that pass no options get it too. The value has to be `'1'`: the CLI reads `'0'` or `'false'` as "force auto-memory on", which overrides even `autoMemoryEnabled: false` in settings. To see what a session loaded, call `client.context_usage[:memoryFiles]` (an entry with `type: "AutoMem"` is the memory index); it costs no model call. + +What else isolates sessions, and what only appears to, is covered in [Session isolation](configuration.md#session-isolation). + ## Session Resumption Persist Claude sessions for multi-turn conversations: @@ -195,8 +430,12 @@ class ChatSession < ApplicationRecord private def build_options - opts = { permission_mode: 'bypassPermissions', setting_sources: [] } - opts[:resume] = claude_session_id if claude_session_id.present? + opts = { + permission_mode: 'bypassPermissions', + setting_sources: [], # isolates settings files, not the CLI's auto-memory: + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }, # this does (see "Per-user isolation") + resume: claude_session_id.presence # nil for the first message: a new session + } ClaudeAgentSDK::ClaudeAgentOptions.new(**opts) end end @@ -214,7 +453,12 @@ class ClaudeAgentJob < ApplicationJob def perform(task_id) task = Task.find(task_id) - ClaudeAgentSDK::Client.open(options: ClaudeAgentSDK::ClaudeAgentOptions.new(max_turns: 10)) do |client| + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + max_turns: 10, + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # see "Per-user isolation" + ) + + ClaudeAgentSDK::Client.open(options: options) do |client| client.query(task.prompt) client.receive_response do |message| task.update!(status: 'done', result: message.result) if message.is_a?(ClaudeAgentSDK::ResultMessage) @@ -241,7 +485,8 @@ mcp_servers = { options = ClaudeAgentSDK::ClaudeAgentOptions.new( mcp_servers: mcp_servers, - permission_mode: 'bypassPermissions' + permission_mode: 'bypassPermissions', + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } # see "Per-user isolation" ) ``` @@ -251,7 +496,7 @@ Add OpenTelemetry tracing to your Rails app with a single initializer: ```ruby # config/initializers/opentelemetry.rb -require 'base64' +require 'base64' # a bundled gem since Ruby 3.4 — no Gemfile entry needed: Active Support 7.1+ depends on it require 'opentelemetry/sdk' require 'opentelemetry/exporter/otlp' diff --git a/docs/sessions.md b/docs/sessions.md index 7d7ddb75..547cc29f 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -2,7 +2,7 @@ Browse, read, mutate, fork, and resume Claude Code sessions directly from Ruby — no CLI subprocess required. By default these APIs read and write `~/.claude/projects/` JSONL files directly, respecting the `CLAUDE_CONFIG_DIR` environment variable (an empty value is treated as unset, falling back to `~/.claude`) and auto-detecting git worktrees. On a host with no usable home directory (`HOME` unset with no passwd entry — e.g. `docker --user` in a minimal image — or an empty/relative `HOME`) and no `CLAUDE_CONFIG_DIR`, the local-disk path raises `ClaudeAgentSDK::ConfigDirError`; set `CLAUDE_CONFIG_DIR` there. Every one of them also takes an optional `session_store:` to operate on a [`SessionStore`](#mirroring-to-a-sessionstore) instead (see [Store-backed sessions](#store-backed-sessions)). -Not-found semantics: the read APIs return `[]`/`nil` for unknown sessions and for directories that do not exist or have no recorded sessions. An explicit `directory:` strictly scopes the search to that project and its git worktrees — there is no cross-project fallback (pass `directory: nil` to search all projects). 0-byte transcript stubs are skipped during session-file resolution. Ids are validated at the boundary: a `session_id` that is not a UUID String, or an `agent_id` that is not a String of `[A-Za-z0-9._-]` characters (or is `.`/`..`), gets the same `[]`/`nil` as an unknown session (`import_session_to_store` raises `ArgumentError`), on the disk and store readers alike. The mutations (`rename_session`, `tag_session`, `delete_session`, `fork_session`, with or without `session_store:`) apply the same check to `session_id` and `up_to_message_id` and raise `ArgumentError` (`Invalid session_id: ...`) for anything that is not a UUID String. +Not-found semantics: the read APIs return `[]`/`nil` for unknown sessions and for directories that do not exist or have no recorded sessions. A `directory:` that was removed after its sessions were recorded (a deleted worktree) still names its project: the path is resolved as far as it exists, so those sessions can be listed, read, renamed, tagged, forked and deleted through it. An explicit `directory:` strictly scopes the search to that project and its git worktrees — there is no cross-project fallback (pass `directory: nil` to search all projects). 0-byte transcript stubs are skipped during session-file resolution. Ids are validated at the boundary: a `session_id` that is not a UUID String, or an `agent_id` that is not a String of `[A-Za-z0-9._-]` characters (or is `.`/`..`), gets the same `[]`/`nil` as an unknown session (`import_session_to_store` raises `ArgumentError`), on the disk and store readers alike. The mutations (`rename_session`, `tag_session`, `delete_session`, `fork_session`, with or without `session_store:`) apply the same check to `session_id` and `up_to_message_id` and raise `ArgumentError` (`Invalid session_id: ...`) for anything that is not a UUID String. `rename_session` raises `ArgumentError` for a `title:` that is blank or not a usable String (`nil`, another type, invalidly encoded), and `tag_session` for such a `tag:` — except `nil`, which clears the tag. ## Listing Sessions @@ -25,9 +25,16 @@ ClaudeAgentSDK.list_sessions(directory: '.', include_worktrees: true) Each `SDKSessionInfo` includes: `session_id`, `summary`, `last_modified`, `file_size`, `custom_title`, `first_prompt`, `git_branch`, `cwd`, `tag`, `created_at`. -Listings are newest first; sessions with the same `last_modified` are ordered by `session_id`, so `offset:`/`limit:` pages are stable across calls and the disk and store listings order identically. Blank (empty or whitespace-only) custom/AI titles, last-prompt and summary entries, `git_branch`, `cwd`, and `tag` values read as absent on both paths. `cwd` is the first non-blank top-level `cwd` in the transcript (a key nested in a tool input doesn't count), falling back to the project path; `first_prompt` is `nil` when the session has no usable prompt. `last_modified` is always Integer epoch milliseconds — the file mtime on disk, the adapter's `mtime` coerced as described under [Implementing an adapter](#implementing-an-adapter) on the store paths. +Listings are newest first; sessions with the same `last_modified` are ordered by `session_id`, so `offset:`/`limit:` pages are stable across calls and the disk and store listings order identically. Blank (empty or whitespace-only) custom/AI titles, last-prompt and summary entries, `git_branch`, `cwd`, and `tag` values read as absent on both paths. `cwd` is the first non-blank top-level `cwd` in the transcript (a key nested in a tool input doesn't count), falling back to the project path; `first_prompt` is `nil` when the session has no usable prompt; `created_at` is the first top-level `timestamp`. `last_modified` is always Integer epoch milliseconds — the file mtime on disk, the adapter's `mtime` coerced as described under [Implementing an adapter](#implementing-an-adapter) on the store paths (`get_session_info` takes it from the store's `list_sessions`, or else `list_session_summaries`, row for the session — one extra adapter call; a store that implements neither reports the timestamp of the session's last entry instead, `0` when no entry has one). -When `list_sessions` finds the same session in several project directories (copied config dirs, worktrees), it keeps one copy: the newest `last_modified`; on equal mtimes the larger file (the more complete copy); then the copy in the project directory whose name sorts first (worktree listings: the worktree `git worktree list` reports first, i.e. the main worktree). +The disk path does not read whole transcripts to list them: it looks at the first and the last 64 KiB of each file, and for `first_prompt` and `created_at` at up to the first 1 MiB when those first 64 KiB hold none (a large hook attachment, or a long prompt, often comes first). The store paths fold every entry. The two report the same values unless the entry that decides a field lies outside what the disk path reads: + +- a custom title or tag appended while the session was still running stops being seen once more than 64 KiB of transcript follows it (`custom_title` falls back to the AI title or `nil`, `summary` to the next source, `tag` to `nil`) until the CLI resumes the session, which re-appends them at the end; +- a last-prompt entry that is only at the start of the file is not used for `summary`; +- `cwd` comes from the first 64 KiB: a first entry larger than that leaves it at the project path; +- a first prompt whose transcript line ends beyond the first 1 MiB is not found: `first_prompt` is the name of a slash command seen before it, or `nil` — and a session with neither that nor a title or last-prompt entry in its last 64 KiB is not listed from disk at all. Likewise `created_at` is `nil` when no line ending within the first 1 MiB carries a timestamp of its own. + +When `list_sessions` finds the same session in several project directories (copied config dirs, worktrees), it keeps one copy: the newest `last_modified`; on equal mtimes the larger file (the more complete copy); then the copy in the project directory whose name sorts first (`directory:` listings: the directory's own copy, then the worktrees in the order `git worktree list` reports them, main worktree first). ## Reading Session Messages @@ -40,7 +47,7 @@ messages.each { |msg| puts "[#{msg.type}] #{msg.message}" } ClaudeAgentSDK.get_session_messages(session_id: 'abc-123-...', offset: 10, limit: 20) ``` -Each `SessionMessage` includes `type` (`"user"` or `"assistant"`), `uuid`, `session_id`, and `message` (the raw API message Hash, read from the transcript, so its keys are Strings: `msg.message['content']`; see [Hash keys](types.md#hash-keys)). +Each `SessionMessage` includes `type` (`"user"` or `"assistant"`), `uuid`, `session_id`, and `message` (the raw API message Hash, read from the transcript, so its keys are Strings: `msg.message['content']`; see [Hash keys](types.md#hash-keys)). Messages come back in conversation order. The CLI writes one entry per content block, so an assistant turn with several tool calls comes back as several `assistant` messages and one `user` message per `tool_result`; every result follows the `tool_use` it answers and precedes the next assistant turn. Transcripts are read as UTF-8 whatever the process locale is; a line that does not parse (the last line of a session whose CLI was killed mid-write) is skipped, and bytes that are not valid UTF-8 inside a line that does parse come back as U+FFFD. ## Reading Subagent Transcripts @@ -112,6 +119,8 @@ ClaudeAgentSDK.delete_session( ) ``` +Delete only sessions whose CLI process has exited. A CLI that is still running the session does not notice the deletion: its next write recreates the file with only the entries it writes from then on, so the session comes back in listings holding just the later part of the conversation. + ## Forking a Session ```ruby @@ -129,7 +138,9 @@ ClaudeAgentSDK.fork_session( ) ``` -> Session mutations use append-only JSONL writes with `O_WRONLY | O_APPEND` (no `O_CREAT`) for TOCTOU safety. They are safe to call while the session is open in a CLI process. `fork_session` writes and closes a private staging file before atomically publishing it with a hard link, so partial forks are not discoverable and existing sessions are never overwritten. The project filesystem must support hard links; publication failures leave the source and any existing destination untouched. +Without `title:` the fork is named after its source, ` (fork)`: the title the listing shows for the source (its custom title, else its AI title), else its first prompt, else `Forked session`. The disk and the store path use the same rule. + +> `rename_session` and `tag_session` use append-only JSONL writes with `O_WRONLY | O_APPEND` (no `O_CREAT`) for TOCTOU safety. They, and `fork_session`, are safe to call while the session is open in a CLI process; `delete_session` is not (see [Deleting a Session](#deleting-a-session)). `fork_session` writes and closes a private staging file before atomically publishing it with a hard link, so partial forks are not discoverable and existing sessions are never overwritten. The project filesystem must support hard links; publication failures leave the source and any existing destination untouched. ## Resuming at a Specific Message @@ -261,10 +272,11 @@ normal spawn path and `continue_conversation` moves on to the next candidate. > `HOME` the subprocess will see — `options.env`'s `HOME` when it sets one): > > - `.credentials.json`, with the OAuth `refreshToken` removed so the resumed -> subprocess can't consume it. On macOS with the default config dir and no +> subprocess can't consume it. On macOS with no > `ANTHROPIC_API_KEY`/`CLAUDE_CODE_OAUTH_TOKEN`, the credentials come from -> the Keychain entry when one exists (the redirected config dir would -> otherwise miss it). +> the CLI's Keychain entry for your config dir when one exists (the +> redirected config dir would otherwise miss it) — with a custom +> `CLAUDE_CONFIG_DIR`, only when that directory has no `.credentials.json`. > - `.claude.json` (from `$CLAUDE_CONFIG_DIR/.claude.json` when set, else > `~/.claude.json`). > - User `settings.json` and `cowork_settings.json` — so `apiKeyHelper`, `env`, @@ -276,7 +288,10 @@ normal spawn path and `continue_conversation` moves on to the next candidate. > Everything else in your config dir is **not** visible to the subprocess — > notably user `CLAUDE.md`, `agents/`, `skills/`, and `plugins/` (so, with the > plugin keys stripped, user plugins are off) — so a store-backed resume can -> still behave differently from a plain `resume:` of the same session. +> still behave differently from a plain `resume:` of the same session. The +> project's auto-memory is part of that: the temp config dir has no +> `projects/<key>/memory/`, so a store-backed resume neither loads the memory +> you already have nor keeps what the session writes to it. > Project-level `.claude/*` still applies (it resolves from `cwd`), and > hooks/options passed programmatically via `ClaudeAgentOptions` are unaffected. > Seeded files are written owner-only (`0600`); a missing source file is simply @@ -286,14 +301,26 @@ normal spawn path and `continue_conversation` moves on to the next candidate. > (terminal append failures — timeouts immediately, other failures after up to > three attempts — surfaced as `MirrorErrorMessage`): > the store copy is then incomplete and the temp dir holds the only copy of the -> dropped turns, so the SDK scrubs the credential copies, keeps the transcripts, -> and warns with the preserved path so you can import them into the store. +> dropped turns, so the SDK moves it into a fresh private directory next to it +> (`claude-preserved-resume-*`), keeps the transcripts (`projects/`) there, +> deletes everything else — the seeded credential and settings copies and +> whatever the CLI wrote beside them, such as its `backups/` copy of +> `.claude.json` — and warns with the new path so you can import them into the +> store. If what it moved is not the directory the SDK created (the path had +> been replaced, for instance by a symlink), or it cannot be moved, the SDK +> deletes nothing and the warning says the scrub was skipped and why; if an +> entry cannot be removed, the warning says the scrub failed and names what is +> left. ### Implementing an adapter Subclass `ClaudeAgentSDK::SessionStore` (or duck-type it). Only `#append` and `#load` are required; `#list_sessions`, `#delete`, `#list_subkeys`, and `#list_session_summaries` are optional and probed via `SessionStore.implements?`. +An optional method that raises `NotImplementedError` when called counts as not +implemented too (the stub a delegating wrapper inherits, or an adapter declining +it at run time): the SDK takes the same fallback, and the conformance suite +skips that method's contracts. Report `mtime` as epoch milliseconds; the SDK also accepts numeric-string, ISO-8601-string and `Time` mtimes (ordering them correctly and reporting them as Integer epoch ms in `last_modified`), but anything else sorts as oldest and @@ -489,7 +516,8 @@ be resumed with `session_store:` + `resume:` from the original directory. Re-importing appends the entries again, so adapters should dedupe by `entry['uuid']`. It raises `ArgumentError` for an invalid `session_id` and `Errno::ENOENT` when the transcript cannot be found; an unparseable line is -skipped with a warning. +skipped with a warning, and bytes that are not valid UTF-8 inside a line that +does parse are stored as U+FFFD (an adapter could not serialize them). > **Deprecated:** the separate store functions (`list_sessions_from_store`, > `get_session_info_from_store`, `get_session_messages_from_store`, diff --git a/docs/subagents.md b/docs/subagents.md index 426cc484..1fec2737 100644 --- a/docs/subagents.md +++ b/docs/subagents.md @@ -158,7 +158,7 @@ See [hook fields and permission cancellation](hooks-and-permissions.md). ## Minimal example -[`examples/subagent_status_example.rb`](../examples/subagent_status_example.rb) +[`examples/subagent_status_example.rb`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/subagent_status_example.rb) defines a tool-free reviewer, registers lifecycle hooks, reads metadata, and prints task events and forwarded child text without building a status model: diff --git a/docs/types.md b/docs/types.md index e667a668..85e7203f 100644 --- a/docs/types.md +++ b/docs/types.md @@ -82,9 +82,24 @@ currently plain classes, not `Type`s: use their snake_case accessors ```ruby # Union type of all possible messages Message = UserMessage | AssistantMessage | SystemMessage | ResultMessage | - StreamEvent | RateLimitEvent | ConversationResetMessage + StreamEvent | RateLimitEvent | ConversationResetMessage | + ToolProgressMessage | AuthStatusMessage | ToolUseSummaryMessage | + PromptSuggestionMessage ``` +This is everything the block of `query()`, `ask`, `Client#receive_messages` and +`Client#receive_response` can receive. `SystemMessage` stands for its typed +subclasses as well (`InitMessage`, `TaskStartedMessage`, ...; see +[System and progress messages](#system-and-progress-messages)): each of them +`is_a?(SystemMessage)`. The other ten classes share nothing but the SDK's +`Type` base class. + +A message type this SDK version does not know is skipped before it reaches +your block, and a `system` message with an unknown `subtype` arrives as a plain +`SystemMessage` (`subtype` and `data` set). Give a `case` over messages an +`else` that ignores the rest: a later gem release can add a class to this +list. + ### UserMessage User input message. @@ -108,11 +123,20 @@ class AssistantMessage attr_accessor :content, # Array<ContentBlock> :model, # String :parent_tool_use_id, # String | nil - :error, # String | nil ('authentication_failed', 'billing_error', 'rate_limit', 'invalid_request', 'server_error', 'unknown') - :usage # Hash | nil - Token usage info from the API response + :error, # String | nil - see ASSISTANT_MESSAGE_ERRORS below + :usage, # Hash | nil - Token usage info from the API response + :message_id, # String | nil - the API message id + :stop_reason, # String | nil + :session_id, # String | nil + :uuid # String | nil - UUID of this message in the transcript end ``` +`error` is passed through from the CLI. The values this SDK version knows are +in `ASSISTANT_MESSAGE_ERRORS`: `authentication_failed`, `billing_error`, +`rate_limit`, `invalid_request`, `server_error`, `max_output_tokens`, +`unknown`. A newer CLI can send others. + ### SystemMessage System message with metadata. Task lifecycle events are typed subclasses. @@ -120,7 +144,7 @@ System message with metadata. Task lifecycle events are typed subclasses. ```ruby class SystemMessage attr_accessor :subtype, # String ('init', 'task_started', 'task_progress', 'task_notification', 'task_updated', etc.) - :data # Hash + :data # Hash - the frame's own `data` value when that is neither nil nor false, otherwise the whole frame (see below) end # Typed subclasses (all inherit from SystemMessage, so is_a?(SystemMessage) still works) @@ -175,6 +199,40 @@ end See [subagent capabilities](subagents.md) for the contracts behind these fields. +### System and progress messages + +The remaining typed messages. An attribute the CLI did not send reads `nil`, +with two exceptions on `RateLimitEvent`: `rate_limit_info` is then an empty +`RateLimitInfo`, and `RateLimitEvent#data` is always the whole event as a +Symbol-keyed Hash. The first twelve are `SystemMessage` subclasses (wire +`type` is `system`), so they also have `subtype` and `data`. Their `data` is +the whole frame as a Symbol-keyed Hash, unless the frame carries a `data` +value of its own that is neither `nil` nor `false`: then `data` is that +value, and the frame is not kept. A frame whose `data` is `nil` or `false` +reads like one without the key: `data` is the whole frame. The last six are +message types of their own. + +| Class | Wire type | Attributes | Notes | +|-------|-----------|------------|-------| +| `InitMessage` | `system` / `init` | `uuid`, `session_id`, `model`, `cwd`, `tools`, `mcp_servers`, `agents`, `skills`, `plugins`, `slash_commands`, `permission_mode`, `claude_code_version`, `api_key_source`, `betas`, `output_style`, `fast_mode_state` | Start of every turn, with the session as the CLI sees it (so a multi-query `Client` session receives one per query) | +| `CompactBoundaryMessage` | `system` / `compact_boundary` | `uuid`, `session_id`, `compact_metadata` (a `CompactMetadata`: `pre_tokens`, `post_tokens`, `trigger`, `preserved_segment`, `custom_instructions`) | Context compaction completed | +| `StatusMessage` | `system` / `status` | `uuid`, `session_id`, `status`, `permission_mode` | Compacting status, permission mode changes | +| `APIRetryMessage` | `system` / `api_retry` | `uuid`, `session_id`, `attempt`, `max_retries`, `retry_delay_ms`, `error_status`, `error` | The CLI is retrying an API request | +| `LocalCommandOutputMessage` | `system` / `local_command_output` | `uuid`, `session_id`, `content` | Output of a local command | +| `HookStartedMessage` | `system` / `hook_started` | `uuid`, `session_id`, `hook_id`, `hook_name`, `hook_event` | Hook lifecycle; `include_hook_events: true` asks the CLI for all of these | +| `HookProgressMessage` | `system` / `hook_progress` | the `HookStartedMessage` attributes, `stdout`, `stderr`, `output` | | +| `HookResponseMessage` | `system` / `hook_response` | the `HookProgressMessage` attributes, `exit_code`, `outcome` (`'success'`, `'error'`, `'cancelled'`) | | +| `SessionStateChangedMessage` | `system` / `session_state_changed` | `uuid`, `session_id`, `state` (`'idle'`, `'running'`, `'requires_action'`) | Reaches your block only with `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1` in `env` | +| `FilesPersistedMessage` | `system` / `files_persisted` | `uuid`, `session_id`, `files`, `failed`, `processed_at` | | +| `ElicitationCompleteMessage` | `system` / `elicitation_complete` | `uuid`, `session_id`, `mcp_server_name`, `elicitation_id` | | +| `MirrorErrorMessage` | `system` / `mirror_error` | `uuid`, `session_id`, `error`, `key` | Produced by the SDK, not the CLI: a `session_store` mirror batch was dropped (see [Sessions](sessions.md#mirroring-to-a-sessionstore)) | +| `ToolProgressMessage` | `tool_progress` | `uuid`, `session_id`, `tool_use_id`, `tool_name`, `parent_tool_use_id`, `elapsed_time_seconds`, `task_id` | Progress of a running tool call | +| `ToolUseSummaryMessage` | `tool_use_summary` | `uuid`, `session_id`, `summary`, `preceding_tool_use_ids` | | +| `AuthStatusMessage` | `auth_status` | `uuid`, `session_id`, `is_authenticating`, `output`, `error` | | +| `PromptSuggestionMessage` | `prompt_suggestion` | `uuid`, `session_id`, `suggestion` | | +| `StreamEvent` | `stream_event` | `uuid`, `session_id`, `event` (the raw API stream event, Symbol keys), `parent_tool_use_id` | Partial message chunks; only with `include_partial_messages: true` | +| `RateLimitEvent` | `rate_limit_event` | `uuid`, `session_id`, `rate_limit_info` (a `RateLimitInfo`: `status`, `resets_at`, `rate_limit_type`, `utilization`, `overage_status`, `overage_resets_at`, `overage_disabled_reason`, `raw`), `data` (the whole event) | Rate limit information changed | + ### ResultMessage Final result message with cost and usage information. @@ -199,10 +257,16 @@ class ResultMessage :fast_mode_state, # String | nil ('off', 'cooldown', 'on') :api_error_status, # Integer | nil (HTTP status on api_error subtype) :terminal_reason, # String | nil (see below) - :origin # Hash | nil - origin of the triggering user message (see below) + :origin, # Hash | nil - origin of the triggering user message (see below) + :deferred_tool_use # DeferredToolUse | nil (see below) end ``` +`deferred_tool_use` is set when a `PreToolUse` hook answered a tool call with +`permissionDecision: 'defer'`: a `DeferredToolUse` with the `id`, `name` and +`input` of the call that was put off. The session can be resumed later to run +the deferred call. + `terminal_reason` says why the query loop ended (`"completed"`, `"max_turns"`, `"aborted_streaming"`, ...). `"aborted_streaming"` / `"aborted_tools"` mean the turn was cancelled via `Client#interrupt`. `nil` when the CLI did not report @@ -287,7 +351,8 @@ cached session title). Read the new session id from the next message. ```ruby # Union type of all content blocks -ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock | UnknownBlock +ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock | + ServerToolUseBlock | ServerToolResultBlock | UnknownBlock ``` ### TextBlock @@ -333,6 +398,30 @@ class ToolResultBlock end ``` +### ServerToolUseBlock and ServerToolResultBlock + +`ServerToolUseBlock` is a call to a tool that runs server-side (wire type +`server_tool_use`), and `ServerToolResultBlock` is the advisor tool's result +(wire type `advisor_tool_result`). An +[advisor](configuration.md#advisor-model) consultation shows up as a +`ServerToolUseBlock` named `'advisor'` and a `ServerToolResultBlock`. Any +other block type, a different server-side result included, arrives as an +`UnknownBlock`. + +```ruby +class ServerToolUseBlock + attr_accessor :id, # String + :name, # String ('advisor', ...) + :input # Hash +end + +class ServerToolResultBlock + attr_accessor :tool_use_id, # String + :content, # the result payload, as the CLI sent it + :is_error # Boolean | nil +end +``` + ### UnknownBlock Generic content block for types the SDK doesn't explicitly handle (e.g., `document` for PDFs, `image` for inline images). Preserves the raw data for forward compatibility with newer CLI versions. @@ -349,7 +438,7 @@ end | Type | Description | |------|-------------| | `Configuration` | Global defaults via `ClaudeAgentSDK.configure` block | -| `ClaudeAgentOptions` | Main configuration for queries and clients | +| `ClaudeAgentOptions` | Main configuration for queries and clients. Every option is listed in the [options reference](options.md) | | `HookMatcher` | Hook configuration with matcher pattern and timeout | | `PermissionResultAllow` | Permission callback result to allow tool use | | `PermissionResultDeny` | Permission callback result to deny tool use | @@ -368,11 +457,11 @@ end | `McpToolInfo` | MCP tool name, description, and annotations | | `McpToolAnnotations` | MCP tool annotation hints (`read_only`, `destructive`, `open_world`) | | `TaskUsage` | Typed usage data (`total_tokens`, `tool_uses`, `duration_ms`) with `from_hash` factory | -| `SDKSessionInfo` | Session metadata from `list_sessions` | +| `SDKSessionInfo` | Session metadata from `list_sessions` and `get_session_info` | | `SessionMessage` | Single message from `get_session_messages` | -| `SandboxSettings` | Sandbox settings for isolated command execution | +| `SandboxSettings` | Sandbox settings for isolated command execution. `ignore_violations` (which violations to ignore) is a plain Hash | | `SandboxNetworkConfig` | Network configuration for sandbox | -| `SandboxIgnoreViolations` | Configure which sandbox violations to ignore | +| `SandboxFilesystemConfig` | Filesystem configuration for sandbox (`allow_write`, `deny_write`, `deny_read`, `allow_read`, `allow_managed_read_paths_only`) | | `SystemPromptPreset` | System prompt preset configuration (`preset`, `append`, `exclude_dynamic_sections`, `snapshot`) | | `SystemPromptCustom` | Custom system prompt configuration — the object form of a String prompt, so `snapshot` can be set alongside it | | `SystemPromptFile` | System prompt loaded from a file path | @@ -403,7 +492,7 @@ Accepted: Symbol or String keys, snake_case or camelCase spellings, and the fixe `#[]`, `#[]=` and the camelCase readers (`msg[:session_id]`, `msg['sessionId']`, `msg.sessionId`) are public API for a type's **attributes**: the fields it declares, plus predicates such as `options.forkSession?`. Methods your own code adds to a subclass (an `attr_accessor`, a hand-written reader or setter, a mixin's accessors, a singleton method) count as attributes too. -A name that is not an attribute behaves like an undefined one: `#[]` returns `nil`, `#[]=` ignores it (on the strict types above it raises `ArgumentError`), a camelCase call raises `NoMethodError`, and `respond_to?` answers `false`. So `msg[:to_h]` is `nil`, `msg['freeze']` does not freeze the message, and `msg.toH` raises; call the method directly instead (`msg.to_h`). `UserMessage#text` and `AssistantMessage#text` are convenience methods, not attributes. +A name that is not an attribute behaves like an undefined one: `#[]` returns `nil`, `#[]=` ignores it (on the strict types above it raises `ArgumentError`), a camelCase call raises `NoMethodError`, and `respond_to?` answers `false`. So `msg[:to_h]` is `nil`, `msg['freeze']` does not freeze the message, and `msg.toH` raises; call the method directly instead (`msg.to_h`). `#[]` only reads: a writer's name, as in `msg['session_id=']`, is `nil` too. `UserMessage#text` and `AssistantMessage#text` are convenience methods, not attributes. (Through 0.37 these accessors reached any public method, with a one-time warning in 0.37.) diff --git a/examples/advanced_hooks_example.rb b/examples/advanced_hooks_example.rb index 87dff227..85b2e53e 100644 --- a/examples/advanced_hooks_example.rb +++ b/examples/advanced_hooks_example.rb @@ -32,12 +32,15 @@ # Check for dangerous patterns if original_command.match?(/rm\s+-rf|sudo\s+rm/) - # Create typed deny output + # Create typed deny output. The CLI reads the decision from + # hookSpecificOutput, so the hook returns it inside a + # SyncHookJSONOutput: output.to_h on its own would put the fields at + # the top level, where the CLI ignores them and the command runs. output = ClaudeAgentSDK::PreToolUseHookSpecificOutput.new( permission_decision: 'deny', permission_decision_reason: 'Destructive commands are not allowed' ) - return output.to_h + return ClaudeAgentSDK::SyncHookJSONOutput.new(hook_specific_output: output) end # Modify command to be safer (example: add echo prefix for demo) @@ -46,7 +49,7 @@ output = ClaudeAgentSDK::PreToolUseHookSpecificOutput.new( permission_decision: 'allow' ) - return output.to_h + return ClaudeAgentSDK::SyncHookJSONOutput.new(hook_specific_output: output) end end @@ -182,10 +185,12 @@ puts "Hook context signal available: #{!context.signal.nil?}" - # Return allow decision - ClaudeAgentSDK::PreToolUseHookSpecificOutput.new( - permission_decision: 'allow' - ).to_h + # Return allow decision (inside a SyncHookJSONOutput, as above) + ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: ClaudeAgentSDK::PreToolUseHookSpecificOutput.new( + permission_decision: 'allow' + ) + ) end puts "Hook with context signal defined (for demonstration)" diff --git a/examples/lifecycle_hooks_example.rb b/examples/lifecycle_hooks_example.rb index 5e92bada..e609d97d 100755 --- a/examples/lifecycle_hooks_example.rb +++ b/examples/lifecycle_hooks_example.rb @@ -17,10 +17,15 @@ # SessionStart: fires at session startup, resume, clear, or compact session_start = lambda do |input, _id, _ctx| puts "[SessionStart] source=#{input.source}, model=#{input.model}" - # Return additional context to inject into the conversation - ClaudeAgentSDK::SessionStartHookSpecificOutput.new( - additional_context: "Session started at #{Time.now}" - ).to_h + # Return additional context to inject into the conversation. The CLI + # reads event-specific fields from hookSpecificOutput, so the typed + # output goes inside a SyncHookJSONOutput: its own #to_h alone would + # leave the fields at the top level, where the CLI ignores them. + ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: ClaudeAgentSDK::SessionStartHookSpecificOutput.new( + additional_context: "Session started at #{Time.now}" + ) + ) end # SessionEnd: fires when the session ends @@ -58,16 +63,20 @@ # Setup: fires on init or maintenance setup = lambda do |input, _id, _ctx| puts "[Setup] trigger=#{input.trigger}" # "init" or "maintenance" - ClaudeAgentSDK::SetupHookSpecificOutput.new( - additional_context: "Environment: #{RUBY_PLATFORM}" - ).to_h + ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: ClaudeAgentSDK::SetupHookSpecificOutput.new( + additional_context: "Environment: #{RUBY_PLATFORM}" + ) + ) end # PermissionDenied: fires when a tool permission is denied permission_denied = lambda do |input, _id, _ctx| puts "[PermissionDenied] tool=#{input.tool_name}, reason=#{input.reason}" # Optionally retry - ClaudeAgentSDK::PermissionDeniedHookSpecificOutput.new(retry: false).to_h + ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: ClaudeAgentSDK::PermissionDeniedHookSpecificOutput.new(retry: false) + ) end # ConfigChange: fires when settings files change @@ -80,17 +89,21 @@ cwd_changed = lambda do |input, _id, _ctx| puts "[CwdChanged] #{input.old_cwd} -> #{input.new_cwd}" # Optionally return watch paths - ClaudeAgentSDK::CwdChangedHookSpecificOutput.new( - watch_paths: [input.new_cwd] - ).to_h + ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: ClaudeAgentSDK::CwdChangedHookSpecificOutput.new( + watch_paths: [input.new_cwd] + ) + ) end # FileChanged: fires when watched files change file_changed = lambda do |input, _id, _ctx| puts "[FileChanged] #{input.event}: #{input.file_path}" - ClaudeAgentSDK::FileChangedHookSpecificOutput.new( - watch_paths: [File.dirname(input.file_path)] - ).to_h + ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: ClaudeAgentSDK::FileChangedHookSpecificOutput.new( + watch_paths: [File.dirname(input.file_path)] + ) + ) end # InstructionsLoaded: fires when CLAUDE.md or memory files are loaded diff --git a/examples/message_types_example.rb b/examples/message_types_example.rb index 78821051..99bbb191 100755 --- a/examples/message_types_example.rb +++ b/examples/message_types_example.rb @@ -4,10 +4,11 @@ require 'bundler/setup' require 'claude_agent_sdk' -# Example: Handling all 24 SDK message types +# Example: Handling all 28 SDK message types # -# The Ruby SDK provides typed classes for every message the CLI emits. -# This example shows a comprehensive message handler. +# The Ruby SDK has a typed class for each message type it parses: the 28 +# handled below, plus the generic SystemMessage for a system subtype it has +# no class for. This example shows a comprehensive message handler. puts "=== Message Types Example ===" @@ -72,6 +73,16 @@ when ClaudeAgentSDK::TaskNotificationMessage puts "[task:done] #{msg.task_id} #{msg.status}: #{msg.summary}" + when ClaudeAgentSDK::TaskUpdatedMessage + # A task can finish here without a TaskNotificationMessage: clear your + # tracking when status is in ClaudeAgentSDK::TERMINAL_TASK_STATUSES. + puts "[task:update] #{msg.task_id}: #{msg.status}" if msg.status + puts "[task:update] #{msg.task_id} moved to the background" if msg.is_backgrounded == true + + when ClaudeAgentSDK::BackgroundTasksChangedMessage + # The full set of live background tasks: replace yours with it. + puts "[tasks] live background tasks: #{Array(msg.tasks).map { |t| t[:task_id] }.join(', ')}" + # --- Tool progress --- when ClaudeAgentSDK::ToolProgressMessage puts "[tool:progress] #{msg.tool_name} running (#{msg.elapsed_time_seconds}s)" @@ -110,6 +121,15 @@ when ClaudeAgentSDK::PromptSuggestionMessage puts "[suggestion] Next: #{msg.suggestion}" + # --- Auto-denied tool calls --- + when ClaudeAgentSDK::PermissionDeniedMessage + # Advisory only: ResultMessage#permission_denials is the authoritative list. + puts "[denied] #{msg.tool_name} (#{msg.decision_reason_type}): #{msg.message}" + + # --- SessionStore mirroring (only with session_store:) --- + when ClaudeAgentSDK::MirrorErrorMessage + puts "[mirror] a transcript batch was not stored: #{msg.error}" + # --- Rate limits --- when ClaudeAgentSDK::RateLimitEvent info = msg.rate_limit_info @@ -143,5 +163,11 @@ end puts " Errors: #{msg.errors.join(', ')}" if msg.errors&.any? + + # --- A system subtype this SDK version has no class for --- + # Keep this branch after every typed subclass above: they are all + # SystemMessages, and `case` takes the first branch that matches. + when ClaudeAgentSDK::SystemMessage + puts "[system] #{msg.subtype}" end end diff --git a/examples/rails_actioncable_example.rb b/examples/rails_actioncable_example.rb index 5d9a4b24..addb6809 100644 --- a/examples/rails_actioncable_example.rb +++ b/examples/rails_actioncable_example.rb @@ -94,6 +94,13 @@ def execute(prompt, session_id: nil, resume_session_id: nil) }, permission_mode: 'bypassPermissions', setting_sources: [], + # One executor serves every chat from one working directory, and the + # CLI's auto-memory is per project directory: every session reads it, + # and a claude_code-preset session writes it without a permission check + # (setting_sources: [] does not turn it off). Without this line, what + # one user asks the agent to remember reaches every other user's + # session. Use exactly '1' — the CLI reads '0' / 'false' as "force on". + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }, resume: resume_session_id # Resume existing session if provided ) diff --git a/examples/rails_background_job_example.rb b/examples/rails_background_job_example.rb index 6a4532cd..b190c046 100644 --- a/examples/rails_background_job_example.rb +++ b/examples/rails_background_job_example.rb @@ -124,6 +124,13 @@ def build_options(session) system_prompt: build_system_prompt, permission_mode: 'bypassPermissions', setting_sources: [], + # Every job runs from the same cwd, and the CLI's auto-memory is per + # project directory: every session reads it, and a claude_code-preset + # session writes it without a permission check (setting_sources: [] + # does not turn it off). Without this line, what one user asks the + # agent to remember reaches every other user's session. Use exactly + # '1' — the CLI reads '0' / 'false' as "force on". + env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }, model: 'claude-sonnet-5', # Sonnet for faster responses in jobs max_turns: 50, # Limit turns for safety cwd: Dir.pwd diff --git a/examples/session_stores/README.md b/examples/session_stores/README.md index c27cba48..95edcedb 100644 --- a/examples/session_stores/README.md +++ b/examples/session_stores/README.md @@ -131,7 +131,11 @@ through the relevant items below. - Pass a connection backed by a pool sized ≥ expected concurrent sessions for heavy use; don't share one connection with request-handler code that holds it. -- `jsonb` reorders keys — contract-safe, but don't byte-compare entries. +- Keep `entry` a `json` column. `jsonb` rejects the `\u0000` escape, so one + entry with a NUL character (binary tool output) fails the whole `INSERT` and + the mirror drops that batch. A table created by an earlier copy of this + adapter still has `jsonb`, and `create_schema` does not alter an existing + table: run the migration under [`json`, not `jsonb`](#json-not-jsonb). - Add a retention job (`DELETE WHERE mtime < ...`) — the table grows unbounded. --- @@ -250,7 +254,7 @@ CREATE TABLE IF NOT EXISTS claude_session_store ( session_id text NOT NULL, subpath text NOT NULL DEFAULT '', seq bigserial, - entry jsonb NOT NULL, + entry json NOT NULL, mtime bigint NOT NULL, PRIMARY KEY (project_key, session_id, subpath, seq) ); @@ -262,14 +266,30 @@ CREATE INDEX IF NOT EXISTS claude_session_store_list_idx `#load` is `SELECT entry ... ORDER BY seq`. The empty string is the `subpath` sentinel for the main transcript so the composite primary key is total. -### JSONB key ordering +### `json`, not `jsonb` -Entries are stored as `jsonb`, which **reorders object keys** on read-back. This -is explicitly allowed by the SessionStore contract — `#load` requires -*deep-equal*, not *byte-equal*, returns. The Ruby SDK reads entry fields by key -from the parsed Hash (it never does a byte/prefix scan over stored entries), so -reordering is transparent. Use a `json` or `text` column if you need byte-stable -storage. +Entries are stored in a `json` column. `jsonb` rejects the `\u0000` escape, and +transcripts do carry it: a NUL character in binary tool output is serialized +that way. One such entry fails the whole multi-row `INSERT`, so the mirror +drops every entry of that batch (surfaced as a `MirrorErrorMessage`) and a +later store-backed resume replays a transcript with that turn missing. `json` +keeps the text exactly as given, key order included. The adapter only ever +reads whole entries, so it needs none of `jsonb`'s operators or indexes. If you +add SQL that looks inside entries, note that `->` and `->>` still fail on an +entry that contains the escape. + +`create_schema` is `CREATE TABLE IF NOT EXISTS`, so it does **not** change a +table created by an earlier copy of this adapter. That table keeps its `jsonb` +column, and keeps rejecting those entries, until you migrate it: + +```sql +ALTER TABLE claude_session_store ALTER COLUMN entry TYPE json USING entry::json; +``` + +The statement rewrites the table under an `ACCESS EXCLUSIVE` lock, so pick a +quiet moment for a large one. Rows written while the column was `jsonb` come +back with the key order `jsonb` gave them, which the SessionStore contract +allows: `#load` requires *deep-equal*, not *byte-equal*, returns. `#delete` cascades to subpath rows; it is only invoked via `ClaudeAgentSDK.delete_session(session_id:, session_store: store)`. diff --git a/examples/session_stores/postgres_session_store.rb b/examples/session_stores/postgres_session_store.rb index 08a10a76..668e617c 100644 --- a/examples/session_stores/postgres_session_store.rb +++ b/examples/session_stores/postgres_session_store.rb @@ -34,7 +34,7 @@ # session_id text NOT NULL, # subpath text NOT NULL DEFAULT '', # seq bigserial, -# entry jsonb NOT NULL, +# entry json NOT NULL, # mtime bigint NOT NULL, # PRIMARY KEY (project_key, session_id, subpath, seq) # ); @@ -53,11 +53,18 @@ # many concurrent sessions, use one adapter instance (with its own # connection) per session instead. # -# JSONB key ordering: entries are stored as `jsonb`, which REORDERS object keys -# on read-back. This is explicitly allowed by the SessionStore contract — #load -# requires deep-equal, not byte-equal, returns. The Ruby SDK reads entry fields -# by key from the parsed Hash (never a byte/prefix scan), so reordering is -# transparent. Use a `json` or `text` column if you need byte-stable storage. +# Column type: entries are stored as `json`, not `jsonb`. `jsonb` rejects the +# \u0000 escape, and transcripts do carry it (a NUL character in binary tool +# output): one such entry fails the whole multi-row INSERT, so the mirror drops +# every entry of that batch. `json` keeps the text as given, key order +# included; this adapter only ever reads whole entries, so it needs none of +# jsonb's operators or indexes. +# +# A table created by an earlier copy of this adapter keeps its `jsonb` column: +# #create_schema is CREATE TABLE IF NOT EXISTS and never alters an existing +# table. Migrate it explicitly: +# +# ALTER TABLE claude_session_store ALTER COLUMN entry TYPE json USING entry::json; # # Retention: this adapter never deletes rows on its own. Add a scheduled # DELETE ... WHERE mtime < $cutoff (or partition by mtime). @@ -103,7 +110,7 @@ def create_schema session_id text NOT NULL, subpath text NOT NULL DEFAULT '', seq bigserial, - entry jsonb NOT NULL, + entry json NOT NULL, mtime bigint NOT NULL, PRIMARY KEY (project_key, session_id, subpath, seq) ); @@ -128,7 +135,7 @@ def append(key, entries) db_exec_params( <<~SQL, INSERT INTO #{@table} (project_key, session_id, subpath, entry, mtime) - SELECT $1, $2, $3, e::jsonb, $5 + SELECT $1, $2, $3, e::json, $5 FROM unnest($4::text[]) WITH ORDINALITY AS t(e, ord) ORDER BY ord SQL @@ -145,8 +152,8 @@ def load(key) ) return nil if result.ntuples.zero? - # The pg gem returns jsonb as its JSON text; parse each row. (If a type map - # decodes jsonb to a Hash already, pass it through.) + # The pg gem returns json as its JSON text; parse each row. (If a type map + # decodes json to a Hash already, pass it through.) out = result.map do |row| v = row['entry'] v.is_a?(String) ? JSON.parse(v) : v diff --git a/formal/tla/CLIInstaller.tla b/formal/tla/CLIInstaller.tla index 86ccfdf4..766ddb8d 100644 --- a/formal/tla/CLIInstaller.tla +++ b/formal/tla/CLIInstaller.tla @@ -183,7 +183,26 @@ Fail(i) == /\ Leave(i, "fail") /\ UNCHANGED <<tag, vfile, published, everInstalled>> -\* SIGKILL / OOM / power loss: no `ensure`, the temp file stays behind. +\* The process dies with no `ensure`: SIGKILL, OOM, or the machine losing +\* power. The temp file stays behind and the flock is gone (released by the +\* kernel, or lost with it). +\* +\* UNCHANGED is exact for a dead process: the files are what the completed +\* steps left. For a power loss it holds for the two files the properties +\* read, because of what the code fsyncs: the downloaded bytes before Rename +\* and the VERSION bytes before Record's rename. A name that survives the +\* outage therefore holds a complete file -- `binary` and `vfile` are each the +\* old value or the new one, never an empty or partial file. (Before those +\* fsyncs a first install could come back as an empty executable `claude` +\* beside a VERSION naming it, which breaks ReadersSeeVerifiedBinary.) +\* A rename that had not reached the disk is undone; on a filesystem that +\* commits directory operations in order, that is this step at an earlier pc. +\* +\* Not modelled: the directory fsync after Rename, which is what keeps a +\* FINISHED install across an outage (best-effort; skipped where a directory +\* cannot be synced), and `tmps` after an outage -- a leftover temp file may be +\* missing or short, which is harmless because temp files are only ever +\* swept, never read. All of it assumes fsync(2) reaches stable storage. Crash(i) == /\ pc[i] # "idle" /\ Leave(i, "crash") diff --git a/formal/tla/ControlProtocol.tla b/formal/tla/ControlProtocol.tla index 9b06706f..1c4ef8fb 100644 --- a/formal/tla/ControlProtocol.tla +++ b/formal/tla/ControlProtocol.tla @@ -6,10 +6,15 @@ (* ensure. *) (* *) (* Senders call a control method (interrupt, set_model, ...). Sender 1 is *) -(* a reactor fiber (Async::Condition: edge-triggered, but checking the *) -(* result slot and parking happen without a suspension point in between). *) -(* Sender 2 runs on a FiberBoundary worker thread (ThreadWaiter): between *) -(* its slot check and its park the read loop can run. *) +(* a fiber of the reactor that owns the Query, the one its read loop runs *) +(* on (Async::Condition: edge-triggered, but checking the result slot and *) +(* parking happen without a suspension point in between). Sender 2 is any *) +(* caller NOT on the owning reactor (ThreadWaiter): a FiberBoundary worker *) +(* thread, a plain thread, or a fiber on another thread's reactor. Between *) +(* its slot check and its park the read loop can run. The code picks the *) +(* waiter by that ownership (Fiber.scheduler.equal?(@owning_scheduler)), *) +(* not by whether the caller has an Async task. Kind's "fiber" / "thread" *) +(* below are this model's names for sender 1 / sender 2. *) (* *) (* Timeouts are left out ON PURPOSE: with the 1200s deadline everything *) (* terminates trivially. The question is whether a sender ever NEEDS it. *) @@ -23,7 +28,10 @@ (* mutex, shared with the EOF snapshot *) (* CHECK_SLOT_FIRST `waiter.wait until slot.key?` (vs. a bare wait) *) (* LEVEL_TRIGGERED ThreadWaiter pushes a token (vs. a plain *) -(* edge-triggered condition on the thread path) *) +(* edge-triggered condition for sender 2; FALSE *) +(* is also what a fiber on another thread's *) +(* reactor got while the waiter was picked by *) +(* "has an Async task" instead of by ownership) *) (***************************************************************************) EXTENDS Naturals, FiniteSets @@ -121,8 +129,9 @@ Detect(s) == /\ UNCHANGED <<slot, tokens, woken, written, answered, unread, delivered, cliGone, streamErr, snapshot>> -\* `until @pending_control_results.key?(id)` -- the check. A fiber parks in -\* the same step (no suspension point); a thread parks in a separate one. +\* `until @pending_control_results.key?(id)` -- the check. A fiber of the +\* owning reactor parks in the same step (no suspension point); any other +\* caller parks in a separate one. Wait(s) == /\ spc[s] = "wait" /\ IF CHECK_SLOT_FIRST /\ slot[s] # NONE diff --git a/formal/tla/README.md b/formal/tla/README.md index 0b2ad1d5..4ccfe5f7 100644 --- a/formal/tla/README.md +++ b/formal/tla/README.md @@ -29,7 +29,7 @@ Properties: `ReadersSeeVerifiedBinary` (a lock-free reader never sees a partial ## `ControlProtocol.tla`: outbound control requests -Models the outbound half of the control protocol in `lib/claude_agent_sdk/query.rb`: `send_control_request` / `await_control_response`, the read loop's `handle_control_response`, and the EOF broadcast in `read_messages`' `ensure`. There are two senders: a reactor fiber (`Async::Condition`, which checks the result slot and parks with no suspension point between them) and a FiberBoundary worker thread (`ThreadWaiter`, where the read loop can run between the check and the park). The CLI may answer each request or never answer, and it eventually exits. The model checks liveness under weak fairness. Control-request timeouts are left out on purpose, because the 1200 s deadline would make every liveness property hold trivially. What the model asks is whether a caller ever *needs* the timeout. +Models the outbound half of the control protocol in `lib/claude_agent_sdk/query.rb`: `send_control_request` / `await_control_response`, the read loop's `handle_control_response`, and the EOF broadcast in `read_messages`' `ensure`. There are two senders. One is a fiber of the reactor that owns the `Query`, the reactor its read loop runs on (`Async::Condition`, which checks the result slot and parks with no suspension point between them). The other is any caller that is not on the owning reactor: a FiberBoundary worker thread, a plain thread, or a fiber on another thread's reactor (`ThreadWaiter`, where the read loop can run between the check and the park). `send_control_request` picks the waiter by that ownership, not by whether the caller has an Async task. The CLI may answer each request or never answer, and it eventually exits. The model checks liveness under weak fairness. Control-request timeouts are left out on purpose, because the 1200 s deadline would make every liveness property hold trivially. What the model asks is whether a caller ever *needs* the timeout. There are three properties. `EverySenderFinishes` says every control method returns or raises. `NoHalfExecutedRequest` says a method that fails locally never reached the CLI. `DeliveredMeansAnswered` says a response the read loop consumed is never replaced by the stream-end error; this holds because the broadcast uses `||=`. None of the variants below targets `DeliveredMeansAnswered`, but every config still checks it. @@ -39,11 +39,13 @@ There are three properties. `EverySenderFinishes` says every control method retu | `bug_detect_after_write` | `DETECT_MODE_BEFORE_WRITE = FALSE` (the code before `420ee09`) | `NoHalfExecutedRequest` violated: the worker thread registers and writes the request, then `Async::Task.current` raises, and its `ensure` evicts the waiter. The CLI has the request, and its answer will be dropped by the `key?` guard | `send_control_request`: "Detecting after the write left a half-executed request (written to the CLI, then RuntimeError; the eventual response dropped by the key? guard)" | | `bug_nonatomic` | `ATOMIC_REGISTRATION = FALSE` | `EverySenderFinishes` violated: a sender passed the stream-error check, EOF snapshotted the pending map before it registered, and it waits forever | `send_control_request`: "Register atomically with the terminal-state check so EOF cannot strand a sender that missed the final broadcast" | | `bug_nocheck` | `CHECK_SLOT_FIRST = FALSE` | `EverySenderFinishes` violated: the fiber's write suspended after delivery, and the broadcast signal fired before it waited, so the signal was lost | `await_control_response`: "a signal arriving before the sender reaches wait would otherwise be dropped" | -| `bug_edge_thread` | `LEVEL_TRIGGERED = FALSE` | `EverySenderFinishes` violated: the thread checked the slot, the signal fired before it parked, and the signal was lost | `ThreadWaiter`: "closing the check-then-wait gap that an edge-triggered Condition would lose across threads" | +| `bug_edge_thread` | `LEVEL_TRIGGERED = FALSE` | `EverySenderFinishes` violated: the sender off the owning reactor checked the slot, the signal fired before it parked, and the signal was lost. A fiber on another thread's reactor was in exactly this position while the waiter was picked by "has an Async task" | `ThreadWaiter`: "closing the check-then-wait gap that an edge-triggered Condition would lose across threads" | ## What the models assume and leave out - File contents are abstracted to "the version they are the bytes of", so a checksum match is equality. Platform detection is not modelled. - The models assume that POSIX `rename` is atomic and that the kernel releases a dead process's flock. `ReadersSeeVerifiedBinary` holds by construction given those assumptions. +- Durability is assumed, not modelled. `Crash` leaves the files as the completed steps left them, which is exact when a process dies. Across a power loss it rests on the installer's `fsync`s — the downloaded bytes before `Rename`, the `VERSION` bytes before `Record`'s rename, and (best-effort) the directory after `Rename` — and on `fsync(2)` reaching stable storage: a surviving name then holds a complete file, and a rename that had not reached the disk is undone, which is `Crash` at an earlier step on a filesystem that commits directory operations in order. +- Not modelled: `install`'s read-only shortcut. When the lock file cannot be opened (`EACCES`, `EROFS`, `EPERM`) and the request is a concrete version that is already installed and re-hashes to its recorded checksum, `install` returns the binary without the lock. That path takes no lock and writes nothing, so it is one more lock-free reader, which `ReadersSeeVerifiedBinary` already covers; no modelled ordering and no `run.sh` expectation changes. - Not modelled: control-request timeouts (see above); the inbound half (`handle_control_request`, the #119 process-exit path, cancellation during a response write, which the transport answers by poisoning itself so no partial frame is ever followed by another); `Metadata.write`'s own temp file; `TranscriptMirrorBatcher`. - **The Ruby is not verified.** A model can drift from the code it describes. When you change one of the orderings the tables cite, update the model and `run.sh`'s expectations in the same change. Each counterexample trace can also be turned into an RSpec regression by stubbing `CLIInstaller::Http` / `Release` / `Metadata`, or with a scripted transport for `Query`, so that the step TLC found is exercised against the real code. diff --git a/lib/claude_agent_sdk.rb b/lib/claude_agent_sdk.rb index 1eed0e3a..81315bba 100644 --- a/lib/claude_agent_sdk.rb +++ b/lib/claude_agent_sdk.rb @@ -79,7 +79,10 @@ def self.extract_sdk_mcp_servers(mcp_servers) end # Internal: normalize hook lists for the control protocol. An absent or - # disabled event must not become an empty registration in initialize. + # disabled event must not become an empty registration in initialize. An + # event written as a String under one key and as a Symbol under another + # ('PreToolUse' and :PreToolUse) is one event: its matcher lists are + # joined in the order they were written, never replaced. # @api private def self.convert_hooks_to_internal_format(hooks) return nil unless hooks @@ -94,7 +97,7 @@ def self.convert_hooks_to_internal_format(hooks) config[:timeout] = matcher.timeout if matcher.timeout entries << config end - internal_hooks[event.to_s] = entries unless entries.empty? + (internal_hooks[event.to_s] ||= []).concat(entries) unless entries.empty? end internal_hooks.empty? ? nil : internal_hooks end @@ -139,7 +142,8 @@ def self.extract_exclude_dynamic_sections(system_prompt) eds = system_prompt.exclude_dynamic_sections return eds if [true, false].include?(eds) elsif system_prompt.is_a?(Hash) - type = system_prompt[:type] || system_prompt['type'] + # The tag may be a Symbol (type: :preset), as CommandBuilder reads it. + type = (system_prompt[:type] || system_prompt['type']).to_s if type == 'preset' eds = system_prompt.fetch(:exclude_dynamic_sections) { system_prompt['exclude_dynamic_sections'] } return eds if [true, false].include?(eds) @@ -160,7 +164,7 @@ def self.extract_system_prompt_snapshot(system_prompt) snapshot = system_prompt.snapshot return snapshot if [true, false].include?(snapshot) when Hash - type = system_prompt[:type] || system_prompt['type'] + type = (system_prompt[:type] || system_prompt['type']).to_s if %w[preset custom].include?(type) snapshot = system_prompt.fetch(:snapshot) { system_prompt['snapshot'] } return snapshot if [true, false].include?(snapshot) @@ -173,14 +177,39 @@ def self.extract_system_prompt_snapshot(system_prompt) # Each observer is invoked through FiberBoundary so that user code runs # on a plain thread (no Fiber scheduler) even when called from inside # the SDK's Async reactor — or in place when scheduling is :inline. + # + # What the observer raises, and what its callback_wrapper raises, is + # contained where it happens: inside the hop, on the execution context + # the observer runs on. The rescue must not sit around the hop, because + # the calling fiber WAITS there, and an exception raised into a waiting + # fiber is not the observer's — a caller's `task.with_timeout` / + # `Timeout.timeout` deadline is delivered exactly that way. Rescued out + # here, an expired deadline was swallowed as if the observer had failed, + # and the turn carried on. So the wrapper is composed inside the rescued + # body rather than handed to FiberBoundary.invoke, which would run it + # outside that rescue. + # + # Known residue with `scheduling: :inline` (and with no scheduler at all): + # the observer runs on the calling fiber itself, so a deadline that lands + # while the observer is suspended is raised inside the observer's own + # frames, cannot be told from the observer's own timeout, and is still + # swallowed here. # @api private def self.notify_observers(observers, method, *args, scheduling: :thread, wrapper: nil) observers.each do |obs| - FiberBoundary.invoke(scheduling: scheduling, wrapper: wrapper) { obs.send(method, *args) } - rescue StandardError, ScriptError - # ScriptError too: NotImplementedError < ScriptError (not - # StandardError), and a stubbed observer must never mask the original - # error being notified or abort connect/teardown cleanup. + FiberBoundary.invoke(scheduling: scheduling) do + invocation = proc { obs.send(method, *args) } + wrapper ? wrapper.call(invocation) : invocation.call + rescue StandardError, ScriptError + # ScriptError too: NotImplementedError < ScriptError (not + # StandardError), and a stubbed observer must never mask the original + # error being notified or abort connect/teardown cleanup. + nil + end + rescue ScriptError + # A ScriptError raised around the body rather than in it (the hop's own + # plumbing). No deadline is a ScriptError, so nothing a caller injects + # into the waiting fiber is caught here. nil end end @@ -703,7 +732,14 @@ def self.query(prompt:, options: nil, transport: nil, &block) # rubocop:disable raise ArgumentError, 'transport must respond to #connect (see ClaudeAgentSDK::Transport)' end - Async(&FiberBoundary.capture_otel_context do # rubocop:disable Metrics/BlockLength -- the reactor task body of query() + # finished: false tells Async that a waiter handles this task's failure + # (the .wait at the end re-raises it), as Kernel#Sync does for its own + # task. Without it a task that fails before anyone waits for it — always + # the case outside a reactor, where Async runs the whole task before it + # returns — is also logged as "Task may have ended with unhandled + # exception", message and backtrace included, although the caller gets + # the same error raised and may well rescue it. + Async(finished: false, &FiberBoundary.capture_otel_context do # rubocop:disable Metrics/BlockLength -- the reactor task body of query() materialized = nil query_handler = nil begin @@ -903,16 +939,13 @@ def self.ask(prompt, options: nil, transport: nil, &block) # The Client class always uses streaming mode for bidirectional communication. # # @example Basic usage - # Async do - # client = ClaudeAgentSDK::Client.new - # client.connect # No arguments needed - automatically uses streaming mode - # + # # Client.open connects, yields the client and always disconnects, + # # also when the block raises. + # ClaudeAgentSDK::Client.open do |client| # client.query("What is the capital of France?") # client.receive_response do |msg| # puts msg if msg.is_a?(ClaudeAgentSDK::AssistantMessage) # end - # - # client.disconnect # end # # @example With hooks @@ -1043,17 +1076,22 @@ def connect(prompt = nil) # (The enumerator branch streams in the background and cannot raise # out of connect.) No on_close follows for pre-handshake failures # (disconnect gates it on @connected): the session never opened. - notify_error(e) if e.is_a?(StandardError) && !@connected - # Tear down the partial connect, but never let a cleanup failure (e.g. a - # custom transport whose #close raises) mask the original connect error. - # Rescue Exception (not StandardError) so reactor cancellation - # (Async::Stop < Exception) after materialize_resume set @materialized - # still runs disconnect -> @materialized.cleanup, never leaking the temp - # CLAUDE_CONFIG_DIR that holds the redacted .credentials.json copy. begin - disconnect - rescue StandardError => cleanup_error - warn "Claude SDK: cleanup after failed connect raised: #{cleanup_error.message}" + notify_error(e) if e.is_a?(StandardError) && !@connected + ensure + # Tear down the partial connect, but never let a cleanup failure (e.g. a + # custom transport whose #close raises) mask the original connect error. + # Rescue Exception (not StandardError) so reactor cancellation + # (Async::Stop < Exception) after materialize_resume set @materialized + # still runs disconnect -> @materialized.cleanup, never leaking the temp + # CLAUDE_CONFIG_DIR that holds the redacted .credentials.json copy. + # In an ensure: an exception raised into this fiber while it waits + # for the on_error observer (a caller's deadline) must not skip it. + begin + disconnect + rescue StandardError => cleanup_error + warn "Claude SDK: cleanup after failed connect raised: #{cleanup_error.message}" + end end raise end @@ -1314,11 +1352,11 @@ def get_server_info # holds, in every scheduling mode, for a streaming-input enumerator that # calls disconnect: it is iterated on the reactor inside a task the close # stops, so it unwinds with Async::Stop once the teardown has completed. + # + # Interrupted while an observer's on_close runs — the caller's deadline + # expires, or its task is stopped — disconnect still completes the + # teardown, then lets the interruption propagate. def disconnect - if @connected - ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close, - scheduling: @callback_scheduling, wrapper: @callback_wrapper) - end # Tear down whatever exists — robust to a partial/failed connect, where # @connected is still false but a transport and/or materialized temp dir # were already created. #close on the query handler also closes the @@ -1335,26 +1373,36 @@ def disconnect # and the materialized-dir decision at the bottom needs to ask it. query_handler = @query_handler begin - @query_handler&.close + # Notified inside the begin: an exception raised into this fiber while + # it waits for an observer (a caller's deadline) propagates, and must + # not skip the teardown below. + if @connected + ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close, + scheduling: @callback_scheduling, wrapper: @callback_wrapper) + end ensure - @query_handler = nil begin - @transport&.close + @query_handler&.close ensure - @transport = nil - @connected = false - # Remove the materialized resume temp dir AFTER the subprocess - # exited — unless the mirror dropped batches: the store copy is then - # incomplete and the temp dir holds the only copy of the dropped - # turns, so it is preserved (scrubbed of credentials) with a warning - # instead of deleted. - if @materialized - if query_handler&.mirror_batches_dropped? - @materialized.preserve_transcripts - else - @materialized.cleanup + @query_handler = nil + begin + @transport&.close + ensure + @transport = nil + @connected = false + # Remove the materialized resume temp dir AFTER the subprocess + # exited — unless the mirror dropped batches: the store copy is then + # incomplete and the temp dir holds the only copy of the dropped + # turns, so it is preserved (scrubbed of credentials) with a warning + # instead of deleted. + if @materialized + if query_handler&.mirror_batches_dropped? + @materialized.preserve_transcripts + else + @materialized.cleanup + end + @materialized = nil end - @materialized = nil end end end diff --git a/lib/claude_agent_sdk/cli_installer.rb b/lib/claude_agent_sdk/cli_installer.rb index ee7795b0..368c2428 100644 --- a/lib/claude_agent_sdk/cli_installer.rb +++ b/lib/claude_agent_sdk/cli_installer.rb @@ -60,6 +60,11 @@ module CLIInstaller # rubocop:disable Metrics/ModuleLength -- Http/Platform/Rele VERSION_FILE = 'VERSION' # @api private LOCK_FILE = '.install.lock' + # How often a waiting installer retries the install lock. See + # .with_install_lock for why it polls instead of blocking in flock. + # + # @api private + LOCK_POLL_SECONDS = 0.05 # Relative to .root (Dir.pwd when unset), resolved at CALL time by # .default_dir — an absolute constant would freeze the working directory # as of require time, which is wrong for anything that chdirs (Rake @@ -168,6 +173,11 @@ def fetch_text(url, limit:) # +max_bytes+ (the manifest's declared size, when it has one) aborts a # response that runs long instead of filling the disk before the # checksum gets a chance to reject it. + # + # The file is fsynced before it is closed: the caller renames it into + # place, and a rename only orders metadata. Without the sync, a power + # loss shortly after an install could leave the published name + # pointing at an empty or partial file. def download_to(url, path, max_bytes: nil) with_response(url) do |response| written = 0 @@ -180,6 +190,7 @@ def download_to(url, path, max_bytes: nil) file.write(chunk) end + file.fsync end end path @@ -195,9 +206,9 @@ def with_response(url, redirects_left = MAX_REDIRECTS, &block) uri = url.is_a?(URI::Generic) ? url : URI(url.to_s) raise CLIInstallError, "Refusing to fetch non-HTTPS URL: #{uri}" unless uri.is_a?(URI::HTTPS) - Net::HTTP.start(uri.host, uri.port, use_ssl: true, - open_timeout: OPEN_TIMEOUT_SECONDS, - read_timeout: READ_TIMEOUT_SECONDS) do |http| + Net::HTTP.start(uri.host, uri.port, *proxy_args(uri), use_ssl: true, + open_timeout: OPEN_TIMEOUT_SECONDS, + read_timeout: READ_TIMEOUT_SECONDS) do |http| http.request(Net::HTTP::Get.new(uri)) do |response| # Branch on the status BEFORE touching the body: a redirect or an # error page must never be streamed into the target file. @@ -213,6 +224,35 @@ def with_response(url, redirects_left = MAX_REDIRECTS, &block) raise CLIInstallError, "Failed to fetch #{url}: #{e.class}: #{e.message}" end + # The proxy arguments for Net::HTTP.start: address, port, user, + # password — or none. + # + # Left to itself, Net::HTTP looks its proxy up as if for an http:// + # URL: it reads http_proxy even though this connection is TLS. An + # environment that exports only HTTPS_PROXY (what curl, RubyGems and + # the CLI itself read for an https URL) was therefore bypassed. + # URI#find_proxy on the https URL reads https_proxy / HTTPS_PROXY and + # applies no_proxy / NO_PROXY; the proxy it names is passed + # explicitly, its credentials percent-decoded the way Net::HTTP + # decodes the ones it finds itself. + # + # No arguments means "as before": Net::HTTP's own lookup (http_proxy) + # stays in charge. That is the answer when the variable is unset or + # no_proxy excludes the host, and also when its value is nothing + # Net::HTTP can use as an HTTP proxy — no scheme, socks5://, https://, + # not a URL at all. Such a value was ignored before and still is, + # rather than turning a download that works directly into a failure. + # ALL_PROXY is not consulted. + def proxy_args(uri) + proxy = uri.find_proxy + return [] unless proxy.instance_of?(URI::HTTP) && !proxy.hostname.to_s.empty? + + credentials = [proxy.user, proxy.password].map { |part| part && URI.decode_www_form_component(part) } + [proxy.hostname, proxy.port, *credentials] + rescue URI::InvalidURIError + [] + end + def follow_redirect(uri, response, redirects_left, &) raise CLIInstallError, "Too many redirects while fetching #{uri}" if redirects_left <= 0 @@ -322,11 +362,14 @@ def read(dir) # Atomic: an unpredictable temp name opened O_EXCL, then renamed over # the old file. Without this a reader could observe a half-written # VERSION, or (worse) the previous version paired with a new binary. + # Fsynced before the rename, so that after a power loss VERSION is + # the old file or the new one, not an empty one. def write(dir, version, checksum, platform) tmp = File.join(dir, "#{VERSION_FILE}.#{SecureRandom.hex(8)}.tmp") begin File.open(tmp, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file| file.write("#{version}\n#{checksum}\n#{platform}\n") + file.fsync end File.rename(tmp, File.join(dir, VERSION_FILE)) ensure @@ -376,7 +419,7 @@ def default_dir # +version+ is 'stable', 'latest', or a concrete version like '2.1.220'. # # Idempotent and safe to run concurrently: an exclusive lock on - # dir/.install.lock covers the whole check-download-place-record + # dir/.install.lock covers the whole check-download-record-place # sequence, so parallel boots (Docker layers, `foreman start`, CI matrix # jobs sharing a cache) never race each other into a partially written # binary — the loser of the race observes a finished install. @@ -386,6 +429,12 @@ def default_dir # touches the network: repeat boots must work offline (with a pinned # version — a dist-tag has to be re-resolved to be resolved at all). # + # That shortcut also holds in a directory this process cannot write (an + # image built as root and run as another user, a read-only root + # filesystem): a concrete version that is already installed and intact + # is returned. Anything that would need a write — a dist-tag, another + # version, a damaged binary — raises there. + # # An upgrade never destroys a working install: see #publish. def install(version: 'stable', dir: nil) dir = File.expand_path(dir || default_dir) @@ -396,7 +445,19 @@ def install(version: 'stable', dir: nil) requested = Release.validate_version(version) binary = File.join(dir, BINARY_NAME) FileUtils.mkdir_p(dir) - with_install_lock(dir) do + begin + lock = open_lock_file(dir) + rescue Errno::EACCES, Errno::EROFS, Errno::EPERM + # The lock file cannot be opened for writing, so nothing can be + # installed here — and nothing has to be when the requested version + # already is (see .installed_without_lock?). This rescue covers + # OPENING the lock file and nothing else: a permission error raised + # inside the critical section must keep failing the install. + raise unless installed_without_lock?(dir, requested) + + return binary + end + with_install_lock(lock) do sweep_stale_temp_files(dir) resolved = Release.resolve_version(requested) platform = Platform.detect @@ -410,8 +471,11 @@ def install(version: 'stable', dir: nil) rescue SystemCallError, IOError => e # Filesystem failures (EACCES on the install dir, ENOSPC mid-download, # a read-only mount) reach callers as CLIInstallError like every other - # install failure; `cause` keeps the original for debugging. - raise CLIInstallError, "Failed to install the Claude Code CLI into #{dir}: #{e.class}: #{e.message}" + # install failure; `cause` keeps the original for debugging. +dir+ is + # still nil when resolving the default directory is what failed (the + # working directory was deleted): name it by its relative path then. + raise CLIInstallError, + "Failed to install the Claude Code CLI into #{dir || DEFAULT_DIR}: #{e.class}: #{e.message}" end # Install PINNED_CLI_VERSION — the version this gem release was tested @@ -441,20 +505,50 @@ def installed_path(dir: nil) private # Cross-process mutual exclusion for the whole install. flock is - # advisory and per open file description, so concurrent threads in one - # process contend here exactly like separate processes do. - def with_install_lock(dir) + # advisory and per open file description, so concurrent threads — and + # concurrent fibers — in one process contend here exactly like separate + # processes do. + # + # The lock is polled (LOCK_NB + sleep), never taken with a blocking + # LOCK_EX. File#flock has no Fiber-scheduler hook, so a blocking call + # parks the whole reactor THREAD; when the holder is another fiber of + # that reactor, waiting inside the critical section for the network, it + # is never resumed and neither install returns. Kernel#sleep yields to + # a scheduler and is an ordinary sleep on a plain thread. Mutual + # exclusion is the same either way; like flock itself, the order in + # which waiters get the lock is unspecified. + # + # +lock+ is the open lock file (see .open_lock_file); it is closed here. + def with_install_lock(lock) + sleep(LOCK_POLL_SECONDS) until lock.flock(File::LOCK_EX | File::LOCK_NB) + begin + yield + ensure + lock.flock(File::LOCK_UN) + end + ensure + lock.close + end + + # Opened read-write and created when missing. Kept apart from + # .with_install_lock so that .install can tell a lock file that cannot + # be opened from a failure inside the critical section. + def open_lock_file(dir) flags = File::RDWR | File::CREAT # Never follow a symlink planted at the lock path. flags |= File::NOFOLLOW if defined?(File::NOFOLLOW) - File.open(File.join(dir, LOCK_FILE), flags, 0o644) do |lock| - lock.flock(File::LOCK_EX) - begin - yield - ensure - lock.flock(File::LOCK_UN) - end - end + File.open(File.join(dir, LOCK_FILE), flags, 0o644) + end + + # Whether .install can answer +requested+ without the install lock: it + # has to be a concrete version — a dist-tag must be resolved and may + # have to be published, which needs the lock — whose binary is in place + # and re-hashes to the checksum recorded for it. This is the lock-free + # read .installed_path already documents (#publish only ever renames a + # complete, verified binary into place) plus the re-hash, and it writes + # nothing. + def installed_without_lock?(dir, requested) + !DIST_TAGS.include?(requested) && installed?(dir, requested, Platform.detect) end # Remove temp files abandoned by an earlier install that died before its @@ -503,17 +597,36 @@ def installed?(dir, version, platform) # (The reverse order — rename then record — briefly published a binary # nothing vouched for, and a metadata failure then had to delete the # freshly renamed file, taking the previous working install with it.) + # + # Across a power loss, a rename only orders metadata. So the downloaded + # bytes (Http.download_to) and the VERSION bytes (Metadata.write) are + # fsynced before their renames: each name then holds a complete file, + # the old one or the new one, never an empty or partial one. The + # directory is synced after the rename so that an install that returned + # is still there afterwards; that sync is best-effort and cannot fail + # the install, which keeps the rename the last step that can. def publish(dir, binary, version, platform, entry) tmp = "#{binary}.download.#{SecureRandom.hex(8)}" begin fetch_verified(version, platform, entry, tmp) Metadata.write(dir, version, entry[:checksum], platform) File.rename(tmp, binary) + sync_directory(dir) ensure FileUtils.rm_f(tmp) end end + # Makes the renames in +dir+ durable (VERSION's and the binary's). + # Best-effort by necessity: it runs after the rename, where nothing may + # fail the install, and not every platform or filesystem can fsync a + # directory — some refuse to open one, others answer EINVAL or EBADF. + def sync_directory(dir) + File.open(dir, File::RDONLY, &:fsync) + rescue SystemCallError, IOError, NotImplementedError + nil + end + # Download to an unpredictable sibling temp name (same filesystem, so the # rename is atomic; O_EXCL, so a pre-planted path or symlink cannot be # written through), bounded by the manifest's declared size, then verify diff --git a/lib/claude_agent_sdk/command_builder.rb b/lib/claude_agent_sdk/command_builder.rb index d0734c08..e190cf82 100644 --- a/lib/claude_agent_sdk/command_builder.rb +++ b/lib/claude_agent_sdk/command_builder.rb @@ -76,7 +76,7 @@ def append_system_prompt(cmd) when String cmd.push('--system-prompt', @options.system_prompt) when SystemPromptFile - cmd.push('--system-prompt-file', @options.system_prompt.path) + cmd.push('--system-prompt-file', path_string(@options.system_prompt.path)) when SystemPromptCustom # The object form of a String prompt; snapshot travels on the # initialize request, not as a CLI flag. @@ -90,12 +90,25 @@ def append_system_prompt(cmd) end end + # The type tag of a Hash option, as a String: `type: :preset` is the + # natural Ruby spelling of `type: 'preset'`, and thinking and the MCP + # server configs already read it that way. A missing tag is '', which + # matches no branch, like any other tag the SDK does not know. + def hash_type(hash) + (hash[:type] || hash['type']).to_s + end + + # A path as the String the command line takes. A Pathname (anything that + # answers #to_path) is converted; every other value is returned as it is. + def path_string(path) + path.respond_to?(:to_path) ? path.to_path : path + end + def append_hash_system_prompt(cmd, prompt_hash) - prompt_type = prompt_hash[:type] || prompt_hash['type'] - case prompt_type + case hash_type(prompt_hash) when 'file' prompt_path = prompt_hash[:path] || prompt_hash['path'] - cmd.push('--system-prompt-file', prompt_path) if prompt_path + cmd.push('--system-prompt-file', path_string(prompt_path)) if prompt_path when 'custom' prompt = prompt_hash.fetch(:prompt) { prompt_hash['prompt'] } cmd.push('--system-prompt', custom_prompt_text(prompt)) @@ -129,8 +142,12 @@ def append_allowed_tools(cmd, allowed_tools) # Each listed name is validated before being formatted into a rule (see # #validate_skill_name). Both SDKs reject non-list, non-'all' shapes # loudly; this raises ArgumentError where Python raises TypeError. + # + # allowed_tools, like disallowed_tools, add_dirs and extra_args below, is + # nil when it was set to nil after construction (the readers are signed + # nilable); each use site reads nil as the constructor's default. def skills_defaults - allowed_tools = @options.allowed_tools.dup + allowed_tools = (@options.allowed_tools || []).dup setting_sources = @options.setting_sources&.dup skills = @options.skills return [allowed_tools, setting_sources] if skills.nil? @@ -210,7 +227,8 @@ def validate_skill_name(name) # rubocop:disable Metrics/AbcSize, Metrics/Cycloma end def append_disallowed_tools(cmd) - cmd.push('--disallowedTools', @options.disallowed_tools.join(',')) unless @options.disallowed_tools.empty? + disallowed_tools = @options.disallowed_tools || [] + cmd.push('--disallowedTools', disallowed_tools.join(',')) unless disallowed_tools.empty? end def append_max_turns(cmd) @@ -301,16 +319,14 @@ def append_settings(cmd) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCo settings_is_path = false if @options.settings - if @options.settings.is_a?(String) + if @options.settings.respond_to?(:to_path) + # A Pathname names a settings file; it is never tried as inline JSON. + settings_hash, settings_is_path = settings_file(cmd, @options.settings.to_path) + elsif @options.settings.is_a?(String) begin settings_hash = JSON.parse(@options.settings) rescue JSON::ParserError - if @options.sandbox.nil? # rubocop:disable Metrics/BlockNesting -- settings-is-a-path fallback inside the JSON parse rescue - settings_is_path = true - cmd.push('--settings', @options.settings) - else - settings_hash = load_settings_file(@options.settings) - end + settings_hash, settings_is_path = settings_file(cmd, @options.settings) end elsif @options.settings.is_a?(Hash) settings_hash = @options.settings.dup @@ -318,12 +334,40 @@ def append_settings(cmd) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCo end if !settings_is_path && !@options.sandbox.nil? - settings_hash[:sandbox] = @options.sandbox.is_a?(SandboxSettings) ? @options.sandbox.to_h : @options.sandbox + # The option replaces any sandbox section the settings carry. Settings + # read from JSON spell that key as a String; left next to the Symbol + # key below it would be written twice (json 3.x raises on that). + settings_hash = settings_hash.reject { |key, _| key.to_s == 'sandbox' } + settings_hash[:sandbox] = sandbox_section(@options.sandbox) end cmd.push('--settings', JSON.generate(settings_hash)) if !settings_is_path && !settings_hash.empty? end + # --settings for a settings file, as [settings_hash, settings_is_path]. + # Without a sandbox option the path itself is passed and the CLI reads + # the file. With one, the file is read here, so that the option can be + # folded into its content. + def settings_file(cmd, path) + return [load_settings_file(path), false] unless @options.sandbox.nil? + + cmd.push('--settings', path) + [{}, true] + end + + # The sandbox section as the CLI reads it. A Hash stands for the + # SandboxSettings with the same fields: the CLI only knows the camelCase + # keys that class writes, and it ignores the others without an error, so + # a Hash in Ruby spelling (deny_read, denied_domains) is renamed like the + # typed value would be (SandboxKeys). Booleans go out as they are. + def sandbox_section(sandbox) + case sandbox + when SandboxSettings then sandbox.to_h + when Hash then SandboxKeys.normalize(sandbox) + else sandbox + end + end + def append_budget(cmd) cmd.push('--max-budget-usd', @options.max_budget_usd.to_s) if @options.max_budget_usd @@ -408,10 +452,13 @@ def append_tools(cmd) when Array tools_value = @options.tools.empty? ? '' : @options.tools.join(',') cmd.push('--tools', tools_value) + when String + # The CLI's own syntax ("Read,Grep", "default", ""): passed as written. + cmd.push('--tools', @options.tools) when ToolsPreset cmd.push('--tools', 'default') when Hash - if (@options.tools[:type] || @options.tools['type']) == 'preset' + if hash_type(@options.tools) == 'preset' cmd.push('--tools', 'default') else cmd.push('--tools', JSON.generate(@options.tools)) @@ -422,13 +469,7 @@ def append_tools(cmd) def append_output_format(cmd) return unless @options.output_format - schema = if @options.output_format.is_a?(Hash) && @options.output_format[:type] == 'json_schema' - @options.output_format[:schema] - elsif @options.output_format.is_a?(Hash) && @options.output_format['type'] == 'json_schema' - @options.output_format['schema'] - else - @options.output_format - end + schema = output_schema(@options.output_format) # A json_schema output_format with a nil/absent schema must skip the # flag — `--json-schema null` is rejected by the CLI (Python guards # `schema is not None`). @@ -438,8 +479,24 @@ def append_output_format(cmd) cmd.push('--json-schema', schema_json) end + # The schema of a { type: 'json_schema', schema: ... } output format; any + # other value is the schema itself. The tag may be a Symbol, and `schema` + # is read under the key style `type` was written in, or under the other + # one when that key is absent ({ 'type' => 'json_schema', schema: {...} }). + def output_schema(format) + return format unless format.is_a?(Hash) + + if format[:type].to_s == 'json_schema' + format.fetch(:schema) { format['schema'] } + elsif format['type'].to_s == 'json_schema' + format.fetch('schema') { format[:schema] } + else + format + end + end + def append_additional_dirs(cmd) - @options.add_dirs.each { |dir| cmd.push('--add-dir', dir.to_s) } + (@options.add_dirs || []).each { |dir| cmd.push('--add-dir', dir.to_s) } end def append_mcp_servers(cmd) @@ -484,15 +541,15 @@ def append_plugins(cmd) @options.plugins.each do |plugin| plugin_config = plugin.is_a?(SdkPluginConfig) ? plugin.to_h : plugin - plugin_type = plugin_config[:type] || plugin_config['type'] plugin_path = plugin_config[:path] || plugin_config['path'] - unless %w[local plugin].include?(plugin_type) + unless %w[local plugin].include?(hash_type(plugin_config)) + plugin_type = plugin_config[:type] || plugin_config['type'] raise ArgumentError, "Unsupported plugin type: #{plugin_type.inspect}" end next unless plugin_path - cmd.push('--plugin-dir', plugin_path) + cmd.push('--plugin-dir', path_string(plugin_path)) end end @@ -503,7 +560,7 @@ def append_setting_sources(cmd, setting_sources) end def append_extra_args(cmd) - @options.extra_args.each do |flag, value| + (@options.extra_args || {}).each do |flag, value| unless EXTRA_ARG_FLAG_REGEXP.match?(flag) raise ArgumentError, "Invalid extra_args flag name: #{flag.inspect} (expected lowercase kebab-case)" end diff --git a/lib/claude_agent_sdk/fiber_boundary.rb b/lib/claude_agent_sdk/fiber_boundary.rb index 878db026..567281d4 100644 --- a/lib/claude_agent_sdk/fiber_boundary.rb +++ b/lib/claude_agent_sdk/fiber_boundary.rb @@ -135,6 +135,23 @@ def close end end + # What a failing user callback raises — a hook, can_use_tool, an SDK MCP + # tool / resource / prompt handler, or the callback_wrapper around one. + # The Ruby spelling of Python's `except Exception`: NotImplementedError + # and LoadError (both ScriptError), SystemStackError and SecurityError + # are not StandardErrors, so `rescue StandardError` lets them through. + # The control request then goes unanswered and its handler task ends + # with an exception Async treats as fatal for the whole reactor. Rescue + # `*FiberBoundary::CALLBACK_FAILURES` wherever a callback's failure is + # turned into the answer the CLI is waiting for. + # + # An explicit list, not `rescue Exception`: cancellation (Async::Stop, + # InlineCancellation) and process exits (SystemExit, SignalException — + # see .invoke_callback) must keep propagating. NoMemoryError stays out + # as well: building the answer would most likely fail again. + # @api private + CALLBACK_FAILURES = [StandardError, ScriptError, SystemStackError, SecurityError].freeze + # Carries a SystemExit / SignalException (Interrupt included) raised by # a user callback out of the FiberBoundary hop — see .invoke_callback. # A StandardError so the hop ends normally: a :thread worker that died @@ -335,9 +352,35 @@ def invoke(timeout: nil, scheduling: :thread, wrapper: nil, &block) Thread.current.report_on_exception = false work.call end - return thread.value if timeout.nil? - raise JoinTimeout, "timed out after #{timeout}s" unless thread.join(timeout) + await_worker(thread, timeout) + end + + # Wait for the worker thread of one hop and return its value (or re-raise + # what it raised); with +timeout+, raise JoinTimeout once that many + # seconds have passed. + # + # Only "the thread has finished" or "the deadline has really passed" ends + # the wait. Under a fiber scheduler Thread#join parks the fiber, and MRI + # reports ANY wakeup that finds the thread alive as a timeout: join + # returns nil, and Thread#value returns nil with it. The wakeup need not + # belong to this join — one queued by an earlier hop's thread stays behind + # when an exception (Async::Stop, a deadline) is raised into the fiber + # before it is consumed, and resumes this hop instead. Trusting a single + # join then returned nil while the callback was still running, or raised + # JoinTimeout with no time passed. + # @api private + def await_worker(thread, timeout) + if timeout.nil? + nil until thread.join + return thread.value + end + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + until thread.join([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) + next if Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline + + raise JoinTimeout, "timed out after #{timeout}s" + end thread.value end diff --git a/lib/claude_agent_sdk/instrumentation/otel.rb b/lib/claude_agent_sdk/instrumentation/otel.rb index 4a0d8630..f55da16b 100644 --- a/lib/claude_agent_sdk/instrumentation/otel.rb +++ b/lib/claude_agent_sdk/instrumentation/otel.rb @@ -7,8 +7,10 @@ module ClaudeAgentSDK module Instrumentation # OpenTelemetry observer that emits spans for Claude Agent SDK messages. # - # Uses standard gen_ai.* semantic conventions recognized by Langfuse, Datadog, - # Jaeger, and other OTel-compatible backends. + # Span attributes follow the Langfuse and OpenInference conventions, plus a + # subset of the OTel gen_ai.* attributes. docs/observability.md lists every + # attribute and says where they differ from the OTel GenAI semantic + # conventions. # # Requires the `opentelemetry-api` gem at runtime. Users must configure # `opentelemetry-sdk` and an exporter (e.g., `opentelemetry-exporter-otlp`) @@ -49,10 +51,12 @@ def initialize(tracer_name: TRACER_NAME, **default_attributes) @default_attributes = default_attributes @root_span = nil @root_context = nil + @trace_started = false # an InitMessage opened a trace since this observer was created or last closed @tool_spans = {} # tool_use_id => span @first_user_input = nil # first user prompt of the current trace @pending_prompt = nil # prompt that belongs to the NEXT trace (see on_user_prompt) @last_assistant_text = nil # capture last assistant text for trace output + @usage_reported_message_ids = Set.new # API responses whose usage a generation span of this trace carries @cost_session_id = nil @last_total_cost_usd = nil end @@ -99,16 +103,25 @@ def on_message(message) end end - # Recording-only by design: a Client session can survive an error (the - # user may rescue one bad message and keep receiving), so finishing here - # would orphan later spans and break the next turn. Finish ownership - # stays with end_trace/on_close; start_trace also finishes any dangling - # span from a previous trace as the never-disconnected backstop. + # With a trace open this is recording-only by design: a Client session + # can survive an error (the user may rescue one bad message and keep + # receiving), so finishing here would orphan later spans and break the + # next turn. Finish ownership stays with end_trace/on_close; start_trace + # also finishes any dangling span from a previous trace as the + # never-disconnected backstop. + # + # With no trace open, an error that arrives before the session's first + # InitMessage gets a session span of its own (record_failed_start). Once + # a trace has run, an error between traces stays unrecorded: the usual + # one is the ResultError query() raises when the CLI exits non-zero + # after an error result, and the trace that just ended already reports + # that failure. def on_error(error) - return unless @root_span - - @root_span.record_exception(error) - @root_span.status = OpenTelemetry::Trace::Status.error(error.message) + if @root_span + record_error(@root_span, error) + elsif !@trace_started + record_failed_start(error) + end end def on_close @@ -120,11 +133,12 @@ def on_close @pending_prompt = nil @cost_session_id = nil @last_total_cost_usd = nil + @trace_started = false end private - def start_trace(message) # rubocop:disable Metrics/AbcSize -- flat mapping of init-message fields to root span attributes + def start_trace(message) # A new init without an intervening ResultMessage (e.g. /clear or an # interrupted turn) supersedes the current trace; finish it so it is # exported instead of leaking as a never-ended span, and reset the @@ -141,21 +155,9 @@ def start_trace(message) # rubocop:disable Metrics/AbcSize -- flat mapping of in # because it came first chronologically. @first_user_input = @pending_prompt if @pending_prompt @pending_prompt = nil + @trace_started = true - attrs = { - # gen_ai semantic conventions (recognized by Langfuse, Datadog, etc.) - 'gen_ai.system' => 'anthropic', - 'gen_ai.request.model' => message.model, - # OpenInference conventions (recognized by Langfuse, Arize) - 'openinference.span.kind' => 'AGENT', - 'llm.model_name' => message.model, - 'input.mime_type' => 'text/plain', - 'output.mime_type' => 'text/plain', - # Langfuse: 'agent' type triggers the trace flow diagram (DAG graph) - 'langfuse.observation.type' => 'agent', - # Session tracking - 'session.id' => message.session_id - }.merge(@default_attributes) + attrs = session_span_attrs(model: message.model, session_id: message.session_id) if message.respond_to?(:claude_code_version) && message.claude_code_version attrs['claude_code.version'] = message.claude_code_version @@ -174,6 +176,50 @@ def start_trace(message) # rubocop:disable Metrics/AbcSize -- flat mapping of in @root_span.set_attribute('input.value', truncate(@first_user_input)) end + # The attributes every session span starts with. The model and the + # session id come from the InitMessage, so the span of a session that + # failed before sending one (record_failed_start) has neither. + def session_span_attrs(model: nil, session_id: nil) + { + # gen_ai semantic conventions (recognized by Langfuse, Datadog, etc.) + 'gen_ai.system' => 'anthropic', + 'gen_ai.request.model' => model, + # OpenInference conventions (recognized by Langfuse, Arize) + 'openinference.span.kind' => 'AGENT', + 'llm.model_name' => model, + 'input.mime_type' => 'text/plain', + 'output.mime_type' => 'text/plain', + # Langfuse: 'agent' type triggers the trace flow diagram (DAG graph) + 'langfuse.observation.type' => 'agent', + # Session tracking + 'session.id' => session_id + }.merge(@default_attributes) + end + + # A session that fails before its first InitMessage (the CLI cannot be + # found or started, initialize fails or times out, the process dies + # before its first frame) has no trace to record the error on, so the + # error gets a session span of its own, with the prompt if one was sent. + # + # The span is finished here and never becomes @root_span. query() does + # call on_close next, but a Client#connect that fails before the + # handshake never does, and an unfinished span is never exported; with + # no root span set, a later on_close has nothing to finish a second + # time. The buffers are reset so that the failed session's prompt cannot + # label the next trace of a reused observer. + def record_failed_start(error) + span = @tracer.start_span('claude_agent.session', attributes: compact_attrs(session_span_attrs)) + span.set_attribute('input.value', truncate(@first_user_input)) if @first_user_input + record_error(span, error) + span.finish + reset_session_buffers + end + + def record_error(span, error) + span.record_exception(error) + span.status = OpenTelemetry::Trace::Status.error(error.message) + end + def handle_assistant(message) return unless @root_context @@ -203,7 +249,7 @@ def handle_assistant(message) 'gen_ai.completion' => truncate(combined_text), # OpenInference: Langfuse maps output.value to the Preview Output field 'output.value' => truncate(combined_text) - }.merge(usage_token_attrs(message.usage || {})) + }.merge(generation_usage_attrs(message)) OpenTelemetry::Context.with_current(@root_context) do span = @tracer.start_span('claude_agent.generation', attributes: compact_attrs(attrs)) @@ -343,10 +389,12 @@ def finish_open_spans # Clear per-trace buffers so a reused observer instance (sequential # query() calls or multi-turn Client sessions) does not stamp stale - # input/output onto later traces. + # input/output onto later traces, and so the message ids remembered by + # generation_usage_attrs never outlive their trace. def reset_session_buffers @first_user_input = nil @last_assistant_text = nil + @usage_reported_message_ids.clear end def record_retry_event(message) @@ -397,6 +445,20 @@ def usage_token_attrs(usage) } end + # gen_ai.usage.* attributes for one generation span. The CLI sends one + # assistant frame per content block, and every frame of an API response + # repeats that response's message id and usage snapshot, so putting the + # snapshot on every span counted the response's input, cache-read and + # cache-creation tokens once per block. Only the first span of a message + # id carries it. A message without an id cannot be grouped and keeps its + # usage; a frame without usage does not use up its id. + def generation_usage_attrs(message) + attrs = compact_attrs(usage_token_attrs(message.usage || {})) + return attrs if attrs.empty? || message.message_id.nil? + + @usage_reported_message_ids.add?(message.message_id) ? attrs : {} + end + # Usage hashes arrive symbol-keyed from the live CLI (symbolize_names: # true) and string-keyed from session transcripts. def usage_value(usage, key) diff --git a/lib/claude_agent_sdk/query.rb b/lib/claude_agent_sdk/query.rb index 5be6be42..7d555933 100644 --- a/lib/claude_agent_sdk/query.rb +++ b/lib/claude_agent_sdk/query.rb @@ -136,14 +136,16 @@ def self.parse_streamed_message(string) end private_class_method :parse_streamed_message - # Waiter for control responses awaited OFF the reactor — i.e. a control - # method called from inside a hook/can_use_tool/SDK-MCP callback, which - # runs on a FiberBoundary worker thread (Python supports this reentrancy - # natively: callbacks are event-loop tasks and anyio.Event is - # level-triggered). Duck-types Async::Condition#signal for the read - # loop's signal sites; the unconditional token push makes it - # level-triggered, closing the check-then-wait gap that an - # edge-triggered Condition would lose across threads. + # Waiter for control responses awaited OFF the reactor that owns the + # Query — a control method called from inside a hook/can_use_tool/SDK-MCP + # callback, which runs on a FiberBoundary worker thread (Python supports + # this reentrancy natively: callbacks are event-loop tasks and + # anyio.Event is level-triggered), from any other plain thread, or from a + # fiber on another thread's reactor (see #send_control_request for the + # choice). Duck-types Async::Condition#signal for the read loop's signal + # sites; the unconditional token push makes it level-triggered, closing + # the check-then-wait gap that an edge-triggered Condition would lose + # across threads. class ThreadWaiter def initialize @queue = ::Queue.new @@ -193,6 +195,8 @@ def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sd # Message stream @message_queue = Async::Queue.new + # Set once #receive_messages has consumed the read loop's end sentinel. + @stream_ended = false # Ends when the run is over, so the stdin-closing waiter can wake; see # #read_messages and @inflight_tasks below (Python #1088, #1190/#1279). # Work the CLI takes up after the run ended swaps in a fresh one @@ -287,6 +291,10 @@ def initialize_protocol # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticCom agents_dict = nil if @agents agents_dict = @agents.transform_values do |agent_def| + # A Hash stands for the AgentDefinition with the same attributes + # (the options signature allows either). Built through .new: the + # user wrote it, so a misspelled key raises as on the typed class. + agent_def = AgentDefinition.new(agent_def) if agent_def.is_a?(Hash) { description: agent_def.description, prompt: agent_def.prompt, @@ -365,18 +373,15 @@ def start # Reactor-side agent for #close calls arriving from foreign threads # (FiberBoundary callbacks, plain user threads): Async::Task#stop needs # the owning thread's Fiber.scheduler, so the off-thread caller hands the - # whole close over and waits. Transient: must never keep the reactor - # alive, and is stopped automatically when the parent task finishes. - # One-shot: after serving a close it is done; a reactor-side close wakes - # it via @close_requests.close (pop -> nil) so it exits without serving. + # whole close over and waits. Transient: while it waits for a request it + # must never keep the reactor alive, and it is stopped automatically + # once the reactor has nothing else to run. One-shot: it passes the + # first request on to a task of its own (#serve_marshalled_close) and + # is done; a reactor-side close wakes it via @close_requests.close + # (pop -> nil) so it exits without serving. @close_watcher = parent.async(transient: true, &FiberBoundary.capture_otel_context do - if (reply = @close_requests.pop) - begin - close - ensure - reply << true - end - end + reply = @close_requests.pop + serve_marshalled_close(reply) if reply end) end @@ -669,9 +674,16 @@ def maybe_end_run end # The run is over: wake the stdin-closing waiter. Idempotent. + # + # The run is marked ended BEFORE the ceiling is cleared: stopping the + # sleeper task yields to whatever else is ready, and a #stream_input task + # that writes its next message in that gap must find the run already + # ended, so that #reopen_run gives the message a run of its own. Cleared + # first, the message joined the run that was about to end, and stdin + # closed before the message's own run had produced a frame. def end_run - clear_run_end_ceiling @run_end.end! + clear_run_end_ceiling end # Reopen an ended run for work that started after it ended. A waiter the @@ -842,15 +854,42 @@ def handle_control_request(request) # rubocop:disable Metrics/MethodLength -- su raise rescue StandardError => e send_control_error(request_id, e.message) + rescue *FiberBoundary::CALLBACK_FAILURES => e + # What is left of the list: a callback (or its callback_wrapper) + # failing outside StandardError — NotImplementedError, LoadError, + # SystemStackError, SecurityError. No `rescue StandardError` on the + # way here caught it, not even the one in #handle_sdk_mcp_request that + # turns a resource or prompt handler's failure into its JSON-RPC + # error, so the answer an ordinary failure gets is built here. + # Unanswered, it would also end this task with an exception Async + # treats as fatal for the whole reactor. + respond_to_callback_failure(request_id, request_data, e.message) + end + + # Answers the request, then leaves the process-exit exception to its + # caller to re-raise: the response an ordinary exception from the + # callback would have produced, naming the exception by class. + # + # The text has the format of FiberBoundary.process_exit_message, built + # here from the normalized message instead of calling it: that method + # joins the class name to the raw message, which raises + # Encoding::CompatibilityError for an encoding that is not + # ASCII-compatible (UTF-16). Raised in the rescue clause this runs in, + # that error would leave the request unanswered and replace the exit. + # +error+ is only read, never changed. + def respond_to_process_exit(request_id, request_data, error) + detail = wire_text(error.message) + message = detail.empty? || detail == error.class.name ? error.class.name : "#{error.class}: #{detail}" + respond_to_callback_failure(request_id, request_data, message) end # The response an ordinary exception from the callback would have - # produced, with the process-exit exception named by class: an error - # control response for hooks / can_use_tool; for SDK MCP requests an - # in-band isError result (tools/call) or a JSON-RPC internal error - # (resources/read, prompts/get), inside a successful control response. - def respond_to_process_exit(request_id, request_data, error) - message = FiberBoundary.process_exit_message(error) + # produced, with +message+ as its text: an error control response for + # hooks / can_use_tool; for SDK MCP requests an in-band isError result + # (tools/call) or a JSON-RPC internal error (resources/read, + # prompts/get), inside a successful control response. + def respond_to_callback_failure(request_id, request_data, message) + message = wire_text(message) mcp_message = request_data[:message] if request_data.is_a?(Hash) && request_data[:subtype] == 'mcp_message' return send_control_error(request_id, message) unless mcp_message.is_a?(Hash) @@ -867,10 +906,19 @@ def respond_to_process_exit(request_id, request_data, error) response: { mcp_response: mcp_response } } })) + rescue JSON::GeneratorError + # Not reachable through the text, which #wire_text made encodable. + # Kept because an exception leaving this method would take the place + # of the process exit the caller is about to re-raise. + send_control_error(request_id, message) rescue CLIConnectionError nil # the CLI is already gone; nothing is waiting for the answer end + # Called from the rescue clauses of #handle_control_request, where an + # exception raised while building the answer has no rescue left: the + # request would stay unanswered. So the text goes through #wire_text, and + # whatever JSON.generate still rejects is replaced by a fixed one. def send_control_error(request_id, message) error_response = { type: 'control_response', @@ -878,10 +926,16 @@ def send_control_error(request_id, message) subtype: 'error', request_id: request_id, requestId: request_id, - error: message + error: wire_text(message) } } - writeln(JSON.generate(error_response)) + line = begin + JSON.generate(error_response) + rescue JSON::GeneratorError + error_response[:response][:error] = 'Control request failed; its error message could not be encoded as JSON' + JSON.generate(error_response) + end + writeln(line) rescue CLIConnectionError # EOF/close can invalidate a callback after the peer has gone away. # Only this best-effort reply is discarded; read errors still reach @@ -889,6 +943,27 @@ def send_control_error(request_id, message) nil end + # Error text as JSON.generate accepts it. An exception message can hold + # anything: a multibyte character cut by byteslice, the raw bytes of a + # subprocess or an HTTP body, a driver's own encoding. Valid UTF-8 + # passes through. A UTF-8, BINARY or US-ASCII string is read as UTF-8, + # with U+FFFD in place of each byte that is not valid there. Any other + # encoding is transcoded, so valid text in it survives, with U+FFFD for + # what cannot be converted. + def wire_text(text) + text = text.to_s + return text if text.encoding == Encoding::UTF_8 && text.valid_encoding? + + if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(text.encoding) + text.dup.force_encoding(Encoding::UTF_8).scrub + else + text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) + end + rescue EncodingError + # No converter for the declared encoding (a dummy one such as UTF-7). + text.dup.force_encoding(Encoding::UTF_8).scrub + end + def handle_permission_request(request_data, request_id: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- permission round-trip: input, callback, result conversion raise 'canUseTool callback is not provided' unless @can_use_tool @@ -1272,36 +1347,24 @@ def handle_mcp_message(request_data) { mcp_response: mcp_response } end - def convert_hook_output_for_cli(hook_output) # rubocop:disable Metrics/CyclomaticComplexity -- one optional field per hook output key - # Handle typed output objects - return hook_output.to_h if hook_output.respond_to?(:to_h) && !hook_output.is_a?(Hash) - + # What a hook callback returned, as the object the CLI reads. A typed + # output (or anything else with #to_h) contributes its own Hash; a typed + # value inside a Hash, such as a *HookSpecificOutput under + # hook_specific_output, does the same. The keys of the result are then + # normalized, so a Hash written in snake_case or with String keys means + # what the typed output means (HookOutputKeys) — and so does the Hash a + # typed output carried through as its hook_specific_output. + def convert_hook_output_for_cli(hook_output) + if hook_output.is_a?(Hash) + hook_output = hook_output.transform_values do |value| + value.respond_to?(:to_h) && !value.is_a?(Hash) ? value.to_h : value + end + elsif hook_output.respond_to?(:to_h) + hook_output = hook_output.to_h + end return {} unless hook_output.is_a?(Hash) - # Convert Ruby hash with symbol keys to CLI format - # Handle special keywords that might be Ruby-safe versions - converted = {} - hook_output.each do |key, value| - converted_key = case key - when :async_, 'async_' then 'async' - when :continue_, 'continue_' then 'continue' - when :hook_specific_output then 'hookSpecificOutput' - when :suppress_output then 'suppressOutput' - when :stop_reason then 'stopReason' - when :system_message then 'systemMessage' - when :async_timeout then 'asyncTimeout' - else key.to_s - end - - # Recursively convert nested objects - converted_value = if value.respond_to?(:to_h) && !value.is_a?(Hash) - value.to_h - else - value - end - converted[converted_key] = converted_value - end - converted + HookOutputKeys.normalize(hook_output) end def send_control_request(request) @@ -1316,10 +1379,25 @@ def send_control_request(request) # RuntimeError; the eventual response dropped by the key? guard). task = Async::Task.current? - # Reactor callers wait on an Async::Condition; worker-thread callers - # on a ThreadWaiter. Register atomically with the terminal-state check - # so EOF cannot strand a sender that missed the final broadcast. - waiter = task ? Async::Condition.new : ThreadWaiter.new + # The waiter is chosen by reactor OWNERSHIP, not by "has a task". Only + # a fiber of the reactor that owns this Query (the one #start ran on, + # whose read loop does the signaling) checks its result slot and parks + # with no chance for the read loop to run in between, so only there is + # the edge-triggered Async::Condition safe. Every other caller races + # the read loop between its check and its park and gets the + # level-triggered ThreadWaiter: a FiberBoundary worker thread, a plain + # thread, and also a fiber on ANOTHER thread's reactor (`Sync { + # client.interrupt }` on a request thread while the session lives on a + # background reactor; a Sync block inside a :thread-mode callback). + # Given a Condition, such a fiber could lose its wakeup on async >= + # 2.29, and on async 2.10-2.28 the cross-thread signal raised + # FiberError in the read loop, ending the whole session. The deadline + # mechanism (#await_control_response) is still chosen by "has a task". + # + # Register atomically with the terminal-state check so EOF cannot + # strand a sender that missed the final broadcast. + on_owning_reactor = task && Fiber.scheduler.equal?(@owning_scheduler) + waiter = on_owning_reactor ? Async::Condition.new : ThreadWaiter.new request_id = @request_counter_mutex.synchronize do raise @control_stream_error if @control_stream_error @@ -1369,7 +1447,12 @@ def await_control_response(request_id, waiter, task, timeout_seconds, subtype) # only this deadline is translated, not an outer task's cancellation. FiberBoundary.with_cooperative_timeout(task, timeout_seconds, on_timeout: expired) do yield - waiter.wait until @pending_control_results.key?(request_id) + # A ThreadWaiter here belongs to a fiber on a reactor that does not + # own this Query: Thread::Queue#pop parks that fiber through its + # own scheduler, and the deadline cancels it like any suspension. + until @pending_control_results.key?(request_id) + waiter.is_a?(ThreadWaiter) ? waiter.wait(nil) : waiter.wait + end end else # Only schedulerless callers use stdlib Timeout. A fresh, private @@ -1717,10 +1800,21 @@ def stream_input(stream) # open until the run ends so hooks/SDK MCP control replies can still # be written (the run's end or process exit is guaranteed to signal). # - No complete message ever reached the CLI (empty stream, or the - # stream raised before the first write): no result can ever arrive, - # so waiting would park query() forever beside an idle CLI. Close - # stdin so the CLI sees EOF and exits. Deliberate improvement over - # Python, which leaves stdin open and hangs on this path. + # stream raised before the first write): no result is owed, so there + # is nothing to wait for. Close stdin at once so the CLI sees EOF and + # exits, as the Python and TypeScript SDKs do. + # This is a trade-off, not a free win. An empty stream is also how a + # caller says "send nothing, just resume", and a resumed session can + # have work of its own: a tool call that a PreToolUse hook deferred + # is re-run by the CLI on resume. If that tool is served by an SDK + # MCP server, the CLI's request for it finds stdin already closed and + # the CLI exits with an error (ProcessError, exit code 1; + # anthropics/claude-agent-sdk-python#1226). Waiting instead would + # hang every empty resume that has nothing pending: a CLI that is + # idle with no input reports no session state (seen with 2.1.286), + # so the two cases cannot be told apart without a signal from the + # CLI. Until there is one, resume a deferred tool through Client, + # which keeps stdin open. unless @closed if wrote_message wait_for_result_and_end_input @@ -1748,8 +1842,21 @@ def receive_messages(&block) # reception — ResultMessage dropped, the query reported as complete, and # on_error never fired. `while` propagates it like any other error. while true # rubocop:disable Style/InfiniteLoop + # End of stream is sticky. The read loop enqueues ONE sentinel and is + # gone, so once a receive has consumed it, every later one must end + # at once rather than wait on a queue nothing writes to any more + # (Python closes the send side of its stream, so later iterations + # end at once there too). The end is remembered, not put back on the + # queue: a receive can be made after the reactor has finished, and + # async < 2.29 cannot enqueue outside a task. Anything still queued, + # such as a mirror error reported after the end, is delivered first. + break if @stream_ended && @message_queue.empty? + message = @message_queue.dequeue - break if message[:type] == 'end' + if message[:type] == 'end' + @stream_ended = true + break + end raise message[:error] if message[:type] == 'error' block.call(message) @@ -1764,14 +1871,18 @@ def receive_messages(&block) # plain user threads — don't have; stopping from one raised NoMethodError # and left the read/child tasks running. Such callers hand the close to # the reactor-side watcher (spawned in #start) and wait for it to finish, - # so close semantics are identical regardless of the calling thread. + # so close semantics are identical regardless of the calling thread. The + # reactor waits for a close it was handed: it stays alive until the + # transport's teardown is done, even when the session's own task has + # nothing left to do. def close if @close_watcher&.alive? && !Fiber.scheduler.equal?(@owning_scheduler) marshal_close_to_reactor else - # Same scheduler (reactor-side caller, including the watcher itself), - # or no live watcher: when the reactor is gone its task fibers are - # dead, so stopping them no longer touches Fiber.scheduler. + # Same scheduler (reactor-side caller, including the task serving a + # marshalled close), or no live reactor-side task to hand it to: when + # the reactor is gone its task fibers are dead, so stopping them no + # longer touches Fiber.scheduler. close_now end end @@ -1852,8 +1963,8 @@ def close_now # surfaces as Async::Stop (Python parity: a hook that awaits # disconnect() gets CancelledError). Applied ONLY inside the trees of # the tasks being stopped: the reactor-side caller (Client#disconnect - # from the connect task, the close watcher) and foreign threads keep - # the plain path, unchanged. + # from the connect task, the task serving a marshalled close) and + # foreign threads keep the plain path, unchanged. # # The deferred Stop SUPERSEDES anything the teardown raises: async # raises it from defer_stop's ensure with an explicit `cause:`, so a @@ -1929,11 +2040,51 @@ def task_inside_stopped_trees nil end - # Hand the close to the reactor and wait for completion. Polls watcher - # liveness instead of waiting forever: if the reactor shuts down - # concurrently (the transient watcher is stopped without serving the - # request), no reply will ever arrive — fall back to a direct close, - # which is safe once the reactor's fibers are dead. + # Reactor side of a marshalled close; runs on the close watcher, which + # must not run the close itself. The watcher is transient, and a reactor + # does not wait for transient tasks: stopping the read task releases the + # session's own task (the end sentinel), and if that was the last + # non-transient task the reactor winds down and stops the watcher at the + # first suspension point of the transport teardown. SubprocessCLITransport + # then TERMs the CLI instead of closing stdin and granting the grace + # period that lets it finish its last session write, and the caller is + # released before the child is reaped. + # + # So the close runs in a task of its own that the reactor does wait for + # (bounded by the transport's teardown). `defer_stop` on the watcher is + # not enough: async 2.10 terminates the tasks of a finished reactor with + # repeated stops, and a deferral survives only the first. + # + # The task is a SIBLING of the watcher, created under the watcher's + # current parent. A reactor counts only its direct children, so a child + # of the transient watcher would not hold it open — and that is what + # `parent.async` creates once the task that started the session is gone + # and async has re-parented the watcher to the reactor (Scheduler#async + # adopts the current task as the parent). + # + # @close_watcher follows the close: the waiting caller polls it for + # liveness (#marshal_close_to_reactor), so it has to name the task that + # will answer. Switched before the new task can suspend, while the + # watcher that spawned it is still alive. + def serve_marshalled_close(reply) + body = FiberBoundary.capture_otel_context do |task| + @close_watcher = task + begin + close + ensure + reply << true + end + end + Async::Task.new(Async::Task.current.parent, &body).run + end + + # Hand the close to the reactor and wait for completion. Polls the + # liveness of the reactor-side task that is to answer (@close_watcher: + # the idle watcher, then the task serving the close) instead of waiting + # forever: if the reactor shuts down concurrently (the transient watcher + # is stopped without serving the request), no reply will ever arrive — + # fall back to a direct close, which is safe once the reactor's fibers + # are dead. def marshal_close_to_reactor reply = ::Thread::Queue.new @close_requests << reply diff --git a/lib/claude_agent_sdk/railtie.rb b/lib/claude_agent_sdk/railtie.rb index d2791850..4fdd1212 100644 --- a/lib/claude_agent_sdk/railtie.rb +++ b/lib/claude_agent_sdk/railtie.rb @@ -57,11 +57,18 @@ class Railtie < ::Rails::Railtie # the interlock keeps a reload from unloading code under it until the # whole SDK call returns. # 3. Otherwise (production: no reloading, concurrency allowed) — run the - # callback inside `Rails.application.executor.wrap`. + # callback inside the executor (see .run_in_executor). # # The configuration is read on every call, so the same wrapper is correct # in every environment. # + # The wrapper reports nothing to `Rails.error`, in any branch. An exception + # that escapes a callback reaches the code that called the SDK, whose own + # layer (the request middleware, ActiveJob) reports it with its context; + # the failures the SDK handles itself — a hook or tool error answered to + # the CLI, a swallowed observer error, a cancellation — are not errors of + # the application. + # # @return [Proc] a callable suitable for `callback_wrapper:` # @example config/initializers/claude_agent_sdk.rb # ClaudeAgentSDK.configure do |config| @@ -71,7 +78,7 @@ def self.callback_wrapper lambda do |invocation| app = ::Rails.application next invocation.call if app.nil? || app.executor.active? - next app.executor.wrap { invocation.call } unless executor_locks?(app.config) + next run_in_executor(app.executor, invocation) unless executor_locks?(app.config) begin invocation.call @@ -81,6 +88,24 @@ def self.callback_wrapper end end + # `executor.wrap { invocation.call }` minus its error report. `wrap` + # rescues what passes through it (every Exception on Rails 8.1, 8.0.2+ + # and 7.2.3+; StandardError before) and reports it to `Rails.error` as + # unhandled, from a callback thread that has none of the caller's + # context — and Rails then skips the same exception as already reported + # when it reaches the request or job. It reported the SDK's own + # cancellations and the callback failures the SDK answers or swallows, + # too. `run!` / `complete!` run the same hooks. + def self.run_in_executor(executor, invocation) + execution = executor.run! + begin + invocation.call + ensure + execution.complete! + end + end + private_class_method :run_in_executor + # Whether railties registered a process-wide lock hook on the executor # (Rails::Application::Finisher, initializer # :configure_executor_for_concurrency). diff --git a/lib/claude_agent_sdk/sdk_mcp_server.rb b/lib/claude_agent_sdk/sdk_mcp_server.rb index cdb099e3..9112a218 100644 --- a/lib/claude_agent_sdk/sdk_mcp_server.rb +++ b/lib/claude_agent_sdk/sdk_mcp_server.rb @@ -36,6 +36,9 @@ def self.deep_normalize_schema(obj) # mangled into nonsense parameter lists ("additionalProperties" as a # required string param). A $ref-only schema without type: 'object' remains # indistinguishable from a params hash — declare the type alongside $ref. + # For the same reason { type: :object } is this prebuilt accept-any-object + # schema even though :object is also a shorthand type: a simple schema with + # an object parameter literally named type has to spell it { type: Hash }. # @api private def self.prebuilt_json_schema?(schema) return false unless schema.is_a?(Hash) @@ -67,13 +70,16 @@ def self.normalize_tool_schema(schema) # @api private def self.ruby_type_to_json_schema(type) # Class#=== matches instances, not the class object used in { id: Integer }. - type = { String => :string, Integer => :integer, Float => :float, - TrueClass => :boolean, FalseClass => :boolean }.fetch(type, type) + type = { String => :string, Integer => :integer, Float => :float, TrueClass => :boolean, + FalseClass => :boolean, Array => :array, Hash => :object }.fetch(type, type) case type when :string, String then { type: 'string' } when :integer, Integer then { type: 'integer' } when :float, Float, :number then { type: 'number' } when :boolean, TrueClass, FalseClass then { type: 'boolean' } + # The class or the Symbol only. An Array or Hash VALUE ([String], a nested + # schema fragment) is not a shorthand and falls through like before. + when :array, :object then { type: type.to_s } else { type: 'string' } # rubocop:disable Lint/DuplicateBranch -- default fallback; the :string arm stays explicit end end @@ -301,13 +307,25 @@ def call_tool(name, arguments) ClaudeAgentSDK.normalize_tool_result(tool.handler.call(arguments)) end - # Guard before flexible_fetch: it raises on non-Hash inputs. - content = result.is_a?(Hash) ? ClaudeAgentSDK.flexible_fetch(result, 'content', 'content') : nil - return error_tool_result("Tool '#{name}' must return a hash with :content key") unless content + # Guard before flexible_fetch: it raises on non-Hash inputs. This first + # diagnostic is about the KEY: a present false or nil is a wrong value + # and gets the Array diagnostic below, like any other non-Array. + unless result.is_a?(Hash) && (result.key?(:content) || result.key?('content')) + return error_tool_result("Tool '#{name}' must return a hash with :content key") + end + + content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content') + # A String or a single block here is not a result an MCP client accepts. + unless content.is_a?(Array) + return error_tool_result("Tool '#{name}' must return :content as an Array of content blocks " \ + "(got #{content.class})") + end result - rescue StandardError => e - # Bare e.message like Python's str(e) — no prefix. + rescue *FiberBoundary::CALLBACK_FAILURES => e + # Bare e.message like Python's str(e) — no prefix. The list, not just + # StandardError: a handler's NotImplementedError, LoadError or + # SystemStackError is a tool failure the model should read as well. error_tool_result(e.message) end @@ -412,7 +430,7 @@ def normalize_tools_call_errors(message, response) end # Create dynamic Tool classes from tool definitions - def create_tool_classes(tools) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- builds each dynamic MCP::Tool subclass inline + def create_tool_classes(tools) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- builds each dynamic MCP::Tool subclass inline # Captured so the dynamic class can resolve the effective scheduling # mode at call time — same pattern as prompt classes. sdk_server = self @@ -444,16 +462,22 @@ def description_value end def input_schema_value - # Full-schema construction: the gem JSON-round-trips and - # validates against the draft4 metaschema. additionalProperties/ - # enum/description survive. Empty required arrays are stripped — - # draft4's metaschema mandates non-empty required (Python's - # modern jsonschema accepts []). Schemas the draft4 metaschema - # rejects (numeric exclusiveMinimum, $ref/$defs — valid modern - # JSON Schema that Python accepts) fall back to a permissive - # schema with a one-time warning: the tool keeps working with - # argument validation disabled instead of being permanently - # uncallable while tools/list advertises it as healthy. + # Full-schema construction: the gem JSON-round-trips the schema + # and validates it against the JSON Schema 2020-12 metaschema + # (every mcp version this gem supports; older ones used draft4). + # additionalProperties/enum/description survive, and so do a + # numeric exclusiveMinimum and same-document $ref/$defs. Empty + # required arrays are stripped — a holdover from draft4, whose + # metaschema mandated a non-empty required. + # + # A schema the gem refuses (ArgumentError: a draft4-style + # boolean exclusiveMinimum, an unknown type, a $ref that leaves + # the document, an invalid pattern, ...) falls back to a + # permissive schema with a warning, once per tool: the tool + # keeps working with argument validation disabled instead of + # being permanently uncallable while tools/list advertises it + # as healthy. (The warning's "not draft4-compatible" wording + # dates from the draft4 days.) @input_schema_value ||= begin schema = ClaudeAgentSDK.normalize_tool_schema(@tool_def.input_schema) schema = schema.except(:required) if schema[:required].is_a?(Array) && schema[:required].empty? @@ -493,11 +517,21 @@ def call(server_context: nil, **args) # rubocop:disable Lint/UnusedMethodArgumen # Guard BEFORE flexible_fetch: on a non-Hash it raises # TypeError/NoMethodError, surfacing garbage instead of the - # friendly message. - raise "Tool '#{@tool_def.name}' must return a hash with :content key" unless result.is_a?(Hash) + # friendly message. This first diagnostic is about the KEY: a + # present false or nil is a wrong value and gets the Array + # diagnostic below, like any other non-Array. + unless result.is_a?(Hash) && (result.key?(:content) || result.key?('content')) + raise "Tool '#{@tool_def.name}' must return a hash with :content key" + end content = ClaudeAgentSDK.flexible_fetch(result, 'content', 'content') - raise "Tool '#{@tool_def.name}' must return a hash with :content key" if content.nil? + # A String or a single block Hash would go out as it is, and the + # CLI rejects that frame against its schema: the model is told + # the SERVER returned a malformed result and never sees the text. + unless content.is_a?(Array) + raise "Tool '#{@tool_def.name}' must return :content as an Array of content blocks " \ + "(got #{content.class})" + end is_error = ClaudeAgentSDK.flexible_fetch(result, 'isError', 'is_error') structured_content = ClaudeAgentSDK.flexible_fetch(result, 'structuredContent', 'structured_content') @@ -507,12 +541,15 @@ def call(server_context: nil, **args) # rubocop:disable Lint/UnusedMethodArgumen error: !!is_error, structured_content: structured_content ) - rescue StandardError => e + rescue *FiberBoundary::CALLBACK_FAILURES => e # Report handler failures in-band HERE rather than letting them # reach the gem: mcp >= 1.2 deliberately drops e.message from # its "Internal error calling tool X" wrapper (CWE-209), which # would hide the text the model needs to self-correct. Bare # e.message like Python's str(e) and #call_tool — no prefix. + # The list, not just StandardError: the gem rescues only + # StandardError, so a handler's NotImplementedError / LoadError / + # SystemStackError would pass it unanswered. # Nothing gem-internal can be swallowed here today: handlers get # no server_context, so MCP::CancelledError never originates # inside this method. Revisit if cancellation is ever plumbed in. @@ -606,7 +643,17 @@ def convert_input_schema(schema) # # @param name [String] Unique identifier for the tool # @param description [String] Human-readable description - # @param input_schema [Hash] Schema defining input parameters + # @param input_schema [Hash] Schema defining input parameters: a full JSON + # Schema (+{ type: 'object', properties: ... }+), or the shorthand + # +{ name: type }+, in which every parameter is required and each type is + # +String+ / +:string+, +Integer+ / +:integer+, +Float+ / +:float+ / + # +:number+, +TrueClass+ / +FalseClass+ / +:boolean+, +Array+ / +:array+ + # or +Hash+ / +:object+ + # @param annotations [Hash, nil] MCP tool annotations (+title+, + # +readOnlyHint+, ...). +maxResultSizeChars+ is also forwarded as + # +_meta['anthropic/maxResultSizeChars']+, the form the CLI reads + # @param meta [Hash, nil] The tool's +_meta+. Merged with the size hint + # derived from +annotations+; a key given here wins # @param handler [Proc] Block that implements the tool logic. It returns a # String, sent to Claude as a single text block, or a Hash with a # +:content+ Array of MCP content blocks plus optional +:is_error+ / @@ -641,11 +688,16 @@ def convert_input_schema(schema) def self.create_tool(name, description, input_schema, annotations: nil, meta: nil, &handler) raise ArgumentError, 'Block required for tool handler' unless handler - # Auto-populate _meta with maxResultSizeChars from annotations if present + # Auto-populate _meta with maxResultSizeChars from annotations if present. + # An explicit meta: is merged with that hint rather than replacing it (an + # unrelated key used to drop it); a size key the caller sets itself wins, + # in either spelling — adding the String key beside a Symbol one would + # put the same JSON key in the frame twice. + size_key = 'anthropic/maxResultSizeChars' resolved_meta = meta - if resolved_meta.nil? && annotations + if annotations.is_a?(Hash) && (meta.nil? || (meta.is_a?(Hash) && meta.keys.none? { |key| key.to_s == size_key })) max_chars = annotations[:maxResultSizeChars] || annotations['maxResultSizeChars'] - resolved_meta = { 'anthropic/maxResultSizeChars' => max_chars } if max_chars + resolved_meta = { size_key => max_chars }.merge(meta || {}) if max_chars end # tools/call arrives from the CLI with the name as a JSON String; the mcp diff --git a/lib/claude_agent_sdk/session_mutations.rb b/lib/claude_agent_sdk/session_mutations.rb index ddd0ecce..2540e0a6 100644 --- a/lib/claude_agent_sdk/session_mutations.rb +++ b/lib/claude_agent_sdk/session_mutations.rb @@ -27,19 +27,25 @@ module SessionMutations # rubocop:disable Metrics/ModuleLength -- rename/tag/del # Rename a session by appending a custom-title entry. # - # list_sessions reads the LAST custom-title from the file tail, so - # repeated calls are safe — the most recent wins. + # Repeated calls are safe: the disk listing takes the LAST custom-title + # in the final 64 KiB of the file (then in the first 64 KiB), the store + # fold the last one overall. On disk the entry is only seen while it + # stays inside one of those windows: rename a session that is still + # running, let 64 KiB of transcript follow, and list_sessions / + # get_session_info report the previous title again until the CLI resumes + # the session and re-appends its metadata at the end (the same holds for + # tag_session). Not fixed here: finding it again means reading the whole + # file on every listing. # # @param session_id [String] UUID of the session to rename # @param title [String] New session title (whitespace stripped) # @param directory [String, nil] Project directory path - # @raise [ArgumentError] if session_id is invalid or title is empty + # @raise [ArgumentError] if session_id is invalid, or title is blank or not a String # @raise [Errno::ENOENT] if the session file cannot be found def rename_session(session_id:, title:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id) - stripped = title.strip - raise ArgumentError, 'title must be non-empty' if stripped.empty? + stripped = stripped_title(title) data = "#{JSON.generate({ type: 'custom-title', customTitle: stripped, sessionId: session_id })}\n" @@ -54,19 +60,12 @@ def rename_session(session_id:, title:, directory: nil) # @param session_id [String] UUID of the session to tag # @param tag [String, nil] Tag string, or nil to clear # @param directory [String, nil] Project directory path - # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization + # @raise [ArgumentError] if session_id is invalid, or tag is not a String or is empty after sanitization # @raise [Errno::ENOENT] if the session file cannot be found def tag_session(session_id:, tag:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id) - if tag - sanitized = sanitize_unicode(tag).strip - raise ArgumentError, 'tag must be non-empty (use nil to clear)' if sanitized.empty? - - tag = sanitized - end - - data = "#{JSON.generate({ type: 'tag', tag: tag || '', sessionId: session_id })}\n" + data = "#{JSON.generate({ type: 'tag', tag: sanitized_tag(tag), sessionId: session_id })}\n" append_to_session(session_id, data, directory) end @@ -164,13 +163,12 @@ def fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil) # appended entry carries a fresh uuid + ISO timestamp so adapters that dedupe # by entry["uuid"] (per the SessionStore#append contract) treat it correctly. # - # @raise [ArgumentError] if session_id is invalid or title is empty + # @raise [ArgumentError] if session_id is invalid, or title is blank or not a String # @raise [Errno::ENOENT] if the session is not found in the store def rename_session_via_store(session_store:, session_id:, title:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id) - stripped = title.strip - raise ArgumentError, 'title must be non-empty' if stripped.empty? + stripped = stripped_title(title) key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id } ensure_store_session_exists(session_store, key) @@ -188,23 +186,17 @@ def rename_session_via_store(session_store:, session_id:, title:, directory: nil # counterpart to tag_session. Pass nil to clear the tag. Tags are # Unicode-sanitized before storing. # - # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization + # @raise [ArgumentError] if session_id is invalid, or tag is not a String or is empty after sanitization # @raise [Errno::ENOENT] if the session is not found in the store def tag_session_via_store(session_store:, session_id:, tag:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id) - if tag - sanitized = sanitize_unicode(tag).strip - raise ArgumentError, 'tag must be non-empty (use nil to clear)' if sanitized.empty? - - tag = sanitized - end - + tag = sanitized_tag(tag) key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id } ensure_store_session_exists(session_store, key) session_store.append(key, [{ 'type' => 'tag', - 'tag' => tag || '', + 'tag' => tag, 'sessionId' => session_id, 'uuid' => SecureRandom.uuid, 'timestamp' => iso_now @@ -222,10 +214,9 @@ def tag_session_via_store(session_store:, session_id:, tag:, directory: nil) # @raise [ArgumentError] if session_id is invalid def delete_session_via_store(session_store:, session_id:, directory: nil) raise ArgumentError, "Invalid session_id: #{session_id}" unless Sessions.valid_session_id?(session_id) - return unless SessionStore.implements?(session_store, :delete) key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id } - session_store.delete(key) + SessionStores.optional_call(session_store, :delete) { session_store.delete(key) } nil end @@ -263,6 +254,29 @@ def fork_session_via_store(session_store:, session_id:, directory: nil, up_to_me # -- Private helpers -- + # The title to store: stripped and non-empty. A value that is not a usable + # String (nil, another type, bytes invalid in their encoding) gets the + # ArgumentError an empty title gets — the boundary check the session ids + # have — where calling #strip on it raised NoMethodError or an encoding + # error from inside. + def stripped_title(title) + stripped = title.is_a?(String) && title.valid_encoding? ? title.strip : '' + raise ArgumentError, 'title must be non-empty' if stripped.empty? + + stripped + end + + # The tag to store: Unicode-sanitized and stripped, or '' (which clears + # the tag) for nil. Same boundary check as stripped_title. + def sanitized_tag(tag) + return '' unless tag + + sanitized = tag.is_a?(String) && tag.valid_encoding? ? sanitize_unicode(tag).strip : '' + raise ArgumentError, 'tag must be non-empty (use nil to clear)' if sanitized.empty? + + sanitized + end + # Raise Errno::ENOENT (as the disk counterparts and fork_session_via_store # do) unless the store holds entries for +key+. Without this probe, a # rename/tag of a typo'd or stale id APPENDED metadata to a never-written @@ -293,8 +307,12 @@ def find_session_file_with_dir(session_id, directory) end def find_in_directory(file_name, directory) - path = File.realpath(directory).unicode_normalize(:nfc) - result = try_project_dir(file_name, Sessions.find_project_dir(path)) + # canonicalize_path, not File.realpath: the transcripts outlive the + # directory (a removed worktree), and realpath raised Errno::ENOENT for + # it before the session was even looked for — while the readers, which + # canonicalize, still found the session through the same directory. + path = Sessions.canonicalize_path(directory) + result = try_project_dir(file_name, Sessions.find_project_dir(path), path) return result if result worktree_paths = begin @@ -305,7 +323,7 @@ def find_in_directory(file_name, directory) worktree_paths.each do |wt_path| next if wt_path == path - result = try_project_dir(file_name, Sessions.find_project_dir(wt_path)) + result = try_project_dir(file_name, Sessions.find_project_dir(wt_path), wt_path) return result if result end nil @@ -315,11 +333,17 @@ def find_in_directory(file_name, directory) # in one project dir must not stop the search when the real transcript # lives under another (worktree) project dir. Mirrors the read path # (Sessions.stat_candidate) and the append path (try_append). - def try_project_dir(file_name, project_dir) + # With +path+ (a directory-scoped lookup), the candidate must also be one + # of that path's own transcripts (Sessions.own_transcript?: a directory + # the long-path fallback found can hold other paths' sessions). + def try_project_dir(file_name, project_dir, path = nil) return nil unless project_dir candidate = File.join(project_dir, file_name) - File.size(candidate).positive? ? [candidate, project_dir] : nil + return nil unless File.size(candidate).positive? + return nil if path && !Sessions.own_transcript?(project_dir, candidate, path) + + [candidate, project_dir] rescue SystemCallError nil end @@ -433,9 +457,10 @@ def build_fork_lines(transcript, content_replacements, session_id, up_to_message }) end - # Derive title: explicit > original customTitle > original aiTitle > first - # prompt, suffixed with " (fork)" when derived. listSessions reads the LAST - # custom-title from the tail, so this trailer is what surfaces. + # Derive title: explicit > the source's listed title (custom, else AI) > + # its first prompt, suffixed with " (fork)" when derived. listSessions + # reads the LAST custom-title from the tail, so this trailer is what + # surfaces. fork_title = title&.strip fork_title = "#{derive_title.call || 'Forked session'} (fork)" if fork_title.nil? || fork_title.empty? @@ -472,61 +497,30 @@ def partition_fork_entries(raw, source_session_id) [transcript, content_replacements] end - # Derive a fork title by scanning already-parsed store entries — the store - # path's analogue of derive_fork_title's head/tail byte scan. Last occurrence - # wins for both customTitle and aiTitle; customTitle beats aiTitle; the first - # user prompt is the final fallback. Returns nil when nothing is found (the - # caller supplies the "Forked session" default). This scans the RAW entries, - # not the partitioned transcript (which drops customTitle/aiTitle metadata) — - # the store half of #837's P0-1 fix. + # Derive a fork title from already-parsed store entries: the title the + # store listing shows for the session (custom title, else AI title — the + # latest occurrence of each, a blank one counting as absent), else its + # first prompt. Folds the RAW entries (the partitioned transcript has + # dropped the customTitle/aiTitle metadata — the store half of #837's + # P0-1 fix) with the fold the listing uses, and takes the title from the + # folded fields rather than from summary_entry_to_sdk_info: that returns + # nil for a sidechain or summary-less session, which can still be forked. + # nil when the session has none of the three (the caller supplies the + # "Forked session" default). def derive_title_from_entries(raw) - custom = nil - ai = nil - raw.each do |e| - next unless e.is_a?(Hash) - - ct = e['customTitle'] - custom = ct if ct.is_a?(String) && !ct.empty? - at = e['aiTitle'] - ai = at if at.is_a?(String) && !at.empty? - end - return custom if custom - return ai if ai - - # First-prompt fallback: re-serialize to a JSONL string and reuse the head - # extractor so skip-patterns/truncation match the disk path exactly. - # extract_first_prompt_from_head returns '' (truthy in Ruby!) when no - # prompt qualifies — normalize to nil so the caller's 'Forked session' - # default actually fires (Python appends `or None` here for this reason). - jsonl = "#{raw.map { |e| JSON.generate(e) }.join("\n")}\n" - title = Sessions.extract_first_prompt_from_head(jsonl) - title.nil? || title.empty? ? nil : title + data = SessionSummary.fold_session_summary(nil, {}, raw)['data'] + first_prompt = data['first_prompt_locked'] ? data['first_prompt'] : data['command_fallback'] + Sessions.display_title(data['custom_title'], data['ai_title']) || Sessions.presence(first_prompt) end - # Derive a fork title from the source file's head/tail chunks without - # slurping the entire file. Matches the lookup order used for - # SDKSessionInfo.custom_title / ai_title / first_prompt. Returns nil when - # nothing is found (build_fork_lines supplies the "Forked session" default). + # Derive a fork title from the source file without slurping it: the title + # and first prompt the disk listing reports for the session, taken from + # the same head/tail windows by the same rule. nil when it has neither + # (build_fork_lines supplies the "Forked session" default). def derive_fork_title(file_path, file_size) - buf_size = [Sessions::LITE_READ_BUF_SIZE, file_size].min - File.open(file_path, 'rb') do |f| - head = (f.read(buf_size) || '').force_encoding('UTF-8').scrub - tail = if file_size > Sessions::LITE_READ_BUF_SIZE - f.seek(-buf_size, IO::SEEK_END) - (f.read(buf_size) || '').force_encoding('UTF-8').scrub - else - head - end - title = Sessions.extract_json_string_field(tail, 'customTitle', last: true) || - Sessions.extract_json_string_field(head, 'customTitle', last: true) || - Sessions.extract_json_string_field(tail, 'aiTitle', last: true) || - Sessions.extract_json_string_field(head, 'aiTitle', last: true) || - Sessions.extract_first_prompt_from_head(head) - # extract_first_prompt_from_head returns '' (truthy in Ruby!) when no - # prompt qualifies — normalize to nil so the 'Forked session' default - # fires (Python appends `or None` here for the same reason). - title.nil? || title.empty? ? nil : title - end + head, tail = Sessions.read_head_tail(file_path, file_size) + title, first_prompt = Sessions.title_and_first_prompt(file_path, head, tail, file_size) + title || first_prompt end # Build a single forked entry with remapped UUIDs. @@ -587,11 +581,11 @@ def append_to_session(session_id, data, directory) end def append_to_session_in_directory(session_id, data, file_name, directory) - path = File.realpath(directory).unicode_normalize(:nfc) + path = Sessions.canonicalize_path(directory) # see find_in_directory # Try the exact/prefix-matched project directory first. project_dir = Sessions.find_project_dir(path) - return if project_dir && try_append(File.join(project_dir, file_name), data) + return if project_dir && own_append(project_dir, file_name, path, data) # Worktree fallback begin @@ -604,7 +598,7 @@ def append_to_session_in_directory(session_id, data, file_name, directory) next false if wt_path == path wt_project_dir = Sessions.find_project_dir(wt_path) - wt_project_dir && try_append(File.join(wt_project_dir, file_name), data) + wt_project_dir && own_append(wt_project_dir, file_name, wt_path, data) end return if found @@ -626,6 +620,12 @@ def append_to_session_global(session_id, data, file_name) raise Errno::ENOENT, "Session #{session_id} not found in any project directory" end + # try_append, for a transcript of +path+'s own (Sessions.own_transcript?). + def own_append(project_dir, file_name, path, data) + candidate = File.join(project_dir, file_name) + Sessions.own_transcript?(project_dir, candidate, path) && try_append(candidate, data) + end + # Try appending to a path. # # Opens with WRONLY | APPEND (no CREAT) so the open fails with @@ -675,11 +675,11 @@ def unicode_category(char) 'Other' end - private_class_method :find_session_file_with_dir, + private_class_method :stripped_title, :sanitized_tag, :find_session_file_with_dir, :find_in_directory, :try_project_dir, :find_in_all_projects, :parse_fork_transcript, :derive_fork_title, :build_forked_entry, :resolve_parent_uuid, :append_to_session, :append_to_session_in_directory, - :append_to_session_global, :try_append, :sanitize_unicode, :unicode_category, + :append_to_session_global, :own_append, :try_append, :sanitize_unicode, :unicode_category, :iso_now, :build_fork_lines, :partition_fork_entries, :derive_title_from_entries, :ensure_store_session_exists end diff --git a/lib/claude_agent_sdk/session_resume.rb b/lib/claude_agent_sdk/session_resume.rb index 8b6d04c1..c9f0a123 100644 --- a/lib/claude_agent_sdk/session_resume.rb +++ b/lib/claude_agent_sdk/session_resume.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'json' +require 'digest' require 'fileutils' require 'tmpdir' require 'open3' @@ -22,31 +23,167 @@ module ClaudeAgentSDK class MaterializedResume attr_reader :config_dir, :resume_session_id - def initialize(config_dir:, resume_session_id:) + MIRROR_DROPPED = 'Claude SDK: transcript mirror dropped batches; the session store copy is incomplete.' + SKIPPED = 'Scrubbing was skipped and nothing was deleted:' + # Prefix of the private directory a preserved temp dir is moved into. Not + # "claude-resume-": that one is for directories the SDK may still delete. + STAGING_PREFIX = 'claude-preserved-resume-' + private_constant :MIRROR_DROPPED, :SKIPPED, :STAGING_PREFIX + + # +root_identity+ is SessionResume.directory_identity of +config_dir+ as + # the SDK created it; materialize_resume_session takes it right after + # mkdtemp. Without it, it is taken here. + def initialize(config_dir:, resume_session_id:, root_identity: nil) @config_dir = config_dir @resume_session_id = resume_session_id + @root_identity = root_identity || SessionResume.directory_identity(config_dir) + @kept = false end # Best-effort removal of the temp config dir (never raises). + # + # Does nothing once preserve_transcripts was called, however that went. A + # teardown can run twice — a disconnect after one that was cut short — + # and the second time nothing remembers that the mirror dropped batches: + # it asks for a cleanup of the directory holding the only copy of them. + # Leaving copies that were not scrubbed yet is the lesser harm. def cleanup + return if @kept + SessionResume.rmtree_with_retry(@config_dir) end # Teardown when the transcript mirror dropped batches: the CLI's # authoritative transcript lives in this temp dir, and the store copy is # missing the dropped turns — deleting the dir would permanently lose - # them. Keep the transcripts (projects/), remove the redacted credential - # copies, and tell the user where the data is so they can import it into - # the store manually. Never raises. + # them. Keep the transcripts (projects/), delete every other entry, and + # tell the user where the data is so they can import it into the store + # manually. Never raises. + # + # An allow-list on purpose. Besides the files the SDK seeds + # (.credentials.json, .claude.json, settings.json, cowork_settings.json) + # the CLI writes its own: at startup it saves the seeded .claude.json — + # which can hold MCP header secrets — as backups/.claude.json.backup.<ts>. + # A list of names to delete missed that one and would miss the next. + # + # Deleting "everything else" must never reach outside the directory the + # SDK created, and the CLI's tools can write into that directory — + # sandboxed ones too — up to and during this call: they can turn the + # directory, or anything in it, into a symlink between two steps taken + # here. So a path that was checked is never resolved again: + # + # 1. The directory is moved, in one rename, into a fresh private directory + # next to it. A rename moves a symlink itself, never its target, and it + # takes the directory away from the path its writers know. (A read-only + # directory cannot be moved; it is made writable first, through a + # handle that has to be the directory the SDK created.) + # 2. What was moved is then examined: it has to be a real directory with + # the device and inode recorded when the SDK created it. If it is not, + # or it could not be moved, nothing is deleted and the warning says why. + # 3. Its entries are deleted by SessionResume::Scrubber, which takes each + # one out of the tree before it looks at it. + # + # config_dir is the new location afterwards, the one the warning names. def preserve_transcripts - ['.credentials.json', '.claude.json', 'settings.json', 'cowork_settings.json'].each do |name| - FileUtils.rm_f(File.join(@config_dir, name)) - end - warn 'Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. ' \ - "Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \ - 'deleting it — import it into your session store, then remove the directory.' + @kept = true # first: whatever happens below, #cleanup must not delete it + original = @config_dir + announced = false + warn "#{MIRROR_DROPPED} #{move_aside_and_scrub(original)}" + announced = true rescue StandardError => e warn "Claude SDK: failed to scrub preserved transcript dir #{@config_dir}: #{e.message}" + announced = true + ensure + # Cut short by something that is not a StandardError (a cancellation, a + # signal). #cleanup leaves the directory alone from now on, so say what + # is left to do with it. + warn "#{MIRROR_DROPPED} #{interrupted_notice(original)}" unless announced + end + + private + + # What is left to say when the scrub was cut short: where the transcript + # is, that copies which were not removed yet may be left, and which + # directory to remove once the transcript is imported. That is the private + # directory once the move happened (config_dir changed with it; the trash + # is in there too) and the temp dir itself before — never its parent, + # which would be the system's temp directory. + def interrupted_notice(original) + holding = @config_dir == original ? @config_dir : File.dirname(@config_dir) + "Scrubbing was interrupted; the session transcript is under #{File.join(@config_dir, 'projects')}. Copies of " \ + "your credentials and settings that were not removed yet may be left under #{holding} — import the " \ + "transcript into your session store, then remove #{holding}." + end + + # Returns the rest of the preservation warning. +original+ is config_dir + # as it is when preserve_transcripts starts. + def move_aside_and_scrub(original) + staging, failure = move_aside(original) + if failure + return "#{SKIPPED} #{original} could not be moved aside (#{failure.message}). If it is still there, the " \ + "session transcript is under #{File.join(original, 'projects')}, next to copies of your " \ + 'credentials and settings that were not removed.' + end + + @config_dir = File.join(staging, File.basename(original)) + unless @root_identity && SessionResume.directory_identity(@config_dir) == @root_identity + return "#{SKIPPED} what was at #{original} is not the directory the SDK created (it had been replaced). " \ + "It is now at #{@config_dir}; check what it holds before importing anything from it." + end + + preserved = "Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \ + "deleting it — import it into your session store, then remove #{staging}." + [preserved, scrub_all_but_projects(staging)].compact.join(' ') + end + + # Step 1. Returns [private directory the root now lives in, nil], or + # [nil, error] when it stayed where it was. + def move_aside(original) + staging = nil + make_root_movable(original) + staging = Dir.mktmpdir(STAGING_PREFIX, File.dirname(original)) + File.rename(original, File.join(staging, File.basename(original))) + [staging, nil] + rescue SystemCallError, SessionResume::Scrubber::Changed => e + Dir.rmdir(staging) if staging + [nil, e] + end + + # Moving a directory to another parent takes write permission on the + # directory itself. Skipping the scrub for a read-only one would leave the + # credential copies behind, so it is made accessible first — through a + # handle that has to be the directory the SDK created. + def make_root_movable(root) + stat = File.lstat(root) + return if !stat.directory? || SessionResume::Scrubber.owner_rwx?(stat) + + SessionResume::Scrubber.make_accessible(root, @root_identity) + end + + # Step 3. Deletes every entry of the moved root but projects/, then checks + # that nothing else is left. Returns nil, or what the warning has to add. + def scrub_all_but_projects(staging) + trash = Dir.mktmpdir('scrub-', staging) + scrubber = SessionResume::Scrubber.new(trash) + errors = {} + Dir.children(@config_dir).each do |name| + next if name == 'projects' + + scrubber.remove(@config_dir, name) + rescue SessionResume::Scrubber::Changed => e + errors[name] = e + break # something is rearranging the directory under the scrub + rescue SystemCallError => e + errors[name] = e + end + Dir.rmdir(trash) if errors.empty? + + left = (Dir.children(@config_dir) - ['projects'] + errors.keys).uniq.sort + return if left.empty? + + reason = errors.values_at(*left).compact.first&.message + "Scrubbing failed: could not remove #{left.join(', ')}#{" (#{reason})" if reason}. What is left is under " \ + "#{staging} and can hold copies of your credentials and settings." end end @@ -75,6 +212,10 @@ module SessionResume # rubocop:disable Metrics/ModuleLength -- resume materializ KEYCHAIN_SERVICE_NAME = 'Claude Code-credentials' KEYCHAIN_TIMEOUT_SECONDS = 5 + # The default ClaudeAgentOptions gives load_timeout_ms; used when a caller + # set the attribute back to nil. + DEFAULT_LOAD_TIMEOUT_MS = 60_000 + # SystemCallError classes that indicate a transiently-held handle (Windows # AV/indexer scanning a freshly-written file) or a recoverable resource # shortage (file-table exhaustion) rather than a permanent failure. EMFILE/ @@ -89,9 +230,11 @@ module SessionResume # rubocop:disable Metrics/ModuleLength -- resume materializ # Return a copy of +options+ repointed at a materialized temp config dir: # CLAUDE_CONFIG_DIR in env, resume set to the materialized session id, and # continue_conversation cleared (already resolved to a concrete session id). + # options.env reads nil once a caller set it back to nil (the constructor + # default is {}); that means no overrides. def apply_materialized_options(options, materialized) options.dup_with( - env: options.env.merge('CLAUDE_CONFIG_DIR' => materialized.config_dir.to_s), + env: (options.env || {}).merge('CLAUDE_CONFIG_DIR' => materialized.config_dir.to_s), resume: materialized.resume_session_id, continue_conversation: false ) @@ -123,10 +266,11 @@ def build_mirror_batcher(store:, env:, on_error:, eager: false, callback_wrapper # exception or the timeout) if a store call fails or times out. def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- materialization sequence kept in order store = options.session_store - return nil if store.nil? - return nil if options.resume.nil? && !options.continue_conversation + return nil if store.nil? || (options.resume.nil? && !options.continue_conversation) - timeout_s = options.load_timeout_ms / 1000.0 + # load_timeout_ms reads nil once a caller set it back to nil; the + # constructor default applies then. 0 is a valid (immediate) timeout. + timeout_s = (options.load_timeout_ms || DEFAULT_LOAD_TIMEOUT_MS) / 1000.0 # Probed ONCE at materialization entry (the resume path's construction # point) so an invalid callback_scheduling declaration fails fast here, # before any store IO or temp-dir work. @@ -152,6 +296,9 @@ def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- mat session_id, lines = resolved tmp_base = Dir.mktmpdir('claude-resume-') begin + # Taken now: a teardown that deletes inside the directory checks that + # the path still leads to this one (MaterializedResume#preserve_transcripts). + root_identity = directory_identity(tmp_base) project_dir = File.join(tmp_base, 'projects', project_key) FileUtils.mkdir_p(project_dir) write_jsonl(File.join(project_dir, "#{session_id}.jsonl"), lines) @@ -160,9 +307,7 @@ def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- mat # so it can authenticate. Missing files are fine (API-key auth, etc.). copy_auth_files(tmp_base, options.env) - if SessionStore.implements?(store, :list_subkeys) - materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper) - end + materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper) rescue Exception # rubocop:disable Lint/RescueException # Any failure after mkdtemp leaves tmp_base (which may already hold a # .credentials.json copy) on disk with no path for the caller to clean @@ -172,7 +317,7 @@ def materialize_resume_session(options) # rubocop:disable Metrics/AbcSize -- mat raise end - MaterializedResume.new(config_dir: tmp_base, resume_session_id: session_id) + MaterializedResume.new(config_dir: tmp_base, resume_session_id: session_id, root_identity: root_identity) end # -- Helpers -- @@ -276,17 +421,20 @@ def encode_entry(entry, what) # isSidechain check above stays even on the summary path: a missing or # stale sidecar row costs one extra load, never a wrong resume. def sidechain_flags_from_summaries(store, project_key, timeout_s, scheduling, wrapper) - return nil unless SessionStore.implements?(store, :list_session_summaries) - - rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries', scheduling, wrapper) do - store.list_session_summaries(project_key) + # optional_call: NotImplementedError is a ScriptError that with_timeout + # does not wrap. + implemented, rows = SessionStores.optional_call(store, :list_session_summaries) do + with_timeout(timeout_s, 'SessionStore#list_session_summaries', scheduling, wrapper) do + store.list_session_summaries(project_key) + end end + return nil unless implemented + Array(rows).each_with_object({}) do |row, acc| sid = row.is_a?(Hash) ? row['session_id'] : nil acc[sid] = row.dig('data', 'is_sidechain') == true if sid end - # NotImplementedError is a ScriptError that with_timeout does not wrap. - rescue StandardError, NotImplementedError + rescue StandardError nil end @@ -357,16 +505,23 @@ def copy_auth_files(tmp_base, opt_env) # rubocop:disable Metrics/AbcSize, Metric # read_if_present returns raw bytes; the credentials path parses and # re-serializes JSON, so hand it a UTF-8-tagged string (invalid bytes # simply fail to parse and get written through, as before). - creds_bytes = source_config_dir && read_if_present(File.join(source_config_dir, '.credentials.json')) + creds_path = source_config_dir && File.join(source_config_dir, '.credentials.json') + creds_bytes = creds_path && read_if_present(creds_path) creds_json = creds_bytes&.dup&.force_encoding(Encoding::UTF_8) - # macOS default keeps OAuth tokens in the Keychain, not a file. Redirecting - # CLAUDE_CONFIG_DIR changes the Keychain service suffix so the subprocess's - # lookup misses; populate the plaintext file from the parent's Keychain. - # Skipped when env-based auth or a custom config dir is already in play. - if caller_config_dir.nil? && env_value(opt_env, 'ANTHROPIC_API_KEY').nil? && - env_value(opt_env, 'CLAUDE_CODE_OAUTH_TOKEN').nil? - keychain = read_keychain_credentials + # macOS keeps OAuth tokens in the Keychain, not in a file, under a + # service name that depends on the config dir (see + # keychain_service_name). Redirecting CLAUDE_CONFIG_DIR changes that + # name, so the subprocess's own lookup misses; populate the plaintext + # file from the caller's entry instead. Skipped under env-based auth. + # + # Default config dir: a Keychain hit overrides the file. Custom config + # dir: the Keychain is consulted only when the directory has no + # .credentials.json at all — which of the two the CLI prefers when both + # exist is not established, so a file that is there keeps winning. + if env_value(opt_env, 'ANTHROPIC_API_KEY').nil? && env_value(opt_env, 'CLAUDE_CODE_OAUTH_TOKEN').nil? && + (caller_config_dir.nil? || !File.exist?(creds_path)) + keychain = read_keychain_credentials(caller_config_dir) creds_json = keychain unless keychain.nil? end @@ -536,9 +691,11 @@ def redacted_credentials(creds_json) creds_json end - # Read OAuth credentials JSON from the macOS Keychain (default service name). - # Best-effort — returns nil on any error or non-macOS platforms. - def read_keychain_credentials + # Read OAuth credentials JSON from the macOS Keychain entry the CLI keeps + # for +config_dir+ (the caller's CLAUDE_CONFIG_DIR; nil for the default + # config dir). Best-effort — returns nil on any error or non-macOS + # platforms. + def read_keychain_credentials(config_dir) return nil unless RbConfig::CONFIG['host_os'].match?(/darwin/) user = (ENV['USER'] && !ENV['USER'].empty? ? ENV['USER'] : nil) || begin @@ -549,7 +706,7 @@ def read_keychain_credentials end stdout, status = capture_with_timeout( - ['security', 'find-generic-password', '-a', user, '-w', '-s', KEYCHAIN_SERVICE_NAME], + ['security', 'find-generic-password', '-a', user, '-w', '-s', keychain_service_name(config_dir)], KEYCHAIN_TIMEOUT_SECONDS ) return nil if status.nil? || !status.success? @@ -560,6 +717,21 @@ def read_keychain_credentials nil end + # The Keychain service the CLI stores its credentials under. With the + # default config dir that is KEYCHAIN_SERVICE_NAME; with a custom + # CLAUDE_CONFIG_DIR the CLI appends the first 8 hex digits of the SHA-256 + # of that directory — the string the environment carries (no realpath), + # NFC-normalized. The bytes are tagged UTF-8 first, which is how the CLI + # reads them: under a C locale Ruby tags a non-ASCII ENV value as binary, + # and unicode_normalize rejects that. + def keychain_service_name(config_dir) + return KEYCHAIN_SERVICE_NAME if config_dir.nil? + + dir = config_dir.to_s.dup.force_encoding(Encoding::UTF_8) + dir = dir.unicode_normalize(:nfc) if dir.valid_encoding? + "#{KEYCHAIN_SERVICE_NAME}-#{Digest::SHA256.hexdigest(dir)[0, 8]}" + end + # Run a command with a hard timeout, draining stdout on a side thread and # SIGKILL-ing on deadline (Timeout.timeout is unsafe under the fiber # scheduler). Returns [stdout, status] or [nil, nil] on timeout/error. @@ -600,9 +772,17 @@ def capture_with_timeout(argv, timeout_s) # Load and write all subagent transcripts/metadata under session_id. def materialize_subkeys(store, project_dir, project_key, session_id, timeout_s, scheduling, wrapper) # rubocop:disable Metrics/ParameterLists -- store-call context (timeout, scheduling, wrapper) threaded explicitly session_dir = File.join(project_dir, session_id) - subkeys = with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling, wrapper) do - store.list_subkeys('project_key' => project_key, 'session_id' => session_id) + # list_subkeys is optional: without it (or when it raises + # NotImplementedError, see optional_call) only the main transcript is + # materialized. Only this one call sits inside optional_call: the loop + # below calls the REQUIRED #load, and a NotImplementedError from that + # must surface as a failed resume, not read as "no list_subkeys". + listed, subkeys = SessionStores.optional_call(store, :list_subkeys) do + with_timeout(timeout_s, "SessionStore#list_subkeys for session #{session_id}", scheduling, wrapper) do + store.list_subkeys('project_key' => project_key, 'session_id' => session_id) + end end + return unless listed Array(subkeys).each do |subpath| # Subpaths come from an external store and become filesystem path @@ -686,6 +866,143 @@ def resolve_dir(dir) File.expand_path(dir) end + # [device, inode] of +path+ itself when it is a real directory — lstat, so + # a symlink is not followed and does not count — else nil. Taken when the + # temp config dir is created; MaterializedResume compares it before it + # deletes anything inside. + def directory_identity(path) + stat = File.lstat(path) + stat.directory? ? [stat.dev, stat.ino] : nil + rescue SystemCallError, TypeError + nil + end + + # Deletes entries of a directory tree that something else may still be + # writing into, without ever following a symlink — not even one that takes + # an entry's place while this runs. + # + # Looking at an entry and then acting on it by path leaves a gap: what + # lstat called a directory can be a symlink by the time it is listed, + # chmodded or descended into, and a path through it then leads outside the + # tree. So an entry is taken out first — renamed into +trash+, a private + # directory nothing else knows; a rename moves a symlink itself, never its + # target. Only then is the entry examined, and unlinked or, for a + # directory, emptied the same way one level at a time. Every path used is + # therefore an entry of the directory handed to #remove (which the caller + # vouches for), trash/<taken> or trash/<taken>/<child>. + # + # A directory can refuse: moving one to another parent takes write + # permission on the directory itself, emptying it takes read, write and + # search. It is then made accessible (see .make_accessible); nothing else + # is ever chmodded. + class Scrubber + # An entry was no longer what an earlier look at it had found. + class Changed < StandardError; end + + OWNER_RWX = 0o700 + + # How many times a directory that keeps receiving entries is emptied + # before rmdir's "not empty" is left to report it. + EMPTYING_PASSES = 3 + + def self.owner_rwx?(stat) + stat.mode.allbits?(OWNER_RWX) + end + + # chmod 0700 the directory at +path+ without following a symlink: the + # mode is set through a handle (fchmod), and only once that handle + # proved to be a directory this user owns whose [device, inode] is + # +identity+. The path is looked at again afterwards. Raises Changed + # when either look finds something else, and SystemCallError when the + # directory cannot be opened (its owner cannot read it) or chmodded. + def self.make_accessible(path, identity) + File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |handle| + stat = handle.stat + unless stat.directory? && [stat.dev, stat.ino] == identity + raise Changed, "#{path} is not the directory it was a moment ago" + end + raise Errno::EPERM, path unless stat.owned? + + handle.chmod(OWNER_RWX) + end + return if SessionResume.directory_identity(path) == identity + + raise Changed, "#{path} was replaced while it was being made writable" + rescue Errno::ELOOP, Errno::EMLINK + # What O_NOFOLLOW answers for a symlink (EMLINK on some BSDs). + raise Changed, "#{path} became a symlink" + end + + def initialize(trash) + @trash = trash + @taken = 0 + end + + # Remove the entry +name+ of +dir+ and everything under it. Raises + # SystemCallError when something cannot be removed, and Changed when an + # entry was swapped while it was being made accessible. + # + # Nothing here recurses: this runs on the reactor, and a tree can be + # deeper than a fiber's stack. Emptying a directory moves its entries + # into the trash, which is flat, so what is still to be removed is a + # list of trash paths, whatever depth they came from. + def remove(dir, name) + pending = [take(dir, name)] + until pending.empty? + path = pending.pop + stat = File.lstat(path) + next File.unlink(path) unless stat.directory? + + self.class.make_accessible(path, [stat.dev, stat.ino]) unless self.class.owner_rwx?(stat) + pending.concat(empty_and_remove(path)) + end + end + + private + + # Empty the directory at +path+ into the trash and remove it; returns + # where its entries are now. + # + # Something that still holds the directory open can write into it + # meanwhile, and a listing cannot tell: the entry may arrive right after + # it, and the listing may well have been empty. rmdir is what notices + # (ENOTEMPTY; EEXIST on some systems). The directory then gets another + # pass, EMPTYING_PASSES in all; the last refusal is the caller's to + # report. + def empty_and_remove(path) + taken = [] + passes = 0 + begin + passes += 1 + Dir.children(path).each { |child| taken << take(path, child) } + Dir.rmdir(path) + rescue Errno::ENOTEMPTY, Errno::EEXIST + retry if passes < EMPTYING_PASSES + raise + end + taken + end + + # Move dir/name into the trash, whatever it is, and return its new path. + def take(dir, name) + source = File.join(dir, name) + target = File.join(@trash, (@taken += 1).to_s) + begin + File.rename(source, target) + rescue Errno::EACCES, Errno::EPERM + # A directory that is not writable cannot be moved to another + # parent. It is looked at immediately before it is repaired; + # whatever else refuses (the parent, a file) is left as it is. + stat = File.lstat(source) + raise if !stat.directory? || self.class.owner_rwx?(stat) + + self.class.make_accessible(source, [stat.dev, stat.ino]) + File.rename(source, target) + end + target + end + end + # Best-effort recursive removal with retries on transient lock errors # (Windows AV/indexer). Never raises. The temp dir holds an access token, so # the final sweep matters for not leaking secrets. @@ -782,7 +1099,7 @@ def env_value(opt_env, name) private_class_method :load_candidate, :resolve_continue_candidate, :with_timeout, :write_jsonl, :copy_auth_files, :write_redacted_credentials, :read_keychain_credentials, - :capture_with_timeout, :materialize_subkeys, :write_subagent_files, + :keychain_service_name, :capture_with_timeout, :materialize_subkeys, :write_subagent_files, :resolve_dir, :read_if_present, :chmod_owner_only, :copy_if_present, :env_value, :strip_settings_for_resume, :parse_settings_bytes, :mask_surrogate_escapes, :redacted_credentials, :encode_candidate, :encode_jsonl_lines, :encode_entry, diff --git a/lib/claude_agent_sdk/session_store.rb b/lib/claude_agent_sdk/session_store.rb index 58b695e3..198d0d7a 100644 --- a/lib/claude_agent_sdk/session_store.rb +++ b/lib/claude_agent_sdk/session_store.rb @@ -318,6 +318,33 @@ def store_callback_scheduling(store) mode end + # Run a block that calls the OPTIONAL adapter method +method+ + # (list_sessions, list_session_summaries, delete, list_subkeys) and report + # whether the adapter implements it: [true, the block's value], or + # [false, nil] when the method is missing, is the stub inherited from + # SessionStore, or raises NotImplementedError when called. + # + # The last case is the one SessionStore.implements? cannot see. Behind a + # delegating wrapper (a SimpleDelegator around a SessionStore subclass: + # a metrics or tenancy decorator) every inherited stub answers + # respond_to? with the wrapper as its owner, so each optional method + # reads as implemented; and an adapter may decline one at run time by + # raising the marker itself. NotImplementedError is a ScriptError: it + # passed every `rescue StandardError`, the SessionStoreError wrapping + # included, and reached the caller raw where the documented behavior is + # the fallback of a store without the method. + # + # Only for an optional method, and only around the call itself: a + # NotImplementedError from #append or #load is an adapter bug and must + # surface, so nothing that calls those belongs in the block. + def optional_call(store, method) + return [false, nil] unless SessionStore.implements?(store, method) + + [true, yield] + rescue NotImplementedError + [false, nil] + end + # Derive a SessionKey from an absolute transcript file path. # # Main: <projects_dir>/<project_key>/<session_id>.jsonl @@ -427,10 +454,12 @@ def projects_dir(env_override = nil) # NFC like Python's _get_projects_dir(env_override) — a decomposed # Unicode override would otherwise mismatch the NFC paths used for # the mirror's projects-dir prefix comparison and drop every frame. - return File.join(override.unicode_normalize(:nfc), 'projects') if override + # Sessions.nfc_path: under LANG=C the ENV value arrives tagged BINARY, + # which unicode_normalize alone raises on. + return File.join(Sessions.nfc_path(override), 'projects') if override home = Sessions.home_dir(env_override) - home && File.join(home, '.claude', 'projects').unicode_normalize(:nfc) + home && Sessions.nfc_path(File.join(home, '.claude', 'projects')) end end end diff --git a/lib/claude_agent_sdk/sessions.rb b/lib/claude_agent_sdk/sessions.rb index 61df305a..3e0e2c9b 100644 --- a/lib/claude_agent_sdk/sessions.rb +++ b/lib/claude_agent_sdk/sessions.rb @@ -89,6 +89,11 @@ module Sessions # rubocop:disable Metrics/ModuleLength -- session listing/readin LITE_READ_BUF_SIZE = 65_536 MAX_SANITIZED_LENGTH = 200 + # How far into a transcript the disk listing looks for the first prompt, + # and for the first timestamp, when the head window holds none (see + # first_prompt_from_file, created_at_from_file). + FIRST_PROMPT_SCAN_LIMIT = 1_048_576 + UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i # Subagent ids as the CLI writes them (agent-<id>.jsonl): hex ids and @@ -139,28 +144,131 @@ def simple_hash(str) out.join end - # Sanitize a filesystem path to a project directory name + # Sanitize a filesystem path to a project directory name. + # + # The CLI does this with JavaScript's replace(/[^a-zA-Z0-9]/g, "-"), + # without the `u` flag: the replacement runs per UTF-16 code unit, so a + # character outside the BMP (an emoji, a CJK Extension B ideograph) is a + # surrogate pair and becomes TWO hyphens. One hyphen per code point named + # a directory the CLI never created — every directory-scoped session API + # came back empty for such a path, and the store key computed here did + # not match the one the transcript mirror derives from the CLI's own + # path. (The hash below already works on code units, see simple_hash.) def sanitize_path(name) - sanitized = name.gsub(SANITIZE_RE, '-') + sanitized = name.gsub(SANITIZE_RE) { |char| char.ord > 0xFFFF ? '--' : '-' } return sanitized if sanitized.length <= MAX_SANITIZED_LENGTH "#{sanitized[0, MAX_SANITIZED_LENGTH]}-#{simple_hash(name)}" end # Resolve a directory to its canonical form (realpath + NFC), matching the - # CLI's project-directory naming. Falls back to an absolute NFC path when - # realpath can't resolve it (e.g. the directory does not exist yet) — Ruby's - # File.realpath raises on missing paths whereas Python's os.path.realpath is - # lexical for the missing suffix, so expand_path restores that behavior. - # Known divergence: for a MISSING path Python still resolves symlinks in - # the existing prefix (so a deleted /tmp/proj on macOS canonicalizes to - # /private/tmp/proj and its project dir is found); the expand_path - # fallback resolves none, so deleted-directory lookups under symlinked - # prefixes can miss. + # CLI's project-directory naming. + # + # A path that cannot be resolved as a whole (the directory was removed, or + # does not exist yet) is resolved as far as it exists, the way Python's + # os.path.realpath does where Ruby's File.realpath raises + # (resolve_missing_path). The CLI keyed the project by the real path while + # the directory existed, so a removed /tmp/proj on macOS must still + # canonicalize to /private/tmp/proj for its sessions to be found; a plain + # expand_path (the earlier fallback) resolved no symlink at all. The path + # goes to that walk absolute but with its `..` components still in it + # (absolute_path_keeping_dots). def canonicalize_path(dir) - File.realpath(dir).unicode_normalize(:nfc) + nfc_path(File.realpath(dir)) rescue SystemCallError - File.expand_path(dir).unicode_normalize(:nfc) + nfc_path(resolve_missing_path(absolute_path_keeping_dots(dir))) + end + + # +dir+ as an absolute path with its `.` and `..` components left where + # they are, for resolve_missing_path. File.expand_path — what that walk + # was given before — removes a `..` together with the name in front of + # it. When that name is a symlink, the parent meant is the one of the + # link's TARGET: a session recorded through `current/../sibling` while + # `current` pointed at checkouts/project belongs to checkouts/sibling. + # Collapsed by name, the path was the sibling of the link — another + # project key as soon as the target was gone and File.realpath raised. + # + # Otherwise as File.expand_path has it: a relative path starts at the + # working directory, a leading ~ or ~user is that home directory + # (File.realpath expands neither, so `directory: '~/project'` has always + # been resolved through here), and a Pathname is taken as well as a + # String (File.path). + # + # The result is a BINARY String, and resolve_missing_path works on bytes + # throughout. Its parts come tagged by the locale — under LANG=C the + # working directory BINARY and a link target US-ASCII, whatever their + # bytes — and as tagged Strings they did not always go together: a link + # with a non-ASCII target made the walk raise there ("invalid byte + # sequence in US-ASCII"), as did a non-ASCII relative path in a non-ASCII + # working directory. nfc_path tags the result. + def absolute_path_keeping_dots(dir) + dir = File.path(dir).b + return dir if File.absolute_path?(dir) + + first, rest = dir.split(File::SEPARATOR, 2) + base = first&.start_with?('~') ? File.expand_path(first).b : File.join(Dir.pwd.b, first.to_s) + rest ? File.join(base, rest) : base + end + + # +path+ as an NFC-normalized UTF-8 String. Paths are UTF-8 whatever the + # locale says, but under LANG=C Ruby hands out what it gets from the + # system — ENV values, Dir.pwd, File.realpath — tagged BINARY or US-ASCII. + # String#unicode_normalize raises on the first ("Unicode Normalization + # not appropriate for ASCII-8BIT": `directory: Dir.pwd` and a non-ASCII + # CLAUDE_CONFIG_DIR failed every disk session API there) and leaves the + # second as it is, non-ASCII bytes included. So the bytes are tagged + # UTF-8 first, and scrubbed when they are not valid UTF-8. + def nfc_path(path) + utf8 = path.encoding == Encoding::UTF_8 ? path : path.dup.force_encoding(Encoding::UTF_8) + utf8 = utf8.scrub unless utf8.valid_encoding? + utf8.unicode_normalize(:nfc) + end + + # How many symlinks resolve_missing_path follows before it keeps a link as + # written: the guard against links that point at each other. + MAX_SYMLINK_HOPS = 40 + + # Resolve an absolute +path+ that does not exist as a whole, component by + # component: a component that is a symlink is followed (lstat/readlink) + # whether or not its target exists, any other one — existing or missing — + # is kept as written. So is everything after the first missing + # component, and a link past MAX_SYMLINK_HOPS. Following a link whose + # target is gone is the point: a session recorded through a symlinked + # project directory is keyed by the target, and must still be found + # through the link after the target was removed. + # + # A `..` drops the last component of what is resolved so far — after the + # links in front of it were followed, never before: the order of the + # kernel and of Python's os.path.realpath. + def resolve_missing_path(path) + root = path[%r{\A(?:[A-Za-z]:)?/+}] || File::SEPARATOR + resolved = root + pending = path.delete_prefix(root).split(File::SEPARATOR).reject(&:empty?) + hops = 0 + until pending.empty? + name = pending.shift + next if name == '.' + + candidate = name == '..' ? File.dirname(resolved) : File.join(resolved, name) + target = name == '..' || hops >= MAX_SYMLINK_HOPS ? nil : symlink_target(candidate) + if target.nil? + resolved = candidate + next + end + + hops += 1 + resolved = root if target.start_with?(File::SEPARATOR) + pending.unshift(*target.split(File::SEPARATOR).reject(&:empty?)) + end + resolved + end + + # The target of +path+ when it is a symlink (dangling or not), else nil. + # As bytes, like the path it is joined with (absolute_path_keeping_dots). + def symlink_target(path) + File.symlink?(path) ? File.readlink(path).b : nil + rescue SystemCallError + nil end # Derive the SessionStore +project_key+ for a directory (default: cwd). @@ -186,7 +294,7 @@ def project_key_for_directory(directory = nil) # raised a bare ArgumentError from deep inside every disk session API. def config_dir dir = ENV.fetch('CLAUDE_CONFIG_DIR', nil) - return dir.unicode_normalize(:nfc) if dir && !dir.empty? + return nfc_path(dir) if dir && !dir.empty? home = home_dir unless home @@ -196,7 +304,7 @@ def config_dir 'Set CLAUDE_CONFIG_DIR to the directory holding your Claude Code data (normally ~/.claude).' end - File.join(home, '.claude').unicode_normalize(:nfc) + nfc_path(File.join(home, '.claude')) end # A usable home directory, or nil when there is none. The ONE definition @@ -236,17 +344,87 @@ def find_project_dir(path) sanitized = sanitize_path(path) exact_path = File.join(projects_dir, sanitized) return exact_path if File.directory?(exact_path) + return nil unless sanitized.length > MAX_SANITIZED_LENGTH + + # A long path is stored under its first 200 characters plus a hash of + # the whole path. Older CLIs hashed with Bun.hash, so a directory with + # the same prefix and another suffix may be this path's — or that of + # ANY path sharing the prefix (a sibling in a deep per-tenant tree). + # The name cannot tell them apart; a transcript inside can: accept a + # candidate only if one records the path as its cwd (recorded_cwd), and + # only when exactly one candidate does. Taking the first prefix match + # listed, read and renamed another project's sessions for a directory + # that had none of its own. A directory whose transcripts record no cwd + # is not used: no guess from the name alone. The directory returned may + # still hold sessions of other paths sharing the prefix: callers keep + # only the path's own transcripts (own_transcript?). + prefix = sanitized[0, MAX_SANITIZED_LENGTH + 1] # includes the trailing '-' + verified = Dir.children(projects_dir).select do |child| + candidate = File.join(projects_dir, child) + child.start_with?(prefix) && File.directory?(candidate) && project_dir_records_cwd?(candidate, path) + end + verified.length == 1 ? File.join(projects_dir, verified.first) : nil + end - # For long paths, scan for prefix match - if sanitized.length > MAX_SANITIZED_LENGTH - prefix = sanitized[0, MAX_SANITIZED_LENGTH + 1] # includes the trailing '-' - Dir.children(projects_dir).each do |child| - candidate = File.join(projects_dir, child) - return candidate if File.directory?(candidate) && child.start_with?(prefix) - end + # Whether a session transcript in +project_dir+ was recorded for +path+. + def project_dir_records_cwd?(project_dir, path) + Dir.children(project_dir).any? do |name| + name.end_with?('.jsonl') && valid_session_id?(name.delete_suffix('.jsonl')) && + recorded_cwd(File.join(project_dir, name)) == path end + rescue SystemCallError + false + end + # Whether +file_path+, a transcript in +project_dir+ — the directory + # find_project_dir returned for +path+ — is one of +path+'s sessions. Every + # transcript of the directory named after the path is. In a directory the + # long-path prefix fallback found, only one whose own recorded cwd is the + # path: that directory can hold the sessions of every path sharing the + # prefix, and a transcript whose cwd cannot be verified is not counted. + def own_transcript?(project_dir, file_path, path) + !prefix_fallback_dir?(project_dir, path) || recorded_cwd(file_path) == path + end + + # Whether +project_dir+, the directory find_project_dir returned for + # +path+, is one the long-path prefix fallback found rather than the one + # named after the path. + def prefix_fallback_dir?(project_dir, path) + File.basename(project_dir) != sanitize_path(path) + end + + # The directory a session transcript was recorded in: the first non-blank + # top-level cwd of a COMPLETE line in its first LITE_READ_BUF_SIZE bytes, + # NFC-normalized; nil when there is none (or the file cannot be read). A + # line the window cuts establishes nothing: its top-level shape cannot be + # checked, and a raw "cwd" match on it may sit inside a tool input. + def recorded_cwd(file_path) + File.open(file_path, 'rb') do |file| + head = file.read(LITE_READ_BUF_SIZE) || '' + each_parsed_entry(head, file.eof?) do |entry| + cwd = entry['cwd'] + return cwd.unicode_normalize(:nfc) if cwd.is_a?(String) && presence(cwd) + end + end nil + rescue SystemCallError + nil + end + + # Yield each Hash entry parsed from a COMPLETE line of +text+, a window + # read from the start of a transcript: every line that ends in a newline, + # and the last one too when +to_eof+ (the window reaches the end of the + # file). A line that does not parse is skipped. + def each_parsed_entry(text, to_eof) + complete = to_eof ? text.bytesize : (text.byterindex("\n") || -1) + 1 + text.byteslice(0, complete).each_line do |line| + entry = begin + JSON.parse(line) + rescue JSON::ParserError + next + end + yield entry if entry.is_a?(Hash) + end end # Extract a JSON string field value from raw text without full JSON parse @@ -354,11 +532,13 @@ def extract_json_string_value(text, start) nil end - # Unescape a JSON string value + # Unescape a JSON string value. A slice that does not parse as a JSON + # string (a raw control character in it) is returned as it is — tagged + # UTF-8: the windows it is cut from are binary (read_head_tail). def unescape_json_string(str) JSON.parse("\"#{str}\"") rescue JSON::ParserError - str + str.encoding == Encoding::UTF_8 ? str : str.dup.force_encoding(Encoding::UTF_8) end # Python's `x or None` for the summary/title fallback chains: Ruby's || @@ -437,16 +617,25 @@ def listing_sort_key(mtime, session_id) end # Extract the first meaningful user prompt from the head of a JSONL file - def extract_first_prompt_from_head(head) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- first-prompt skip rules, matched by the store fold - command_fallback = nil + def extract_first_prompt_from_head(head) + prompt, command_fallback = first_prompt_in(head) + prompt || command_fallback || '' + end - head.each_line do |line| + # The first real user prompt among the lines of +text+, and the name of + # the first slash command seen on the way (what a session without a real + # prompt reports): [prompt or nil, command name or nil]. +command_fallback+ + # carries a name found in an earlier part of the same transcript. + def first_prompt_in(text, command_fallback = nil) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- first-prompt skip rules, matched by the store fold + text.each_line do |line| next unless line.include?('"type":"user"') || line.include?('"type": "user"') next if line.include?('"tool_result"') next if line.include?('"isMeta":true') || line.include?('"isMeta": true') next if line.include?('"isCompactSummary":true') || line.include?('"isCompactSummary": true') - entry = JSON.parse(line, symbolize_names: false) + # +text+ may be a binary window or chunk: the line becomes UTF-8 here, + # scrubbed, so the text handling below never meets a stray byte. + entry = JSON.parse(utf8_transcript_text(line), symbolize_names: false) texts = user_entry_texts(entry) next unless texts @@ -461,13 +650,65 @@ def extract_first_prompt_from_head(head) # rubocop:disable Metrics/CyclomaticCom next if text.match?(SKIP_FIRST_PROMPT_PATTERN) - return text.length > 200 ? "#{text[0, 200]}…" : text + return [text.length > 200 ? "#{text[0, 200]}…" : text, command_fallback] end rescue JSON::ParserError next end - command_fallback || '' + [nil, command_fallback] + end + + # first_prompt for the disk listing: from the head window, and when that + # holds no real prompt while the file goes on, from a bounded scan past + # it. CLI 2.1.x transcripts often carry a large attachment (a + # SessionStart hook's output) before the first prompt, and an SDK prompt + # that inlines a document is one line longer than the window; the head + # alone reported nil or a slash-command name for those, and a session + # with no other summary source was not listed at all — while the store + # fold, which sees every entry, reported the prompt. + def first_prompt_from_file(file_path, head, size) + prompt, command_fallback = first_prompt_in(head) + if prompt.nil? && size > head.bytesize + limit = [size, FIRST_PROMPT_SCAN_LIMIT].min + prompt, command_fallback = first_prompt_past_head(file_path, head, limit, command_fallback) + end + prompt || command_fallback || '' + end + + # Continue the first-prompt scan past +head+ (see each_line_past_head). An + # IO failure there leaves the answer the head gave. + def first_prompt_past_head(file_path, head, limit, command_fallback) + each_line_past_head(file_path, head, limit) do |line| + prompt, command_fallback = first_prompt_in(line, command_fallback) + return [prompt, command_fallback] if prompt + end + [nil, command_fallback] + end + + # Yield the lines of a transcript that follow the last complete line of + # +head+, up to byte +limit+ of the file. Read in fixed-size chunks, + # never line by line: one transcript line can be gigabytes, and the + # limit has to hold before the bytes are in memory. What is yielded last + # is the final line of a file without a closing newline — or a line cut + # by the limit, which does not parse and is skipped by its consumer like + # any other bad line. An IO failure ends the read quietly: this read is + # an extra, and must not hide a session. + def each_line_past_head(file_path, head, limit, &) + offset = (head.byterindex("\n") || -1) + 1 + open_line = String.new(encoding: Encoding::BINARY) + File.open(file_path, 'rb') do |file| + file.seek(offset) + while offset < limit && (chunk = file.read([LITE_READ_BUF_SIZE, limit - offset].min)) + offset += chunk.bytesize + open_line << chunk + newline = open_line.rindex("\n") + open_line.slice!(0, newline + 1).each_line(&) if newline + end + yield open_line unless open_line.empty? + end + rescue SystemCallError + nil end # Text blocks of a genuine user entry, or nil when the line should be @@ -535,13 +776,24 @@ def sidechain_head?(head, window_truncated) false end + # The first and the last LITE_READ_BUF_SIZE bytes of a transcript, as + # BINARY Strings — on purpose. The field scanners step through a window + # by offset (String#index with a position, text[pos], #length), and Ruby + # keeps no character index for a UTF-8 String that is not ASCII-only: + # there every one of those steps walks the bytes from the start, so a + # scan with many matches is quadratic in the window, and one multibyte + # character anywhere in it is enough (nearly every real transcript has + # one). On bytes each step is O(1). The patterns are ASCII, JSON.parse + # reads a binary source as UTF-8, and the two places that return a raw + # slice of the window tag it UTF-8 (unescape_json_string), so every value + # that leaves the scanners is a UTF-8 String as before. def read_head_tail(file_path, size) head = tail = nil File.open(file_path, 'rb') do |f| - head = (f.read(LITE_READ_BUF_SIZE) || '').force_encoding('UTF-8') + head = f.read(LITE_READ_BUF_SIZE) || String.new(encoding: Encoding::BINARY) tail = if size > LITE_READ_BUF_SIZE f.seek([0, size - LITE_READ_BUF_SIZE].max) - (f.read(LITE_READ_BUF_SIZE) || '').force_encoding('UTF-8') + f.read(LITE_READ_BUF_SIZE) || String.new(encoding: Encoding::BINARY) else head end @@ -549,22 +801,70 @@ def read_head_tail(file_path, size) [head, tail] end - def build_session_info(file_path, head, tail, stat, project_path) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- one optional field per SDKSessionInfo attribute - # User-set title (customTitle) wins over AI-generated title (aiTitle). - # Consult the head only when the tail has no occurrence of that field. - # Normalize blanks AFTER choosing the latest occurrence: an explicit - # clearing entry must not resurrect an older title from the head. - # Summary-chain fields use the top-level-verified scan: a raw byte scan - # also matches these keys nested inside tool_use inputs, reporting tool - # arguments as the session title/summary (and diverging from the store - # fold, which reads top-level keys only). - custom_title = presence(extract_top_level_string_field(tail, 'customTitle', last: true) || - extract_top_level_string_field(head, 'customTitle', last: true)) || - presence(extract_top_level_string_field(tail, 'aiTitle', last: true) || - extract_top_level_string_field(head, 'aiTitle', last: true)) + # [title, first prompt] of a transcript on disk, each nil when absent: the + # two values a disk listing reports as custom_title and first_prompt, and + # the ones fork_session names a fork after (SessionMutations). + # + # Title: the user-set title (customTitle) wins over the AI-generated one + # (aiTitle). The head is consulted only when the tail has no occurrence + # of the field, and blanks are normalized AFTER the latest occurrence was + # chosen (display_title): an explicit clearing entry must not resurrect an + # older title from the head. The top-level-verified scan is used: a raw + # byte scan also matches these keys nested inside tool_use inputs, + # reporting tool arguments as the session title (and diverging from the + # store fold, which reads top-level keys only). + def title_and_first_prompt(file_path, head, tail, size) + custom, generated = %w[customTitle aiTitle].map do |key| + extract_top_level_string_field(tail, key, last: true) || extract_top_level_string_field(head, key, last: true) + end # nil, not '', when there is no prompt — the store path's answer, and # Python's (`_extract_first_prompt_from_head(head) or None`). - first_prompt = presence(extract_first_prompt_from_head(head)) + [display_title(custom, generated), presence(first_prompt_from_file(file_path, head, size))] + end + + # The ONE rule for a session's title, given the latest custom title and the + # latest AI title of its transcript: blank counts as absent, custom first. + # Shared by the disk listing, and by fork_session on the disk and the + # store path (the store listing applies it in SessionSummary). + def display_title(custom_title, ai_title) + presence(custom_title) || presence(ai_title) + end + + # created_at (epoch ms) for the disk listing: the first top-level + # timestamp that parses — what the store fold takes. More reliable than + # stat().birthtime, which is unsupported on some filesystems. Every line + # is looked at, not only the first: the first record may be a + # metadata-only entry (e.g. permission-mode) with no timestamp field, and + # the first user/assistant record that follows carries one (Python #907). + # + # Parsed top-level fields of COMPLETE lines only, never a raw match: a + # file-history-snapshot entry, common near the start of an interactive + # session, has no timestamp of its own but nests one + # (snapshot.timestamp), and on a line the head window cuts a raw match + # cannot be told from such a nested one. When the complete head lines + # hold no timestamp and the file goes on, the lines past them are read + # to their end instead (bounded like the first-prompt scan): the cut line + # is usually the one that carries it — an SDK prompt of more than 64 KiB + # makes the very first line that long. + def created_at_from_file(file_path, head, size) + to_eof = size <= head.bytesize + each_parsed_entry(head, to_eof) do |entry| + created_at = parse_iso_timestamp_ms(entry['timestamp']) + return created_at if created_at + end + return nil if to_eof + + each_line_past_head(file_path, head, [size, FIRST_PROMPT_SCAN_LIMIT].min) do |line| + each_parsed_entry(line, true) do |entry| + created_at = parse_iso_timestamp_ms(entry['timestamp']) + return created_at if created_at + end + end + nil + end + + def build_session_info(file_path, head, tail, stat, project_path) # rubocop:disable Metrics/AbcSize -- one optional field per SDKSessionInfo attribute + custom_title, first_prompt = title_and_first_prompt(file_path, head, tail, stat.size) # lastPrompt tail entry shows what the user was most recently doing. summary = custom_title || presence(extract_top_level_string_field(tail, 'lastPrompt', last: true)) || @@ -577,14 +877,7 @@ def build_session_info(file_path, head, tail, stat, project_path) # rubocop:disa tag_line = tail.lines.reverse.find { |ln| ln.start_with?('{"type":"tag"') } tag_value = presence(tag_line ? extract_json_string_field(tag_line, 'tag', last: true) : nil) - # created_at from the first ISO timestamp found in the head (epoch ms). - # More reliable than stat().birthtime which is unsupported on some - # filesystems. Scans the whole head rather than only the first line - # because the first record may be a metadata-only entry (e.g. - # permission-mode) with no timestamp field; the first user/assistant - # record that follows does carry one (Python #907). - first_timestamp = extract_json_string_field(head, 'timestamp', last: false) - created_at = parse_iso_timestamp_ms(first_timestamp) if first_timestamp + created_at = created_at_from_file(file_path, head, stat.size) SDKSessionInfo.new( session_id: File.basename(file_path, '.jsonl'), @@ -616,7 +909,10 @@ def parse_iso_timestamp_ms(timestamp_str) return nil unless timestamp_str.is_a?(String) require 'time' - (Time.iso8601(timestamp_str).to_f * 1000).to_i + # Integer arithmetic: through a Float (to_f * 1000, truncated) about one + # millisecond value in eight came out 1 ms low — ...30.933Z as ...932. + time = Time.iso8601(timestamp_str) + (time.to_i * 1000) + (time.nsec / 1_000_000) rescue ArgumentError nil end @@ -626,10 +922,19 @@ def read_sessions_from_dir(project_dir, project_path = nil) return [] unless File.directory?(project_dir) sessions = [] - Dir.glob(File.join(project_dir, '*.jsonl')).each do |file_path| - stem = File.basename(file_path, '.jsonl') + # Listing a directory found by the long-path prefix fallback: only the + # transcripts recorded for +project_path+ (own_transcript?). + verify = project_path && prefix_fallback_dir?(project_dir, project_path) + # base:, not a pattern built from the directory: a config dir path with + # glob characters in it (`/Volumes/Data [SSD]/…`, `/srv/{tenant}/…`) + # is a path, and as part of the pattern it matched nothing. + Dir.glob('*.jsonl', base: project_dir).each do |name| + stem = File.basename(name, '.jsonl') next unless stem.match?(UUID_RE) + file_path = File.join(project_dir, name) + next if verify && recorded_cwd(file_path) != project_path + session = read_session_lite(file_path, project_path) sessions << session if session end @@ -721,10 +1026,12 @@ def get_session_messages(session_id:, directory: nil, limit: nil, offset: 0) # List subagent IDs recorded for a session on local disk (counterpart to # list_subagents_from_store). Scans # <projectDir>/<sessionId>/subagents/**/agent-<id>.jsonl, including nested - # workflows/<runId>/ paths, in sorted walk order. Mirrors the Python SDK's - # list_subagents (#825) — no dedupe (the store variant dedupes because - # adapter subkey ordering is adapter-defined; the sorted disk walk is - # already deterministic). + # workflows/<runId>/ paths, in sorted walk order (the Python SDK's + # list_subagents, #825). Each id once, at its first position in the walk + # — the transcript the message and metadata readers take for it. Python + # does not dedupe here; an id whose transcript exists both directly and + # under workflows/<runId>/ came back twice, where the store variant + # returns it once. # @param session_id [String] The session UUID # @param directory [String, nil] Working directory to search in (strictly # scopes to that project + its worktrees; nil searches all projects) @@ -735,7 +1042,7 @@ def list_subagents(session_id:, directory: nil) subagents_dir = resolve_subagents_dir(session_id, directory) return [] if subagents_dir.nil? - collect_agent_files(subagents_dir).map(&:first) + collect_agent_files(subagents_dir).map(&:first).uniq end # Read the optional subagent metadata sidecar without reading its transcript. @@ -831,8 +1138,13 @@ def split_agent_metadata(entries) # List sessions from a SessionStore. Store-backed counterpart to # list_sessions. Uses the store's incremental summaries (one batch call + # gap-fill) when available, else falls back to list_sessions + one load per - # session. Sessions are derived through the same fold the disk path uses, so - # both paths agree for identical transcript content. + # session. Sessions are derived by folding EVERY entry + # (SessionSummary.fold_session_summary), field by field under the rules + # of the disk reader — which only reads the head and tail windows of a + # transcript (plus the bounded first-prompt scan). The two paths agree + # for identical transcript content unless the entry deciding a field + # lies outside those windows; docs/sessions.md ("Listing Sessions") lists + # the cases. # # @param session_store [SessionStore] store implementing list_session_summaries and/or list_sessions # @return [Array<SDKSessionInfo>] sorted by last_modified descending @@ -841,17 +1153,18 @@ def list_sessions_from_store(session_store:, directory: nil, limit: nil, offset: project_path = canonicalize_path(directory.nil? ? '.' : directory.to_s) project_key = sanitize_path(project_path) - if SessionStore.implements?(session_store, :list_session_summaries) - via = list_sessions_via_summaries(session_store, project_key, project_path, limit, offset) - return via unless via.nil? - end + via = list_sessions_via_summaries(session_store, project_key, project_path, limit, offset) + return via unless via.nil? - unless SessionStore.implements?(session_store, :list_sessions) + listed, listing = SessionStores.optional_call(session_store, :list_sessions) do + session_store.list_sessions(project_key) + end + unless listed raise ArgumentError, 'session_store implements neither list_session_summaries nor list_sessions -- cannot list sessions' end - listing = Array(session_store.list_sessions(project_key)) + listing = Array(listing) # Build all-placeholder slots (the shape the summaries fast path uses) and # reuse its bounded pagination: sessions are loaded newest-first only # until the page fills (~offset + limit + dropped), instead of one full @@ -874,10 +1187,12 @@ def get_session_info_from_store(session_store:, session_id:, directory: nil) return nil unless valid_session_id?(session_id) project_path = canonicalize_path(directory.nil? ? '.' : directory.to_s) - entries = session_store.load('project_key' => sanitize_path(project_path), 'session_id' => session_id) + project_key = sanitize_path(project_path) + entries = session_store.load('project_key' => project_key, 'session_id' => session_id) return nil if entries.nil? || entries.empty? - derive_info_from_entries(session_id, entries, mtime_from_entries(entries), project_path) + mtime = store_session_mtime(session_store, project_key, session_id) || mtime_from_entries(entries) + derive_info_from_entries(session_id, entries, mtime, project_path) end # Read a session's conversation messages from a SessionStore. Store-backed @@ -897,16 +1212,21 @@ def get_session_messages_from_store(session_store:, session_id:, directory: nil, def list_subagents_from_store(session_store:, session_id:, directory: nil) return [] unless valid_session_id?(session_id) - unless SessionStore.implements?(session_store, :list_subkeys) + project_key = project_key_for_directory(directory) + implemented, subkeys = SessionStores.optional_call(session_store, :list_subkeys) do + session_store.list_subkeys('project_key' => project_key, 'session_id' => session_id) + end + unless implemented raise ArgumentError, 'session_store does not implement list_subkeys -- cannot list subagents' end - project_key = project_key_for_directory(directory) - subkeys = Array(session_store.list_subkeys('project_key' => project_key, 'session_id' => session_id)) seen = {} - subkeys.filter_map do |subpath| - next unless subpath.start_with?('subagents/') + Array(subkeys).filter_map do |subpath| + # A non-String subkey (Symbol, nil, Integer) is an adapter contract + # violation; skip it like resume does instead of calling String + # methods on it. + next unless subpath.is_a?(String) && subpath.start_with?('subagents/') last = subpath.rpartition('/').last next unless last.start_with?('agent-') @@ -996,22 +1316,25 @@ def import_session_to_store(session_id:, session_store:, directory: nil, include # -- Private helpers -- # Summary fast-path for list_sessions_from_store. Returns the paginated - # result, or nil if the store's list_session_summaries raises - # NotImplementedError (caller falls back to the slow path). Sessions missing + # result, or nil if the store does not implement list_session_summaries + # (see SessionStores.optional_call; the caller falls back to the slow + # path). Sessions missing # a sidecar or whose sidecar is stale (summary.mtime < the session's current # mtime) are routed through gap-fill so the fold is recomputed from source. - def list_sessions_via_summaries(store, project_key, project_path, limit, offset) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- fast path plus stale/missing-sidecar gap-fill - begin - # Array(): a non-conformant store returning nil (e.g. a NULL JSONB read) - # degrades to gap-fill instead of crashing on nil.each, matching the - # defensive Array() already applied to list_sessions / list_subkeys. - summaries = Array(store.list_session_summaries(project_key)) - rescue NotImplementedError - return nil + def list_sessions_via_summaries(store, project_key, project_path, limit, offset) # rubocop:disable Metrics/AbcSize -- fast path plus stale/missing-sidecar gap-fill + implemented, summaries = SessionStores.optional_call(store, :list_session_summaries) do + store.list_session_summaries(project_key) end - - has_list_sessions = SessionStore.implements?(store, :list_sessions) - listing = has_list_sessions ? Array(store.list_sessions(project_key)) : [] + return nil unless implemented + + # Array(): a non-conformant store returning nil (e.g. a NULL JSONB read) + # degrades to gap-fill instead of crashing on nil.each, matching the + # defensive Array() already applied to list_sessions / list_subkeys. + summaries = Array(summaries) + has_list_sessions, listing = SessionStores.optional_call(store, :list_sessions) do + store.list_sessions(project_key) + end + listing = Array(listing) known_mtimes = listing.to_h { |e| [e['session_id'], e['mtime']] } slots = [] @@ -1098,6 +1421,27 @@ def derive_info_from_entries(session_id, entries, mtime, project_path) SessionSummary.summary_entry_to_sdk_info(summary, project_path) end + # The adapter's own mtime for one session, as its listing reports it, or + # nil when the store cannot be asked (it implements neither listing + # method) or does not list the session. + # + # get_session_info(session_store:) stamps this as last_modified: it is the + # clock list_sessions(session_store:) reports and orders by, and what the + # docs promise on the store paths. The entries' own timestamps are another + # clock — and absent from metadata entries, which gave last_modified 0 for + # a session the listing showed with a real mtime. They remain the fallback + # (mtime_from_entries) for a store with nothing but #append and #load. + def store_session_mtime(store, project_key, session_id) + listed, rows = SessionStores.optional_call(store, :list_sessions) { store.list_sessions(project_key) } + unless listed + _, rows = SessionStores.optional_call(store, :list_session_summaries) do + store.list_session_summaries(project_key) + end + end + row = Array(rows).find { |candidate| candidate.is_a?(Hash) && candidate['session_id'] == session_id } + row && row['mtime'] + end + # Last parseable entry timestamp (epoch ms), scanning from the tail; 0 if none. def mtime_from_entries(entries) entries.reverse_each do |entry| @@ -1159,25 +1503,33 @@ def entries_to_subagent_messages(entries, limit, offset, parent_tool_use_id = ni end # Find the last user/assistant entry and walk parentUuid links back to the - # root (subagent transcripts are linear). Mirrors Python's - # _build_subagent_chain. + # root, as Python's _build_subagent_chain does. Subagent transcripts are + # not linear either (Python's comment says they are): parallel tool calls + # fan out exactly as in a main transcript, so the results the walk passes + # by are put back. No flag rejection there — every subagent entry is a + # sidechain entry. def build_subagent_chain(entries) return [] if entries.empty? by_uuid = entries.to_h { |e| [e['uuid'], e] } leaf = entries.reverse_each.find { |e| %w[user assistant].include?(e['type']) } - leaf ? walk_to_root(by_uuid, leaf) : [] + return [] unless leaf + + reattach_parallel_tool_results(walk_to_root(by_uuid, leaf), entries, skip_flagged: false) end # Find the subpath for a subagent, scanning subkeys (subagents may be nested # under subagents/workflows/<runId>/agent-<id>) when list_subkeys is # available, else falling back to the direct subagents/agent-<id> path. def resolve_subagent_subpath(store, project_key, session_id, agent_id) - return "subagents/agent-#{agent_id}" unless SessionStore.implements?(store, :list_subkeys) + implemented, subkeys = SessionStores.optional_call(store, :list_subkeys) do + store.list_subkeys('project_key' => project_key, 'session_id' => session_id) + end + return "subagents/agent-#{agent_id}" unless implemented target = "agent-#{agent_id}" - matches = Array(store.list_subkeys('project_key' => project_key, 'session_id' => session_id)) - .select { |sk| sk.start_with?('subagents/') && sk.rpartition('/').last == target } + matches = Array(subkeys) + .select { |sk| sk.is_a?(String) && sk.start_with?('subagents/') && sk.rpartition('/').last == target } # Several subpaths can share a trailing agent-<id> (a top-level agent and a # nested subagents/workflows/<run>/agent-<id>). Prefer the canonical # top-level path, else pick deterministically (shortest, then lexical) so @@ -1252,11 +1604,15 @@ def parent_ids_from_agent_metadata(meta) def append_jsonl_file_in_batches(file_path, key, store, batch_size) batch = [] nbytes = 0 - # encoding: transcripts are UTF-8 regardless of locale; without it a - # LANG=C process raises Encoding::InvalidByteSequenceError on the first - # multibyte line, aborting the import mid-way (Python pins utf-8 here). - File.foreach(file_path, encoding: 'UTF-8').with_index(1) do |line, lineno| - line = line.chomp + # Read as UTF-8 bytes regardless of locale (a LANG=C process raised + # Encoding::InvalidByteSequenceError on the first multibyte line, + # aborting the import mid-way; Python pins utf-8 here), and scrub a + # line that is not valid UTF-8: Ruby's JSON parser accepts a raw + # invalid byte inside a string, and the entry it yields makes every + # adapter that serializes what it is given raise JSON::GeneratorError + # from #append — after the batches before it were already stored. + File.foreach(file_path, mode: 'rb').with_index(1) do |line, lineno| + line = utf8_transcript_text(line).chomp next if line.empty? begin @@ -1314,7 +1670,7 @@ def get_session_info_for_directory(file_name, directory) # os.path.realpath never raises here. canonical = canonicalize_path(directory) project_dir = find_project_dir(canonical) - if project_dir + if project_dir && own_transcript?(project_dir, File.join(project_dir, file_name), canonical) info = read_session_lite(File.join(project_dir, file_name), canonical) return info if info end @@ -1325,7 +1681,7 @@ def get_session_info_for_directory(file_name, directory) next if wt_path == canonical wt_project_dir = find_project_dir(wt_path) - next unless wt_project_dir + next unless wt_project_dir && own_transcript?(wt_project_dir, File.join(wt_project_dir, file_name), wt_path) info = read_session_lite(File.join(wt_project_dir, file_name), wt_path) return info if info @@ -1344,13 +1700,29 @@ def list_sessions_for_directory(directory, include_worktrees) return project_dir ? read_sessions_from_dir(project_dir, path) : [] end - # Multiple worktrees: scan all project dirs for matches + # Several worktrees: the caller's own directory first, unconditionally. + # `git worktree list` reports worktree ROOTS, so a subdirectory (a + # monorepo package) is none of them, and reading only the listed paths + # left out exactly the sessions that were asked for (Python: + # "Always include the user's actual directory"). Then every worktree. + # + # A project dir named after its path is read once. One found by the + # long-path prefix fallback is read once per path: reading it keeps + # that path's own transcripts only (read_sessions_from_dir), and + # worktrees whose paths share the first 200 characters share the + # directory. Marked as read after the first of them, it never gave the + # sessions of the others. all_sessions = [] - worktree_paths.each do |wt_path| - project_dir = find_project_dir(wt_path) - next unless project_dir + seen = {} + [path, *worktree_paths].each do |dir| + project_dir = find_project_dir(dir) + next if project_dir.nil? - all_sessions.concat(read_sessions_from_dir(project_dir, wt_path)) + scan = prefix_fallback_dir?(project_dir, dir) ? [project_dir, dir] : project_dir + next if seen[scan] + + seen[scan] = true + all_sessions.concat(read_sessions_from_dir(project_dir, dir)) end deduplicate_sessions(all_sessions) @@ -1376,10 +1748,11 @@ def list_all_sessions # One entry per session_id when the same session sits in several project # dirs (copied config dirs, worktrees). The newest last_modified wins; on # equal mtimes the larger file (the more complete copy), and then the - # copy scanned first — project dirs in name order for the global listing, - # worktrees in `git worktree list` order (main worktree first) for a - # directory listing. Python keeps the first copy seen in iterdir() order - # (sessions.py _deduplicate_by_session_id), which is arbitrary on a tie. + # copy scanned first — project dirs in name order for the global listing; + # for a directory listing the directory itself, then its worktrees in + # `git worktree list` order (main worktree first). Python keeps the first + # copy seen in iterdir() order (sessions.py _deduplicate_by_session_id), + # which is arbitrary on a tie. def deduplicate_sessions(sessions) by_id = {} sessions.each do |s| @@ -1404,6 +1777,7 @@ def dedup_rank(session) def detect_worktrees(path) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- bounded git subprocess: drained pipes, deadline kill stdin, stdout, stderr, wait_thr = Open3.popen3('git', '-C', path, 'worktree', 'list', '--porcelain') stdin.close + stdout.binmode # bytes: no transcoding to Encoding.default_internal; tagged in worktree_paths # Drain stdout/stderr concurrently — without this, a repo with enough # worktrees to overrun the 64 KB pipe buffer causes git to block on @@ -1433,9 +1807,7 @@ def detect_worktrees(path) # rubocop:disable Metrics/AbcSize, Metrics/Cyclomatic return [path] unless wait_thr.value.success? - paths = stdout_buf.lines.filter_map do |line| - line.strip.delete_prefix('worktree ') if line.start_with?('worktree ') - end + paths = worktree_paths(stdout_buf) paths.empty? ? [path] : paths rescue StandardError [path] @@ -1445,6 +1817,22 @@ def detect_worktrees(path) # rubocop:disable Metrics/AbcSize, Metrics/Cyclomatic [stdout, stderr].each { |io| io&.close rescue nil } # rubocop:disable Style/RescueModifier end + # The paths in the output of `git worktree list --porcelain`. + # + # The output was read as bytes and is UTF-8 here, whatever the locale: + # under LANG=C the pipe yielded US-ASCII Strings, the first non-ASCII + # worktree path made String#strip raise, and detect_worktrees' rescue + # then dropped EVERY worktree. Each path is NFC-normalized, like every + # path the SDK derives a project dir name from (canonicalize_path; Python + # normalizes here too): git prints a path as the filesystem stores it, and + # a decomposed name sanitizes to a different project dir than the one the + # CLI created. + def worktree_paths(porcelain) + porcelain.force_encoding(Encoding::UTF_8).lines.filter_map do |line| + line.strip.delete_prefix('worktree ').unicode_normalize(:nfc) if line.start_with?('worktree ') + end + end + def find_session_file(session_id, directory) projects_dir = File.join(config_dir, 'projects') return nil unless File.directory?(projects_dir) @@ -1453,13 +1841,13 @@ def find_session_file(session_id, directory) if directory path = canonicalize_path(directory) - found = stat_candidate(find_project_dir(path), file_name) + found = stat_candidate(find_project_dir(path), file_name, path) return found if found detect_worktrees(path).each do |wt_path| next if wt_path == path # already tried above - found = stat_candidate(find_project_dir(wt_path), file_name) + found = stat_candidate(find_project_dir(wt_path), file_name, wt_path) return found if found end @@ -1485,11 +1873,16 @@ def find_session_file(session_id, directory) # exists AND is non-empty — a 0-byte stub in one project dir must not # stop the search when the real transcript lives under another # worktree's project dir (same hazard SessionMutations.try_append guards). - def stat_candidate(project_dir, file_name) + # With +path+ (a directory-scoped lookup), the candidate must also be one + # of that path's own transcripts (own_transcript?). + def stat_candidate(project_dir, file_name, path = nil) return nil if project_dir.nil? candidate = File.join(project_dir, file_name) - File.size(candidate).positive? ? candidate : nil + return nil unless File.size(candidate).positive? + return nil if path && !own_transcript?(project_dir, candidate, path) + + candidate rescue SystemCallError nil end @@ -1529,8 +1922,8 @@ def collect_agent_files(base_dir, results = []) def parse_jsonl_entries(file_path) entries = [] - File.foreach(file_path) do |line| - entry = JSON.parse(line.strip, symbolize_names: false) + File.foreach(file_path, mode: 'rb') do |line| + entry = JSON.parse(utf8_transcript_text(line).strip, symbolize_names: false) next unless entry.is_a?(Hash) next unless TRANSCRIPT_ENTRY_TYPES.include?(entry['type']) next unless entry['uuid'].is_a?(String) @@ -1542,6 +1935,20 @@ def parse_jsonl_entries(file_path) entries end + # Transcript text (one line, or a run of lines) read in binary mode, as + # UTF-8. Transcripts are UTF-8 whatever the process locale says: a line + # tagged with the locale's encoding (File.foreach's default) raised from + # String#strip on the first non-ASCII character under LANG=C. Bytes that + # are not valid UTF-8 — a final line the CLI was killed in the middle of, + # raw binary in a tool result — become U+FFFD, the policy + # SessionMutations.parse_fork_transcript already has: a torn line then + # fails JSON.parse and is skipped like any other bad line instead of + # raising, and a complete line keeps its entry. + def utf8_transcript_text(text) + text.force_encoding(Encoding::UTF_8) + text.valid_encoding? ? text : text.scrub + end + # Build the conversation chain by finding the leaf and walking parentUuid. # Returns messages in chronological order (root -> leaf). # @@ -1570,15 +1977,153 @@ def build_conversation_chain(entries) walk_to_leaf(by_uuid, uuid) end - # Keep only main-chain candidates (not sidechain, team, or meta) - main_leaves = leaf_candidates.reject do |e| - e['isSidechain'] || e['teamName'] || e['isMeta'] + best_leaf = pick_leaf(leaf_candidates, by_uuid, by_position) + return [] unless best_leaf + + reattach_parallel_tool_results(walk_to_root(by_uuid, best_leaf), entries, skip_flagged: true) + end + + # The leaf a conversation is read back from: the main-chain candidate + # (not sidechain, team or meta) with the highest file position. + # + # Without one, fall back to the other candidates instead of reading the + # conversation as empty, as Python does (`_pick_best(main_leaves) if + # main_leaves else _pick_best(leaves)`): a session can end on a meta + # entry nobody answered (a slash-command or skill body, a stop-hook + # message, a system reminder — the user closed the session first), and + # filter_visible_messages drops the flagged entries of the chain anyway. + # Among those candidates one whose path to the root passes a visible + # message comes first, then file position: the latest of them may be a + # sidechain or teammate leaf with nothing visible above it, and taking + # it would still read the conversation as empty. + def pick_leaf(candidates, by_uuid, by_position) + latest = ->(leaves) { leaves.max_by { |e| by_position[e['uuid']] || 0 } } + main_leaves = candidates.reject { |e| off_main_conversation?(e) } + return latest.call(main_leaves) unless main_leaves.empty? + + known = {} + with_visible = candidates.select { |e| visible_ancestor?(by_uuid, e, known) } + latest.call(with_visible.empty? ? candidates : with_visible) + end + + # Whether the path from +leaf+ to its root passes an entry + # filter_visible_messages returns. +known+ carries the answer for every + # uuid already walked, so all the candidates of one transcript cost one + # pass over it. + def visible_ancestor?(by_uuid, leaf, known) + walked = [] + current = leaf + found = false + while current && !known.key?(current['uuid']) + known[current['uuid']] = false # a parentUuid cycle ends here + walked << current['uuid'] + break if (found = visible_message?(current)) + + current = by_uuid[current['parentUuid']] end - return [] if main_leaves.empty? + found ||= current ? known[current['uuid']] : false + walked.each { |uuid| known[uuid] = found } + found + end - # Pick the leaf with highest file position, walk to root - best_leaf = main_leaves.max_by { |e| by_position[e['uuid']] || 0 } - walk_to_root(by_uuid, best_leaf) + # An entry that is not part of the user's own conversation: written by a + # subagent (sidechain) or a teammate, or a meta injection. + def off_main_conversation?(entry) + entry['isSidechain'] || entry['teamName'] || entry['isMeta'] + end + + # A user/assistant entry of the user's own conversation. + def visible_message?(entry) + %w[user assistant].include?(entry['type']) && !off_main_conversation?(entry) + end + + # Put the results of parallel tool calls back on a leaf-to-root chain. + # + # The CLI writes one assistant entry per tool_use block (chained through + # parentUuid) and parents every tool_result on the entry that holds ITS + # tool_use. With two or more calls in one API message, only the result + # the conversation continued from is an ancestor of the leaf; the others + # are siblings of the next tool_use entry, and a single-path walk returns + # their tool_use without them. + # + # For each assistant entry on the chain, take its user children that are + # not on the chain and carry a tool_result for a tool_use on the chain, + # and insert them — in file order — before the next user entry of the + # chain (the batch's own on-chain result), or at the end when the chain + # has none. The anchor, not the raw file position, decides the place: a + # result that arrived after the conversation had already moved on to a + # further tool_use of the same message still lands with its batch, so + # every result follows the assistant turn that asked for it. + # + # The tool_use_id match is what keeps other user siblings out: a prompt + # abandoned by a rewind is a second child of a chain entry too, and + # starts a branch that was dropped — and so is the old result of a call + # the conversation was rewound to and answered again: the chain's own + # result for a tool_use_id wins, and at most one off-chain result per id + # is ever added (the first in file order). +skip_flagged+ additionally + # rejects sidechain / meta / team entries (main transcripts; a subagent + # transcript is sidechain throughout). + def reattach_parallel_tool_results(chain, entries, skip_flagged:) + off_chain = off_chain_tool_results(chain, entries, skip_flagged) + return chain if off_chain.empty? + + placed = [] + pending = [] + chain.each do |entry| + if entry['type'] == 'user' && !pending.empty? + placed.concat(pending.sort_by(&:first).map(&:last)) + pending = [] + end + placed << entry + pending.concat(off_chain.fetch(entry['uuid'], [])) + end + placed.concat(pending.sort_by(&:first).map(&:last)) + end + + # { uuid of an assistant entry on the chain => [[file position, entry], ...] } + # for the off-chain user children reattach_parallel_tool_results places. + def off_chain_tool_results(chain, entries, skip_flagged) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- one filter per condition of the re-attachment rule + on_chain = Set.new + assistants = Set.new + tool_use_ids = Set.new + answered = Set.new # tool_use ids the chain's own results answer + chain.each do |entry| + on_chain << entry['uuid'] + case entry['type'] + when 'assistant' + assistants << entry['uuid'] + tool_use_ids.merge(content_block_values(entry, 'tool_use', 'id')) + when 'user' then answered.merge(content_block_values(entry, 'tool_result', 'tool_use_id')) + end + end + return {} if (tool_use_ids - answered).empty? + + found = {} + entries.each_with_index do |entry, position| + next unless entry['type'] == 'user' && assistants.include?(entry['parentUuid']) + next if on_chain.include?(entry['uuid']) + next if skip_flagged && off_main_conversation?(entry) + + ids = content_block_values(entry, 'tool_result', 'tool_use_id') + next if ids.empty? || !ids.all? { |id| tool_use_ids.include?(id) && !answered.include?(id) } + + # Claimed: a later result for the same call is not added — nor a + # second copy of this entry, which a store can hold (a retried mirror + # batch overlaps the write it retries). + answered.merge(ids) + (found[entry['parentUuid']] ||= []) << [position, entry] + end + found + end + + # Values of +key+ over the +type+ content blocks of an entry's message + # ([] for a message without array content — entries are opaque blobs). + def content_block_values(entry, type, key) + message = entry['message'] + content = message.is_a?(Hash) ? message['content'] : nil + return [] unless content.is_a?(Array) + + content.filter_map { |block| block[key] if block.is_a?(Hash) && block['type'] == type } end def walk_to_leaf(by_uuid, uuid) @@ -1609,10 +2154,7 @@ def walk_to_root(by_uuid, leaf) def filter_visible_messages(chain) chain.filter_map do |entry| - next unless %w[user assistant].include?(entry['type']) - next if entry['isMeta'] - next if entry['isSidechain'] - next if entry['teamName'] + next unless visible_message?(entry) # NOTE: isCompactSummary messages are intentionally included. They contain # the summarized content from compacted conversations and are the only @@ -1628,16 +2170,24 @@ def filter_visible_messages(chain) end end - private_class_method :get_session_info_for_directory, + private_class_method :absolute_path_keeping_dots, :resolve_missing_path, :symlink_target, + :project_dir_records_cwd?, :prefix_fallback_dir?, :recorded_cwd, + :each_parsed_entry, :get_session_info_for_directory, :list_sessions_for_directory, :list_all_sessions, :deduplicate_sessions, :dedup_rank, - :find_session_file, :stat_candidate, :resolve_subagents_dir, - :collect_agent_files, :parse_jsonl_entries, + :worktree_paths, :find_session_file, :stat_candidate, :resolve_subagents_dir, + :collect_agent_files, :parse_jsonl_entries, :utf8_transcript_text, :build_conversation_chain, :walk_to_leaf, :walk_to_root, - :filter_visible_messages, :read_head_tail, :build_session_info, :user_entry_texts, + :pick_leaf, :visible_ancestor?, :off_main_conversation?, :visible_message?, + :reattach_parallel_tool_results, :off_chain_tool_results, + :content_block_values, + :filter_visible_messages, :build_session_info, :created_at_from_file, :user_entry_texts, + :each_line_past_head, + :first_prompt_in, :first_prompt_from_file, :first_prompt_past_head, :valid_agent_id?, :sidechain_head?, :list_sessions_via_summaries, :paginate_resolving_gaps, :resolve_gap_slot, - :derive_info_from_entries, :mtime_from_entries, :apply_sort_limit_offset, + :derive_info_from_entries, :store_session_mtime, :mtime_from_entries, + :apply_sort_limit_offset, :filter_transcript_entries, :entries_to_messages, :entries_to_subagent_messages, :build_subagent_chain, :resolve_subagent_subpath, :import_subagent_files, :append_jsonl_file_in_batches, :collect_jsonl_files, @@ -1646,6 +2196,9 @@ def filter_visible_messages(chain) # These remain accessible for SessionMutations / SessionResume: # config_dir, sanitize_path, find_project_dir, detect_worktrees, # valid_session_id? (mutation boundary checks), listing_sort_key - # (--continue candidate order) + # (--continue candidate order), nfc_path (SessionStores.projects_dir), + # read_head_tail, title_and_first_prompt + # and display_title (the fork title), own_transcript? (the mutations' + # lookups) end end diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index 7218ad0e..6f1eea3f 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -29,10 +29,22 @@ class SubprocessCLITransport < Transport # rubocop:disable Metrics/ClassLength - SKIP_VERSION_CHECK_ENV_VAR = 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK' # @api private CLI_PATH_ENV_VAR = 'CLAUDE_CLI_PATH' + # What Ruby's own spawn searches for a bare command name when PATH is + # unset (dln_find_exe_r). #executable_on_path uses it for the same case, + # so a bare `cli_path` keeps resolving where it did. + # + # @api private + DEFAULT_EXEC_SEARCH_PATH = '/usr/local/bin:/usr/ucb:/usr/bin:/bin:.' # @api private VERSION_CHECK_TIMEOUT_SECONDS = 2 # mirrors Python's anyio.fail_after(2) # @api private RECENT_STDERR_LINES_LIMIT = 20 + # How the CLI's stderr says that a requested sandbox could not start and + # that the session carries on without one — see + # #warn_if_sandbox_unavailable. + # + # @api private + SANDBOX_DISABLED_MARKER = 'Sandbox disabled:' # After stdout EOF the child has closed (or lost) its last stdout handle, # so it is normally already exiting; a CLI still running this long # afterwards is wedged and gets the same TERM -> KILL ladder as #close. @@ -126,7 +138,11 @@ def initialize(options_or_prompt = nil, options = nil) super() # Transport defines no state today; keep the chain intact if it ever does # Support both new single-arg form and legacy two-arg form @options = options.nil? ? options_or_prompt : options - @cli_path = @options.cli_path || find_cli + # What the caller named (any falsy cli_path means discovery, as it + # always did), as a String: the option may be a Pathname. Settled + # here, and again by #connect. + @given_cli_path = (@options.cli_path || find_cli).to_s + @cli_path = settle_cli_path(@given_cli_path) @cwd = @options.cwd @process = nil @stdin = nil @@ -138,6 +154,7 @@ def initialize(options_or_prompt = nil, options = nil) @stderr_task = nil @recent_stderr = [] @recent_stderr_mutex = Mutex.new + @sandbox_warning_emitted = false # Serializes stdin access across the reactor fiber (transport writes # from inside Async) and user-callback threads spawned via FiberBoundary # (tool handlers / hooks calling Client#query). Without this lock, @@ -158,15 +175,23 @@ def initialize(options_or_prompt = nil, options = nil) end # Probe order (first hit wins): - # 1. CLAUDE_CLI_PATH — explicit operator override, no discovery at all. + # 1. CLAUDE_CLI_PATH — the operator's override, when it names an + # executable regular file. A value that does not (a missing file, a + # directory, a file that is not executable) is skipped without a + # warning, and discovery goes on with the steps below. # 2. A project-local vendored binary (CLIInstaller). Deliberately ahead # of PATH: the point of a pinned, vendored CLI is that it beats # whatever version happens to be installed globally on the host. - # 3. `which claude`. + # 3. `claude` on this process's PATH, searched here + # (#executable_on_path). Not by running `which`: that program would + # itself be looked up on PATH, relative entries included, and its + # answer would have to be believed. # 4. Well-known install locations. # + # Every hit is returned as an absolute path — see #settle_cli_path. + # # @api private - def find_cli # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- ordered discovery probes (env, vendored, PATH, known locations) + def find_cli # rubocop:disable Metrics/MethodLength -- ordered discovery probes (env, vendored, PATH, known locations) env_path = ENV.fetch(CLI_PATH_ENV_VAR, nil).to_s unless env_path.empty? # Absolutize against the CURRENT working directory, which is where the @@ -189,15 +214,11 @@ def find_cli # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Me end return vendored if vendored - # Try which command first (using Open3 for thread safety) - cli = nil - begin - stdout, _status = Open3.capture2('which', 'claude') - cli = stdout.strip - rescue StandardError - # which command failed, try common locations - end - return cli if cli && !cli.empty? && File.executable?(cli) + # The process's own PATH, the one `which claude` used to search here: a + # PATH set through options.env is the session's, and only a bare + # cli_path is searched on it (#spawn_search_path). No PATH, no step. + on_path = executable_on_path('claude', ENV.fetch('PATH', nil)) + return on_path if on_path # Try common locations. The home-relative ones are skipped when no # usable home exists (see #home_dir), so a HOME-less container still @@ -274,13 +295,19 @@ def build_command def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- spawn sequence kept in order return if @process + # Settled again where spawn used to look the CLI up: a CLI installed + # after this transport was built is found, and a relative path + # follows the process cwd of this connect, where the probe runs. + @cli_path = settle_cli_path(@given_cli_path) check_claude_version cmd = build_command # Build environment - # Convert symbol keys to strings for spawn compatibility - custom_env = @options.env.transform_keys(&:to_s) + # Convert symbol keys to strings for spawn compatibility. `|| {}`: the + # constructor defaults env (and extra_args, below) to {}, but both are + # nil again after `options.dup_with(env: nil)` or `options.env = nil`. + custom_env = (@options.env || {}).transform_keys(&:to_s) # Explicitly unset CLAUDECODE to prevent "nested session" detection when the SDK # launches Claude Code from within an existing Claude Code terminal. # NOTE: Must set to nil (not just omit the key) — Ruby's spawn only overlays @@ -310,9 +337,19 @@ def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Met process_env['PWD'] = @cwd.to_s if @cwd # Determine stderr handling - should_pipe_stderr = @options.stderr || @options.debug_stderr || @options.extra_args.key?('debug-to-stderr') + should_pipe_stderr = @options.stderr || @options.debug_stderr || + (@options.extra_args || {}).key?('debug-to-stderr') begin + # A path #settle_cli_path could not settle is never handed to spawn: + # spawn searches PATH on its own, in this process's cwd, and then + # executes a relative hit inside options.cwd. Reported like any + # missing CLI, by the Errno::ENOENT branch below. Only when that + # path is the program about to be spawned: a subclass whose + # #build_command wraps the CLI in another program (docker exec, ssh) + # names a file on the far side, and its argv is spawned as built. + raise Errno::ENOENT, @cli_path if cmd.first == @cli_path && !cli_path_settled? + # Start process using Open3 # :uid mirrors Python's anyio.open_process(user=...): String username # or Integer uid (Unix; requires privileges — typically root). The @@ -389,15 +426,21 @@ def handle_stderr next if line_str.empty? record_bounded_stderr(line_str) + warn_if_sandbox_unavailable(line_str) # Per-line isolation: a callback that raises (e.g. user's logger # transiently failing) must not poison the rest of the stderr stream. # Without this, the first exception terminates the each_line loop and # the SDK silently stops capturing stderr for the lifetime of the # process. Matches Python SDK v0.2.82 (PR #932). + # ScriptError and SystemStackError as well: a callback raising + # NotImplementedError or LoadError (both ScriptErrors), or recursing + # too deep, used to end this thread, and with nothing reading the + # pipe a CLI that wrote one more pipe buffer of stderr (64 KiB) + # blocked in write(2) for good. begin @options.stderr&.call(line_str) - rescue StandardError + rescue StandardError, ScriptError, SystemStackError # Drop the callback error; the line is already in the recent-stderr # ring buffer, which is what ProcessError surfaces on non-zero exit. end @@ -429,6 +472,7 @@ def drain_stderr_with_accumulation next if line_str.empty? record_bounded_stderr(line_str) + warn_if_sandbox_unavailable(line_str) end end @@ -641,17 +685,18 @@ def force_terminate_in_background(process, grace_seconds: 2) # Wait for the spawned process to exit, up to +timeout_seconds+. Polls # process.alive? rather than using stdlib Timeout.timeout, which raises # across threads via Thread#raise and corrupts Async fiber-scheduler state - # (close is always called inside an Async task). Yields to the current - # Async task when one is active so the reactor keeps running. + # (close is always called inside an Async task). Kernel#sleep is + # scheduler-aware: on a reactor it parks only the calling fiber, so the + # reactor keeps running. (Async::Task#sleep did the same but is + # deprecated, and warns on every call under `ruby -w`.) # # @api private def wait_process_with_timeout(timeout_seconds, process = @process) deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds - task = defined?(Async::Task) ? Async::Task.current? : nil while process.alive? raise Timeout::Error if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline - task ? task.sleep(0.05) : sleep(0.05) + sleep(0.05) end process.value end @@ -833,7 +878,7 @@ def read_messages(&) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComple # reached query()/receive_response. Past the grace period escalate # like #close (TERM, then KILL) and report it as an error below: a # child that outlives its stdout is wedged, whatever its exit code. - # The poll parks only this task (task.sleep) on a reactor. + # The poll parks only this fiber (Kernel#sleep) on a reactor. if process && !process_exited_within?(process, EOF_EXIT_GRACE_SECONDS) forced_exit = true begin @@ -920,6 +965,8 @@ def check_claude_version # including '0'/'false'/' '; unset or empty string runs the check. skip = ENV.fetch(SKIP_VERSION_CHECK_ENV_VAR, nil) return if skip && !skip.empty? + # Nothing to probe: #connect reports the unsettled path right after. + return unless cli_path_settled? begin output = capture_cli_version_output @@ -960,6 +1007,102 @@ def ready? private + # Settle the CLI on ONE absolute path, used for the version probe and for + # the spawn alike. The probe runs in this process's working directory, + # the CLI is spawned with `chdir: options.cwd`: a path still relative at + # the spawn names a different file there — whatever sits at that path + # inside the directory the agent was pointed at — while the probe vouched + # for the one here. + # + # - a path with a separator is anchored to the process cwd (an absolute + # one is kept as given); a leading `~` becomes a home directory, as + # for CLAUDE_CLI_PATH; + # - a bare name is searched on PATH here (#executable_on_path) rather + # than left to spawn. + # + # Nothing is normalized: `link/..` must resolve through the filesystem, + # as it did when the path went to spawn as given. (File.expand_path + # collapses it lexically, which names another file when `link` is a + # symlink.) + # + # Never raises. A path that cannot be settled (a bare name not on PATH, + # `~user` naming nobody, a relative path when the cwd is gone, a NUL + # byte) is returned as given, and #connect reports it as + # CLINotFoundError. + def settle_cli_path(path) + return executable_on_path(path, spawn_search_path) || path unless path.include?(File::SEPARATOR) + + path.start_with?('~') ? expand_leading_tilde(path) : anchor_to_cwd(path) + rescue ArgumentError, EncodingError, SystemCallError + path + end + + # An absolute path as given, a relative one joined to the process cwd. + def anchor_to_cwd(path) + File.absolute_path?(path) ? path : File.join(Dir.pwd, path) + end + + # `~/x` or `~user/x`: the first component becomes that home directory, + # the rest is kept as written. + def expand_leading_tilde(path) + head, rest = path.split(File::SEPARATOR, 2) + File.join(File.expand_path(head), rest) + end + + # The first executable regular file called +name+ on +search_path+, as an + # absolute path, or nil. This is the lookup spawn did for a bare command + # name (and `which` for discovery), done here so that nothing is run to + # find the CLI and nothing relative comes back: a relative entry — `bin`, + # `.`, the empty entry — is anchored to the process cwd, where spawn + # found the file here and then executed that relative path inside + # options.cwd. + def executable_on_path(name, search_path) + return nil if name.empty? || search_path.nil? + + search_path_entries(search_path).each do |entry| + candidate = File.join(path_entry_dir(entry), name) + return candidate if File.file?(candidate) && File.executable?(candidate) + rescue ArgumentError, EncodingError, SystemCallError + next # a NUL byte, mixed encodings, or a relative entry when the cwd is gone + end + nil + end + + # The PATH spawn searched for a bare cli_path: the one the child is given + # through options.env when it sets one, else this process's, else Ruby's + # built-in default. (Discovery searches the process's PATH — #find_cli.) + def spawn_search_path + env = @options.env + from_options = env.transform_keys(&:to_s)['PATH'] if env.is_a?(Hash) + from_options || ENV.fetch('PATH', DEFAULT_EXEC_SEARCH_PATH) + end + + # A PATH value as its entries. + def search_path_entries(search_path) + search_path = search_path.to_s + # spawn searched bytes; a PATH that is invalid in its encoding cannot + # be split as text. + search_path = search_path.b unless search_path.valid_encoding? + # -1 keeps a trailing empty entry; PATH="" is one empty entry. + entries = search_path.split(File::PATH_SEPARATOR, -1) + entries.empty? ? [''] : entries + end + + # As in spawn's lookup, `~` and `~/x` entries start at the home directory + # (a `~user` entry is taken literally, as there). + def path_entry_dir(entry) + return File.join(File.expand_path('~'), entry[1..]) if entry == '~' || entry.start_with?('~/') + + anchor_to_cwd(entry) + end + + # False when #settle_cli_path had to keep a path as given. + def cli_path_settled? + File.absolute_path?(@cli_path) + rescue StandardError + false # nil from a subclass's #find_cli, a NUL byte, an incompatible encoding + end + # Run `claude -v` with a hard deadline. Arg-vector popen3 — no shell, same # injection-safety as capture3. Raises Timeout::Error past # VERSION_CHECK_TIMEOUT_SECONDS (swallowed by check_claude_version's @@ -972,15 +1115,14 @@ def ready? # also bounds CLI exit. ensure always reaps the probe (mirrors Python's # finally: terminate(); wait()). def capture_cli_version_output # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- bounded subprocess probe: drained pipes, reaping - stdin, stdout, stderr, wait_thr = Open3.popen3(@cli_path.to_s, '-v') + stdin, stdout, stderr, wait_thr = Open3.popen3(@cli_path, '-v') stdin.close drainer = Thread.new { [stdout.read, stderr.read] } deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + VERSION_CHECK_TIMEOUT_SECONDS - task = defined?(Async::Task) ? Async::Task.current? : nil until drainer.join(0) raise Timeout::Error if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline - task ? task.sleep(0.05) : sleep(0.05) + sleep(0.05) end out, err = drainer.value (out.to_s + err.to_s).force_encoding(Encoding::UTF_8).scrub.strip @@ -1106,6 +1248,42 @@ def record_bounded_stderr(line) end end + # When the sandbox was requested but cannot start (and + # sandbox.failIfUnavailable is not set), the CLI writes + # "⚠ Sandbox disabled: <reason>" to stderr and runs the session's + # commands unsandboxed. A host only sees stderr through a `stderr:` + # callback, so repeat that line as a Ruby warning: once per transport, + # and only when this session's own options asked for the sandbox. + # + # Called for every stderr line, on the drain thread. Best-effort like + # OptionWarnings#emit: a closed or broken $stderr must not raise out of + # here and end the drain (the CLI would stall on a full stderr pipe). + def warn_if_sandbox_unavailable(line) + return if @sandbox_warning_emitted || !line.include?(SANDBOX_DISABLED_MARKER) + return unless sandbox_requested? + + @sandbox_warning_emitted = true + begin + warn '[claude-agent-sdk] The sandbox this session requested is not active: the CLI is running ' \ + "commands WITHOUT sandboxing. It reported: \"#{line.strip}\". To make this an error instead, " \ + 'set fail_if_unavailable: true on SandboxSettings (failIfUnavailable: true in a Hash).' + rescue StandardError + nil + end + end + + # True when ClaudeAgentOptions#sandbox enables the sandbox: `true`, a + # SandboxSettings with `enabled` true, or a Hash with an `enabled` / + # 'enabled' key that is true (Hashes are forwarded to the CLI verbatim). + def sandbox_requested? + sandbox = @options.sandbox + case sandbox + when SandboxSettings then sandbox.enabled == true + when Hash then sandbox[:enabled] == true || sandbox['enabled'] == true + else sandbox == true + end + end + # The (parent's) home directory for the well-known install probes, or nil # when none is usable — see Sessions.home_dir, the one definition. def home_dir diff --git a/lib/claude_agent_sdk/testing/session_store_conformance.rb b/lib/claude_agent_sdk/testing/session_store_conformance.rb index e93d816d..c5d8f0fa 100644 --- a/lib/claude_agent_sdk/testing/session_store_conformance.rb +++ b/lib/claude_agent_sdk/testing/session_store_conformance.rb @@ -42,7 +42,7 @@ class ConformanceError < StandardError; end # returns a fresh SessionStore (or duck-typed adapter). # @param skip_optional [Array<String>] optional method names to skip. # Contracts for an optional method are also skipped automatically when the - # store does not override it. + # store does not override it, or raises NotImplementedError from it. # @param check_uuid_dedupe [Boolean] additionally assert the ADVISORY # uuid-dedupe recommendation: re-appending a batch that overlaps a prior # write (exactly what the mirror batcher's retry can produce) must not @@ -58,11 +58,22 @@ def run_session_store_conformance(make_store, skip_optional: [], check_uuid_dedu fresh = -> { make_store.call } + # Each optional method is called once on an empty store before its + # contracts are selected: an adapter that inherits the SessionStore stub + # behind a delegating wrapper, or declines the method at run time, + # raises NotImplementedError — "not implemented", so its contracts are + # skipped as for a method the adapter does not define. The calls are + # ones the contracts make anyway (unknown project / never-written key). probe = fresh.call - has_list_sessions = optional?(probe, 'list_sessions', skip_optional) - has_list_summaries = optional?(probe, 'list_session_summaries', skip_optional) - has_delete = optional?(probe, 'delete', skip_optional) - has_list_subkeys = optional?(probe, 'list_subkeys', skip_optional) + unwritten = { 'project_key' => 'proj', 'session_id' => 'never-written' } + has_list_sessions = optional?(probe, 'list_sessions', skip_optional) do + probe.list_sessions('never-appended-project') + end + has_list_summaries = optional?(probe, 'list_session_summaries', skip_optional) do + probe.list_session_summaries('never-appended-project') + end + has_delete = optional?(probe, 'delete', skip_optional) { probe.delete(unwritten) } + has_list_subkeys = optional?(probe, 'list_subkeys', skip_optional) { probe.list_subkeys(unwritten) } check_callback_scheduling_declaration(fresh) check_append_and_load(fresh, has_list_sessions) @@ -370,10 +381,10 @@ def summaries_by_id(store, project, expected_ids, message) rows.to_h { |s| [s['session_id'], s] } end - def optional?(store, method, skip_optional) + def optional?(store, method, skip_optional, &) return false if skip_optional.include?(method) - SessionStore.implements?(store, method.to_sym) + SessionStores.optional_call(store, method.to_sym, &).first end def assert(condition, message) diff --git a/lib/claude_agent_sdk/transcript_mirror_batcher.rb b/lib/claude_agent_sdk/transcript_mirror_batcher.rb index d2f4cb58..682e1908 100644 --- a/lib/claude_agent_sdk/transcript_mirror_batcher.rb +++ b/lib/claude_agent_sdk/transcript_mirror_batcher.rb @@ -31,8 +31,8 @@ module ClaudeAgentSDK # (which surfaces them as a MirrorErrorMessage). Adapters should dedupe by # entry["uuid"] when present, since a retried batch may overlap a prior write. # - # The semaphore serializes appends (FIFO, and every drain detaches its batch - # immediately before queueing on it, so append order matches enqueue order), + # The semaphore serializes appends (FIFO, and every drain detaches the whole + # buffer once it holds the lock, so append order matches enqueue order), # but a #send that exceeds send_timeout is abandoned (its worker thread keeps # running) and the next drain proceeds, so two #append calls for the SAME # key can briefly overlap. SessionStore#append must be thread-safe per key @@ -107,14 +107,16 @@ def initialize(store:, projects_dir:, on_error:, send_timeout: SEND_TIMEOUT_SECO # # Frames still buffered once #close has started count too. Query#close # stops the read task (the background drainer's parent) right after - # #close returns, so a frame the read loop enqueued during the close - # window — or any below-threshold frame after it — is never appended. - # The old per-frame drain detached it into a parked task whose - # cancellation counted it; with coalescing it stays in @pending, so it is - # accounted here. Evaluated lazily: frames the drainer still manages to - # deliver before the check are not drops, and nothing past #close has to - # run for a stranded frame to be counted. (A plain ivar/Array#empty? read - # is safe cross-thread under the GVL, like @dropped_batches.) + # #close returns, so a frame the read loop enqueued after #close detached + # — during its final append, or any below-threshold frame after it — is + # never appended. (One that arrives while #close is still waiting for the + # lock goes out with #close's own batch.) The old per-frame drain detached + # such a frame into a parked task whose cancellation counted it; with + # coalescing it stays in @pending, so it is accounted here. Evaluated + # lazily: frames the drainer still manages to deliver before the check + # are not drops, and nothing past #close has to run for a stranded frame + # to be counted. (A plain ivar/Array#empty? read is safe cross-thread + # under the GVL, like @dropped_batches.) def batches_dropped? @dropped_batches.positive? || (@closed && !@pending.empty?) end @@ -150,9 +152,10 @@ def flush drain end - # Final flush before teardown. Never raises. Bounded: it waits behind at - # most the in-flight append, and frames arriving after it detached are - # not chased (they count as dropped unless delivered; #batches_dropped?). + # Final flush before teardown. Never raises. Bounded: it waits its turn on + # the lock, takes whatever is pending at that moment, and does not chase + # frames that arrive after that (they count as dropped unless delivered; + # #batches_dropped?). def close @closed = true flush @@ -168,16 +171,16 @@ def over_threshold? # Fire-and-forget on the reactor. The drainer loops until the buffer is # back under the thresholds, so while it is live every later frame is - # simply buffered and coalesced into its next #drain — live background - # tasks <= 1 and detached batches <= 1 + one per #flush / #close caller - # parked on @lock, independent of frame rate and store latency. + # simply buffered and coalesced into the next #drain — live background + # tasks <= 1 and detached batches <= 1 (only the holder of @lock has + # one), independent of frame rate and store latency. # # No lost wakeup: the loop's final over_threshold? check and the ensure # clearing the flag run with no suspension point between them, so an # #enqueue that saw the flag set is always observed by that check. - # Every drainer (this one, #flush, #close) keeps detach-then-acquire, so - # detach order == @lock FIFO order and append ordering holds. #drain - # never raises. + # Every drainer (this one, #flush, #close) detaches inside @lock and + # takes everything pending at that moment, so batches leave in @lock FIFO + # order and append ordering holds. #drain never raises. def schedule_background_drain(task) return if @background_drain_live @@ -194,25 +197,29 @@ def schedule_background_drain(task) raise end - # Detach the pending buffer, await any prior flush, then send. Detaching - # before acquiring the lock lets #enqueue keep accumulating into a fresh - # buffer while a prior flush is in flight. Never raises. + # Await any prior flush, detach the pending buffer, then send. The buffer + # is detached INSIDE the lock: a drain cancelled while it waits for the + # lock has taken nothing, so its frames stay in @pending for the next + # drain. (It used to detach first, and a #flush cancelled while queued + # behind an in-flight append lost its batch — upstream Python PR #1289.) + # #enqueue never takes the lock, so it keeps accumulating while a flush + # is in flight. Never raises. def drain - items = @pending - @pending = [] - @pending_entries = 0 - @pending_bytes = 0 - errors = [] - # Cancellation (Async::Stop — not a StandardError) delivered while - # waiting on the lock or inside the append's thread join loses the - # detached items without either rescue firing. Teardown would then read - # batches_dropped? as false and delete a materialized resume dir holding - # the only copy of these entries — count the batch as dropped unless the - # flush path ran to completion (do_flush counts its own failures). - accounted = items.empty? + # Cancellation (Async::Stop — not a StandardError) delivered inside the + # append's thread join loses the detached items without either rescue + # firing. Teardown would then read batches_dropped? as false and delete + # a materialized resume dir holding the only copy of these entries — + # count the batch as dropped unless the flush path ran to completion + # (do_flush counts its own failures). Until a batch is detached there + # is nothing to account for. + accounted = true begin @lock.acquire do + items = @pending + @pending = [] + @pending_entries = 0 + @pending_bytes = 0 # Emptiness is checked INSIDE the lock (matching the Python batcher): # an empty #flush/#close still serializes behind any in-flight or # queued drain, so they are true barriers — at result-yield and at @@ -220,6 +227,7 @@ def drain # the read task while a detached batch is still being appended. next if items.empty? + accounted = false begin do_flush(items, errors) rescue StandardError => e @@ -349,10 +357,10 @@ def invoke_append(key, entries) [:error, e] end - # Sleep that yields the reactor when one is active, else a plain sleep. + # Kernel#sleep is scheduler-aware: it parks only this fiber when a reactor + # is active and blocks the thread otherwise. def sleep_backoff(seconds) - task = Async::Task.current? - task ? task.sleep(seconds) : sleep(seconds) + sleep(seconds) end def deep_stringify(obj) diff --git a/lib/claude_agent_sdk/transport.rb b/lib/claude_agent_sdk/transport.rb index 78edc50b..a7761666 100644 --- a/lib/claude_agent_sdk/transport.rb +++ b/lib/claude_agent_sdk/transport.rb @@ -1,42 +1,58 @@ # frozen_string_literal: true module ClaudeAgentSDK - # Abstract transport for Claude communication + # Abstract base class for transports: the channel between the SDK and a + # Claude Code CLI process. # - # WARNING: This internal API is exposed for custom transport implementations - # (e.g., remote Claude Code connections). The Claude Code team may change or - # remove this abstract class in any future release. Custom implementations - # must be updated to match interface changes. + # Public API, covered by this gem's SemVer promise like the rest of the + # documented surface. {SubprocessCLITransport}, the default, spawns the CLI + # locally. To run the CLI somewhere else (a container, an SSH host, a + # sandbox VM), subclass this class, or write any object with the same + # methods, and pass it to {Client#initialize} as `transport_class:` or to + # {ClaudeAgentSDK.query} / {ClaudeAgentSDK.ask} as `transport:`. See + # docs/client.md, "Custom Transport". + # + # The SDK calls five methods: {#connect}, {#write}, {#read_messages}, + # {#end_input} and {#close}. It never calls {#ready?}, so that one is + # optional. Every method here raises NotImplementedError until a subclass + # overrides it. class Transport - # Connect the transport and prepare for communication + # Establish the connection (spawn or reach the CLI) and prepare for IO. def connect raise NotImplementedError, 'Subclasses must implement #connect' end - # Write raw data to the transport + # Write raw data to the CLI's stdin. # @param data [String] Raw string data to write (typically JSON + newline) def write(data) raise NotImplementedError, 'Subclasses must implement #write' end - # Read and parse messages from the transport - # @return [Enumerator] Async enumerator of parsed JSON messages + # Read the CLI's stdout and yield each line as a parsed message, blocking + # until the stream ends. Parse each line with + # `JSON.parse(line, symbolize_names: true)`: the SDK reads Symbol keys. + # The SDK always passes a block. + # @yield [Hash{Symbol => Object}] Each parsed message def read_messages raise NotImplementedError, 'Subclasses must implement #read_messages' end - # Close the transport connection and clean up resources + # Terminate the CLI and clean up resources. Must be idempotent: the SDK + # can call it more than once, and calls it after a failed {#connect} too. def close raise NotImplementedError, 'Subclasses must implement #close' end - # Check if transport is ready for communication + # Check if transport is ready for communication. Optional: the SDK never + # calls it. # @return [Boolean] True if transport is ready to send/receive messages def ready? raise NotImplementedError, 'Subclasses must implement #ready?' end - # End the input stream (close stdin for process transports) + # End the input stream (close stdin for process transports). The SDK + # calls it when a one-shot query's run is over and when a streamed prompt + # is exhausted. def end_input raise NotImplementedError, 'Subclasses must implement #end_input' end diff --git a/lib/claude_agent_sdk/types/attributes.rb b/lib/claude_agent_sdk/types/attributes.rb index be1744c3..f9d256ea 100644 --- a/lib/claude_agent_sdk/types/attributes.rb +++ b/lib/claude_agent_sdk/types/attributes.rb @@ -184,6 +184,10 @@ def assign_attribute(name, value) end def read_attribute(name) + # A writer's name (`session_id=`) is not readable. Checked before the + # cache, which also holds the camelCase writers method_missing resolved. + return if writer_name?(name) + reader = self.class.cached_attribute_reader(name) return public_send(reader) if reader @@ -198,6 +202,11 @@ def read_attribute(name) end end + def writer_name?(name) + name = name.to_s unless name.is_a?(Symbol) || name.is_a?(String) + name.end_with?('=') + end + # Whether a normalized reader, writer or predicate name belongs to an # attribute: a declared one (`session_id`, `session_id=`, # `fork_session?`), or a method user code defined on its own subclass, a diff --git a/lib/claude_agent_sdk/types/base.rb b/lib/claude_agent_sdk/types/base.rb index 0d7cac15..fb4da6a4 100644 --- a/lib/claude_agent_sdk/types/base.rb +++ b/lib/claude_agent_sdk/types/base.rb @@ -122,8 +122,19 @@ def self.option_hash_key(key, hash) # dumps every ivar recursively — those (SDK MCP server instances, store # adapters, observers) show as `#<ClassName>`. For display only: nothing # sent to the CLI goes through #inspect or #to_s (wire output uses #to_h). + # + # Attributes that carry credentials print as `[FILTERED]` (a Hash keeps + # its keys): `env`, `settings` and `extra_args` of ClaudeAgentOptions, + # and `env`, `args` and `headers` of the MCP server configs. An MCP + # server `url` prints its scheme and host only, and a Hash server config + # (in `mcp_servers`, or echoed by the CLI in McpServerStatus#config) is + # filtered like a typed one. `mcp_servers` given as a String prints as + # `[FILTERED]`. + # + # It does not raise: a value that fails while it is rendered shows as + # `#<ClassName>` (`#<?>` when even its class cannot be asked). def inspect - inspect_with(0, {}.compare_by_identity) + inspect_bounded(self, 0, {}.compare_by_identity) end # Object#to_s ignores instance variables, so `puts message` would print @@ -134,9 +145,10 @@ def to_s inspect end - # Declares attributes that carry credentials (env vars, auth headers). - # Objects get logged, so #inspect shows them filtered; #to_h and - # everything sent to the CLI are unaffected. Inherited by subclasses. + # Declares attributes that carry credentials (env vars, auth headers, + # settings, extra CLI arguments, an MCP server's args). Objects get + # logged, so #inspect shows them filtered; #to_h and everything sent to + # the CLI are unaffected. Inherited by subclasses. # # @api private def self.inspect_filtered(*names) @@ -182,7 +194,7 @@ def inspect_attributes filtered = self.class.inspect_filtered_attributes instance_variables.filter_map do |ivar| value = instance_variable_get(ivar) - next if value.nil? + next if nil.equal?(value) # not value.nil?: a BasicObject has no such method name = ivar.to_s.delete_prefix('@') [name, filtered.include?(name) ? inspect_filter(value) : value] @@ -191,15 +203,65 @@ def inspect_attributes # A credential-bearing Hash keeps its keys (useful when debugging which # variables are set) with every value replaced; anything else is replaced - # outright. Builds a new Hash; the object itself is never touched. + # outright, and so is a value that fails while it is asked for its keys. + # Builds a new Hash; the object itself is never touched. def inspect_filter(value) value.respond_to?(:each_key) ? value.each_key.to_h { |key| [key, '[FILTERED]'] } : '[FILTERED]' + rescue StandardError + '[FILTERED]' + end + + INSPECT_URL_ORIGIN = %r{ + \A(https?|wss?):// # scheme + (?:[^/?\#\\]*@)? # userinfo, up to the last @ before the path + (\[[0-9a-f:.]+\]|[[:alnum:]._-]+) # host: an IPv6 literal or a name + (:\d{1,5})? # port + (?=[/?\#]|\z) # then the path, the query, the fragment or the end + }ix + private_constant :INSPECT_URL_ORIGIN + + # A URL can carry a credential in its userinfo, path, query or fragment, + # so only where it points is shown: `https://mcp.example.com/[FILTERED]` + # (scheme, host and port). Anything else is replaced outright: a value + # that is not a String, another scheme, a host or port with a character + # that does not belong there. + def inspect_filter_url(value) + origin = value.is_a?(String) && INSPECT_URL_ORIGIN.match(value) + origin ? "#{origin[1]}://#{origin[2]}#{origin[3]}/[FILTERED]" : '[FILTERED]' + rescue StandardError + '[FILTERED]' + end + + # Keys of a raw Hash MCP server config whose values #inspect shows as they are. + INSPECT_MCP_CONFIG_KEYS = %w[type command name instance].freeze + private_constant :INSPECT_MCP_CONFIG_KEYS + + # An MCP server config held as a raw Hash (in ClaudeAgentOptions#mcp_servers, + # or echoed by the CLI in McpServerStatus#config) carries the same + # credentials as a typed one: env, headers, args, a token in the url. It + # keeps its keys and shows what identifies the server: its type, its + # command (or the SDK server's name and instance) and the scheme and host + # of its url. Every other value is filtered here rather than left to the + # nesting limit of #inspect. + def inspect_mcp_server_config(config) + config.to_h do |key, value| + name = key.to_s + next [key, value] if INSPECT_MCP_CONFIG_KEYS.include?(name) + + [key, name == 'url' ? inspect_filter_url(value) : '[FILTERED]'] + end end def inspect_class_name self.class.name || self.class.inspect end + # Printing must never raise (it runs inside loggers and `puts`). The + # value's own methods run here (a String subclass's #length, a Hash + # subclass's #size, a Type subclass's #inspect_attributes), so a value + # that fails while it is rendered becomes a placeholder and the rest of + # the object still prints. Only StandardError is rescued: Interrupt, + # SystemExit and the like pass through. def inspect_bounded(value, depth, seen) case value when Type then value.inspect_with(depth, seen) @@ -212,6 +274,16 @@ def inspect_bounded(value, depth, seen) when Proc, Method, UnboundMethod then inspect_callable(value) else inspect_leaf(value) end + rescue StandardError + inspect_placeholder(value) + end + + # `#<ClassName>` for a value that could not be rendered; a BasicObject + # cannot even be asked for its class. + def inspect_placeholder(value) + inspect_truncated_text("#<#{value.class}>") + rescue StandardError + '#<?>' end def inspect_container(value, open, close, depth, seen, &) @@ -231,7 +303,10 @@ def inspect_container(value, open, close, depth, seen, &) # Rendered by hand rather than via Hash#inspect, whose format differs # between Ruby 3.3 (`{:a=>1}`) and 3.4 (`{a: 1}`). def inspect_hash_key(key, depth, seen) - return "#{key.name}: " if key.is_a?(Symbol) && key.inspect.match?(/\A:\w+[?!]?\z/) + case key # not key.is_a?: a BasicObject key has no such method + when Symbol + return "#{key.name}: " if key.inspect.match?(/\A:\w+[?!]?\z/) + end "#{inspect_bounded(key, depth, seen)} => " end @@ -265,19 +340,14 @@ def inspect_truncated_text(rendered) "#{rendered[0, INSPECT_MAX_STRING]}…(+#{rendered.length - INSPECT_MAX_STRING} chars)" end - # Printing must never raise (it runs inside loggers and `puts`), so an - # object whose #inspect raises, or a BasicObject without one, falls back - # to a placeholder. + # An object whose #inspect raises, or a BasicObject without one, falls + # back to a placeholder. def inspect_leaf(value) return "#<#{value.class}>" if kernel_inspect_only?(value) inspect_truncated_text(value.inspect) rescue StandardError - begin - "#<#{value.class}>" - rescue StandardError - '#<?>' - end + inspect_placeholder(value) end def kernel_inspect_only?(value) diff --git a/lib/claude_agent_sdk/types/hooks.rb b/lib/claude_agent_sdk/types/hooks.rb index a1baf03b..88bc6170 100644 --- a/lib/claude_agent_sdk/types/hooks.rb +++ b/lib/claude_agent_sdk/types/hooks.rb @@ -637,4 +637,77 @@ def to_h result end end + + # The spellings a hook callback's return value may use for a field of the + # typed output classes above, mapped to the key the CLI reads. A Hash a + # callback returns stands for the typed output with the same fields: its + # keys may be Symbols or Strings, the attribute names (snake_case) or what + # #to_h emits (camelCase), at the top level and inside hook_specific_output. + # + # Only names that differ from their wire key are listed. A key that is not + # listed is sent as written, so a field of a newer CLI that the typed + # classes do not model still gets through, in the CLI's own spelling. + # spec/unit/hook_output_normalization_spec.rb walks every typed output + # class and fails when an attribute and these tables disagree. + # + # @api private + module HookOutputKeys + # SyncHookJSONOutput and AsyncHookJSONOutput attributes, plus the + # Ruby-safe spellings of the two keywords. + TOP_LEVEL = { + 'continue_' => 'continue', + 'async_' => 'async', + 'suppress_output' => 'suppressOutput', + 'stop_reason' => 'stopReason', + 'system_message' => 'systemMessage', + 'hook_specific_output' => 'hookSpecificOutput', + 'async_timeout' => 'asyncTimeout' + }.freeze + + # Attributes of the *HookSpecificOutput classes. + HOOK_SPECIFIC = { + 'hook_event_name' => 'hookEventName', + 'permission_decision' => 'permissionDecision', + 'permission_decision_reason' => 'permissionDecisionReason', + 'updated_input' => 'updatedInput', + 'additional_context' => 'additionalContext', + 'updated_tool_output' => 'updatedToolOutput', + 'updated_mcp_tool_output' => 'updatedMCPToolOutput', + 'watch_paths' => 'watchPaths' + }.freeze + + # The hook output Hash as the CLI reads it: String keys in wire + # spelling, at the top level and one level down, inside + # hookSpecificOutput. Values are never rewritten: updatedInput and the + # tool outputs are the tool's own payloads, and a PermissionRequest + # decision goes out as the caller wrote it. + def self.normalize(output) + normalized = rename(output, TOP_LEVEL) + specific = normalized['hookSpecificOutput'] + normalized['hookSpecificOutput'] = rename(specific, HOOK_SPECIFIC) if specific.is_a?(Hash) + normalized + end + + # Every key ends up as one String, so a Symbol and a String spelling the + # same field cannot both reach JSON.generate (json 3.x raises on that; + # 2.x emits the key twice). When a Hash carries both spellings of one + # field the wire spelling wins, whichever comes first; between two keys + # in the same spelling the later one does. + def self.rename(hash, table) + renamed = {} + wire_spelled = {} + hash.each do |key, value| + name = key.to_s + wire = table.fetch(name, name) + if wire == name + wire_spelled[wire] = true + elsif wire_spelled.key?(wire) + next + end + renamed[wire] = value + end + renamed + end + private_class_method :rename + end end diff --git a/lib/claude_agent_sdk/types/mcp.rb b/lib/claude_agent_sdk/types/mcp.rb index f9fa1a8c..16a87f87 100644 --- a/lib/claude_agent_sdk/types/mcp.rb +++ b/lib/claude_agent_sdk/types/mcp.rb @@ -109,6 +109,25 @@ def self.parse_config(config) config end end + + private + + # The CLI echoes a server's config back with its credentials (headers, a + # token in the url, args). A Hash config prints like a raw Hash config in + # ClaudeAgentOptions#mcp_servers; the typed proxy and SDK configs print + # as they are. + def inspect_attributes + super.map { |name, value| [name, name == 'config' ? inspect_config(value) : value] } + end + + def inspect_config(config) + case config # not config.is_a?: a BasicObject has no such method + when Hash then inspect_mcp_server_config(config) + else config + end + rescue StandardError + '[FILTERED]' + end end # Response from get_mcp_status containing all server statuses @@ -138,7 +157,8 @@ class McpStdioServerConfig < Type attr_accessor :command, :args, :env attr_reader :type - inspect_filtered :env + # `args` carries flags such as `--api-key <key>`. + inspect_filtered :env, :args def initialize(attributes = {}) super @@ -173,6 +193,14 @@ def to_h result[:headers] = @headers if @headers result end + + private + + # The url can carry a token (userinfo, path or query): #inspect shows + # its scheme and host. + def inspect_attributes + super.map { |name, value| [name, name == 'url' ? inspect_filter_url(value) : value] } + end end class McpHttpServerConfig < Type @@ -195,6 +223,14 @@ def to_h result[:headers] = @headers if @headers result end + + private + + # The url can carry a token (userinfo, path or query): #inspect shows + # its scheme and host. + def inspect_attributes + super.map { |name, value| [name, name == 'url' ? inspect_filter_url(value) : value] } + end end class McpSdkServerConfig < Type diff --git a/lib/claude_agent_sdk/types/option_values.rb b/lib/claude_agent_sdk/types/option_values.rb index e0fd8422..af67f7f3 100644 --- a/lib/claude_agent_sdk/types/option_values.rb +++ b/lib/claude_agent_sdk/types/option_values.rb @@ -158,7 +158,19 @@ def to_h end end - # Sandbox settings for isolated command execution + # Sandbox settings for isolated command execution. + # + # +network+ and +filesystem+ take a SandboxNetworkConfig / + # SandboxFilesystemConfig or a Hash. A Hash may spell the fields of those + # classes as their attributes (+denied_domains+) or as the CLI does + # (+deniedDomains+), with Symbol or String keys; any other key is sent as + # written. The same holds for a Hash given as +sandbox:+ in place of a + # SandboxSettings. + # + # A snake_case key in such a Hash is sent under the CLI's name only when + # its value has the shape the CLI accepts there (an Array of Strings for + # +denied_domains+, +true+ or +false+ for +allow_local_binding+, ...). + # With any other value it is sent as written, and the CLI ignores it. class SandboxSettings < Type include Type::OptionValue @@ -169,15 +181,15 @@ class SandboxSettings < Type :ignore_violations, :enable_weaker_nested_sandbox, :enable_weaker_network_isolation, :ripgrep - def to_h # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- one optional key per sandbox field + def to_h result = {} result[:enabled] = @enabled unless @enabled.nil? result[:failIfUnavailable] = @fail_if_unavailable unless @fail_if_unavailable.nil? result[:autoAllowBashIfSandboxed] = @auto_allow_bash_if_sandboxed unless @auto_allow_bash_if_sandboxed.nil? result[:excludedCommands] = @excluded_commands if @excluded_commands result[:allowUnsandboxedCommands] = @allow_unsandboxed_commands unless @allow_unsandboxed_commands.nil? - result[:network] = @network.is_a?(SandboxNetworkConfig) ? @network.to_h : @network if @network - result[:filesystem] = @filesystem.is_a?(SandboxFilesystemConfig) ? @filesystem.to_h : @filesystem if @filesystem + result[:network] = SandboxKeys.network(@network) if @network + result[:filesystem] = SandboxKeys.filesystem(@filesystem) if @filesystem result[:ignoreViolations] = @ignore_violations if @ignore_violations result[:enableWeakerNestedSandbox] = @enable_weaker_nested_sandbox unless @enable_weaker_nested_sandbox.nil? unless @enable_weaker_network_isolation.nil? @@ -188,6 +200,176 @@ def to_h # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComple end end + # The spellings a sandbox Hash may use for a field of the three sandbox + # classes above, mapped to the key their #to_h emits, which is the key the + # CLI reads. A Hash given as the +sandbox:+ option, or as the network / + # filesystem of a SandboxSettings, stands for the typed value with the same + # fields: its keys may be Symbols or Strings, the attribute names + # (snake_case) or the wire keys (camelCase). + # + # Every attribute is listed, also the ones whose name is their wire key. A + # key that is not listed is sent as written, so a field of the CLI that the + # typed classes do not model (allowAppleEvents, network.strictAllowlist, + # filesystem.disabled, ...) still gets through, in the CLI's own spelling. + # spec/unit/sandbox_hash_keys_spec.rb walks the attributes of the three + # classes and fails when one of them and these tables disagree. + # + # @api private + module SandboxKeys + # SandboxSettings attribute => wire key. + TOP_LEVEL = { + 'enabled' => :enabled, + 'fail_if_unavailable' => :failIfUnavailable, + 'auto_allow_bash_if_sandboxed' => :autoAllowBashIfSandboxed, + 'excluded_commands' => :excludedCommands, + 'allow_unsandboxed_commands' => :allowUnsandboxedCommands, + 'network' => :network, + 'filesystem' => :filesystem, + 'ignore_violations' => :ignoreViolations, + 'enable_weaker_nested_sandbox' => :enableWeakerNestedSandbox, + 'enable_weaker_network_isolation' => :enableWeakerNetworkIsolation, + 'ripgrep' => :ripgrep + }.freeze + + # SandboxNetworkConfig attribute => wire key. + NETWORK = { + 'allowed_domains' => :allowedDomains, + 'denied_domains' => :deniedDomains, + 'allow_managed_domains_only' => :allowManagedDomainsOnly, + 'allow_unix_sockets' => :allowUnixSockets, + 'allow_all_unix_sockets' => :allowAllUnixSockets, + 'allow_local_binding' => :allowLocalBinding, + 'allow_mach_lookup' => :allowMachLookup, + 'http_proxy_port' => :httpProxyPort, + 'socks_proxy_port' => :socksProxyPort + }.freeze + + # SandboxFilesystemConfig attribute => wire key. + FILESYSTEM = { + 'allow_write' => :allowWrite, + 'deny_write' => :denyWrite, + 'deny_read' => :denyRead, + 'allow_read' => :allowRead, + 'allow_managed_read_paths_only' => :allowManagedReadPathsOnly + }.freeze + + # Wire key => the kind of value CLI 2.1.287's sandbox schema accepts + # under it, for every wire key an attribute spells differently (read from + # the schema in the CLI binary and checked against the running CLI): + # + # :boolean true or false + # :strings an Array of Strings + # :mach_services an Array of Strings; a "*" only as the last character + # :port an Integer from 0 to 65535 + # :string_lists a Hash whose values are Arrays of Strings + # + # One value outside its kind makes the CLI discard the whole --settings + # value, the sandbox and the permissions next to it. It lists the error + # in its get_settings response only, so the SDK is not told. A key in + # snake_case is one the CLI does not know and ignores, so it is renamed + # only when its value is of the kind listed here (see #rename). A kind + # that is too strict leaves a key without effect, as it was before the + # renaming existed; one that is too loose can cost a session its sandbox. + # + # :port is the strict side of what the CLI does. It takes any number it + # can read as a port, but an Integer too large for that (2**1024 and up, + # say 10**400) reaches it as a non-finite value and fails its schema. + SHAPES = { + boolean: %i[failIfUnavailable autoAllowBashIfSandboxed allowUnsandboxedCommands enableWeakerNestedSandbox + enableWeakerNetworkIsolation allowManagedDomainsOnly allowAllUnixSockets allowLocalBinding + allowManagedReadPathsOnly], + strings: %i[excludedCommands allowedDomains deniedDomains allowUnixSockets + allowWrite denyWrite denyRead allowRead], + mach_services: %i[allowMachLookup], + port: %i[httpProxyPort socksProxyPort], + string_lists: %i[ignoreViolations] + }.flat_map { |kind, wire_keys| wire_keys.map { |wire| [wire, kind] } }.to_h.freeze + + # A Hash +sandbox:+ as the CLI reads it: its known keys under their wire + # keys, at the top level and inside its network / filesystem, which may + # each be a Hash or the typed config. Other values are not rewritten: + # ignore_violations and ripgrep hold structures of the CLI's own. + def self.normalize(sandbox) + normalized = rename(sandbox, TOP_LEVEL) + normalized[:network] = network(normalized[:network]) if normalized.key?(:network) + normalized[:filesystem] = filesystem(normalized[:filesystem]) if normalized.key?(:filesystem) + normalized + end + + # A network section as the CLI reads it: the typed config's #to_h, or a + # Hash with its known keys renamed. Any other value is returned as it is. + def self.network(section) + case section + when SandboxNetworkConfig then section.to_h + when Hash then rename(section, NETWORK) + else section + end + end + + # A filesystem section as the CLI reads it (see .network). + def self.filesystem(section) + case section + when SandboxFilesystemConfig then section.to_h + when Hash then rename(section, FILESYSTEM) + else section + end + end + + # A known key ends up as the one Symbol #to_h emits, so the spellings of + # a field (attribute name or wire key, Symbol or String) cannot reach + # JSON.generate side by side: json 3.x raises on a key given as a Symbol + # and as a String, 2.x writes it twice. When a Hash carries both + # spellings of a field the wire spelling wins, whichever comes first; + # between two keys in the same spelling the later one does. + # + # A known key holding nil is left out, as #to_h leaves out a nil + # attribute. The CLI rejects null under every one of these keys, and + # when it does it drops the whole --settings value, sandbox included. + # + # A key in snake_case is renamed only when the CLI accepts its value + # under the wire key (SHAPES). Otherwise it is sent as written, which + # the CLI ignores, as it ignored every snake_case key before: renaming + # must not be what makes the CLI drop the settings. A key the caller + # wrote in wire spelling is not looked at; it goes out as it always did. + def self.rename(hash, table) + renamed = {} + wire_spelled = {} + hash.each do |key, value| + name = key.to_s + wire = table[name] || table.each_value.find { |candidate| candidate.name == name } + next if wire && value.nil? + + if wire && wire.name == name + wire_spelled[wire] = true + renamed[wire] = value + elsif wire && well_shaped?(wire, value) + renamed[wire] = value unless wire_spelled.key?(wire) + else + renamed[key] = value + end + end + renamed + end + + # Whether the CLI accepts +value+ under +wire+. A key without a kind is + # answered false, the side that cannot cost the sandbox. + def self.well_shaped?(wire, value) + case SHAPES[wire] + when :boolean then [true, false].include?(value) + when :strings then strings?(value) + when :mach_services then strings?(value) && value.none? { |name| name.delete_suffix('*').include?('*') } + when :port then value.is_a?(Integer) && value.between?(0, 65_535) + when :string_lists then value.is_a?(Hash) && value.each_value.all? { |list| strings?(list) } + else false + end + end + + def self.strings?(value) + value.is_a?(Array) && value.all?(String) + end + private_class_method :rename, :well_shaped?, :strings? + end + # API-side task budget in tokens. # When set, the model is made aware of its remaining token budget so it can # pace tool use and wrap up before the limit. diff --git a/lib/claude_agent_sdk/types/options.rb b/lib/claude_agent_sdk/types/options.rb index 0d0eefc3..1353185c 100644 --- a/lib/claude_agent_sdk/types/options.rb +++ b/lib/claude_agent_sdk/types/options.rb @@ -18,8 +18,10 @@ module ClaudeAgentSDK # Claude Agent Options for configuring queries class ClaudeAgentOptions < Type - # `env` routinely carries credentials (ANTHROPIC_API_KEY, ...). - inspect_filtered :env + # `env` routinely carries credentials (ANTHROPIC_API_KEY, ...). So does + # `settings` (its own `env`, `apiKeyHelper`), as a Hash or a JSON String, + # and an `extra_args` value can be one. + inspect_filtered :env, :settings, :extra_args attr_accessor :allowed_tools, :system_prompt, :mcp_servers, :permission_mode, :resume, :resume_session_at, :session_id, :max_turns, :disallowed_tools, @@ -345,16 +347,44 @@ def fill_nil_defaults self.callback_scheduling = :thread if callback_scheduling.nil? end + # `mcp_servers` holds typed configs, which filter themselves, next to raw + # Hash configs carrying the same credentials: those are rendered by + # Type#inspect_mcp_server_config. Anything but a Hash (the JSON of a + # config as a String, or the path to one) is replaced outright, like a + # String `settings`. + def inspect_attributes + super.map { |name, value| [name, name == 'mcp_servers' ? inspect_mcp_servers(value) : value] } + end + + def inspect_mcp_servers(servers) + return '[FILTERED]' unless servers.is_a?(Hash) + + servers.to_h { |name, config| [name, config.is_a?(Hash) ? inspect_mcp_server_config(config) : config] } + rescue StandardError + '[FILTERED]' + end + # Strict key validation: unlike other Type subclasses (which silently drop # unknown keys for forward-compat with newer CLI output), ClaudeAgentOptions # is a developer-facing config object — typos should fail loudly. def assign_attribute(name, value) - setter = :"#{normalize_name(name)}=" - raise ArgumentError, "unknown ClaudeAgentOptions option: #{name.inspect}" unless respond_to?(setter) + setter = option_setter(normalize_name(name)) + raise ArgumentError, "unknown ClaudeAgentOptions option: #{name.inspect}" unless setter public_send(setter, value) end + # The writer of the option with this normalized name, nil when the name + # is not an option. An option is a declared attribute, or a setter user + # code defined (on a subclass, a module it includes, or the object); + # respond_to? alone would also answer for '[]' (#[]=) and '=' (#==). + def option_setter(normalized) + setter = :"#{normalized}=" + return unless respond_to?(setter) + + setter if self.class.attribute?(normalized) || user_defined_method?(setter) + end + # Merge caller-provided attributes with configured defaults. # Only keys the caller explicitly passed are treated as overrides; # method-signature defaults ([], {}, false) are NOT present unless the caller wrote them. @@ -403,7 +433,7 @@ def merge_with_defaults(attributes) # reports the typo exactly as the developer wrote it. def option_key(name) normalized = normalize_name(name) - respond_to?(:"#{normalized}=") ? normalized.to_sym : name + option_setter(normalized) ? normalized.to_sym : name end end end diff --git a/lib/claude_agent_sdk/types/permissions.rb b/lib/claude_agent_sdk/types/permissions.rb index c1420a00..50d35bf0 100644 --- a/lib/claude_agent_sdk/types/permissions.rb +++ b/lib/claude_agent_sdk/types/permissions.rb @@ -34,20 +34,21 @@ def rules=(value) @rules = value&.map { |rule| rule.is_a?(Hash) ? PermissionRuleValue.new(rule) : rule } end + # The wire form. The CLI validates the updatedPermissions of a + # can_use_tool reply as one unit and drops the whole array when a single + # entry does not fit its schema, so two things it rejects are never + # written: a rule without content has no ruleContent key (the schema + # wants a String or no key, not null), and an update that has a type but + # no destination goes to 'session' — the narrowest one: it lasts for this + # run and writes no settings file (the default Python PR #1330 proposes). def to_h result = { type: @type } - result[:destination] = @destination if @destination + destination = @destination || ('session' unless @type.nil?) + result[:destination] = destination if destination case @type when 'addRules', 'replaceRules', 'removeRules' - if @rules - result[:rules] = @rules.map do |rule| - { - toolName: rule.tool_name, - ruleContent: rule.rule_content - } - end - end + result[:rules] = @rules.map { |rule| rule_to_h(rule) } if @rules result[:behavior] = @behavior if @behavior when 'setMode' result[:mode] = @mode if @mode @@ -57,6 +58,14 @@ def to_h result end + + private + + def rule_to_h(rule) + wire_rule = { toolName: rule.tool_name } + wire_rule[:ruleContent] = rule.rule_content unless rule.rule_content.nil? + wire_rule + end end # Tool permission context delivered to `can_use_tool` callbacks. diff --git a/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt b/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt index 2a2b67fd..31952cbd 100644 --- a/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +++ b/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt @@ -22,6 +22,12 @@ ClaudeAgentSDK.configure do |config| # whatever the process cwd. To run a binary from somewhere else instead: # cli_path: '/usr/local/bin/claude', + # Multi-user apps: turn off the CLI's auto-memory — kept per project + # directory, read by every session and written by claude_code-preset + # sessions, so one user's "remember ..." reaches all others (the value + # must stay '1'; '0' forces it on). See docs/rails.md ("Per-user isolation"). + # env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }, + # OpenTelemetry tracing (Langfuse, Honeycomb, ...). A factory lambda gives # every query/session its own observer — safe under Puma and job threads. # observers: [-> { ClaudeAgentSDK::Instrumentation::OTelObserver.new }], diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md index 79aa1c70..951e2eeb 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/SKILL.md @@ -10,8 +10,8 @@ Use this skill to build or refactor Ruby integrations with Claude Code via `clau ## Decision Guide - Choose `ClaudeAgentSDK.ask(prompt, options:)` when only the final answer matters: it runs `query` to completion and returns the `ResultMessage` (`#result` is the text); an optional block still sees every message. -- Choose `ClaudeAgentSDK.query` for one-shot queries or streaming input. Internally uses the control protocol (streaming mode). -- Choose `ClaudeAgentSDK::Client` for multi-turn sessions, hooks, permission callbacks, MCP server control, or dynamic model switching. Prefer `ClaudeAgentSDK::Client.open(options: ...) { |client| ... }` — it creates its own reactor and always disconnects (return a value instead of `break` outside a reactor); use `Client.new` + `connect` / `ensure disconnect` only inside an existing `Async` block. +- Choose `ClaudeAgentSDK.query` for one-shot queries or streaming input. It runs the same control protocol as `Client`, so hooks, `can_use_tool` and SDK MCP tools work with it (and with `ask`). +- Choose `ClaudeAgentSDK::Client` for multi-turn sessions and for control calls while a session runs: interrupt, model or permission-mode switching, MCP server control, file rewind. Prefer `ClaudeAgentSDK::Client.open(options: ...) { |client| ... }` — it creates its own reactor and always disconnects (return a value instead of `break` outside a reactor); use `Client.new` + `connect` / `ensure disconnect` only inside an existing `Async` block. - Choose SDK MCP servers (`create_tool`, `create_sdk_mcp_server`) for in-process tools; choose external MCP configs for subprocess/HTTP servers. - Choose `ClaudeAgentSDK.list_sessions` / `ClaudeAgentSDK.get_session_messages` for browsing previous session transcripts (pure filesystem, no CLI needed). @@ -68,7 +68,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( - Include `mcp__<server>__<tool>` in `allowed_tools` - Use `annotations:` on `create_tool` for MCP tool annotations - Use `meta:` on `create_tool` for `_meta` field forwarding (e.g., `{ 'anthropic/maxResultSizeChars' => 100000 }` to prevent truncation of large results) -- If `annotations[:maxResultSizeChars]` is set, `_meta` is auto-populated +- If `annotations[:maxResultSizeChars]` is set, `_meta` is auto-populated with it; an explicit `meta:` is merged with that hint, and a key set in `meta:` wins - Both symbol-keyed and string-keyed `input_schema` hashes are accepted ## Session Management @@ -94,7 +94,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ## Where To Look For Exact Details - Locate the gem: `bundle show claude-agent-sdk` - Read `<gem_path>/README.md` for the overview, install, and minimal API examples -- Read `<gem_path>/docs/*.md` for topic subpages — `client.md` (bidirectional + custom transports), `mcp-servers.md` (SDK MCP tools/resources/prompts), `hooks-and-permissions.md` (27 hook events + permission callbacks), `configuration.md` (structured output, thinking, budget, sandbox, bare mode, file checkpointing), `sessions.md` (list/read/rename/tag/fork/resume + SessionStore mirroring/store-backed helpers), `observability.md` (OTel + Langfuse), `rails.md` (ActionCable, jobs, initializers), `types.md` (Hash-key rule, `#[]`/camelCase access, message/content-block/configuration types), `errors.md` (error hierarchy + timeout) +- Read `<gem_path>/docs/*.md` for topic subpages — `client.md` (bidirectional + custom transports), `mcp-servers.md` (SDK MCP tools/resources/prompts), `hooks-and-permissions.md` (27 hook events + permission callbacks), `configuration.md` (structured output, thinking, budget, sandbox, bare mode, session isolation, file checkpointing), `options.md` (every `ClaudeAgentOptions` attribute: type, default, CLI flag), `sessions.md` (list/read/rename/tag/fork/resume + SessionStore mirroring/store-backed helpers), `observability.md` (OTel + Langfuse), `rails.md` (ActionCable, jobs, initializers), `types.md` (Hash-key rule, `#[]`/camelCase access, message/content-block/configuration types), `errors.md` (error hierarchy + timeout) - Inspect `<gem_path>/lib/claude_agent_sdk/types/` for all types (one file per area: messages, hooks, options, ...) - Inspect `<gem_path>/lib/claude_agent_sdk/message_parser.rb` for message parsing - Inspect `<gem_path>/lib/claude_agent_sdk/sessions.rb` for session browsing diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md index f73aa8fc..99d1b468 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/options.md @@ -10,13 +10,13 @@ Set defaults once, then override only when needed per call. ClaudeAgentSDK.configure do |config| config.default_options = { model: 'claude-sonnet-5', - permission_mode: 'bypassPermissions', - env: { 'ANTHROPIC_API_KEY' => ENV.fetch('ANTHROPIC_API_KEY') } + permission_mode: 'bypassPermissions' } end ``` Notes: +- Credentials need no option. The CLI process inherits your process's environment, so `ANTHROPIC_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`) set there already reaches it. Do not copy it into `env` with `ENV.fetch` in an initializer: every boot without the key (asset precompilation in a Docker build, `db:migrate` in CI) would raise `KeyError`. - `ClaudeAgentOptions.new(...)` still overrides defaults you pass explicitly. - Hash options like `env` and `mcp_servers` merge with configured defaults. - Assignment stores a frozen deep copy (containers, typed values and Strings included): change defaults by assigning a new Hash. In-place mutation of `config.default_options` raises `FrozenError`, and later changes to the Hash, typed objects or Strings you passed have no effect. Sessions get mutable copies of the containers and typed values but keep the snapshot's frozen Strings, so reassign `options.model = ...` rather than `options.model << ...`. @@ -70,7 +70,7 @@ ClaudeAgentSDK::HookMatcher.new( Hook callbacks receive typed input objects (for example `ClaudeAgentSDK::PreToolUseHookInput`) and a `ClaudeAgentSDK::HookContext` (`request_id`, plus `signal` — a `CancellationSignal` to poll with `cancelled?` or `wait(timeout:)` while waiting on external work; it fires on CLI cancel, EOF, disconnect, callback failure, and `HookMatcher` timeout). `ToolPermissionContext` carries the same `request_id` / `signal` for `can_use_tool`. Access fields via Ruby accessors like `input.tool_name` and `input.tool_input`. -Hook callbacks should return a hash. Only top-level keys are auto-converted; use CLI-style camelCase for nested keys. +Hook callbacks return a Hash, a typed output (`ClaudeAgentSDK::AsyncHookJSONOutput`, or `SyncHookJSONOutput`, which can hold a `*HookSpecificOutput`) or `nil`. In a Hash, the fields those typed classes model may be written in snake_case or camelCase, with Symbol or String keys, at the top level and inside `hook_specific_output` (which must carry `hook_event_name`). Any other key is sent as written, so spell a field the typed classes do not model the way the CLI does (camelCase). The keys inside `updated_input`, the tool outputs (`updated_tool_output`, `updated_mcp_tool_output`) and a `PermissionRequest` `decision` are not renamed. In 1.1.0 and earlier only top-level keys were converted, and only when written as Symbols (except `continue_` / `async_`, which also worked as Strings): use camelCase for nested keys and for String keys there. ## Structured output @@ -99,13 +99,15 @@ Use `mcp_servers:` to add SDK MCP servers (in-process) or external MCP servers. Use `sandbox:` with `ClaudeAgentSDK::SandboxSettings` to run tool execution in an isolated sandbox when supported. +A Hash works too, for `sandbox:` and for its `network:` / `filesystem:`. It may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; other keys are sent as written. A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, a port number from 0 to 65535); otherwise it is sent as written and ignored. The CLI discards the whole `--settings` value, sandbox and `permissions` included, when one sandbox value fails its schema. It reports that in the `errors` of its `get_settings` control response, which the SDK does not surface: `connect` succeeds and nothing appears on stderr. + ## Agents Use `agents:` to configure sub-agent definitions passed to the CLI via the control protocol. ## Custom transport -`Client.new` accepts `transport_class:` and `transport_args:` to swap the default `SubprocessCLITransport` for a custom transport (must implement the `Transport` interface: `connect`, `write`, `read_messages`, `close`). +`Client.new` accepts `transport_class:` and `transport_args:` to swap the default `SubprocessCLITransport` for a custom transport; `ClaudeAgentSDK.query` and `.ask` take a ready-made instance as `transport:`. A custom transport must implement `connect`, `write`, `read_messages`, `end_input` and `close` (`ready?` is optional: the SDK never calls it). A transport without `end_input` makes `query` and `ask` hang (or raise `NotImplementedError`, if it subclasses `Transport`). `read_messages` yields each stdout line parsed with `JSON.parse(line, symbolize_names: true)`, and `close` must be safe to call twice. ```ruby client = ClaudeAgentSDK::Client.new( diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/rails.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/rails.md index a6873f50..ab55fc1d 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/rails.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/rails.md @@ -21,7 +21,6 @@ ClaudeAgentSDK.configure do |config| config.default_options = { model: 'claude-sonnet-5', permission_mode: 'bypassPermissions', - env: { 'ANTHROPIC_API_KEY' => ENV.fetch('ANTHROPIC_API_KEY') }, # AR connections go back to the pool after each callback. Never a bare # `->(inv) { Rails.application.executor.wrap { inv.call } }`: that deadlocks # with development code reloading in the default :thread scheduling. @@ -30,6 +29,8 @@ ClaudeAgentSDK.configure do |config| end ``` +The API key needs no entry here: the CLI process inherits the app's environment, so `ANTHROPIC_API_KEY` set for the app already reaches it. An `ENV.fetch('ANTHROPIC_API_KEY')` in the initializer would make every boot without the key (asset precompilation, `db:migrate` in CI) raise `KeyError`. + ## Stream chunks to the frontend (ActionCable) Run the agent in an `ActiveJob`, broadcast assistant text as it arrives, and finalize on `ResultMessage`. diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/troubleshooting.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/troubleshooting.md index 05458a8d..b8f44738 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/troubleshooting.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/troubleshooting.md @@ -8,6 +8,7 @@ Symptoms: Fix: - Install Claude Code CLI (Node.js required), or vendor a pinned binary with `ClaudeAgentSDK::CLIInstaller.install_pinned` (0.34.0+; installs the CLI version the gem release was tested against, `CLIInstaller::PINNED_CLI_VERSION`) or `CLIInstaller.install(version: 'x.y.z')` for a concrete pin of your own (`'stable'`, the default, and `'latest'` are floating dist-tags that re-resolve on every install). Downloads into `vendor/claude/`; discovery prefers it over `PATH` — since 0.30.0. - If the CLI is installed in a non-standard path, set `ClaudeAgentSDK::ClaudeAgentOptions#cli_path` (see `references/options.md`) or the `CLAUDE_CLI_PATH` environment variable. +- `CLAUDE_CLI_PATH` is only the first place the SDK looks. A value that does not name an executable file (a typo, a binary not vendored yet) is skipped without a warning, and discovery continues with the vendored binary, `PATH` and the common install locations. If a different CLI than expected starts, check that path first. An explicit `cli_path:` is used as given. ## Session APIs fail in a container without a home directory diff --git a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/usage-map.md b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/usage-map.md index b0aac4b7..dabcc6d7 100644 --- a/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/usage-map.md +++ b/plugins/claude-agent-ruby/skills/claude-agent-ruby/references/usage-map.md @@ -18,7 +18,8 @@ For everything else, open the matching `docs/` subpage: | Bidirectional `Client` advanced features (`interrupt`, mid-session model/permission switching, MCP status); custom transports (E2B example, transport interface) | `docs/client.md` | | SDK MCP servers — tool definitions, JSON schema normalization, resources, prompts, mixed SDK + external server config | `docs/mcp-servers.md` | | All 27 hook events and typed input classes; permission callbacks (`PermissionResultAllow` / `PermissionResultDeny`) | `docs/hooks-and-permissions.md` | -| Structured output (`output_format`), thinking config (adaptive/enabled/disabled + effort levels), cross-user prompt caching, budget control, fallback model, beta features, tools config, skills, sandbox settings, bare mode, file checkpointing & rewind | `docs/configuration.md` | +| Structured output (`output_format`), thinking config (adaptive/enabled/disabled + effort levels), cross-user prompt caching, budget control, fallback model, beta features, tools config, skills, sandbox settings, bare mode, session isolation (the CLI's auto-memory and how to turn it off), file checkpointing & rewind | `docs/configuration.md` | +| Every `ClaudeAgentOptions` attribute with its type, default and the CLI flag, `initialize` field or environment variable it becomes; `extra_args`; the environment variables the SDK reads | `docs/options.md` | | Session listing, reading messages, renaming, tagging, deleting, forking, resume-at-message | `docs/sessions.md` | | OpenTelemetry observer setup, Langfuse export, span attributes, W3C trace-context propagation to the CLI, custom `Observer` module | `docs/observability.md` | | Rails integration — fiber-safety guarantee, ActionCable streaming, session resumption, background jobs, HTTP MCP servers, observability initializer | `docs/rails.md` | diff --git a/sig/claude_agent_sdk/types/hooks.rbs b/sig/claude_agent_sdk/types/hooks.rbs index 3c0e842d..19374e20 100644 --- a/sig/claude_agent_sdk/types/hooks.rbs +++ b/sig/claude_agent_sdk/types/hooks.rbs @@ -47,8 +47,9 @@ module ClaudeAgentSDK | CwdChangedHookSpecificOutput | FileChangedHookSpecificOutput - # What a hook callback returns: a Hash (snake_case or camelCase keys), a - # typed output, or nil (treated as {}). + # What a hook callback returns: a Hash (Symbol or String keys, snake_case + # or camelCase, also inside hook_specific_output), a typed output, or nil + # (treated as {}). type hook_output = Hash[Symbol | String, untyped] | SyncHookJSONOutput | AsyncHookJSONOutput | nil # A hook callback (HookMatcher#hooks entries). tool_use_id is nil for @@ -520,7 +521,9 @@ module ClaudeAgentSDK attr_accessor reason: String? - # A typed output, or the equivalent Hash (camelCase wire keys, sent as-is). + # A typed output, or the equivalent Hash (snake_case or camelCase keys). + # #to_h returns a Hash as written; the SDK puts its keys in wire spelling + # when it sends the output. attr_accessor hook_specific_output: (hook_specific_output | Hash[Symbol | String, untyped])? def to_h: () -> Hash[Symbol, untyped] diff --git a/sig/claude_agent_sdk/types/option_values.rbs b/sig/claude_agent_sdk/types/option_values.rbs index 397618b7..3d8bb1c6 100644 --- a/sig/claude_agent_sdk/types/option_values.rbs +++ b/sig/claude_agent_sdk/types/option_values.rbs @@ -7,6 +7,16 @@ module ClaudeAgentSDK # the Hash form (String or camelCase keys, e.g. `klass.new(value.to_h)`) # is accepted too. An unknown key raises ArgumentError. Discriminators # (`type`) are fixed by the class and read-only. + # + # The keyword overload documents the accepted keys and their types; no + # type checker enforces it. `klass.new(typo: 1)` is also a Symbol-keyed + # Hash, which the second overload accepts whatever its keys and values, so + # Steep reports neither a misspelled keyword nor a wrong value type, and + # the runtime checker behind `rake rbs:test` never reports a constructor + # mismatch (it only checks the attribute setters the constructor calls). + # The enforcement is the ArgumentError the constructor raises at runtime + # for an unknown key. This holds for every constructor in sig/ that has + # both overloads. # Adaptive thinking: the model decides when and how much to think. class ThinkingConfigAdaptive < Type @@ -81,15 +91,15 @@ module ClaudeAgentSDK # A local plugin directory (ClaudeAgentOptions#plugins entries). class SdkPluginConfig < Type - def initialize: (?path: String?) -> void + def initialize: (?path: (String | Pathname)?) -> void | (Hash[Symbol | String, untyped]? attributes) -> void - attr_accessor path: String? + attr_accessor path: (String | Pathname)? # "local". attr_reader type: String - def to_h: () -> { type: String, path: String? } + def to_h: () -> { type: String, path: (String | Pathname)? } end class SandboxNetworkConfig < Type @@ -138,6 +148,13 @@ module ClaudeAgentSDK # Sandbox settings for isolated command execution # (ClaudeAgentOptions#sandbox). + # + # network / filesystem take the typed config or a Hash. A Hash may spell + # the fields of the typed class in snake_case or in the CLI's camelCase, + # with Symbol or String keys; other keys are sent as written. A snake_case + # key is sent under the CLI's name only when its value has the shape the + # CLI accepts for it; otherwise it is sent as written, which the CLI + # ignores. class SandboxSettings < Type def initialize: (?enabled: bool?, ?fail_if_unavailable: bool?, ?auto_allow_bash_if_sandboxed: bool?, ?excluded_commands: Array[String]?, ?allow_unsandboxed_commands: bool?, ?network: (SandboxNetworkConfig | Hash[Symbol | String, untyped])?, ?filesystem: (SandboxFilesystemConfig | Hash[Symbol | String, untyped])?, ?ignore_violations: Hash[Symbol | String, Array[String]]?, ?enable_weaker_nested_sandbox: bool?, ?enable_weaker_network_isolation: bool?, ?ripgrep: Hash[Symbol | String, untyped]?) -> void | (Hash[Symbol | String, untyped]? attributes) -> void @@ -181,15 +198,15 @@ module ClaudeAgentSDK # A system prompt loaded from a file. class SystemPromptFile < Type - def initialize: (?path: String?) -> void + def initialize: (?path: (String | Pathname)?) -> void | (Hash[Symbol | String, untyped]? attributes) -> void - attr_accessor path: String? + attr_accessor path: (String | Pathname)? # "file". attr_reader type: String - def to_h: () -> { type: String, path: String? } + def to_h: () -> { type: String, path: (String | Pathname)? } end # The Claude Code preset system prompt, optionally extended. diff --git a/sig/claude_agent_sdk/types/options.rbs b/sig/claude_agent_sdk/types/options.rbs index d8803e3f..2a503000 100644 --- a/sig/claude_agent_sdk/types/options.rbs +++ b/sig/claude_agent_sdk/types/options.rbs @@ -48,7 +48,7 @@ module ClaudeAgentSDK ?permission_prompt_tool_name: String?, ?cwd: (String | Pathname)?, ?cli_path: (String | Pathname)?, - ?settings: (String | Hash[Symbol | String, untyped])?, + ?settings: (String | Pathname | Hash[Symbol | String, untyped])?, ?add_dirs: Array[String | Pathname]?, ?env: Hash[String | Symbol, String?]?, ?extra_args: Hash[String | Symbol, untyped]?, @@ -68,7 +68,7 @@ module ClaudeAgentSDK ?plugins: Array[SdkPluginConfig | Hash[Symbol | String, untyped]]?, ?debug_stderr: (_Puts | String)?, ?betas: Array[String]?, - ?tools: (Array[String] | ToolsPreset | Hash[Symbol | String, untyped])?, + ?tools: (Array[String] | ToolsPreset | Hash[Symbol | String, untyped] | String)?, ?sandbox: (SandboxSettings | Hash[Symbol | String, untyped] | bool)?, ?thinking: thinking_config?, ?effort: (String | Symbol | Integer)?, @@ -133,8 +133,9 @@ module ClaudeAgentSDK attr_accessor cli_path: (String | Pathname)? - # A settings JSON String, a path to a settings file, or a Hash. - attr_accessor settings: (String | Hash[Symbol | String, untyped])? + # A settings JSON String, a path to a settings file (a String or a + # Pathname), or a Hash. + attr_accessor settings: (String | Pathname | Hash[Symbol | String, untyped])? attr_accessor add_dirs: Array[String | Pathname]? @@ -185,10 +186,13 @@ module ClaudeAgentSDK # Entries of SDK_BETAS. attr_accessor betas: Array[String]? - # The base tool set: tool names, or a preset. - attr_accessor tools: (Array[String] | ToolsPreset | Hash[Symbol | String, untyped])? + # The base tool set: tool names (an Array, or the CLI's comma-separated + # String such as "Read,Grep"), or a preset. + attr_accessor tools: (Array[String] | ToolsPreset | Hash[Symbol | String, untyped] | String)? - # true / false are forwarded as-is (a bare toggle). + # true / false are forwarded as-is (a bare toggle). A Hash may spell the + # fields of SandboxSettings, and of its network / filesystem, in + # snake_case or in the CLI's camelCase (see SandboxSettings). attr_accessor sandbox: (SandboxSettings | Hash[Symbol | String, untyped] | bool)? attr_accessor thinking: thinking_config? diff --git a/sig/claude_agent_sdk/types/permissions.rbs b/sig/claude_agent_sdk/types/permissions.rbs index af97344e..dcef2ed0 100644 --- a/sig/claude_agent_sdk/types/permissions.rbs +++ b/sig/claude_agent_sdk/types/permissions.rbs @@ -41,7 +41,8 @@ module ClaudeAgentSDK attr_accessor directories: Array[String]? - # One of PERMISSION_UPDATE_DESTINATIONS. + # One of PERMISSION_UPDATE_DESTINATIONS. When nil, #to_h sends "session" + # (unless type is nil too). attr_accessor destination: String? attr_reader rules: Array[PermissionRuleValue]? @@ -49,7 +50,8 @@ module ClaudeAgentSDK # Hash entries (camelCase or snake_case keys) become PermissionRuleValue. def rules=: (Array[PermissionRuleValue | Hash[Symbol | String, untyped]]? value) -> Array[PermissionRuleValue | Hash[Symbol | String, untyped]]? - # The wire form (camelCase keys). + # The wire form (camelCase keys). A rule without content has no + # ruleContent key. def to_h: () -> Hash[Symbol, untyped] end diff --git a/skills/SKILL.md b/skills/SKILL.md index 79aa1c70..951e2eeb 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -10,8 +10,8 @@ Use this skill to build or refactor Ruby integrations with Claude Code via `clau ## Decision Guide - Choose `ClaudeAgentSDK.ask(prompt, options:)` when only the final answer matters: it runs `query` to completion and returns the `ResultMessage` (`#result` is the text); an optional block still sees every message. -- Choose `ClaudeAgentSDK.query` for one-shot queries or streaming input. Internally uses the control protocol (streaming mode). -- Choose `ClaudeAgentSDK::Client` for multi-turn sessions, hooks, permission callbacks, MCP server control, or dynamic model switching. Prefer `ClaudeAgentSDK::Client.open(options: ...) { |client| ... }` — it creates its own reactor and always disconnects (return a value instead of `break` outside a reactor); use `Client.new` + `connect` / `ensure disconnect` only inside an existing `Async` block. +- Choose `ClaudeAgentSDK.query` for one-shot queries or streaming input. It runs the same control protocol as `Client`, so hooks, `can_use_tool` and SDK MCP tools work with it (and with `ask`). +- Choose `ClaudeAgentSDK::Client` for multi-turn sessions and for control calls while a session runs: interrupt, model or permission-mode switching, MCP server control, file rewind. Prefer `ClaudeAgentSDK::Client.open(options: ...) { |client| ... }` — it creates its own reactor and always disconnects (return a value instead of `break` outside a reactor); use `Client.new` + `connect` / `ensure disconnect` only inside an existing `Async` block. - Choose SDK MCP servers (`create_tool`, `create_sdk_mcp_server`) for in-process tools; choose external MCP configs for subprocess/HTTP servers. - Choose `ClaudeAgentSDK.list_sessions` / `ClaudeAgentSDK.get_session_messages` for browsing previous session transcripts (pure filesystem, no CLI needed). @@ -68,7 +68,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( - Include `mcp__<server>__<tool>` in `allowed_tools` - Use `annotations:` on `create_tool` for MCP tool annotations - Use `meta:` on `create_tool` for `_meta` field forwarding (e.g., `{ 'anthropic/maxResultSizeChars' => 100000 }` to prevent truncation of large results) -- If `annotations[:maxResultSizeChars]` is set, `_meta` is auto-populated +- If `annotations[:maxResultSizeChars]` is set, `_meta` is auto-populated with it; an explicit `meta:` is merged with that hint, and a key set in `meta:` wins - Both symbol-keyed and string-keyed `input_schema` hashes are accepted ## Session Management @@ -94,7 +94,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new( ## Where To Look For Exact Details - Locate the gem: `bundle show claude-agent-sdk` - Read `<gem_path>/README.md` for the overview, install, and minimal API examples -- Read `<gem_path>/docs/*.md` for topic subpages — `client.md` (bidirectional + custom transports), `mcp-servers.md` (SDK MCP tools/resources/prompts), `hooks-and-permissions.md` (27 hook events + permission callbacks), `configuration.md` (structured output, thinking, budget, sandbox, bare mode, file checkpointing), `sessions.md` (list/read/rename/tag/fork/resume + SessionStore mirroring/store-backed helpers), `observability.md` (OTel + Langfuse), `rails.md` (ActionCable, jobs, initializers), `types.md` (Hash-key rule, `#[]`/camelCase access, message/content-block/configuration types), `errors.md` (error hierarchy + timeout) +- Read `<gem_path>/docs/*.md` for topic subpages — `client.md` (bidirectional + custom transports), `mcp-servers.md` (SDK MCP tools/resources/prompts), `hooks-and-permissions.md` (27 hook events + permission callbacks), `configuration.md` (structured output, thinking, budget, sandbox, bare mode, session isolation, file checkpointing), `options.md` (every `ClaudeAgentOptions` attribute: type, default, CLI flag), `sessions.md` (list/read/rename/tag/fork/resume + SessionStore mirroring/store-backed helpers), `observability.md` (OTel + Langfuse), `rails.md` (ActionCable, jobs, initializers), `types.md` (Hash-key rule, `#[]`/camelCase access, message/content-block/configuration types), `errors.md` (error hierarchy + timeout) - Inspect `<gem_path>/lib/claude_agent_sdk/types/` for all types (one file per area: messages, hooks, options, ...) - Inspect `<gem_path>/lib/claude_agent_sdk/message_parser.rb` for message parsing - Inspect `<gem_path>/lib/claude_agent_sdk/sessions.rb` for session browsing diff --git a/skills/references/options.md b/skills/references/options.md index f73aa8fc..99d1b468 100644 --- a/skills/references/options.md +++ b/skills/references/options.md @@ -10,13 +10,13 @@ Set defaults once, then override only when needed per call. ClaudeAgentSDK.configure do |config| config.default_options = { model: 'claude-sonnet-5', - permission_mode: 'bypassPermissions', - env: { 'ANTHROPIC_API_KEY' => ENV.fetch('ANTHROPIC_API_KEY') } + permission_mode: 'bypassPermissions' } end ``` Notes: +- Credentials need no option. The CLI process inherits your process's environment, so `ANTHROPIC_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`) set there already reaches it. Do not copy it into `env` with `ENV.fetch` in an initializer: every boot without the key (asset precompilation in a Docker build, `db:migrate` in CI) would raise `KeyError`. - `ClaudeAgentOptions.new(...)` still overrides defaults you pass explicitly. - Hash options like `env` and `mcp_servers` merge with configured defaults. - Assignment stores a frozen deep copy (containers, typed values and Strings included): change defaults by assigning a new Hash. In-place mutation of `config.default_options` raises `FrozenError`, and later changes to the Hash, typed objects or Strings you passed have no effect. Sessions get mutable copies of the containers and typed values but keep the snapshot's frozen Strings, so reassign `options.model = ...` rather than `options.model << ...`. @@ -70,7 +70,7 @@ ClaudeAgentSDK::HookMatcher.new( Hook callbacks receive typed input objects (for example `ClaudeAgentSDK::PreToolUseHookInput`) and a `ClaudeAgentSDK::HookContext` (`request_id`, plus `signal` — a `CancellationSignal` to poll with `cancelled?` or `wait(timeout:)` while waiting on external work; it fires on CLI cancel, EOF, disconnect, callback failure, and `HookMatcher` timeout). `ToolPermissionContext` carries the same `request_id` / `signal` for `can_use_tool`. Access fields via Ruby accessors like `input.tool_name` and `input.tool_input`. -Hook callbacks should return a hash. Only top-level keys are auto-converted; use CLI-style camelCase for nested keys. +Hook callbacks return a Hash, a typed output (`ClaudeAgentSDK::AsyncHookJSONOutput`, or `SyncHookJSONOutput`, which can hold a `*HookSpecificOutput`) or `nil`. In a Hash, the fields those typed classes model may be written in snake_case or camelCase, with Symbol or String keys, at the top level and inside `hook_specific_output` (which must carry `hook_event_name`). Any other key is sent as written, so spell a field the typed classes do not model the way the CLI does (camelCase). The keys inside `updated_input`, the tool outputs (`updated_tool_output`, `updated_mcp_tool_output`) and a `PermissionRequest` `decision` are not renamed. In 1.1.0 and earlier only top-level keys were converted, and only when written as Symbols (except `continue_` / `async_`, which also worked as Strings): use camelCase for nested keys and for String keys there. ## Structured output @@ -99,13 +99,15 @@ Use `mcp_servers:` to add SDK MCP servers (in-process) or external MCP servers. Use `sandbox:` with `ClaudeAgentSDK::SandboxSettings` to run tool execution in an isolated sandbox when supported. +A Hash works too, for `sandbox:` and for its `network:` / `filesystem:`. It may spell the fields of the typed classes in snake_case or in the CLI's camelCase, with Symbol or String keys; other keys are sent as written. A snake_case key is sent under the CLI's name only when its value has the shape the CLI accepts for it (`true` / `false`, an Array of Strings, a port number from 0 to 65535); otherwise it is sent as written and ignored. The CLI discards the whole `--settings` value, sandbox and `permissions` included, when one sandbox value fails its schema. It reports that in the `errors` of its `get_settings` control response, which the SDK does not surface: `connect` succeeds and nothing appears on stderr. + ## Agents Use `agents:` to configure sub-agent definitions passed to the CLI via the control protocol. ## Custom transport -`Client.new` accepts `transport_class:` and `transport_args:` to swap the default `SubprocessCLITransport` for a custom transport (must implement the `Transport` interface: `connect`, `write`, `read_messages`, `close`). +`Client.new` accepts `transport_class:` and `transport_args:` to swap the default `SubprocessCLITransport` for a custom transport; `ClaudeAgentSDK.query` and `.ask` take a ready-made instance as `transport:`. A custom transport must implement `connect`, `write`, `read_messages`, `end_input` and `close` (`ready?` is optional: the SDK never calls it). A transport without `end_input` makes `query` and `ask` hang (or raise `NotImplementedError`, if it subclasses `Transport`). `read_messages` yields each stdout line parsed with `JSON.parse(line, symbolize_names: true)`, and `close` must be safe to call twice. ```ruby client = ClaudeAgentSDK::Client.new( diff --git a/skills/references/rails.md b/skills/references/rails.md index a6873f50..ab55fc1d 100644 --- a/skills/references/rails.md +++ b/skills/references/rails.md @@ -21,7 +21,6 @@ ClaudeAgentSDK.configure do |config| config.default_options = { model: 'claude-sonnet-5', permission_mode: 'bypassPermissions', - env: { 'ANTHROPIC_API_KEY' => ENV.fetch('ANTHROPIC_API_KEY') }, # AR connections go back to the pool after each callback. Never a bare # `->(inv) { Rails.application.executor.wrap { inv.call } }`: that deadlocks # with development code reloading in the default :thread scheduling. @@ -30,6 +29,8 @@ ClaudeAgentSDK.configure do |config| end ``` +The API key needs no entry here: the CLI process inherits the app's environment, so `ANTHROPIC_API_KEY` set for the app already reaches it. An `ENV.fetch('ANTHROPIC_API_KEY')` in the initializer would make every boot without the key (asset precompilation, `db:migrate` in CI) raise `KeyError`. + ## Stream chunks to the frontend (ActionCable) Run the agent in an `ActiveJob`, broadcast assistant text as it arrives, and finalize on `ResultMessage`. diff --git a/skills/references/troubleshooting.md b/skills/references/troubleshooting.md index 05458a8d..b8f44738 100644 --- a/skills/references/troubleshooting.md +++ b/skills/references/troubleshooting.md @@ -8,6 +8,7 @@ Symptoms: Fix: - Install Claude Code CLI (Node.js required), or vendor a pinned binary with `ClaudeAgentSDK::CLIInstaller.install_pinned` (0.34.0+; installs the CLI version the gem release was tested against, `CLIInstaller::PINNED_CLI_VERSION`) or `CLIInstaller.install(version: 'x.y.z')` for a concrete pin of your own (`'stable'`, the default, and `'latest'` are floating dist-tags that re-resolve on every install). Downloads into `vendor/claude/`; discovery prefers it over `PATH` — since 0.30.0. - If the CLI is installed in a non-standard path, set `ClaudeAgentSDK::ClaudeAgentOptions#cli_path` (see `references/options.md`) or the `CLAUDE_CLI_PATH` environment variable. +- `CLAUDE_CLI_PATH` is only the first place the SDK looks. A value that does not name an executable file (a typo, a binary not vendored yet) is skipped without a warning, and discovery continues with the vendored binary, `PATH` and the common install locations. If a different CLI than expected starts, check that path first. An explicit `cli_path:` is used as given. ## Session APIs fail in a container without a home directory diff --git a/skills/references/usage-map.md b/skills/references/usage-map.md index b0aac4b7..dabcc6d7 100644 --- a/skills/references/usage-map.md +++ b/skills/references/usage-map.md @@ -18,7 +18,8 @@ For everything else, open the matching `docs/` subpage: | Bidirectional `Client` advanced features (`interrupt`, mid-session model/permission switching, MCP status); custom transports (E2B example, transport interface) | `docs/client.md` | | SDK MCP servers — tool definitions, JSON schema normalization, resources, prompts, mixed SDK + external server config | `docs/mcp-servers.md` | | All 27 hook events and typed input classes; permission callbacks (`PermissionResultAllow` / `PermissionResultDeny`) | `docs/hooks-and-permissions.md` | -| Structured output (`output_format`), thinking config (adaptive/enabled/disabled + effort levels), cross-user prompt caching, budget control, fallback model, beta features, tools config, skills, sandbox settings, bare mode, file checkpointing & rewind | `docs/configuration.md` | +| Structured output (`output_format`), thinking config (adaptive/enabled/disabled + effort levels), cross-user prompt caching, budget control, fallback model, beta features, tools config, skills, sandbox settings, bare mode, session isolation (the CLI's auto-memory and how to turn it off), file checkpointing & rewind | `docs/configuration.md` | +| Every `ClaudeAgentOptions` attribute with its type, default and the CLI flag, `initialize` field or environment variable it becomes; `extra_args`; the environment variables the SDK reads | `docs/options.md` | | Session listing, reading messages, renaming, tagging, deleting, forking, resume-at-message | `docs/sessions.md` | | OpenTelemetry observer setup, Langfuse export, span attributes, W3C trace-context propagation to the CLI, custom `Observer` module | `docs/observability.md` | | Rails integration — fiber-safety guarantee, ActionCable streaming, session resumption, background jobs, HTTP MCP servers, observability initializer | `docs/rails.md` | diff --git a/spec/README.md b/spec/README.md index a68a8f7b..89874211 100644 --- a/spec/README.md +++ b/spec/README.md @@ -4,54 +4,49 @@ This directory contains the test suite for the Claude Agent SDK for Ruby. ## Running Tests -### Run all tests ```bash -bundle exec rspec +bundle exec rspec # the default suite (spec/, without spec/rails) +bundle exec rspec spec/unit/message_parser_spec.rb # one file +bundle exec rspec spec/unit/types_spec.rb:42 # one example +bundle exec rspec --seed 81 # a given order (the suite runs in random order) +COVERAGE=1 bundle exec rspec # with a simplecov report in coverage/ +PROFILE=1 bundle exec rspec # and the 10 slowest examples ``` -### Run with detailed output -```bash -bundle exec rspec --format documentation -``` +The other bundles (`gemfiles/floor.gemfile`, `gemfiles/latest.gemfile`), the Rails specs, `rake rbs:test` and RuboCop are described in [CONTRIBUTING.md](../CONTRIBUTING.md). -### Run specific test file -```bash -bundle exec rspec spec/unit/message_parser_spec.rb -``` +## Layout -### Run specific test by line number -```bash -bundle exec rspec spec/unit/types_spec.rb:42 -``` +### `spec/unit/` -### Run with profiling (show slowest 10 tests) -```bash -PROFILE=1 bundle exec rspec -``` +One file per area of `lib/`, e.g. `query_spec.rb`, `subprocess_cli_transport_spec.rb`, `sdk_mcp_server_spec.rb`, `sessions_spec.rb`, `types_spec.rb`. Most of them drive one object with doubles. A few go further on purpose: -## Test Structure +- **Through the control protocol, in process**: `query_hook_input_spec.rb` (the typed input of every hook event), `query_sdk_mcp_messages_spec.rb` (the JSON-RPC exchange with an SDK MCP server) and `query_permission_result_spec.rb` (a `can_use_tool` callback that returns something else than a `PermissionResult`) open a real `Client` on `ScriptedCLI` and assert on the frames the SDK writes back. +- **Against a real child process**: `subprocess_cli_transport_fake_cli_spec.rb` runs `SubprocessCLITransport` and one `query()` against `FakeClaude`, with nothing stubbed. +- **In child processes**: `callback_process_exit_spec.rb` runs every cell of `CallbackExitHarness` in its own Ruby process, because those cells end the process with `exit` or a signal. -### Unit Tests (`spec/unit/`) +### `spec/integration/` -Unit tests verify individual components in isolation: +Both files spawn the real `claude` binary and are skipped by default. -- **errors_spec.rb** - Tests for error classes and their behavior -- **types_spec.rb** - Tests for all type classes (Messages, ContentBlocks, Options, etc.) -- **message_parser_spec.rb** - Tests for JSON message parsing and validation -- **sdk_mcp_server_spec.rb** - Tests for SDK MCP server functionality and tool execution -- **transport_spec.rb** - Tests for the Transport abstract base class +- **`real_cli_integration_spec.rb`** makes live, budget-capped API calls. It is tagged `:integration` and runs only with `RUN_INTEGRATION=1` (`RUN_REAL_INTEGRATION` is accepted as a legacy alias); it then skips itself when `claude` is not on `PATH` or `ANTHROPIC_API_KEY` is unset: -### Integration Tests (`spec/integration/`) + ```bash + RUN_INTEGRATION=1 ANTHROPIC_API_KEY=... bundle exec rspec spec/integration/real_cli_integration_spec.rb + ``` -- **real_cli_integration_spec.rb** - Real Claude CLI end-to-end tests (opt-in only, live API calls) +- **`keyless_smoke_spec.rb`** needs no credentials and reaches no model: with a fresh `HOME` and `CLAUDE_CONFIG_DIR` it completes the `initialize` handshake and checks that a prompt the CLI cannot send comes back as a `ResultError`. It runs only with `RUN_KEYLESS_SMOKE=1` and, once enabled, skips nothing (a missing CLI fails). It finds the CLI the way the SDK does (`CLAUDE_CLI_PATH`, the vendored install, `PATH`): -**Note:** Integration tests are tagged with `:integration` and are skipped by default. `RUN_INTEGRATION=1` is the single gate (`RUN_REAL_INTEGRATION` is accepted as a legacy alias); the suite self-skips green when the `claude` CLI is not on PATH or `ANTHROPIC_API_KEY` is not set: + ```bash + bundle exec rake claude_agent_sdk:install_cli # the pinned CLI, into vendor/claude + RUN_KEYLESS_SMOKE=1 bundle exec rspec spec/integration/keyless_smoke_spec.rb + ``` -```bash -RUN_INTEGRATION=1 ANTHROPIC_API_KEY=... bundle exec rspec spec/integration/real_cli_integration_spec.rb -``` + When you run it from a shell that is itself inside Claude Code, start it under `env -i` with only `HOME`, `PATH` and `LANG`: that shell exports `CLAUDE_CODE_*` variables the CLI child would inherit. + +The workflow `.github/workflows/integration.yml` runs the keyless smoke on every trigger, and the live suite only when the repository has an `ANTHROPIC_API_KEY` secret. -### Rails Integration Tests (`spec/rails/`) +### `spec/rails/` Railtie, `claude_agent_sdk:install_cli` rake task, install generator and `Railtie.callback_wrapper`. They load railties/ActiveSupport, which patch core classes process-wide, so the root `.rspec` excludes `spec/rails` from the default run; they run in their own process against a Rails bundle, with `spec/rails/.rspec` replacing the root options: @@ -60,116 +55,33 @@ BUNDLE_GEMFILE=gemfiles/rails_8.gemfile bundle exec rspec --options spec/rails/. BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rspec --options spec/rails/.rspec # Rails 7.1 floor ``` -### Test Helpers (`spec/support/`) - -- **test_helpers.rb** - Shared test fixtures and helper methods used across test files - -## Test Configuration - -Test configuration is managed in `spec_helper.rb`: - -- **Random order** - Tests run in random order to detect order dependencies -- **Persistence** - Test status is saved to `.rspec_status` for `--only-failures` and `--next-failure` -- **No monkey patching** - RSpec's clean syntax without global method pollution -- **Integration filter** - Integration tests skipped by default (enable with `RUN_INTEGRATION=1`) - -## Test Coverage - -The test suite covers: - -### Error Handling -- All error class instantiation and attributes -- Error inheritance hierarchy -- Error message formatting - -### Type System -- Content blocks (TextBlock, ThinkingBlock, ToolUseBlock, ToolResultBlock) -- Messages (UserMessage, AssistantMessage, SystemMessage, ResultMessage, StreamEvent) -- ClaudeAgentOptions with all configuration fields -- Permission types (PermissionResultAllow, PermissionResultDeny, PermissionUpdate) -- Hook matchers - -### Message Parser -- Parsing all message types from JSON -- Content block parsing -- Validation and error handling for malformed messages -- Missing required field detection - -### SDK MCP Server -- Tool creation with schemas -- Server configuration -- Tool execution and error handling -- JSON schema generation from Ruby types - -### Transport -- Abstract base class interface -- NotImplementedError for unimplemented methods +### `spec/examples/` -### Integration -- Component interaction verification -- SDK MCP server integration -- Hook configuration and execution -- Permission callback handling -- End-to-end workflow simulation +The SessionStore reference adapters under `examples/session_stores/`. The S3 one runs against an in-process fake; the Redis and Postgres ones are live-only and filter themselves out unless their client gem is installed (the optional `examples` Bundler group) and a server is reachable (see the comment at the top of each file). -**Total:** Run `bundle exec rspec` to see the current example count. +## Test Helpers (`spec/support/`) -## Writing New Tests +`spec_helper.rb` requires every file here. -### Test Fixtures +- **`test_helpers.rb`** (included in every example group): `sample_user_message`, `sample_assistant_message`, `sample_assistant_message_with_tool_use`, `sample_result_message` and `sample_system_message` return small message Hashes with Symbol keys, the key style the transport yields (they are trimmed, not copies of real frames); `mock_transport` takes no arguments and returns a double whose `connect`, `close`, `write`, `ready?` and `end_input` are stubbed. An example that needs `read_messages` stubs it on that double itself (`query_run_end_spec.rb` feeds it from a queue). +- **`scripted_cli.rb`** — `ScriptedCLI`, the CLI's end of the control protocol in process. `ScriptedCLI.session(options) { |cli, client| ... }` opens a real `Client` on it; `cli.request(...)` sends a control request (`hook_callback`, `can_use_tool`, `mcp_message`) and returns the control response the SDK wrote; `initialize` is answered the way CLI 2.1.286 answers it. +- **`fake_claude.rb`** — `FakeClaude`, a stand-in for the `claude` executable: a plain-Ruby child process that speaks the stream-JSON protocol. `FakeClaude.install(dir)` writes the executable and returns its absolute path for `cli_path:`; `FakeClaude.env(scenario:, log:)` selects what the child does (a conversation, a slow exit after stdin EOF, a process that ignores EOF and SIGTERM, stderr lines followed by a failure, an immediate exit); `FakeClaude.events(log)` reads back what the child received and did. +- **`callback_exit_harness.rb`** — `CallbackExitHarness`, the child-process side of `callback_process_exit_spec.rb`. -Use the fixtures provided in `spec/support/test_helpers.rb`: +## Conventions -```ruby -include TestHelpers - -# Use sample messages -message = sample_user_message -result = sample_result_message -``` - -### Mock Transports - -Create mock transports for testing without CLI: - -```ruby -mock = mock_transport( - messages: [sample_user_message, sample_result_message] -) -``` - -### Testing SDK Tools - -```ruby -tool = ClaudeAgentSDK.create_tool('test_tool', 'Description', { arg: :string }) do |args| - { content: [{ type: 'text', text: "Result: #{args[:arg]}" }] } -end - -server = ClaudeAgentSDK.create_sdk_mcp_server( - name: 'test_server', - tools: [tool] -) - -result = server[:instance].call_tool('test_tool', { arg: 'value' }) -``` - -## Continuous Integration - -In CI environments, tests run with: - -- Documentation formatter for better output visibility -- Random seed for reproducibility -- Strict failure reporting - -The test suite should always pass with 0 failures before merging. +- `expect` syntax only (`disable_monkey_patching!`), random order, and status persisted to `.rspec_status` for `--only-failures` / `--next-failure`. +- An example must not leave anything in `SubprocessCLITransport`'s at-exit process registry: a check in `spec_helper.rb` fails the example that does, and empties the registry for the next one. +- Synchronize on events, never on the clock: a queue, a barrier, the child's own output. `sleep` to "let things settle" makes an example pass or fail with the machine's load. +- Bound every wait that a regression could turn into a hang (`task.with_timeout`, `Thread#join(seconds)`, `Queue#pop(timeout:)`), and stop or kill whatever is left waiting, so a hang fails the example instead of the run. CI jobs also have a time limit. +- Fixtures should look like what the real CLI sends: same keys, same nesting. Several bugs hid behind hand-simplified frames. +- Examples that pass values outside the RBS signatures on purpose carry `rbs_incompatible: '<reason>'` metadata, which `rake rbs:test` skips (see CONTRIBUTING.md). ## Troubleshooting -### Tests hanging or timing out +### A test hangs -If tests hang, it may be due to: -- Process not terminating properly in transport tests -- Async operations not completing +Run it alone with `--format documentation` to see which example it is. A hang usually means something waits without a bound: a process that does not exit, a task left parked on a condition, a queue that is never fed. ### LoadError or require failures @@ -179,27 +91,13 @@ Ensure dependencies are installed: bundle install ``` -### Integration tests failing - -Integration tests require Claude Code CLI to be installed: +If `bundle exec` cannot find gems that `bundle install` just installed, see the note on version managers in CONTRIBUTING.md. -```bash -npm install -g @anthropic-ai/claude-code -``` +### Integration tests are skipped -Check installation: +`real_cli_integration_spec.rb` needs `RUN_INTEGRATION=1`, `ANTHROPIC_API_KEY` and `claude` on `PATH`; `keyless_smoke_spec.rb` needs `RUN_KEYLESS_SMOKE=1` and a CLI (see above). Check the CLI with: ```bash which claude claude -v ``` - -## Contributing - -When adding new features: - -1. Write unit tests for new classes/methods -2. Add integration tests for feature workflows -3. Update this README if adding new test categories -4. Ensure all tests pass: `bundle exec rspec` -5. Aim for comprehensive coverage of error cases and edge conditions diff --git a/spec/examples/postgres_session_store_payload_spec.rb b/spec/examples/postgres_session_store_payload_spec.rb new file mode 100644 index 00000000..e95e5ccf --- /dev/null +++ b/spec/examples/postgres_session_store_payload_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' + +# Gated live spec, like postgres_session_store_spec.rb: runs only when the `pg` +# gem is installed (optional :examples Bundler group) AND +# SESSION_STORE_POSTGRES_URL points at a reachable server. Each example uses a +# random-suffixed table and DROPs it on teardown. +postgres_url = ENV.fetch('SESSION_STORE_POSTGRES_URL', nil) +postgres_reachable = begin + require 'pg' + require_relative '../../examples/session_stores/postgres_session_store' + if postgres_url + probe = PG.connect(postgres_url) + probe.exec('SELECT 1') + probe.close + true + else + false + end +rescue LoadError, StandardError + false +end + +# Transcripts carry NUL characters (binary tool output). JSON.generate writes +# one as the six-character escape for U+0000, which PostgreSQL's `jsonb` input +# rejects and `json` stores as given. One such entry used to fail the whole +# multi-row INSERT, so the mirror dropped every entry of that batch. +RSpec.describe 'PostgresSessionStore with a NUL character in a transcript entry', if: postgres_reachable do + let(:conn) { PG.connect(postgres_url) } + let(:table) { "cst_test_#{SecureRandom.hex(6)}" } + let(:store) { PostgresSessionStore.new(conn: conn, table: table) } + let(:key) { { 'project_key' => '-Users-dev-app', 'session_id' => SecureRandom.uuid } } + let(:binary_output) do + { 'type' => 'user', 'uuid' => 'u-nul', + 'message' => { 'role' => 'user', + 'content' => [{ 'type' => 'tool_result', 'tool_use_id' => 'toolu_01', + 'content' => "ELF#{0.chr}#{1.chr}binary" }] } } + end + + after do + conn.exec("DROP TABLE IF EXISTS #{table}") + conn.close + end + + def turn(uuid) + { 'type' => 'user', 'uuid' => uuid, 'message' => { 'role' => 'user', 'content' => "turn #{uuid}" } } + end + + it 'stores the entry, and the clean entries appended with it' do + store.create_schema + batch = [turn('u-1'), binary_output, turn('u-2')] + + store.append(key, batch) + + expect(store.load(key)).to eq(batch) + end + + # #create_schema is CREATE TABLE IF NOT EXISTS: a table created by an earlier + # copy of the adapter keeps its jsonb column until it is migrated. This is + # the migration the README gives. + it 'still rejects it on a table with the old jsonb column, until that column is migrated' do + conn.exec(<<~SQL) + CREATE TABLE #{table} ( + project_key text NOT NULL, + session_id text NOT NULL, + subpath text NOT NULL DEFAULT '', + seq bigserial, + entry jsonb NOT NULL, + mtime bigint NOT NULL, + PRIMARY KEY (project_key, session_id, subpath, seq) + ) + SQL + store.create_schema # leaves the existing table as it is + store.append(key, [turn('u-1')]) + expect { store.append(key, [binary_output]) }.to raise_error(PG::UntranslatableCharacter) + + conn.exec("ALTER TABLE #{table} ALTER COLUMN entry TYPE json USING entry::json") + + store.append(key, [binary_output]) + expect(store.load(key)).to eq([turn('u-1'), binary_output]) + end +end diff --git a/spec/fixtures/claude_config_dir.rb b/spec/fixtures/claude_config_dir.rb new file mode 100644 index 00000000..126ca8fe --- /dev/null +++ b/spec/fixtures/claude_config_dir.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' + +# A private Claude config dir and a project directory for one example, both +# real and canonical (macOS tmpdirs sit behind the /var symlink), reached +# through CLAUDE_CONFIG_DIR exactly as an application would set it. +RSpec.shared_context 'with a Claude config dir' do + attr_reader :config_dir, :cwd + + around do |example| + saved = ENV.fetch('CLAUDE_CONFIG_DIR', nil) + Dir.mktmpdir('claude-config') do |config| + Dir.mktmpdir('claude-project') do |project| + @config_dir = File.realpath(config) + @cwd = File.realpath(project) + ENV['CLAUDE_CONFIG_DIR'] = @config_dir + example.run + end + end + ensure + saved.nil? ? ENV.delete('CLAUDE_CONFIG_DIR') : ENV['CLAUDE_CONFIG_DIR'] = saved + end + + # The directory the CLI keeps a project's transcripts in. + def project_dir_for(path) + File.join(config_dir, 'projects', ClaudeAgentSDK::Sessions.sanitize_path(path)) + end + + def transcript_path(session_id, path = cwd) + File.join(project_dir_for(path), "#{session_id}.jsonl") + end + + def subagent_transcript_path(session_id, agent_id, path = cwd) + File.join(project_dir_for(path), session_id, 'subagents', "agent-#{agent_id}.jsonl") + end +end diff --git a/spec/fixtures/cli_frames/README.md b/spec/fixtures/cli_frames/README.md new file mode 100644 index 00000000..c40d369a --- /dev/null +++ b/spec/fixtures/cli_frames/README.md @@ -0,0 +1,19 @@ +# Recorded CLI frames + +Each `.jsonl` file is the stdout of one real `claude` run in stream-JSON mode: one frame per line, in the order the CLI wrote them, as `SubprocessCLITransport#read_messages` yields them. Specs replay these instead of hand-building frames because the shape is the point. The CLI sends one `assistant` frame per content block, and every frame of one API response repeats that response's `message.id` and `usage`; a hand-built message that holds a whole response in one frame hides bugs that only show with the real shape. + +| File | CLI | What it holds | +| --- | --- | --- | +| `tool_turn.jsonl` | 2.1.286 | One prompt. Response 1 is a thinking frame and a `tool_use` (Bash) frame, then the tool result; response 2 is a thinking frame and a text frame; then the `result`. | + +## What a recording keeps and what it does not + +A recording is sanitized before it is committed. + +- **Left out:** the `control_response` that answers `initialize` (the first line the CLI writes). It lists the installed commands, agents and models and names the account. `Query` consumes it, so no message consumer or observer ever sees it. +- **Replaced with placeholders:** every text, thinking signature, tool input and tool output, and the result text; the session id and the per-frame `uuid`s; the local paths in `init` (`cwd`, `memory_paths`, `scratchpad_path`, `messaging_socket_path`); the account state in `rate_limit_info` (the utilization figures, the overage status and reason, the reset times). +- **Replaced with neutral ids that keep the grouping:** `message.id`, tool-use ids and request ids are placeholders of the same form as the real ones (`msg_fixture…`, `toolu_fixture…`, `req_fixture…`). Every equality of the recording still holds: all frames of one API response share one `message.id` and one `request_id`, and a tool result's `tool_use_id` matches its `tool_use` block. +- **Trimmed:** the `tools`, `slash_commands`, `skills`, `agents` and `plugins` lists in `init`. +- **Kept as recorded:** the order, type and keys of every frame; the model; usage, cost and duration numbers; stop reasons; timestamps. + +When you add a recording, read the sanitized file line by line before committing it. diff --git a/spec/fixtures/cli_frames/tool_turn.jsonl b/spec/fixtures/cli_frames/tool_turn.jsonl new file mode 100644 index 00000000..54c9a996 --- /dev/null +++ b/spec/fixtures/cli_frames/tool_turn.jsonl @@ -0,0 +1,10 @@ +{"type":"system","subtype":"session_state_changed","state":"running","sdk_host_only":true,"uuid":"00000000-0000-4000-8000-000000000001","session_id":"00000000-0000-4000-8000-000000000000"} +{"type":"system","subtype":"init","cwd":"/workspace/project","session_id":"00000000-0000-4000-8000-000000000000","tools":["Task","Bash","Read"],"mcp_servers":[],"model":"claude-haiku-4-5-20251001","permissionMode":"bypassPermissions","slash_commands":["compact","context","clear"],"terminal_slash_commands":["doctor","color","focus","reload-plugins"],"apiKeySource":"none","claude_code_version":"2.1.286","output_style":"default","agents":["general-purpose","Explore"],"skills":["code-review","simplify"],"plugins":[{"name":"cc-plugin-agents-md","path":"builtin","source":"cc-plugin-agents-md@builtin"}],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1","sdk_mcp_tools_list_changed","sdk_mcp_manifests","mcp_read_resource_v1","mcp_tool_ui_meta_v1","ui_surface_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"00000000-0000-4000-8000-000000000002","memory_paths":{"auto":"/home/user/.claude/projects/-workspace-project/memory/"},"scratchpad_path":"/tmp/claude/-workspace-project/00000000-0000-4000-8000-000000000000/scratchpad","messaging_socket_path":"/tmp/cc-socks/1.sock","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","per_turn_effort_active":false,"view_mode":"default"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1800000000,"rateLimitType":"five_hour","overageStatus":"allowed","overageDisabledReason":null,"isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0.1,"resetsAt":1800000000},"seven_day":{"utilization":0.2,"resetsAt":1800500000}}},"uuid":"00000000-0000-4000-8000-000000000003","session_id":"00000000-0000-4000-8000-000000000000"} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_fixture00000000000000001","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"placeholder-signature"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":4714,"cache_read_input_tokens":20157,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4714},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000004","timestamp":"2026-10-01T19:00:24.619Z","request_id":"req_fixture00000000000000001"} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_fixture00000000000000001","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_fixture00000000000000001","name":"Bash","input":{"command":"echo placeholder","description":"placeholder"},"caller":{"type":"direct"}}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":4714,"cache_read_input_tokens":20157,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4714},"output_tokens":4,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000005","timestamp":"2026-10-01T19:00:25.035Z","request_id":"req_fixture00000000000000001","wire_tool_inputs":{"toolu_fixture00000000000000001":{"command":"echo placeholder","description":"placeholder"}}} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_fixture00000000000000001","type":"tool_result","content":"placeholder","is_error":false}]},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000006","timestamp":"2026-10-01T19:00:27.782Z","tool_use_result":{"stdout":"placeholder","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_fixture00000000000000002","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"placeholder-signature"}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":214,"cache_read_input_tokens":24871,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":214},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000007","timestamp":"2026-10-01T19:00:28.733Z","request_id":"req_fixture00000000000000002"} +{"type":"assistant","message":{"model":"claude-haiku-4-5-20251001","id":"msg_fixture00000000000000002","type":"message","role":"assistant","content":[{"type":"text","text":"Placeholder answer."}],"container":null,"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":8,"cache_creation_input_tokens":214,"cache_read_input_tokens":24871,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":214},"output_tokens":2,"service_tier":"standard","inference_geo":"not_available"},"input_transformations":[],"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000000","uuid":"00000000-0000-4000-8000-000000000008","timestamp":"2026-10-01T19:00:28.741Z","request_id":"req_fixture00000000000000002"} +{"duration_api_ms":5094,"stop_reason":"end_turn","session_id":"00000000-0000-4000-8000-000000000000","total_cost_usd":0.0161638,"usage":{"input_tokens":18,"cache_creation_input_tokens":4928,"cache_read_input_tokens":45028,"output_tokens":161,"output_tokens_details":{"thinking_tokens":74},"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":4928,"ephemeral_5m_input_tokens":0},"inference_geo":"not_available","iterations":[{"input_tokens":8,"output_tokens":39,"cache_read_input_tokens":24871,"cache_creation_input_tokens":214,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":214},"type":"message"}],"speed":"standard","fallback_credit":null},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":935,"outputTokens":174,"cacheReadInputTokens":45028,"cacheCreationInputTokens":4928,"webSearchRequests":0,"costUSD":0.0161638,"contextWindow":200000,"maxOutputTokens":32000,"thinkingTokens":74,"canonicalModel":"claude-haiku-4-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":false,"num_turns":2,"subtype":"success","api_error_status":null,"result":"Placeholder answer.","ttft_ms":2446,"type":"result","duration_ms":6591,"uuid":"00000000-0000-4000-8000-000000000009","ttft_stream_ms":1907,"time_to_request_ms":196,"first_content_frame_ms":1908,"queued_turn_count":0,"result_index":0} +{"type":"system","subtype":"session_state_changed","state":"idle","sdk_host_only":true,"uuid":"00000000-0000-4000-8000-000000000010","session_id":"00000000-0000-4000-8000-000000000000"} diff --git a/spec/fixtures/cli_transcript.rb b/spec/fixtures/cli_transcript.rb new file mode 100644 index 00000000..e29b210f --- /dev/null +++ b/spec/fixtures/cli_transcript.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +require 'digest' +require 'fileutils' +require 'json' + +# Hand-built Claude Code transcripts for the session specs. +# +# The shape is the one CLI 2.1.285-2.1.287 writes: key names, key order and +# nesting were read off real transcripts, every value here is invented. +# What the fixtures must not lose (each has hidden a bug behind a +# hand-simplified fixture before): +# +# * An assistant API message is written as ONE ENTRY PER CONTENT BLOCK. The +# entries are chained through parentUuid and share message.id. +# * A tool_result is a user entry whose parentUuid (and +# sourceToolAssistantUUID) names the assistant entry that holds ITS +# tool_use. With parallel tool calls the results are therefore siblings of +# the next tool_use entry, not links of one chain. +# * Hook attachments hang off the tool_use entry (PreToolUse) and off the +# tool_result entry (PostToolUse); the next API message is parented on the +# last entry written under the result it follows. +# * Metadata lines (queue-operation, permission-mode, ai-title, custom-title, +# last-prompt, tag, file-history-snapshot, ...) have no uuid. +# * Every entry of a subagent transcript carries isSidechain: true and agentId. +# +# Entries carry a subset of the real keys, never a different structure. +# +# Labels stand in for uuids: #id maps a label to a stable UUID (the CLI's +# uuid / parentUuid values are UUIDs) and #labels maps SessionMessages back, +# so an expectation reads %w[prompt use_a use_b result_a result_b]. +class CLITranscript + CLI_VERSION = '2.1.286' + MODEL = 'claude-haiku-4-5-20251001' + + attr_reader :session_id, :cwd, :entries + + # The branch stamped on the entries built from here on. + attr_writer :git_branch + + # @param agent_id [String, nil] build a SUBAGENT transcript (agent-<id>.jsonl) + def initialize(session_id:, cwd: '/work/app', agent_id: nil, git_branch: 'main', + start: Time.utc(2026, 9, 8, 5, 19, 30)) + @session_id = session_id + @cwd = cwd + @agent_id = agent_id + @git_branch = git_branch + @clock = start + @entries = [] + @labels = {} + end + + # Stable UUID for a label (nil stays nil: a root entry's parentUuid). + def id(label) + return nil if label.nil? + + hex = Digest::MD5.hexdigest("#{@session_id}/#{@agent_id}/#{label}") + uuid = "#{hex[0, 8]}-#{hex[8, 4]}-4#{hex[13, 3]}-8#{hex[17, 3]}-#{hex[20, 12]}" + @labels[uuid] = label.to_s + uuid + end + + # Labels of the given SessionMessages, in order. + def labels(messages) + messages.map { |message| @labels.fetch(message.uuid, message.uuid) } + end + + # -- conversation entries (each is appended to the file and returned) ------ + + # A prompt the user typed: message.content is a String. + def prompt(label, text, parent: nil, **extra) + add(label, parent, + { 'promptId' => id("prompt-id-#{label}"), 'type' => 'user', + 'message' => { 'role' => 'user', 'content' => text } }, + { 'permissionMode' => 'default' }.merge(extra.transform_keys(&:to_s))) + end + + # A meta injection: a user entry the CLI writes itself (the body of a slash + # command or skill, a stop-hook message, a system reminder). +content+ is a + # String or an Array of content blocks. + def meta(label, content, parent:, **extra) + add(label, parent, + { 'promptId' => id('prompt-id-turn'), 'type' => 'user', + 'message' => { 'role' => 'user', 'content' => content }, 'isMeta' => true }, + extra.transform_keys(&:to_s)) + end + + # A system entry, e.g. the stop-hook summary that closes a turn. + def system(label, parent:, subtype: 'stop_hook_summary', **extra) + add(label, parent, + { 'type' => 'system', 'subtype' => subtype, 'hookCount' => 1, + 'hookInfos' => [{ 'command' => 'true', 'durationMs' => 3 }], 'hookErrors' => [], + 'preventedContinuation' => false, 'stopReason' => '', 'hasOutput' => false, 'level' => 'suggestion' }, + extra.transform_keys(&:to_s)) + end + + # One content block of an assistant API message (see the header). + def assistant(label, block, parent:, message: 'msg_01', **extra) + add(label, parent, + { 'message' => { + 'model' => MODEL, 'id' => message, 'type' => 'message', 'role' => 'assistant', + 'content' => [block], + 'stop_reason' => block['type'] == 'tool_use' ? 'tool_use' : 'end_turn', 'stop_sequence' => nil, + 'usage' => { 'input_tokens' => 3, 'cache_creation_input_tokens' => 0, + 'cache_read_input_tokens' => 0, 'output_tokens' => 12 } + }, + 'requestId' => "req_#{message}", 'type' => 'assistant' }, + extra.transform_keys(&:to_s)) + end + + # The result of one tool call: a user entry parented on the assistant entry + # that holds the tool_use. + def tool_result(label, tool_use_id, content, parent:, **extra) + add(label, parent, + { 'promptId' => id('prompt-id-turn'), 'type' => 'user', + 'message' => { 'role' => 'user', + 'content' => [{ 'tool_use_id' => tool_use_id, 'type' => 'tool_result', + 'content' => content }] } }, + { 'toolUseResult' => { 'type' => 'text', 'file' => { 'filePath' => '/work/app/a.rb', 'numLines' => 1 } }, + 'sourceToolAssistantUUID' => id(parent) }.merge(extra.transform_keys(&:to_s))) + end + + # A hook attachment (PreToolUse under a tool_use entry, PostToolUse under a + # tool_result entry), or any other attachment type. + def attachment(label, parent:, hook: 'PostToolUse', type: 'hook_success', **extra) + body = if type == 'hook_success' + { 'type' => type, 'hookName' => "#{hook}:Read", 'toolUseID' => id("hook-#{label}"), + 'hookEvent' => hook, 'content' => '', 'stdout' => '', 'stderr' => '', 'exitCode' => 0, + 'command' => 'true', 'durationMs' => 3 } + else + { 'type' => type } + end + add(label, parent, { 'attachment' => body.merge(extra.transform_keys(&:to_s)), 'type' => 'attachment' }) + end + + # -- content blocks -------------------------------------------------------- + + def text(text) = { 'type' => 'text', 'text' => text } + + def thinking(text = 'Let me look.') = { 'type' => 'thinking', 'thinking' => text, 'signature' => 'sig' } + + def tool_use(tool_use_id, name = 'Read', input = { 'file_path' => '/work/app/a.rb' }) + { 'type' => 'tool_use', 'id' => tool_use_id, 'name' => name, 'input' => input } + end + + # -- metadata lines (no uuid) ---------------------------------------------- + + # The two lines an SDK-driven session starts with. + def queue_operations(text) + raw({ 'type' => 'queue-operation', 'operation' => 'enqueue', 'timestamp' => tick, + 'sessionId' => @session_id, 'content' => text }) + raw({ 'type' => 'queue-operation', 'operation' => 'dequeue', 'timestamp' => tick, 'sessionId' => @session_id }) + end + + # The first line of an interactive session. + def mode(mode = 'default') + raw({ 'type' => 'mode', 'mode' => mode, 'sessionId' => @session_id }) + end + + def permission_mode(mode = 'default') + raw({ 'type' => 'permission-mode', 'permissionMode' => mode, 'sessionId' => @session_id }) + end + + # Its timestamp is NESTED: the entry itself has no top-level timestamp. + def file_history_snapshot(timestamp:) + message_id = id("snapshot-#{@entries.length}") + raw({ 'type' => 'file-history-snapshot', 'messageId' => message_id, + 'snapshot' => { 'messageId' => message_id, 'trackedFileBackups' => {}, 'timestamp' => timestamp }, + 'isSnapshotUpdate' => false }) + end + + def custom_title(title) + raw({ 'type' => 'custom-title', 'customTitle' => title, 'sessionId' => @session_id }) + end + + def ai_title(title) + raw({ 'type' => 'ai-title', 'aiTitle' => title, 'sessionId' => @session_id }) + end + + def last_prompt(text, leaf: nil) + raw({ 'type' => 'last-prompt', 'lastPrompt' => text, 'leafUuid' => id(leaf), 'sessionId' => @session_id }) + end + + def tag(tag) + raw({ 'type' => 'tag', 'tag' => tag, 'sessionId' => @session_id }) + end + + # Append any other line verbatim (a Hash is one entry; a String one raw line). + def raw(entry) + @entries << entry + entry + end + + # -- output ---------------------------------------------------------------- + + # The file as the CLI leaves it: one compact JSON object per line, each + # line newline-terminated. + def to_jsonl + @entries.map { |entry| entry.is_a?(String) ? "#{entry}\n" : "#{JSON.generate(entry)}\n" }.join + end + + def write(path) + FileUtils.mkdir_p(File.dirname(path)) + File.binwrite(path, to_jsonl) + path + end + + # The parsed objects, as a SessionStore receives them from the mirror. + def store_entries + @entries.grep(Hash).map { |entry| JSON.parse(JSON.generate(entry)) } + end + + private + + def add(label, parent, head, tail = {}) + entry = { 'parentUuid' => id(parent), 'isSidechain' => !@agent_id.nil? } + if @agent_id + # Real subagent entries: promptId (user entries) comes before agentId. + prompt_id = head.key?('promptId') ? { 'promptId' => head['promptId'] } : {} + entry.merge!(prompt_id, 'agentId' => @agent_id) + end + entry.merge!(head) + entry['uuid'] = id(label) + entry['timestamp'] = tick + entry.merge!(tail) + entry.merge!('userType' => 'external', 'entrypoint' => 'cli', 'cwd' => @cwd, + 'sessionId' => @session_id, 'version' => CLI_VERSION, 'gitBranch' => @git_branch) + @entries << entry + entry + end + + # Millisecond-precision ISO timestamps, one per entry, as the CLI writes them. + def tick + @clock += 0.25 + @clock.strftime('%Y-%m-%dT%H:%M:%S.%3NZ') + end +end diff --git a/spec/integration/keyless_smoke_spec.rb b/spec/integration/keyless_smoke_spec.rb new file mode 100644 index 00000000..a7e899fe --- /dev/null +++ b/spec/integration/keyless_smoke_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +# A real-CLI smoke that needs no secret, so CI can run it on every PR that +# moves the pinned CLI (integration.yml, job keyless-smoke): the installed +# binary, the real transport, the control protocol, the message parser and +# the error path, against a CLI that has no credentials at all. It reaches +# no model: without credentials the CLI answers a prompt itself. +# +# It runs only with RUN_KEYLESS_SMOKE=1, and from then on it skips nothing: +# a missing CLI is a failure. The CLI is found the way the SDK finds it +# (CLAUDE_CLI_PATH, the vendored install, PATH): +# +# bundle exec rake claude_agent_sdk:install_cli +# RUN_KEYLESS_SMOKE=1 bundle exec rspec spec/integration/keyless_smoke_spec.rb +RSpec.describe 'The real CLI with no credentials', if: ENV['RUN_KEYLESS_SMOKE'] == '1' do + around do |example| + Dir.mktmpdir('keyless-smoke') do |home| + @home = File.realpath(home) + example.run + end + end + + # Nothing the CLI could authenticate with: a fresh HOME and config dir, no + # settings files, and none of the variables that carry a key or a token or + # pick a cloud provider (a developer machine may export any of them). + def keyless_options(**options) + env = { 'HOME' => @home, 'CLAUDE_CONFIG_DIR' => File.join(@home, '.claude'), + 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' } + %w[ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN CLAUDE_CODE_OAUTH_TOKEN + CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX CLAUDE_CODE_USE_FOUNDRY].each { |name| env[name] = nil } + ClaudeAgentSDK::ClaudeAgentOptions.new(cwd: @home, setting_sources: [], env: env, **options) + end + + # Runs the block on a thread of its own: whatever the CLI does without + # credentials, it must not leave the SDK waiting. Generous — a healthy run + # takes a few seconds. + def bounded(seconds = 120) + worker = Thread.new do + Thread.current.report_on_exception = false # re-raised below, by #value + yield + end + return worker.value if worker.join(seconds) + + ClaudeAgentSDK::SubprocessCLITransport.kill_active_processes # the CLI is what it waits on + worker.kill unless worker.join(30) + raise "still running after #{seconds}s (the CLI was terminated to end the example)" + end + + it 'completes the initialize handshake' do + info = bounded do + ClaudeAgentSDK::Client.open(options: keyless_options, &:server_info) + end + + expect(info.fetch(:commands)).to include(a_hash_including(:name, :description)) + expect(info.fetch(:models)).to include(a_hash_including(:value)) + expect(info.fetch(:agents)).to include(a_hash_including(:name)) + expect(info.dig(:account, :tokenSource)).to eq('none') + end + + it 'turns a prompt it cannot send into a typed failure' do + messages = [] + error = bounded do + ClaudeAgentSDK.query(prompt: 'Say hi', options: keyless_options(max_turns: 1)) { |message| messages << message } + nil + rescue ClaudeAgentSDK::ResultError => e + e + end + + expect(messages.map(&:class)).to eq([ClaudeAgentSDK::InitMessage, ClaudeAgentSDK::AssistantMessage, + ClaudeAgentSDK::ResultMessage]) + assistant, result = messages.last(2) + expect(assistant.error).to eq('authentication_failed') + expect(result.is_error).to be(true) + expect(result.total_cost_usd).to eq(0) # the CLI answered itself; no model was called + expect(error).to be_a(ClaudeAgentSDK::ResultError) + expect(error.exit_code).to eq(1) + end +end diff --git a/spec/integration/real_cli_integration_spec.rb b/spec/integration/real_cli_integration_spec.rb index 67262839..1d153db2 100644 --- a/spec/integration/real_cli_integration_spec.rb +++ b/spec/integration/real_cli_integration_spec.rb @@ -72,6 +72,46 @@ def run_one_shot_query(prompt:, options:) expect(hook_invocations.any? { |invocation| invocation[:tool_use_id] }).to eq(true) end + # A hook may answer with a Hash in Ruby spelling. The CLI reads only + # hookEventName / permissionDecision, so while the SDK forwarded the nested + # keys as written the CLI ignored such an answer and ran the command; the + # marker file is what a dropped deny leaves behind. The unit specs pin the + # frame the SDK writes; only the real CLI can show that it acts on it. + it 'denies a Bash call when a PreToolUse hook answers with a snake_case Hash' do + asked = [] + deny = lambda do |input, _tool_use_id, _context| + asked << input.tool_name + { + hook_specific_output: { + hook_event_name: 'PreToolUse', + permission_decision: 'deny', + permission_decision_reason: 'blocked by the snake_case hook' + } + } + end + + Dir.mktmpdir('cas-hook-deny') do |dir| + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + cwd: dir, model: 'haiku', setting_sources: [], max_turns: 4, max_budget_usd: 0.05, + allowed_tools: ['Bash'], tools: ['Bash'], + hooks: { 'PreToolUse' => [ClaudeAgentSDK::HookMatcher.new(matcher: 'Bash', hooks: [deny])] } + ) + messages = [] + ClaudeAgentSDK.query( + prompt: 'Run exactly this one bash command and then stop, without retrying if it is blocked: touch marker.txt', + options: options + ) { |message| messages << message } + + tool_results = messages.grep(ClaudeAgentSDK::UserMessage) + .flat_map { |message| Array(message.content).grep(ClaudeAgentSDK::ToolResultBlock) } + expect(asked).to include('Bash') + expect(tool_results).not_to be_empty + expect(tool_results.first.is_error).to be true + expect(File.exist?(File.join(dir, 'marker.txt'))).to be false + expect(messages.grep(ClaudeAgentSDK::ResultMessage).last.permission_denials).not_to be_empty + end + end + it 'invokes SDK MCP tools for one-shot query() calls through Claude CLI' do executions = [] diff --git a/spec/integration/store_backed_resume_integration_spec.rb b/spec/integration/store_backed_resume_integration_spec.rb new file mode 100644 index 00000000..eb013bf9 --- /dev/null +++ b/spec/integration/store_backed_resume_integration_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require 'tmpdir' +require 'json' +require 'fileutils' + +RSpec.describe 'Store-backed resume through the real Claude CLI', :integration do + # Gated by RUN_INTEGRATION and self-skipping, like real_cli_integration_spec.rb: + # this spawns the real `claude` CLI and makes two small, budget-capped API calls. + before do + skip 'Claude CLI is not available on PATH' unless system('command -v claude >/dev/null 2>&1') + skip 'ANTHROPIC_API_KEY is required for real CLI integration tests' if ENV['ANTHROPIC_API_KEY'].to_s.empty? + end + + # A disposable config dir, so the first turn's local transcript lands where + # the example can delete it before resuming. No auth is copied: the CLI + # authenticates with ANTHROPIC_API_KEY, which also keeps the SDK's macOS + # Keychain bridge out of the picture. + around do |example| + previous_config_dir = ENV.fetch('CLAUDE_CONFIG_DIR', nil) + Dir.mktmpdir('cas-store-resume') do |directory| + @config_dir = File.join(directory, 'config') + @project_dir = File.join(directory, 'project') + FileUtils.mkdir_p(@project_dir) + ENV['CLAUDE_CONFIG_DIR'] = @config_dir + example.run + end + ensure + if previous_config_dir + ENV['CLAUDE_CONFIG_DIR'] = previous_config_dir + else + ENV.delete('CLAUDE_CONFIG_DIR') + end + end + + def run_turn(prompt, options) + messages = [] + ClaudeAgentSDK.query(prompt: prompt, options: options) { |message| messages << message } + + result = messages.grep(ClaudeAgentSDK::ResultMessage).last + expect(result).to be_a(ClaudeAgentSDK::ResultMessage) + expect(result.is_error).to be(false) + expect(messages.grep(ClaudeAgentSDK::MirrorErrorMessage)).to be_empty + result + end + + it 'mirrors a turn into a SessionStore, then resumes the session from the store alone' do + store = ClaudeAgentSDK::InMemorySessionStore.new + marker = "MARK-#{SecureRandom.hex(4)}" + options = ClaudeAgentSDK::ClaudeAgentOptions.new(model: 'haiku', setting_sources: [], tools: [], + max_budget_usd: 0.05, cwd: @project_dir, session_store: store) + materialized = [] + allow(ClaudeAgentSDK::SessionResume).to receive(:materialize_resume_session).and_wrap_original do |original, opts| + original.call(opts).tap { |resume| materialized << resume if resume } + end + + first = run_turn("Remember this code for later: #{marker}. Reply with exactly: OK", options) + key = { 'project_key' => ClaudeAgentSDK.project_key_for_directory(@project_dir), 'session_id' => first.session_id } + mirrored = store.load(key) + expect(JSON.generate(mirrored)).to include(marker) # the turn reached the store + expect(materialized).to be_empty # nothing to resume yet + + # Leave the store as the only copy: what the CLI resumes below is what the + # SDK materialized from it, not the transcript the first turn left on disk. + FileUtils.rm_rf(File.join(@config_dir, 'projects')) + + second = run_turn('What code did I ask you to remember? Reply with the code only.', + options.dup_with(resume: first.session_id)) + + expect(second.session_id).to eq(first.session_id) + expect(second.result.to_s).to include(marker) # the history came back + expect(materialized.length).to eq(1) + expect(File).not_to exist(materialized.first.config_dir) # temp config dir (credential copies) removed + resumed = store.load(key) + expect(resumed.length).to be > mirrored.length # the resumed turn was mirrored on top ... + uuids = resumed.filter_map { |entry| entry['uuid'] } + expect(uuids.uniq.length).to eq(uuids.length) # ... and the materialized history was not mirrored again + end +end diff --git a/spec/rails/callback_wrapper_composition_spec.rb b/spec/rails/callback_wrapper_composition_spec.rb new file mode 100644 index 00000000..5f4e93f8 --- /dev/null +++ b/spec/rails/callback_wrapper_composition_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require_relative 'rails_helper' +require_relative 'support/booted_app' + +# Combining a wrapper of your own with Railtie.callback_wrapper: which side +# of `rails.call` your code is on decides what the callback sees. +# +# In production the Rails wrapper enters the executor, whose run hooks start +# every execution from a clean slate (Rails resets CurrentAttributes and the +# error context there). In development it stays out of the executor. So a +# wrapper that sets state around `rails.call` works in development and loses +# Current and the error context in production only — while Time.zone and log +# tags, which the executor does not reset, survive in both. docs/rails.md +# ("Writing your own wrapper") states the rule; these examples pin it against +# the hooks a booted application of each Rails version really registers. +RSpec.describe 'Composing a wrapper with ClaudeAgentSDK::Railtie.callback_wrapper' do + callbacks = %w[message_block observer hook can_use_tool tool] + everything = { 'user' => 'alice', 'time_zone' => 'Tokyo', 'log_tags' => ['req-123'], + 'error_context' => { 'request_id' => 'req-123' } } + # What the executor's run hooks reset, and what they leave alone. + after_executor_reset = everything.merge('user' => nil, 'error_context' => {}) + + def seen_with(composition, reloading:) + observed = ClaudeAgentSDKRailsSpec::BootedApp.observe(reloading: reloading, isolation: :thread) + observed.fetch("query/thread/state set #{composition} the Rails wrapper") + end + + context 'in production (the Rails wrapper enters the executor)' do + it 'keeps state set inside rails.call' do + seen = seen_with('inside', reloading: false) + + callbacks.each { |kind| expect(seen.fetch(kind)).to contain_exactly(include(everything)), "in #{kind}" } + end + + it 'resets Current and the error context set around rails.call, and nothing else' do + seen = seen_with('outside', reloading: false) + + callbacks.each do |kind| + expect(seen.fetch(kind)).to contain_exactly(include(after_executor_reset)), "in #{kind}" + end + end + end + + context 'in development (code reloading: the Rails wrapper stays out of the executor)' do + it 'keeps state set inside rails.call' do + seen = seen_with('inside', reloading: true) + + callbacks.each { |kind| expect(seen.fetch(kind)).to contain_exactly(include(everything)), "in #{kind}" } + end + + it 'keeps state set around rails.call too, which is what hides the production loss' do + seen = seen_with('outside', reloading: true) + + callbacks.each { |kind| expect(seen.fetch(kind)).to contain_exactly(include(everything)), "in #{kind}" } + end + end +end diff --git a/spec/rails/callback_wrapper_error_reporting_spec.rb b/spec/rails/callback_wrapper_error_reporting_spec.rb new file mode 100644 index 00000000..d1e82367 --- /dev/null +++ b/spec/rails/callback_wrapper_error_reporting_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require_relative 'rails_helper' +require_relative 'support/scripted_cli_transport' + +# What Rails.error — the reporter Sentry, Honeybadger, AppSignal and Solid +# Errors subscribe to — receives when SDK callbacks go through the wrapper's +# executor branch (production: no reloading, concurrency allowed). +# +# `executor.wrap` reports what passes through it as an unhandled error (a +# StandardError on Rails 7.1, any Exception on current releases). Around an +# SDK callback that is wrong twice: the SDK handles most callback failures +# itself (a hook or tool error becomes an error response, an observer error +# is swallowed, a cancellation is control flow), and the one that does escape +# was reported from a thread with none of the caller's context, after which +# Rails skips it at the request / job layer as already reported. So the +# wrapper enters the executor without reporting. +RSpec.describe 'ClaudeAgentSDK::Railtie.callback_wrapper and Rails.error' do + let(:app) { Rails.application } + let(:wrapper) { ClaudeAgentSDK::Railtie.callback_wrapper } + # A fresh executor per example, so hooks registered here never leak. + let(:executor) { Class.new(ActiveSupport::Executor) } + let(:error) { Class.new(StandardError).new('boom') } + # A Rails.error subscriber; reports arrive on callback threads. + let(:subscriber) do + Class.new do + attr_reader :reports + + def initialize + @reports = Thread::Queue.new + end + + def report(error, handled:, context:, source: nil, **) + @reports << { error: error, handled: handled, source: source, context: context } + end + end.new + end + + around do |example| + config = app.config + saved = [config.enable_reloading, config.allow_concurrency] + config.enable_reloading = false + config.allow_concurrency = nil + Rails.error.subscribe(subscriber) + example.run + ensure + Rails.error.unsubscribe(subscriber) + ActiveSupport::ExecutionContext.clear + config.enable_reloading, config.allow_concurrency = saved + end + + before { allow(app).to receive(:executor).and_return(executor) } + + def drain(queue) + Array.new(queue.size) { queue.pop } + end + + def reported + drain(subscriber.reports) + end + + # The FiberBoundary hop in :thread scheduling: the callback runs on a thread + # of its own, and what it raises is raised again on the caller. + def call_on_thread(invocation) + Thread.new do + Thread.current.report_on_exception = false + wrapper.call(invocation) + end.value + end + + # The caller: a job as a queue worker runs it. ActiveJob wraps the + # execution in the executor (app.reloader.wrap, so the default source), + # puts the job into the error context, and the wrap reports what escapes. + # (A request is the same, through ActionDispatch::Executor.) + def as_a_job + executor.wrap do + Rails.error.set_context(job: 'ChatAgentJob') + yield + end + end + + # Reported once, with the job in its context: by the job's executor, not + # from the callback thread, whose report had an empty context. + def caller_report + include(error: error, handled: false, context: include(job: 'ChatAgentJob')) + end + + describe 'an exception from a wrapped callback' do + it 'propagates unchanged and is not reported by the wrapper' do + expect { call_on_thread(-> { raise error }) }.to raise_error(equal(error)) + + expect(reported).to be_empty + end + + it "is left to the caller's reporter, which has the caller's context" do + expect { as_a_job { call_on_thread(-> { raise error }) } }.to raise_error(equal(error)) + + expect(reported).to contain_exactly(caller_report) + end + + it 'is not reported when it is a cancellation (hook timeout or CLI cancel under :inline scheduling)' do + [Async::Stop, Class.new(ClaudeAgentSDK::FiberBoundary::InlineCancellation)].each do |cancellation| + expect { call_on_thread(-> { raise cancellation }) }.to raise_error(cancellation) + end + + expect(reported).to be_empty + end + end + + describe 'the executor around a wrapped callback' do + let(:events) { Thread::Queue.new } + + before do + queue = events # the hook blocks are instance_exec'd on the executor + executor.to_run { queue << :run } + executor.to_complete { queue << :complete } + end + + it 'runs its run and complete hooks once each' do + result = call_on_thread(lambda { + events << :callback + :result + }) + + expect(result).to eq(:result) + expect(drain(events)).to eq(%i[run callback complete]) + end + + it 'runs them once each when the callback raises' do + callback = lambda { + events << :callback + raise error + } + + expect { call_on_thread(callback) }.to raise_error(equal(error)) + expect(drain(events)).to eq(%i[run callback complete]) + end + end + + describe 'over a whole query' do + let(:frames) { ClaudeAgentSDKRailsSpec::Frames } + + def run_query(turn, **options, &block) + transport = ClaudeAgentSDKRailsSpec::ScriptedCLITransport.new(turns: [turn]) + options = ClaudeAgentSDK::ClaudeAgentOptions.new(callback_wrapper: wrapper, **options) + ClaudeAgentSDK.query(prompt: 'hi', options: options, transport: transport, &block || proc {}) + transport + end + + it "leaves a message-block exception to the caller's reporter, with the caller's context" do + expect do + as_a_job do + run_query(frames.text_turn) { |message| raise error if message.is_a?(ClaudeAgentSDK::ResultMessage) } + end + end.to raise_error(equal(error)) + + expect(reported).to contain_exactly(caller_report) + end + + it 'reports nothing when an observer raises: the SDK swallows it' do + observer = Class.new do + include ClaudeAgentSDK::Observer + + def on_message(_message) + raise 'observer boom' + end + end.new + + run_query(frames.text_turn, observers: [observer]) + + expect(reported).to be_empty + end + + it 'reports nothing when a hook raises: the SDK answers the CLI with an error response' do + hook = ->(_input, _tool_use_id, _context) { raise 'hook boom' } + hooks = { 'PreToolUse' => [ClaudeAgentSDK::HookMatcher.new(hooks: [hook])] } + + transport = run_query(frames.tool_turn(ask: [:hook]), hooks: hooks) + + expect(transport.control_responses.values).to contain_exactly(include(subtype: 'error', error: 'hook boom')) + expect(reported).to be_empty + end + + it 'reports nothing when an SDK MCP tool raises: the model gets an error result' do + tool = ClaudeAgentSDK.create_tool('lookup', 'Look up a record', { id: Integer }) { |_args| raise 'tool boom' } + server = ClaudeAgentSDK.create_sdk_mcp_server(name: 'app', tools: [tool]) + + transport = run_query(frames.tool_turn(ask: [:tool]), mcp_servers: { 'app' => server }) + + expect(transport.control_responses.values).to contain_exactly( + include(subtype: 'success', response: include(mcp_response: include(result: include(isError: true)))) + ) + expect(reported).to be_empty + end + end +end diff --git a/spec/rails/request_state_spec.rb b/spec/rails/request_state_spec.rb new file mode 100644 index 00000000..64aa009d --- /dev/null +++ b/spec/rails/request_state_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require_relative 'rails_helper' +require_relative 'support/booted_app' + +# What an SDK callback sees of the request (or job) that runs the agent, and +# the recipe docs/rails.md gives for carrying that state into callbacks. +# +# Callbacks run on a thread or fiber Rails never set up, so whatever Rails +# keeps per execution — Current attributes, Time.zone, log tags, the error +# context — reads as its default there, with or without the Railtie wrapper. +# docs/rails.md ("Request state does not follow into callbacks") says so and +# prints a wrapper that captures the state on the caller and restores it in +# each callback; the child process evaluates that block verbatim. +# +# Observed in a booted application (support/booted_app.rb), one child process +# per configuration: production and development (code reloading, where the +# Railtie wrapper stays out of the executor), default :thread scheduling +# under both isolation levels, and :inline under fiber isolation, its +# documented precondition. ActiveRecord's role / shard / prevent_writes are +# not covered here: the Rails gemfiles carry no ActiveRecord. +RSpec.describe 'Request state inside SDK callbacks' do + callbacks = %w[message_block observer hook can_use_tool tool] + caller_state = { 'user' => 'alice', 'time_zone' => 'Tokyo', 'log_tags' => ['req-123'], + 'error_context' => { 'request_id' => 'req-123' }, 'locale' => 'de' } + # The locale is left out: what a callback gets by itself depends on i18n. + rails_defaults = { 'user' => nil, 'time_zone' => 'UTC', 'log_tags' => [], 'error_context' => {} } + + # What the SDK answered the CLI with: the hook's output, the permission + # result and the tool's result, in the order the CLI asked. + let(:answers) do + tool_result = include('content' => [{ 'type' => 'text', 'text' => 'found' }]) + [ + { 'subtype' => 'success', 'response' => {} }, + { 'subtype' => 'success', 'response' => { 'behavior' => 'allow', 'updatedInput' => { 'id' => 7 } } }, + { 'subtype' => 'success', 'response' => { 'mcp_response' => include('result' => tool_result) } } + ] + end + + def booted_app(reloading:, isolation:) + ClaudeAgentSDKRailsSpec::BootedApp.observe(reloading: reloading, isolation: isolation) + end + + # The I18n.locale table in docs/rails.md: what a callback off the caller's + # fiber gets by itself — the caller's :de or the default :en — depends on + # where i18n keeps its config: a fiber-local up to 1.14.7, a thread variable + # in 1.14.8 (every fiber of the thread shares it), fiber storage from 1.15 + # (new threads and fibers inherit it). + def locale_by_itself(i18n, own_thread:) + version = Gem::Version.new(i18n) + return 'de' if version >= Gem::Version.new('1.15') + return 'de' if version >= Gem::Version.new('1.14.8') && !own_thread + + 'en' + end + + # i18n 1.14.8 under :inline: the locale is shared by every fiber of the + # reactor thread, so I18n.with_locale in one job (the recipe's restore in a + # callback) leaks into another job, and the other job's I18n.locale= leaks + # back. Every other i18n keeps the two apart. + def thread_shared_locale?(i18n) + version = Gem::Version.new(i18n) + version >= Gem::Version.new('1.14.8') && version < Gem::Version.new('1.15') + end + + { 'production' => false, 'development (code reloading)' => true }.each do |environment, reloading| + [%i[thread thread], %i[fiber inline], %i[fiber thread]].each do |isolation, scheduling| + { 'ClaudeAgentSDK.query' => 'query', 'Client.open' => 'client' }.each do |entry_point, api| + context "in #{environment}, #{isolation} isolation, callback_scheduling: :#{scheduling}, #{entry_point}" do + let(:observed) { booted_app(reloading: reloading, isolation: isolation) } + let(:with_railtie_wrapper) { observed.fetch("#{api}/#{scheduling}/railtie") } + let(:with_recipe) { observed.fetch("#{api}/#{scheduling}/recipe") } + # Only a Client's message block and observers run on the fiber + # that called the SDK, and only under :inline scheduling. + let(:on_caller_fiber) { scheduling == :inline && api == 'client' ? %w[message_block observer] : [] } + + # The callback ran once, saw this state, on the caller's fiber or not. + def once_with(state, kind) + contain_exactly(include(state.merge('on_caller_fiber' => on_caller_fiber.include?(kind)))) + end + + it 'boots the application it describes' do + expect(observed).to include('reloading' => reloading, 'isolation' => isolation.to_s, + 'logger' => 'ActiveSupport::BroadcastLogger') + expect(with_railtie_wrapper.fetch('caller')).to contain_exactly( + include(caller_state.merge('on_caller_fiber' => true)) + ) + end + + it 'shows Rails defaults to every callback off the caller fiber, with Railtie.callback_wrapper alone' do + callbacks.each do |kind| + state = on_caller_fiber.include?(kind) ? caller_state : rails_defaults + expect(with_railtie_wrapper.fetch(kind)).to once_with(state, kind), "in #{kind}" + end + end + + it 'gives every callback the I18n.locale the guide states for this i18n version' do + by_itself = locale_by_itself(observed.fetch('i18n'), own_thread: scheduling == :thread) + callbacks.each do |kind| + locale = on_caller_fiber.include?(kind) ? 'de' : by_itself + expect(with_railtie_wrapper.fetch(kind)).to contain_exactly(include('locale' => locale)), "in #{kind}" + end + end + + it 'shows the caller state to every callback, once, with the documented recipe' do + expect(with_recipe).not_to include('error') + callbacks.each do |kind| + expect(with_recipe.fetch(kind)).to once_with(caller_state, kind), "in #{kind}" + end + end + + it "hands the callbacks' return values back unchanged through the documented recipe" do + expect(with_railtie_wrapper.fetch('answers')).to match(answers) + expect(with_recipe.fetch('answers')).to match(answers) + end + end + end + end + end + + # The capture must happen per call: a wrapper is fixed for the lifetime of + # the session it was passed to. + describe 'a recipe wrapper built by one request and still in use during another (a long-lived Client)' do + let(:turns) { booted_app(reloading: false, isolation: :thread).fetch('reused wrapper').fetch('turns') } + let(:later_request) { turns.last } + + it 'shows the callbacks of the later request the state of the first one' do + expect(later_request.fetch('caller')).to contain_exactly( + include('user' => 'bob', 'time_zone' => 'Berlin', 'log_tags' => ['req-456'], + 'error_context' => { 'request_id' => 'req-456' }, 'locale' => 'fr') + ) + callbacks.each do |kind| + expect(later_request.fetch(kind)).to contain_exactly(include(caller_state)), "in #{kind}" + end + end + end + + describe 'I18n.with_locale in one fiber-worker job while another runs on the same reactor thread' do + let(:observed) { booted_app(reloading: false, isolation: :fiber) } + let(:seen) { observed.fetch('two jobs switching locales') } + + it 'stays in the job that set it, unless i18n shares the locale across the thread (1.14.8)' do + if thread_shared_locale?(observed.fetch('i18n')) + expect(seen).to eq('other_job_sees' => 'de', 'own_job_sees_after' => 'fr') + else + expect(seen).to eq('other_job_sees' => 'en', 'own_job_sees_after' => 'de') + end + end + end + + # Rails.logger.tagged { } on a broadcast runs its block once per tagged + # logger; the recipe pushes and pops the tags instead. + describe 'the documented recipe with two tagged loggers in the Rails.logger broadcast' do + [%i[thread thread], %i[fiber inline]].each do |isolation, scheduling| + context "under callback_scheduling: :#{scheduling}" do + let(:observed) { booted_app(reloading: false, isolation: isolation).fetch('two tagged loggers') } + + it 'runs every callback once and hands its return value back' do + expect(observed).not_to include('error') + callbacks.each do |kind| + expect(observed.fetch(kind)).to contain_exactly(include(caller_state)), "in #{kind}" + end + expect(observed.fetch('answers')).to match(answers) + end + + it 'tags the lines callbacks log on both loggers, once' do + expect(observed.fetch('logs').size).to eq(2) + expect(observed.fetch('logs')).to all( + match_array(['caller', *callbacks].map { |kind| "[req-123] seen by #{kind}" }) + ) + end + end + end + end + + # The recipe reads the tags from the tagged logger itself, not from + # Rails.logger.formatter, which a plain logger in the broadcast can own. + describe 'the documented recipe with a plain logger before the tagged one in the Rails.logger broadcast' do + [%i[thread thread], %i[fiber inline]].each do |isolation, scheduling| + context "under callback_scheduling: :#{scheduling}" do + let(:observed) { booted_app(reloading: false, isolation: isolation).fetch('plain logger first') } + + it 'reads the tags from the tagged logger and restores them there' do + expect(observed).not_to include('error') + callbacks.each do |kind| + expect(observed.fetch(kind)).to contain_exactly(include(caller_state)), "in #{kind}" + end + lines = ['caller', *callbacks].map { |kind| "seen by #{kind}" } + expect(observed.fetch('logs')).to match( + 'plain' => match_array(lines), 'tagged' => match_array(lines.map { |line| "[req-123] #{line}" }) + ) + end + end + end + end +end diff --git a/spec/rails/session_isolation_spec.rb b/spec/rails/session_isolation_spec.rb new file mode 100644 index 00000000..0da1b29f --- /dev/null +++ b/spec/rails/session_isolation_spec.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: true + +require_relative 'rails_helper' +require_relative 'support/guide_option_sets' +require 'tmpdir' +require 'generators/claude_agent_sdk/install/install_generator' + +# The Rails guide, its two examples and the generated initializer describe +# one process that serves every user from one working directory. The CLI's +# auto-memory is kept per project directory, every session reads it, and a +# session on the claude_code preset writes it without a permission check +# (setting_sources: [] does not turn it off) — so one user's "remember ..." +# would reach every other user's session. Those option sets therefore carry +# the switch that turns auto-memory off, and its value matters: the CLI reads +# '0' / 'false' as "force on", over autoMemoryEnabled: false. +RSpec.describe 'Auto-memory isolation in the Rails guide, examples and initializer' do + root = File.expand_path('../..', __dir__) + variable = 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' + switch = "env: { '#{variable}' => '1' }" + guide = 'docs/rails.md' + actioncable_example = 'examples/rails_actioncable_example.rb' + background_job_example = 'examples/rails_background_job_example.rb' + template = 'lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt' + + # An example script's classes, loaded under a module of their own so they + # stay out of this process's top level. The demo at the bottom of each + # script only runs when the script is the program. + define_method(:load_example) do |path| + Module.new.tap { |namespace| load(File.join(root, path), namespace) } + end + + [guide, actioncable_example, background_job_example, template].each do |path| + it "#{path} never gives the variable a value other than '1'" do + values = File.read(File.join(root, path)).scan(/#{variable}['"]?\s*(?:=>|=|:)\s*(['"]?\w+['"]?)/).flatten + + expect(values).not_to be_empty + expect(values).to all(eq("'1'")) + end + end + + # Every ClaudeAgentOptions the guide builds, read from the parsed code of + # its Ruby blocks (support/guide_option_sets.rb) rather than from their + # text: a switch that is commented out, or that only appears in a string, + # is not there. A `**opts` argument stands for the `opts = { ... }` literal + # of the same block, which is how the session-resumption model builds its + # options — and only while the block does nothing else with `opts`: one + # `opts[:env] = {}` before the call takes the switch out again, whatever + # the literal says. The reader reports such an option set as not + # established, and here that is a failure. + reader = ClaudeAgentSDKRailsSpec::GuideOptionSets + guide_text = File.read(File.join(root, guide)) + option_sets = reader.in_markdown(guide_text) + carries_switch = ->(set) { set.env.to_h[variable] == '1' } + + # An option set in the guide that must not carry the switch goes here, + # keyed by the heading it sits under, with the reason. None does. + allowed_without_switch = {}.freeze + + describe "#{guide}, every ClaudeAgentOptions it builds" do + it 'finds an option set in every section that builds one' do + expect(option_sets.map(&:heading)).to include( + 'Getting started', "Carrying the caller's state into callbacks", 'ActionCable Streaming', + 'Session Resumption', 'Background Jobs with Error Handling', 'HTTP MCP Servers' + ) + end + + option_sets.each do |set| + it "turns auto-memory off in the options under \"#{set.heading}\"" do + skip allowed_without_switch.fetch(set.heading) if allowed_without_switch.key?(set.heading) + + built = if set.problem + "what these options are built from is not established: #{set.problem}" + else + "these options are built with env: #{set.env.inspect}" + end + expect(set.env.to_h).to include(variable => '1'), "#{guide}:#{set.line}: #{built}" + end + end + + # The safeguard itself, in memory: take the switch away from one option set + # at a time, and that option set — and no other — must stop counting. + option_sets.each_with_index do |set, index| + next unless carries_switch.call(set) + + without_this_one = option_sets.map(&carries_switch) + without_this_one[index] = false + + # By commenting it out. + it "stops counting the switch under \"#{set.heading}\" once it is commented out" do + lines = guide_text.lines + lines[set.env_line - 1] = lines[set.env_line - 1].sub('env:', '# env:') + + expect(reader.in_markdown(lines.join).map(&carries_switch)).to eq(without_this_one) + end + + # And, where the options are a `**splat`, by a write to the splatted + # variable right before the call — the literal and its switch stay as + # they are, which is why the literal alone settles nothing. + set.splats.each do |name| + ["#{name}[:env] = {}", "#{name}.delete(:env)", "#{name}.merge!(env: {})", "#{name} = #{name}.merge(env: {})"].each do |write| + it "stops counting the switch under \"#{set.heading}\" once `#{write}` comes before the call" do + lines = guide_text.lines + lines.insert(set.line - 1, "#{lines[set.line - 1][/\A\s*/]}#{write}\n") + + mutated = reader.in_markdown(lines.join) + + expect(mutated.map(&carries_switch)).to eq(without_this_one) + expect(mutated[index].problem).to eq("**#{name} cannot be followed: `#{name}` appears again in the block (line #{set.line})") + end + end + end + end + end + + describe 'reading the option sets of a guide' do + fence = '```' + in_a_guide = ->(code) { reader.in_markdown("## Section\n\n#{fence}ruby\n#{code}\n#{fence}\n") } + + { + 'commented out' => "ClaudeAgentOptions.new(\n max_turns: 1,\n # #{switch}\n)", + 'in a trailing comment' => "ClaudeAgentOptions.new(max_turns: 1) # #{switch}", + 'in a =begin / =end comment' => "ClaudeAgentOptions.new(max_turns: 1)\n=begin\nClaudeAgentOptions.new(#{switch})\n=end", + 'inside a string' => "ClaudeAgentOptions.new(append_system_prompt: \"#{switch}\")", + 'under another key' => "ClaudeAgentOptions.new(settings: { #{switch} })", + "given the value '0'" => "ClaudeAgentOptions.new(env: { '#{variable}' => '0' })", + 'overridden by a later env:' => "ClaudeAgentOptions.new(#{switch}, env: {})", + 'missing because there are no arguments' => 'ClaudeAgentOptions.new', + 'behind a splat that is not a hash literal of the block' => 'ClaudeAgentOptions.new(**defaults)' + }.each do |shape, code| + it "does not count a switch that is #{shape}" do + expect(in_a_guide.call(code).map(&carries_switch)).to eq([false]) + end + end + + { + 'among the keywords' => "ClaudeAgentSDK::ClaudeAgentOptions.new(max_turns: 1, #{switch})", + 'next to other variables' => "ClaudeAgentOptions.new(env: { 'ANTHROPIC_API_KEY' => ENV.fetch('KEY'), '#{variable}' => '1' })", + 'in a call without parentheses' => "ClaudeAgentSDK::ClaudeAgentOptions.new #{switch}", + 'in the hash literal a **splat stands for' => "opts = {\n #{switch} # here\n}\nClaudeAgentOptions.new(**opts)", + 'in the **splat literal of a method' => + "def build_options\n opts = {\n #{switch},\n resume: session_id.presence\n }\n ClaudeAgentOptions.new(**opts)\nend", + 'in the **splat literal of a call nested in a later statement' => + "opts = { #{switch} }\nClient.open(options: ClaudeAgentOptions.new(**opts)) { |client| client.query('hello') }" + }.each do |shape, code| + it "counts a switch that is #{shape}" do + sets = in_a_guide.call(code) + + expect(sets.map(&carries_switch)).to eq([true]) + expect(sets.map(&:problem)).to eq([nil]) + end + end + + # Fail closed for `**opts`. Its literal is what the call receives only + # while the block does nothing else with `opts`: each of these may change + # it, and the reader does not try to work out which of them do. + [ + 'opts[:env] = {}', 'opts.delete(:env)', 'opts.merge!(env: {})', 'opts = opts.merge(env: {})', + 'opts[:resume] = session_id', 'opts.store(:env, {})', 'opts.update(env: {})', 'opts.replace({})', 'opts.clear', + 'opts ||= {}', 'opts = {}', 'opts[:env] ||= {}', 'opts.compact!', 'strip_env(opts)', 'strip_env(opts:)', 'other = opts' + ].each do |write| + it "does not follow **opts once `#{write}` comes before the call" do + sets = in_a_guide.call("opts = { #{switch} }\n#{write}\nClaudeAgentOptions.new(**opts)") + + expect(sets.map(&carries_switch)).to eq([false]) + expect(sets.map(&:problem)).to eq(['**opts cannot be followed: `opts` appears again in the block (line 5)']) + end + end + + # Nor in any other shape that leaves room for doubt. The first two: what + # stands after the call in the text may still run before it. + not_before_the_call = '**opts cannot be followed: `opts = { ... }` is not a statement that always runs before the call' + not_a_local = 'a ** splat that is not a local variable cannot be followed' + { + '**opts when `opts` is used again after the call' => + ["opts = { #{switch} }\nClaudeAgentOptions.new(**opts)\nopts.clear", + '**opts cannot be followed: `opts` appears again in the block (line 6)'], + '**opts when the call sits in a lambda and `opts` is written before it runs' => + ["opts = { #{switch} }\nbuild = -> { ClaudeAgentOptions.new(**opts) }\nopts[:env] = {}\nbuild.call", + '**opts cannot be followed: `opts` appears again in the block (line 6)'], + '**opts when its literal is assigned under a modifier' => ["opts = { #{switch} } if ready\nClaudeAgentOptions.new(**opts)", not_before_the_call], + '**opts when its literal is assigned inside a condition' => + ["if ready\n opts = { #{switch} }\nend\nClaudeAgentOptions.new(**opts)", not_before_the_call], + '**opts when its literal splats another hash' => + ["opts = { #{switch}, **overrides }\nClaudeAgentOptions.new(**opts)", '**opts cannot be followed: its literal splats another hash'], + '**opts when `opts` is not assigned a hash literal' => + ["opts = defaults.merge(#{switch})\nClaudeAgentOptions.new(**opts)", '**opts cannot be followed: the block has no `opts = { ... }` literal'], + 'a splat, after the switch, of something that is not a local variable' => ["ClaudeAgentOptions.new(#{switch}, **overrides)", not_a_local], + 'a splat, before the switch, of a constant' => ["ClaudeAgentOptions.new(**DEFAULTS, #{switch})", not_a_local], + 'a splat of an expression' => ["opts = { #{switch} }\nClaudeAgentOptions.new(**opts.merge(extra))", not_a_local] + }.each do |shape, (code, problem)| + it "does not follow #{shape}" do + sets = in_a_guide.call(code) + + expect(sets.map(&carries_switch)).to eq([false]) + expect(sets.map(&:problem)).to eq([problem]) + end + end + + it 'reports the heading, the lines of the call and of its env:, and the variables it splats' do + markdown = "# Guide\n\n## Section\n\n#{fence}ruby\nopts = {\n #{switch}\n}\nClaudeAgentOptions.new(**opts)\n#{fence}\n" + + expect(reader.in_markdown(markdown).map(&:to_h)).to eq( + [{ heading: 'Section', line: 9, env: { variable => '1' }, env_line: 7, splats: ['opts'], problem: nil }] + ) + end + + it 'refuses a Ruby block that does not parse, naming its section' do + expect { in_a_guide.call('def (') }.to raise_error(ArgumentError, /"Section" does not parse/) + end + end + + describe actioncable_example do + it 'opens every chat session with auto-memory off' do + options = nil + allow(ClaudeAgentSDK::Client).to receive(:open) { |**kwargs| options = kwargs.fetch(:options) } + + load_example(actioncable_example)::ChatExecutor.new(chat_id: 'chat_1', message_id: 'msg_1').execute('hi') + + expect(options.system_prompt).to include(preset: 'claude_code') + expect(options.env).to include(variable => '1') + end + end + + describe background_job_example do + it 'builds every job session with auto-memory off' do + example = load_example(background_job_example) + + options = example::ChatAgentJob.new.send(:build_options, example::ChatSession.new(id: 'session_1')) + + expect(options.system_prompt).to include(preset: 'claude_code') + expect(options.env).to include(variable => '1') + end + end + + describe 'the generated initializer' do + let(:destination) { Dir.mktmpdir('claude_agent_sdk_generator') } + let(:initializer) { File.join(destination, 'config/initializers/claude_agent_sdk.rb') } + + before do + generate = -> { ClaudeAgentSDK::Generators::InstallGenerator.start([], destination_root: destination) } + expect(&generate).to output.to_stdout + end + + after do + FileUtils.rm_rf(destination) + ClaudeAgentSDK.reset_configuration + end + + it 'offers the switch as a commented default' do + expect(File.read(initializer)).to include(" # #{switch},\n") + + load initializer + + expect(ClaudeAgentSDK::ClaudeAgentOptions.new.env).not_to include(variable) + end + + it 'turns auto-memory off for every session once the line is uncommented, next to a per-call env' do + File.write(initializer, File.read(initializer).sub("# #{switch}", switch)) + load initializer + + expect(ClaudeAgentSDK::ClaudeAgentOptions.new.env).to eq(variable => '1') + expect(ClaudeAgentSDK::ClaudeAgentOptions.new(env: { 'ANTHROPIC_API_KEY' => 'key' }).env).to eq( + variable => '1', 'ANTHROPIC_API_KEY' => 'key' + ) + end + end +end diff --git a/spec/rails/support/booted_app.rb b/spec/rails/support/booted_app.rb new file mode 100644 index 00000000..23a61689 --- /dev/null +++ b/spec/rails/support/booted_app.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'json' +require 'open3' +require 'rbconfig' + +module ClaudeAgentSDKRailsSpec + # Observations made inside a real, booted Rails application. + # + # The spec process's own application (rails_helper.rb) is never + # initialized: its executor has none of the hooks Rails registers at boot, + # Rails.logger is nil, and the isolation level is whatever the process + # started with. What an SDK callback sees of its caller's state depends on + # all three, so specs about that state read it from a child process that + # boots an application first (booted_app_child.rb), once per configuration. + module BootedApp + CHILD = File.expand_path('booted_app_child.rb', __dir__) + LIB = File.expand_path('../../../lib', __dir__) + RESULT = 'BOOTED_APP_RESULT ' + # A hang guard, not a synchronization point: nothing in the child waits + # on a clock, so a healthy run ends in a second or two on an idle machine. + LIMIT_SECONDS = 600 + + @results = {} + @lock = Mutex.new + + # What the child observed, as parsed JSON (String keys). + # + # @param reloading [Boolean] config.enable_reloading — development (true) + # or production (false) + # @param isolation [Symbol] ActiveSupport::IsolatedExecutionState.isolation_level + def self.observe(reloading:, isolation:) + @lock.synchronize { @results[[reloading, isolation]] ||= run(reloading: reloading, isolation: isolation) } + end + + def self.run(reloading:, isolation:) + env = { 'RAILS_ENV' => reloading ? 'development' : 'production', 'BOOTED_APP_ISOLATION' => isolation.to_s } + out, err, status = capture(env, RbConfig.ruby, '-I', LIB, CHILD) + line = out.lines.reverse.find { |candidate| candidate.start_with?(RESULT) } + return JSON.parse(line.delete_prefix(RESULT)) if status.success? && line + + raise "booted app (#{env}) failed with #{status.inspect}:\n#{err}\n#{out}" + end + private_class_method :run + + def self.capture(env, *command) + Open3.popen3(env, *command) do |stdin, stdout, stderr, wait_thread| + stdin.close + readers = [stdout, stderr].map { |io| Thread.new { io.read } } + Process.kill('KILL', wait_thread.pid) unless wait_thread.join(LIMIT_SECONDS) + [*readers.map(&:value), wait_thread.value] + end + end + private_class_method :capture + end +end diff --git a/spec/rails/support/booted_app_child.rb b/spec/rails/support/booted_app_child.rb new file mode 100644 index 00000000..575642ba --- /dev/null +++ b/spec/rails/support/booted_app_child.rb @@ -0,0 +1,349 @@ +# frozen_string_literal: true + +# Runs in a child process started by booted_app.rb: boots a real Rails +# application, plays requests that run an agent against a scripted CLI, and +# prints what every kind of SDK callback saw of the request's state. +# +# RAILS_ENV production, or development (code reloading enabled) +# BOOTED_APP_ISOLATION thread | fiber +# +# Plain Ruby, no RSpec: the parent asserts on the JSON printed at the end. +require 'rails' +require 'claude_agent_sdk' # after Rails, as Bundler.require does: this loads the Railtie +require 'json' +require 'stringio' +require 'tmpdir' +require_relative 'scripted_cli_transport' + +# What an application has in app/models/current.rb. +class Current < ActiveSupport::CurrentAttributes + attribute :user +end + +module ClaudeAgentSDKRailsSpec + LOG = StringIO.new + + class BootedApplication < Rails::Application + config.root = Dir.mktmpdir('claude_agent_sdk_booted_app') + config.load_defaults "#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}" + config.eager_load = false + config.enable_reloading = Rails.env.development? + config.secret_key_base = 'booted-app-child' + config.i18n.available_locales = %i[en de fr] + # A tagged logger, as generated production.rb files set up. Rails wraps + # it in an ActiveSupport::BroadcastLogger at boot. + config.logger = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(LOG)) + # docs/rails.md, "Fiber workers": what a fiber-isolated host sets. + ActiveSupport::IsolatedExecutionState.isolation_level = :fiber if ENV['BOOTED_APP_ISOLATION'] == 'fiber' + end + + # The recipe as printed in docs/rails.md: the first Ruby block after MARKER + # is evaluated verbatim, so the guide cannot drift from what runs here. + module DocumentedRecipe + GUIDE = File.expand_path('../../../docs/rails.md', __dir__) + MARKER = '<!-- spec/rails/request_state_spec.rb runs the next code block verbatim -->' + + # @return [String, nil] why the recipe could not be loaded + def self.load + before, marker, after = File.read(GUIDE).partition(MARKER) + code = after[/^```ruby\n(.*?)^```$/m, 1] + return "docs/rails.md has no Ruby block after #{MARKER}" if marker.empty? || code.nil? + + prelude = before + marker + after[/\A.*?^```ruby\n/m] + TOPLEVEL_BINDING.eval(code, GUIDE, prelude.count("\n") + 1) + nil + end + end + + # What callbacks saw, by kind of callback, in the order they ran. + class Recorder + include ClaudeAgentSDK::Observer + + def initialize + @lock = Mutex.new + @turns = [Hash.new { |seen, kind| seen[kind] = [] }] + end + + # The request that is calling the SDK: observations are relative to it. + def caller! + @caller_fiber = Fiber.current + record('caller') + end + + def next_turn! + @lock.synchronize { @turns << Hash.new { |seen, kind| seen[kind] = [] } } + end + + def record(kind) + Rails.logger.info("seen by #{kind}") + snapshot = { + 'user' => Current.user, + 'time_zone' => Time.zone.name, + 'log_tags' => log_tags, + 'error_context' => ActiveSupport::ExecutionContext.to_h, + 'locale' => I18n.locale.to_s, + 'on_caller_fiber' => Fiber.current.equal?(@caller_fiber) + } + @lock.synchronize { @turns.last[kind] << snapshot } + end + + # The tags in effect, as the tagged logger Rails.logger writes to holds + # them — read from that logger, so a plain logger in the broadcast does not + # hide them. + def log_tags + loggers = Rails.logger.respond_to?(:broadcasts) ? Rails.logger.broadcasts : [Rails.logger] + tagged = loggers.find { |logger| logger.respond_to?(:push_tags) } + tagged ? tagged.formatter.current_tags.dup : [] + end + + # Observer and message block see every message; one observation each is + # enough, and the result is the one every turn has. + def on_message(message) + record('observer') if message.is_a?(ClaudeAgentSDK::ResultMessage) + end + + def message_block + ->(message) { record('message_block') if message.is_a?(ClaudeAgentSDK::ResultMessage) } + end + + def turns + @lock.synchronize { @turns.map(&:dup) } + end + end + + # Requests (or jobs) that run an agent. Each one runs inside the executor + # with the state a controller or job would have set, and its agent turn + # fires every kind of SDK callback: PreToolUse hook, can_use_tool, an SDK + # MCP tool, an observer and the message block. + class Requests + ALICE = { user: 'alice', zone: 'Tokyo', request_id: 'req-123', locale: :de }.freeze + BOB = { user: 'bob', zone: 'Berlin', request_id: 'req-456', locale: :fr }.freeze + + def initialize(recipe_error) + @recipe_error = recipe_error + # Fiber-isolated hosts (solid_queue fiber workers, Falcon) run a request + # or job as a task on a reactor; thread-isolated ones on a plain thread. + @reactor = ActiveSupport::IsolatedExecutionState.isolation_level == :fiber + end + + # One request, one agent run. + def run(api:, scheduling:, wrapper:) + return { 'error' => @recipe_error } if wrapper == 'recipe' && @recipe_error + + recorder = Recorder.new + responses = {} + in_executor do + with_request_state(**ALICE) do + recorder.caller! + options = options_for(recorder, scheduling: scheduling, wrapper: wrapper_named(wrapper)) + run_agent(api, options, recorder, responses) + end + end + recorder.turns.first.merge('answers' => answers(responses)) + end + + # A wrapper of the application's own that sets request state itself, + # composed with the Rails wrapper from the outside or from the inside + # (docs/rails.md, "Writing your own wrapper"). The caller sets nothing. + def run_composing(side) + rails = ClaudeAgentSDK::Railtie.callback_wrapper + state = ->(invocation) { with_request_state(**ALICE) { invocation.call } } + wrapper = if side == 'outside' + ->(invocation) { state.call(-> { rails.call(invocation) }) } + else + ->(invocation) { rails.call(-> { state.call(invocation) }) } + end + recorder = Recorder.new + in_executor do + run_agent('query', options_for(recorder, scheduling: :thread, wrapper: wrapper), recorder, {}) + end + recorder.turns.first + end + + # One Client connected by a first request, with a wrapper built there, + # and used again by a second request with different state. + def run_reusing_a_wrapper + return { 'error' => @recipe_error } if @recipe_error + + recorder = Recorder.new + Sync do + client = nil + in_this_executor(**ALICE) do + recorder.caller! + options = options_for(recorder, scheduling: :thread, wrapper: wrapper_named('recipe')) + client = ClaudeAgentSDK::Client.new(options: options, transport_class: ScriptedCLITransport, + transport_args: { turns: [Frames.tool_turn, Frames.tool_turn] }) + client.connect + take_turn(client, recorder) + end + recorder.next_turn! + in_this_executor(**BOB) do + recorder.caller! + take_turn(client, recorder) + end + ensure + client&.disconnect + end + { 'turns' => recorder.turns } + end + + # The same request with a second tagged logger in Rails.logger's broadcast. + def run_with_two_tagged_loggers(scheduling:) + second_log = StringIO.new + second = ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(second_log)) + Rails.logger.broadcast_to(second) + LOG.rewind + LOG.truncate(0) + seen = run(api: 'query', scheduling: scheduling, wrapper: 'recipe') + seen.merge('logs' => [LOG.string, second_log.string].map { |log| log.lines(chomp: true) }) + ensure + Rails.logger.stop_broadcasting_to(second) + end + + # The same request with Rails.logger broadcasting to a plain logger before + # the tagged one, as `ActiveSupport::BroadcastLogger.new(plain, tagged)` + # builds it. Rails.logger.formatter is then the plain logger's (Rails 8.1) + # or nil (7.1, where only #formatter= sets it). + def run_with_a_plain_logger_first(scheduling:) + plain_log = StringIO.new + tagged_log = StringIO.new + booted = Rails.logger + Rails.logger = ActiveSupport::BroadcastLogger.new( + ActiveSupport::Logger.new(plain_log), + ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(tagged_log)) + ) + seen = run(api: 'query', scheduling: scheduling, wrapper: 'recipe') + seen.merge('logs' => { 'plain' => plain_log.string.lines(chomp: true), + 'tagged' => tagged_log.string.lines(chomp: true) }) + ensure + Rails.logger = booted + end + + # Two fiber-worker jobs on one reactor thread: job A sets its locale with + # I18n.with_locale — as the recipe does in an :inline callback — and + # suspends; job B runs meanwhile and sets its own. Buffered queues order + # the steps, so nothing waits on a clock. No SDK involved: this is what + # i18n does with the restore. + def run_two_jobs_switching_locales + a_inside = Thread::Queue.new + b_done = Thread::Queue.new + seen = {} + Sync do |task| + job_a = task.async do + I18n.with_locale(:de) do + a_inside << true + b_done.pop + seen['own_job_sees_after'] = I18n.locale.to_s + end + end + job_b = task.async do + a_inside.pop + seen['other_job_sees'] = I18n.locale.to_s + I18n.locale = :fr + b_done << true + end + [job_a, job_b].each(&:wait) + end + seen + end + + private + + def in_executor(&block) + return Rails.application.executor.wrap(&block) unless @reactor + + Async { Rails.application.executor.wrap(&block) }.wait + end + + def in_this_executor(**state, &block) + Rails.application.executor.wrap { with_request_state(**state, &block) } + end + + # What a controller's callbacks (or a job) set before calling the SDK. + # The executor resets Current and the error context when the request ends. + def with_request_state(user:, zone:, request_id:, locale:, &block) + Current.user = user + Rails.error.set_context(request_id: request_id) + Rails.logger.push_tags(request_id) # as Rails::Rack::Logger tags a request + I18n.with_locale(locale) { Time.use_zone(zone, &block) } + ensure + Rails.logger.pop_tags + end + + def wrapper_named(name) + case name + when 'railtie' then ClaudeAgentSDK::Railtie.callback_wrapper # what the generated initializer configures + when 'recipe' then AgentContext.callback_wrapper # docs/rails.md: built per call, on the caller + else raise ArgumentError, "unknown wrapper #{name}" + end + end + + def options_for(recorder, scheduling:, wrapper:) + hook = lambda do |_input, _tool_use_id, _context| + recorder.record('hook') + {} + end + can_use_tool = lambda do |_tool_name, _input, _context| + recorder.record('can_use_tool') + ClaudeAgentSDK::PermissionResultAllow.new + end + tool = ClaudeAgentSDK.create_tool('lookup', 'Look up a record', { id: Integer }) do |_args| + recorder.record('tool') + { content: [{ type: 'text', text: 'found' }] } + end + ClaudeAgentSDK::ClaudeAgentOptions.new( + hooks: { 'PreToolUse' => [ClaudeAgentSDK::HookMatcher.new(hooks: [hook])] }, + can_use_tool: can_use_tool, + mcp_servers: { 'app' => ClaudeAgentSDK.create_sdk_mcp_server(name: 'app', tools: [tool]) }, + observers: [recorder], callback_scheduling: scheduling.to_sym, callback_wrapper: wrapper + ) + end + + def run_agent(api, options, recorder, responses) + transport_args = { turns: [Frames.tool_turn], control_responses: responses } + if api == 'client' + ClaudeAgentSDK::Client.open(options: options, transport_class: ScriptedCLITransport, + transport_args: transport_args) { |client| take_turn(client, recorder) } + else + ClaudeAgentSDK.query(prompt: 'Look up record 7', options: options, + transport: ScriptedCLITransport.new(**transport_args), &recorder.message_block) + end + end + + def take_turn(client, recorder) + client.query('Look up record 7') + client.receive_response(&recorder.message_block) + end + + # What the SDK answered the CLI's hook, permission and tool requests with + # — the callbacks' return values, as they came back through the wrapper. + def answers(responses) + responses.values.map { |response| response.slice(:subtype, :response, :error) } + end + end +end + +Rails.application.initialize! + +requests = ClaudeAgentSDKRailsSpec::Requests.new(ClaudeAgentSDKRailsSpec::DocumentedRecipe.load) +isolation = ActiveSupport::IsolatedExecutionState.isolation_level +# :inline needs fiber isolation (the SDK warns otherwise); :thread works under both. +schedulings = isolation == :fiber ? %w[inline thread] : %w[thread] + +observed = { + 'rails' => Rails.version, 'reloading' => Rails.application.config.reloading_enabled?, + 'isolation' => isolation.to_s, 'logger' => Rails.logger.class.name, 'i18n' => I18n::VERSION +} +%w[query client].product(schedulings, %w[railtie recipe]).each do |api, scheduling, wrapper| + observed["#{api}/#{scheduling}/#{wrapper}"] = requests.run(api: api, scheduling: scheduling, wrapper: wrapper) +end +%w[outside inside].each do |side| + observed["query/thread/state set #{side} the Rails wrapper"] = requests.run_composing(side) +end +observed['reused wrapper'] = requests.run_reusing_a_wrapper +observed['two tagged loggers'] = requests.run_with_two_tagged_loggers(scheduling: schedulings.first) +observed['plain logger first'] = requests.run_with_a_plain_logger_first(scheduling: schedulings.first) +observed['two jobs switching locales'] = requests.run_two_jobs_switching_locales + +puts "BOOTED_APP_RESULT #{JSON.generate(observed)}" +FileUtils.rm_rf(Rails.root.to_s) diff --git a/spec/rails/support/guide_option_sets.rb b/spec/rails/support/guide_option_sets.rb new file mode 100644 index 00000000..84203bf0 --- /dev/null +++ b/spec/rails/support/guide_option_sets.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +require 'ripper' + +module ClaudeAgentSDKRailsSpec + # The ClaudeAgentOptions a Markdown guide builds in its fenced Ruby blocks. + # + # Read from the parsed code (Ripper), not from the text, so only what Ruby + # would run counts: an argument that is commented out is not an argument, + # and neither is one that only appears inside a string. Where the tree does + # not settle what a call receives — a `**splat` of a hash the block goes on + # to use — the option set is reported as not established, never guessed. + module GuideOptionSets + # heading: the Markdown heading the block sits under. + # line: where `ClaudeAgentOptions.new` is called, in the Markdown file. + # env: the String => String pairs of the `env:` hash the call is built + # with, or nil when it has none — or when `problem` says why not. + # env_line: where that `env:` key is written, in the Markdown file. + # splats: the local variables the call splats (`**name`), in order. + # problem: why what the call is built from is not established, or nil. + OptionSet = Struct.new(:heading, :line, :env, :env_line, :splats, :problem, keyword_init: true) + + FENCED_RUBY = /^(\#{2,3} [^\n]+)$|^ *```ruby\n(.*?)^ *```$/m + + module_function + + # @param markdown [String] + # @return [Array<OptionSet>] in document order + def in_markdown(markdown) + heading = nil + sets = [] + markdown.scan(FENCED_RUBY) do |title, code| + next heading = title.sub(/\A#+ /, '') if title + + first_line = Regexp.last_match.pre_match.count("\n") + 2 # the line after the opening fence + program = Ripper.sexp(code) || raise(ArgumentError, "the Ruby block under \"#{heading}\" does not parse") + sets.concat(option_sets(program, heading, first_line)) + end + sets + end + + def option_sets(program, heading, first_line) + in_markdown = ->(line) { first_line + line - 1 } + constructions(program).map do |call, arguments| + keywords = keyword_pairs(arguments) + pairs, problem, problem_line = Splat.resolve(keywords, program, call) + key, value = env_pair(pairs) + problem += " (line #{in_markdown.call(problem_line)})" if problem_line + OptionSet.new(heading: heading, line: in_markdown.call(call[3][2][0]), + splats: Splat.names(keywords), problem: problem, + env: value && string_pairs(value), env_line: key && in_markdown.call(key[2][0])) + end + end + + # Every node of the tree — and every list of nodes in it — depth first, + # parents before children. + def each_node(node, &block) + return unless node.is_a?(Array) + + yield node + node.each { |child| each_node(child, &block) } + end + + # Every `ClaudeAgentOptions.new`, as [the call, its arguments or nil]: + # new(...) [:method_add_arg, [:call, receiver, _, [:@ident, "new", _]], arg_paren] + # new ... [:command_call, receiver, _, [:@ident, "new", _], arguments] + # new [:call, receiver, _, [:@ident, "new", _]] + def constructions(program) + found = [] + with_parentheses = [] + each_node(program) do |node| + case node[0] + when :method_add_arg + next unless construction?(node[1]) + + with_parentheses << node[1] + found << [node[1], node[2]] + when :command_call then found << [node, node[4]] if construction?(node) + when :call then found << [node, nil] if construction?(node) && with_parentheses.none? { |call| call.equal?(node) } + end + end + found + end + + def construction?(call) + call.is_a?(Array) && %i[call command_call].include?(call[0]) && call[3].is_a?(Array) && + call[3][1] == 'new' && constant_name(call[1]) == 'ClaudeAgentOptions' + end + + # The last constant of `A::B` or of a bare `B`. + def constant_name(node) + return unless node.is_a?(Array) + + case node[0] + when :const_path_ref then node[2][1] + when :var_ref then node[1][1] if node[1][0] == :@const + end + end + + # The call's own keyword arguments — [:assoc_new, key, value] and + # [:assoc_splat, expression] nodes — without descending into the values. + def keyword_pairs(arguments) + queue = [arguments] + until queue.empty? + node = queue.shift + next unless node.is_a?(Array) + return node[1] if node[0] == :bare_assoc_hash + + queue.concat(node) if node[0].is_a?(Array) || %i[arg_paren args_add_block].include?(node[0]) + end + [] + end + + # [:hash, [:assoclist_from_args, pairs]], or [:hash, nil] for {}. + def hash_pairs(hash) + return [] unless hash.is_a?(Array) && hash[0] == :hash && hash[1] + + hash[1][1] + end + + # The key and the hash literal of the `env:` keyword, if there is one. Of + # two `env:` the last counts, as in Ruby; one that is not a hash literal + # cannot be read, and counts as none. + def env_pair(pairs) + pair = pairs.reverse_each.find { |node| node[0] == :assoc_new && keyword_name(node[1]) == 'env' } + pair && pair[2].is_a?(Array) && pair[2][0] == :hash ? [pair[1], pair[2]] : [nil, nil] + end + + # `env:` is [:@label, "env:", position]. + def keyword_name(key) + key[1].delete_suffix(':') if key[0] == :@label + end + + # The pairs of a hash literal whose key and value are both plain strings. + def string_pairs(hash) + hash_pairs(hash).filter_map do |pair| + key = string_value(pair[1]) + value = string_value(pair[2]) + [key, value] if pair[0] == :assoc_new && key && value + end.to_h + end + + # 'text' is [:string_literal, [:string_content, [:@tstring_content, "text", position]]]. + def string_value(node) + return unless node.is_a?(Array) && node[0] == :string_literal + + parts = node[1][1..] + parts.map { |part| part[1] }.join if parts.all? { |part| part[0] == :@tstring_content } + end + + # What a `**name` among the keywords of a call stands for. + # + # A splat is followed in one shape only, the one that leaves no doubt about + # what the call receives: `name` is a local variable; `name = { ... }`, a + # literal that splats nothing itself, is a statement of the sequence the + # call is in, before it; and the block mentions `name` nowhere else. + # + # Another mention may be a write — `name[:env] = {}`, `name.delete(:env)`, + # `name.merge!(...)`, `name = name.merge(...)`, `name ||= ...`, `helper(name)` + # — and it need not stand before the call to run before it (a loop, a + # lambda). The reader does not work out which mentions are harmless: it + # fails closed on every one of them. + module Splat + module_function + + # The pairs a call is built from, every `**name` among its keywords + # replaced by the pairs of its literal. + # @return [Array] [pairs], or [[], problem, line of the block] when one + # of them cannot be followed + def resolve(pairs, program, call) + resolved = [] + pairs.each do |pair| + next resolved << pair unless pair[0] == :assoc_splat + + literal, problem, line = follow(pair[1], program, call) + return [[], problem, line] if problem + + resolved.concat(GuideOptionSets.hash_pairs(literal)) + end + [resolved] + end + + # The local variables splatted among those keywords. + def names(pairs) + pairs.filter_map { |pair| local_name(pair[1]) if pair[0] == :assoc_splat } + end + + # @return [Array] [the hash literal], or [nil, problem, line of the block] + def follow(expression, program, call) + name = local_name(expression) + return [nil, 'a ** splat that is not a local variable cannot be followed'] unless name + + cannot = "**#{name} cannot be followed: " + assignment = literal_assignment(program, name) + return [nil, "#{cannot}the block has no `#{name} = { ... }` literal"] unless assignment + + own = [expression[1], assignment[1][1]] # the splat's identifier and the assignment's + again = mentions(program, name).find { |mention| own.none? { |identifier| identifier.equal?(mention) } } + return [nil, "#{cannot}`#{name}` appears again in the block", again[2][0]] if again + + literal = assignment[2] + splats = GuideOptionSets.hash_pairs(literal).any? { |pair| pair[0] == :assoc_splat } + return [nil, "#{cannot}its literal splats another hash"] if splats + return [literal] if runs_before?(program, assignment, call) + + [nil, "#{cannot}`#{name} = { ... }` is not a statement that always runs before the call"] + end + + # `name` of [:var_ref, [:@ident, name, position]]: an identifier that is + # a local variable where it stands. One that is not is a :vcall, and a + # constant is [:var_ref, [:@const, ...]]. + def local_name(expression) + expression[1][1] if expression.is_a?(Array) && expression[0] == :var_ref && expression[1][0] == :@ident + end + + # The first [:assign, [:var_field, [:@ident, name, position]], [:hash, ...]]. + def literal_assignment(program, name) + GuideOptionSets.each_node(program) do |node| + next unless node[0] == :assign && node[1].is_a?(Array) && node[1][0] == :var_field + + return node if node[1][1][1] == name && node[2].is_a?(Array) && node[2][0] == :hash + end + nil + end + + # Every place the block names `name`, in source order: [:@ident, name, + # position], and the label of a shorthand pair — `helper(name:)` is + # [:assoc_new, [:@label, "name:", position], nil]. + def mentions(program, name) + found = [] + GuideOptionSets.each_node(program) do |node| + found << node if node[0] == :@ident && node[1] == name + found << node[1] if node[0] == :assoc_new && node[2].nil? && GuideOptionSets.keyword_name(node[1]) == name + end + found.sort_by { |mention| mention[2] } + end + + # Whether `statement` is one of a sequence of statements in which a later + # one holds `call`: only then has it run, whatever happened, by the time + # the call does. (`opts = { ... } if ready` is not: the :if_mod is the + # statement, and the call may receive `**nil`.) A sequence is an Array of + # nodes, as in [:program, statements] or [:bodystmt, statements, ...]. + def runs_before?(program, statement, call) + GuideOptionSets.each_node(program) do |sequence| + position = sequence[0].is_a?(Array) && sequence.index { |member| member.equal?(statement) } + next unless position + + return sequence.drop(position + 1).any? { |later| holds?(later, call) } + end + false + end + + def holds?(node, target) + GuideOptionSets.each_node(node) { |inner| return true if inner.equal?(target) } + false + end + end + end +end diff --git a/spec/rails/support/scripted_cli_transport.rb b/spec/rails/support/scripted_cli_transport.rb new file mode 100644 index 00000000..8a7bf74c --- /dev/null +++ b/spec/rails/support/scripted_cli_transport.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require 'json' +require 'securerandom' + +module ClaudeAgentSDKRailsSpec + # A scripted stand-in for the CLI process, for ClaudeAgentSDK.query(transport:) + # and Client (transport_class: / transport_args:). No subprocess, no model + # call and no clock: it answers the initialize request, and every user + # message the SDK writes plays the next turn of the script — frames in + # order, stopping at each control request until the SDK has answered it, + # so the script alone decides the order user callbacks run in. + # + # Like the process it replaces, it ends the stream once the script is + # played and the SDK has closed stdin (#end_input), or on #close. + # + # Plain Ruby with no RSpec dependency: booted_app_child.rb loads it into a + # child process too. + class ScriptedCLITransport < ClaudeAgentSDK::Transport + END_OF_STREAM = :end_of_stream + + # The hook callback ids the SDK registered in its initialize request, in + # order, and its control responses by request id. + attr_reader :hook_callback_ids, :control_responses + + # @param turns [Array<Array>] one script per user message. A step is a + # frame (Hash), a control request (Frames.control_request: the script + # waits for the SDK's answer before going on), or a callable that is + # given this transport and returns either. + # @param control_responses [Hash] where to record the SDK's answers; pass + # one to read them when a Client builds the transport itself. + def initialize(_options = nil, turns:, control_responses: {}) + super() + @turns = turns.map(&:dup) + @steps = [] + @stdout = Thread::Queue.new + @lock = Mutex.new + @hook_callback_ids = [] + @control_responses = control_responses + @awaited_request_id = nil + @input_ended = false + @closed = false + end + + def connect; end + + def ready? + !@closed + end + + def write(data) + message = JSON.parse(data, symbolize_names: true) + case message[:type] + when 'control_request' then answer_control_request(message) + when 'control_response' then record_control_response(message[:response]) + when 'user' then play_next_turn + end + end + + def read_messages + loop do + frame = @stdout.pop + break if frame == END_OF_STREAM + + yield frame + end + end + + def end_input + @lock.synchronize { @input_ended = true } + end_stream_if_finished + end + + def close + @closed = true + @stdout << END_OF_STREAM + end + + private + + def answer_control_request(message) + request = message[:request] + if request[:subtype] == 'initialize' + (request[:hooks] || {}).each_value do |matchers| + matchers.each { |matcher| @hook_callback_ids.concat(matcher[:hookCallbackIds]) } + end + end + @stdout << Frames.control_success(message[:request_id], request[:subtype]) + end + + def record_control_response(response) + request_id = response[:request_id] + resume = @lock.synchronize do + @control_responses[request_id] = response + next false unless @awaited_request_id == request_id + + @awaited_request_id = nil + true + end + play if resume + end + + def play_next_turn + @lock.synchronize { @steps = @turns.shift || raise('the SDK sent more user messages than the script has turns') } + play + end + + # Emit steps until the script pauses on a control request or runs out. + def play + loop do + step = @lock.synchronize { @steps.shift } + return end_stream_if_finished if step.nil? + + step = step.call(self) if step.respond_to?(:call) + @stdout << step + next unless step[:type] == 'control_request' + + @lock.synchronize { @awaited_request_id = step[:request_id] } + return + end + end + + def end_stream_if_finished + finished = @lock.synchronize { @input_ended && @steps.empty? && @turns.empty? && @awaited_request_id.nil? } + @stdout << END_OF_STREAM if finished + end + end + + # Frames as CLI 2.1.286 writes them to an SDK-spawned session, trimmed from + # a recorded session to the fields the SDK reads (Symbol keys, as + # SubprocessCLITransport#read_messages yields them). The mcp_message and + # can_use_tool requests follow the SDK's own unit fixtures; the rest follow + # the recording, including one content block per `assistant` frame and + # the `session_state_changed` pair around every turn. + module Frames + SESSION_ID = 'fd9f2708-3a4e-460c-8bd7-561c3feb755e' + MODEL = 'claude-haiku-4-5-20251001' + TOOL_USE_ID = 'toolu_01CHuGKVaNkChVspFMWK3zcj' + USAGE = { input_tokens: 8, cache_creation_input_tokens: 214, cache_read_input_tokens: 24_871, + output_tokens: 2, service_tier: 'standard' }.freeze + + module_function + + # The CLI's answer to an SDK control request. + def control_success(request_id, subtype) + response = subtype == 'initialize' ? { commands: [], agents: [], output_style: 'default', models: [], pid: 11_687 } : {} + { type: 'control_response', response: { subtype: 'success', request_id: request_id, response: response } } + end + + # A control request from the CLI; the script waits for the SDK's answer. + def control_request(request) + { type: 'control_request', request_id: SecureRandom.uuid, request: request } + end + + def session_state(state) + { type: 'system', subtype: 'session_state_changed', state: state, sdk_host_only: true, + uuid: SecureRandom.uuid, session_id: SESSION_ID } + end + + def init + { type: 'system', subtype: 'init', cwd: '/srv/app', session_id: SESSION_ID, + tools: %w[Bash Read mcp__app__lookup], mcp_servers: [{ name: 'app', status: 'connected' }], model: MODEL, + permissionMode: 'default', slash_commands: [], apiKeySource: 'none', claude_code_version: '2.1.286', + output_style: 'default', agents: [], skills: [], plugins: [], uuid: SecureRandom.uuid } + end + + def assistant(block, message_id:) + { type: 'assistant', + message: { model: MODEL, id: message_id, type: 'message', role: 'assistant', content: [block], + stop_reason: nil, stop_sequence: nil, usage: USAGE }, + parent_tool_use_id: nil, session_id: SESSION_ID, uuid: SecureRandom.uuid } + end + + def assistant_text(text, message_id: 'msg_011Cfc2rFTtZTztTpTJF3xPG') + assistant({ type: 'text', text: text }, message_id: message_id) + end + + def assistant_tool_use(name:, input:, message_id: 'msg_011Cfc2qvqiaFFYGxduAzxDr') + assistant({ type: 'tool_use', id: TOOL_USE_ID, name: name, input: input }, message_id: message_id) + end + + def tool_result(content) + { type: 'user', + message: { role: 'user', content: [{ tool_use_id: TOOL_USE_ID, type: 'tool_result', content: content }] }, + parent_tool_use_id: nil, session_id: SESSION_ID, uuid: SecureRandom.uuid } + end + + def result(text = 'DONE') + { type: 'result', subtype: 'success', is_error: false, duration_ms: 6591, duration_api_ms: 5094, + num_turns: 2, result: text, stop_reason: 'end_turn', session_id: SESSION_ID, total_cost_usd: 0.0161638, + usage: USAGE, permission_denials: [], terminal_reason: 'completed', uuid: SecureRandom.uuid } + end + + def hook_callback(callback_id, tool_name:, tool_input:) + control_request( + subtype: 'hook_callback', callback_id: callback_id, tool_use_id: TOOL_USE_ID, + input: { session_id: SESSION_ID, transcript_path: "/srv/.claude/projects/-srv-app/#{SESSION_ID}.jsonl", + cwd: '/srv/app', permission_mode: 'default', hook_event_name: 'PreToolUse', + tool_name: tool_name, tool_input: tool_input, tool_use_id: TOOL_USE_ID } + ) + end + + def can_use_tool(tool_name:, input:) + control_request(subtype: 'can_use_tool', tool_name: tool_name, input: input, permission_suggestions: [], + tool_use_id: TOOL_USE_ID) + end + + def mcp_tools_call(server:, tool:, arguments:) + control_request(subtype: 'mcp_message', server_name: server, + message: { jsonrpc: '2.0', id: 1, method: 'tools/call', + params: { name: tool, arguments: arguments } }) + end + + # A turn that answers in text only. + def text_turn(text = 'DONE') + [session_state('running'), init, assistant_text(text), result(text), session_state('idle')] + end + + # A turn in which the model calls the SDK MCP tool `lookup` on server + # `app`. +ask+ lists the control requests the CLI sends on the way, in + # its order: the PreToolUse :hook, the :permission prompt, the :tool call. + def tool_turn(text = 'DONE', ask: %i[hook permission tool]) + tool_name = 'mcp__app__lookup' + input = { id: 7 } + requests = { + hook: ->(cli) { hook_callback(cli.hook_callback_ids.first, tool_name: tool_name, tool_input: input) }, + permission: can_use_tool(tool_name: tool_name, input: input), + tool: mcp_tools_call(server: 'app', tool: 'lookup', arguments: input) + } + [ + session_state('running'), init, + assistant_tool_use(name: tool_name, input: input), + *requests.values_at(*ask), + tool_result('ok'), + assistant_text(text), result(text), session_state('idle') + ] + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 41f827c1..5f05c045 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -61,4 +61,25 @@ # Show the slowest examples config.profile_examples = 10 if ENV['PROFILE'] + + # SubprocessCLITransport's at-exit registry is process-wide, so an entry one + # example leaves in it is seen by whichever example runs next: the suite + # used to fail for the seeds (81, 124) that ran a registry example right + # after a leaking one. Fail the example that leaked, and hand the next one + # an empty registry either way. This runs after a group's own `after` + # hooks. Snapshot and clear in one step under the registry's own mutex: a + # fallback-termination worker started by an earlier example may still be + # deregistering on its own thread. + config.after do + transport = ClaudeAgentSDK::SubprocessCLITransport + leaked = transport.active_processes_mutex.synchronize do + entries = transport.active_processes.to_a + transport.active_processes.clear + entries + end + + leak = "this example left #{leaked.size} process(es) in SubprocessCLITransport's at-exit registry " \ + "(#{leaked.inspect}): close the transport it connected, or deregister what it registered" + expect(leaked).to be_empty, leak + end end diff --git a/spec/support/callback_exit_harness.rb b/spec/support/callback_exit_harness.rb index 9687e029..d9669693 100644 --- a/spec/support/callback_exit_harness.rb +++ b/spec/support/callback_exit_harness.rb @@ -11,6 +11,10 @@ # so the parent can check it went out BEFORE the process died. Plain Ruby # with no RSpec dependency; it is loaded by spec_helper too, which only # defines the module. +# +# spec/unit/callback_failure_containment_spec.rb reuses the callbacks and +# requests IN-PROCESS for the failures the SDK contains instead +# (FAILURE_KINDS): nothing there ends the process. module CallbackExitHarness # rubocop:disable Metrics/ModuleLength -- one self-contained child-process fixture # Every control-request path whose user callback answers the CLI. # :hook_timeout takes the HookMatcher#timeout variants (Async with_timeout @@ -33,6 +37,37 @@ module CallbackExitHarness # rubocop:disable Metrics/ModuleLength -- one self-co sigterm: { message: 'SignalException: SIGTERM', termsig: 'TERM' } }.freeze + # What a failing callback can raise that is neither a StandardError nor a + # process exit: the exception class, and the message the CLI is told. The + # SDK answers the request as for any other callback failure and carries on. + FAILURE_KINDS = { + not_implemented: [NotImplementedError, 'refunds are not supported yet'], + load_error: [LoadError, 'cannot load such file -- callback_exit_harness/missing_gem'], + stack_overflow: [SystemStackError, 'stack level too deep'], + security_error: [SecurityError, 'unsigned payload refused'] + }.freeze + # The failure the SDK has always answered, to compare the above against. + ORDINARY_FAILURE = 'ordinary failure' + + # A process exit whose message is not valid UTF-8: text cut inside a + # multibyte character, as `byteslice` leaves it. The CLI is told the + # message with the stray byte replaced, and the exit still ends the + # process (spec/unit/control_error_encoding_spec.rb). + INVALID_UTF8_EXIT = { + text: 'no such order: 注文'.byteslice(0, 16), message: "SystemExit: no such order: \uFFFD", exitstatus: 3 + }.freeze + + # A process exit whose message is valid text in an encoding that is not + # ASCII-compatible (UTF-16): it cannot be joined to the UTF-8 class name as + # it is. The CLI is told the same text in UTF-8, and the exit still ends + # the process. + UTF16_EXIT = { + text: 'no such order: 注文'.encode(Encoding::UTF_16LE), message: 'SystemExit: no such order: 注文', exitstatus: 3 + }.freeze + + # The kinds that raise the two exits above. + ENCODED_EXITS = { exit_invalid_utf8: INVALID_UTF8_EXIT, exit_utf16: UTF16_EXIT }.freeze + RESPONSE = 'RESPONSE ' SURVIVED = 'CALLBACK_EXIT_HARNESS_SURVIVED' @@ -61,10 +96,22 @@ def trigger(kind) when :interrupt then raise Interrupt when :signal then raise SignalException, 'TERM' when :sigint, :sigterm then busy_until_signalled(kind == :sigint ? 'INT' : 'TERM') + when *ENCODED_EXITS.keys then raise SystemExit.new(*ENCODED_EXITS.fetch(kind).values_at(:exitstatus, :text)) + when :none then nil # the callback succeeds + when :standard_error then raise ORDINARY_FAILURE + when :not_implemented, :security_error then raise(*FAILURE_KINDS.fetch(kind)) + when :load_error then require 'callback_exit_harness/missing_gem' # a lazy require of a gem that is not installed + when :stack_overflow then overflow_stack else raise ArgumentError, "unknown kind #{kind.inspect}" end end + # Runaway recursion: a real SystemStackError, on whichever stack runs the + # callback (the worker thread's, or the handler task's fiber when inline). + def overflow_stack(depth = 0) + overflow_stack(depth + 1) + 1 + end + # CPU-bound (scheduler-opaque) work on the callback's own thread — for an # :inline callback the reactor's, i.e. the main thread — while another # thread sends the process a real signal, which MRI delivers to the main @@ -78,7 +125,7 @@ def busy_until_signalled(signal) nil while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline end - def build_query(path, mode, kind, transport) + def build_query(path, mode, kind, transport, wrapper: nil) can_use_tool = lambda do |_tool_name, _input, _context| trigger(kind) ClaudeAgentSDK::PermissionResultAllow.new @@ -93,7 +140,7 @@ def build_query(path, mode, kind, transport) end ClaudeAgentSDK::Query.new( transport: transport, is_streaming_mode: true, can_use_tool: can_use_tool, - sdk_mcp_servers: { 'srv' => mcp_server(kind) }, callback_scheduling: mode + sdk_mcp_servers: { 'srv' => mcp_server(kind) }, callback_scheduling: mode, callback_wrapper: wrapper ).tap do |query| query.instance_variable_set(:@hook_callbacks, { 'hook' => hook }) timeout = { hook_timeout: 5, hook_timeout_abandoned: 0.05 }[path] diff --git a/spec/support/fake_claude.rb b/spec/support/fake_claude.rb new file mode 100644 index 00000000..261e07de --- /dev/null +++ b/spec/support/fake_claude.rb @@ -0,0 +1,333 @@ +# frozen_string_literal: true + +require 'json' + +# A stand-in for the `claude` executable: a real child process that speaks +# the CLI's stream-JSON protocol on stdin/stdout. With it the SDK's transport +# is tested through its real #connect — Open3.popen3, the environment handed +# to the child, chdir, the stderr thread, the stdin close and the TERM/KILL +# ladder are all the real thing, and nothing is stubbed. +# +# cli = FakeClaude.install(dir) # writes an executable, returns its ABSOLUTE path +# options = ClaudeAgentSDK::ClaudeAgentOptions.new( +# cli_path: cli, cwd: dir, env: FakeClaude.env(log: File.join(dir, 'log.jsonl'), scenario: 'slow_exit') +# ) +# ... +# FakeClaude.events(File.join(dir, 'log.jsonl')) # what the child saw and did, in order +# +# This file is two things. Required by spec_helper it only defines the module; +# run as a program (which is what the installed executable does) it is the +# child. The child is plain Ruby started with --disable-gems and loads +# nothing but json, so it starts in milliseconds. +# +# Scenarios (FAKE_CLAUDE_SCENARIO, see .env): +# conversation (default) answers control requests and user messages; exits 0 at stdin EOF +# slow_exit the same, but after EOF it takes a moment to finish its work before exiting 0 +# wedged ignores stdin EOF and SIGTERM; only SIGKILL ends it +# stderr_then_fail writes FAKE_CLAUDE_STDERR_LINES lines to stderr, waits for EOF, exits 1 +# exit_at_once exits 0 without reading anything +# +# For the control channel alone, in process, see ScriptedCLI. +module FakeClaude + # What `-v` prints: a version the SDK accepts without a warning. + CLI_VERSION = '2.1.286' + + SCENARIOS = %w[conversation slow_exit wedged stderr_then_fail exit_at_once].freeze + + # Writes an executable named `claude` into +dir+ and returns its absolute + # path, for `cli_path:`. A shell wrapper rather than a shebang line, for two + # reasons: the interpreter path can be long or contain spaces, and + # `bundle exec` leaves RUBYOPT=-rbundler/setup in the environment, which + # would make every start of the child load Bundler. + def self.install(dir) + require 'rbconfig' + require 'shellwords' + + path = File.join(File.realpath(dir), 'claude') + File.write(path, <<~SH) + #!/bin/sh + unset RUBYOPT RUBYLIB + exec #{Shellwords.escape(RbConfig.ruby)} --disable-gems #{Shellwords.escape(File.expand_path(__FILE__))} "$@" + SH + File.chmod(0o755, path) + path + end + + # The entries for ClaudeAgentOptions#env that select what the child does. + # +log+ is a file the child appends one JSON event per line to; +report_env+ + # names environment variables whose values the child records at start + # (names of all variables are recorded anyway, values of none). + def self.env(scenario: 'conversation', log: nil, report_env: [], **settings) + raise ArgumentError, "unknown scenario #{scenario.inspect}" unless SCENARIOS.include?(scenario.to_s) + + env = { 'FAKE_CLAUDE_SCENARIO' => scenario.to_s } + env['FAKE_CLAUDE_LOG'] = log.to_s if log + env['FAKE_CLAUDE_REPORT_ENV'] = report_env.join(',') unless report_env.empty? + settings.each { |name, value| env["FAKE_CLAUDE_#{name.to_s.upcase}"] = value.to_s } + env + end + + # The child's log, parsed: [{ 'event' => 'start', ... }, ...]. Complete + # once the child has exited, which is when specs read it. + def self.events(log) + return [] unless File.exist?(log) + + File.readlines(log, chomp: true).reject(&:empty?).map { |line| JSON.parse(line) } + end + + # The pids of the children that logged a start to +log+ and may still be + # running: what a spec kills when its own bound expires. + def self.pids(log) + events(log).filter_map { |event| event['pid'] if event['event'] == 'start' } + end + + # The child process. + class Child + SESSION_ID = '11111111-1111-4111-8111-111111111111' + MODEL = 'claude-haiku-4-5-20251001' + + def initialize(argv, env) + @argv = argv + @env = env + @scenario = env.fetch('FAKE_CLAUDE_SCENARIO', 'conversation') + @log = env['FAKE_CLAUDE_LOG'] && File.open(env['FAKE_CLAUDE_LOG'], File::WRONLY | File::APPEND | File::CREAT) + @backlog = [] + @hooks = {} + @ids = 0 + end + + def run + if @argv.include?('-v') || @argv.include?('--version') + puts "#{CLI_VERSION} (Claude Code)" + return 0 + end + + $stdout.sync = true + $stderr.sync = true + log('start', pid: Process.pid, argv: @argv, cwd: Dir.pwd, env_names: @env.keys.sort, env: reported_env) + status = play + log('exit', status: status) + status + end + + private + + def play + case @scenario + when 'exit_at_once' then 0 + when 'slow_exit' then slow_exit + when 'wedged' then wedged + when 'stderr_then_fail' then stderr_then_fail + else + converse + 0 + end + end + + # After stdin EOF the real CLI still has work to do (it flushes the + # session file) before it exits on its own. A SIGTERM that arrives during + # that work is recorded, and ends the process the way an interrupted CLI + # ends. + def slow_exit + trap('TERM') do + log('signal', name: 'TERM') + exit!(143) + end + converse + sleep Float(@env.fetch('FAKE_CLAUDE_FLUSH_SECONDS', '0.2')) + log('flushed') + 0 + end + + def wedged + trap('TERM') { log('signal', name: 'TERM') } + converse + sleep + end + + def stderr_then_fail + Integer(@env.fetch('FAKE_CLAUDE_STDERR_LINES', '25')).times { |i| warn "fake claude: stderr line #{i + 1}" } + converse + 1 + end + + # The protocol loop: answers every control request, plays one turn per + # user message, returns at stdin EOF. + def converse + while (frame = next_frame) + case frame['type'] + when 'control_request' then answer_control_request(frame) + when 'user' then play_turn + end + end + log('eof') + end + + def answer_control_request(frame) + return emit_control_response(frame, {}) unless frame.dig('request', 'subtype') == 'initialize' + + @hooks = frame.dig('request', 'hooks') || {} + # In the order CLI 2.1.286 does it: each SDK MCP server is initialized + # before the SDK's own `initialize` is answered, and asked for its + # tools after. + sdk_servers.each { |name| ask_sdk_server(name, method: 'initialize', params: mcp_client_info, id: 0) } + emit_control_response(frame, initialize_response) + sdk_servers.each do |name| + ask_sdk_server(name, method: 'notifications/initialized') + ask_sdk_server(name, method: 'tools/list', id: 1) + end + end + + # One turn: the frames CLI 2.1.286 emits for a prompt answered with text, + # plus a PreToolUse hook callback per registered hook and, when + # FAKE_CLAUDE_TOOL_CALL names one, an SDK MCP tool call. + def play_turn + emit_session_state('running') + emit(init_frame) + Array(@hooks['PreToolUse']).flat_map { |matcher| matcher['hookCallbackIds'] }.each do |callback_id| + ask(subtype: 'hook_callback', callback_id: callback_id, tool_use_id: 'toolu_01FakeClaudeBash', + input: pre_tool_use_input) + end + if (call = @env['FAKE_CLAUDE_TOOL_CALL']) + call = JSON.parse(call) + ask_sdk_server(call.fetch('server'), method: 'tools/call', id: 2, + params: { name: call.fetch('name'), arguments: call.fetch('arguments') }) + end + text = @env.fetch('FAKE_CLAUDE_REPLY', 'OK') + emit(assistant_frame(text)) + emit(result_frame(text)) + emit_session_state('idle') + end + + # Sends a control request and waits for its response. Frames that arrive + # in between (the user prompt is written right after `initialize`) are + # kept, in order, for #next_frame. + def ask(request) + id = next_id + emit(type: 'control_request', request_id: id, request: request) + loop do + frame = read_frame + unless frame + log('eof') + exit!(0) + end + return frame if frame['type'] == 'control_response' && frame.dig('response', 'request_id') == id + + @backlog << frame + end + end + + def ask_sdk_server(name, message) + ask(subtype: 'mcp_message', server_name: name, message: message.merge(jsonrpc: '2.0')) + end + + def next_frame + @backlog.empty? ? read_frame : @backlog.shift + end + + def read_frame + line = $stdin.gets or return nil + frame = JSON.parse(line) + log('stdin', frame: frame) + frame + end + + def emit(frame) + $stdout.write("#{JSON.generate(frame)}\n") + end + + def emit_control_response(request_frame, payload) + emit(type: 'control_response', + response: { subtype: 'success', request_id: request_frame['request_id'], response: payload }) + end + + # Only for an SDK that asked for them, as the real CLI does. + def emit_session_state(state) + return unless @env['CLAUDE_CODE_SDK_READS_SESSION_STATE'] == '1' + + emit(type: 'system', subtype: 'session_state_changed', state: state, sdk_host_only: true, uuid: next_id, + session_id: SESSION_ID) + end + + # One line per event, written with a single syswrite: complete on disk + # the moment it returns, and safe inside a signal handler. + def log(event, details = {}) + @log&.syswrite("#{JSON.generate({ event: event }.merge(details))}\n") + end + + def reported_env + names = %w[CLAUDE_AGENT_SDK_VERSION CLAUDE_CODE_SDK_READS_SESSION_STATE PWD] + + @env.fetch('FAKE_CLAUDE_REPORT_ENV', '').split(',') + names.to_h { |name| [name, @env[name]] } + end + + def next_id + @ids += 1 + format('00000000-0000-4000-8000-%012d', @ids) + end + + # The names of the `type: sdk` servers in --mcp-config. + def sdk_servers + index = @argv.index('--mcp-config') or return [] + servers = JSON.parse(@argv[index + 1]).fetch('mcpServers', {}) + servers.select { |_, config| config['type'] == 'sdk' }.keys + rescue JSON::ParserError + [] + end + + def mcp_client_info + { protocolVersion: '2025-11-25', capabilities: {}, + clientInfo: { name: 'claude-code', title: 'Claude Code', description: "Anthropic's agentic coding tool", + websiteUrl: 'https://claude.com/claude-code', version: CLI_VERSION } } + end + + def initialize_response + { commands: [{ name: 'design', description: 'Grant or revoke Claude agent access to your Design projects', + argumentHint: 'consent | revoke', builtin: true }], + agents: [{ name: 'claude', description: "Catch-all for any task that doesn't fit a more specific agent." }], + output_style: 'default', available_output_styles: %w[default Proactive Concise Explanatory Learning], + models: [{ value: 'default', resolvedModel: 'claude-opus-5-5', displayName: 'Default (recommended)', + description: 'Use the default model', supportsEffort: true, + supportedEffortLevels: %w[low medium high xhigh max], supportsAdaptiveThinking: true, + supportsFastMode: true, supportsAutoMode: true }], + account: { tokenSource: 'none', apiProvider: 'firstParty' }, pid: Process.pid, + current_permission_mode: 'auto', session_state: 'idle' } + end + + def pre_tool_use_input + { session_id: SESSION_ID, transcript_path: File.join(Dir.pwd, "#{SESSION_ID}.jsonl"), cwd: Dir.pwd, + scratchpad_dir: File.join(Dir.pwd, 'scratchpad'), prompt_id: next_id, permission_mode: 'default', + hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'ls', description: 'List files' }, + tool_use_id: 'toolu_01FakeClaudeBash' } + end + + def init_frame + { type: 'system', subtype: 'init', cwd: Dir.pwd, session_id: SESSION_ID, tools: %w[Bash Read], + mcp_servers: sdk_servers.map { |name| { name: name, status: 'connected' } }, model: MODEL, + permissionMode: 'default', slash_commands: %w[compact context], apiKeySource: 'none', + claude_code_version: CLI_VERSION, output_style: 'default', agents: %w[claude general-purpose], skills: [], + plugins: [], capabilities: %w[interrupt_receipt_v1 msg_lifecycle_v1], uuid: next_id, fast_mode_state: 'off' } + end + + def assistant_frame(text) + { type: 'assistant', + message: { model: MODEL, id: 'msg_01FakeClaude', type: 'message', role: 'assistant', + content: [{ type: 'text', text: text }], stop_reason: nil, stop_sequence: nil, + usage: { input_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, + output_tokens: 4, service_tier: 'standard' } }, + parent_tool_use_id: nil, session_id: SESSION_ID, uuid: next_id } + end + + def result_frame(text) + { type: 'result', subtype: 'success', is_error: false, duration_ms: 1973, duration_api_ms: 1937, num_turns: 1, + result: text, stop_reason: 'end_turn', session_id: SESSION_ID, total_cost_usd: 0.01533, + usage: { input_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 38, + service_tier: 'standard' }, + modelUsage: { MODEL => { inputTokens: 10, outputTokens: 38, cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, costUSD: 0.01533, contextWindow: 200_000 } }, + permission_denials: [], terminal_reason: 'completed', uuid: next_id } + end + end +end + +exit(FakeClaude::Child.new(ARGV, ENV.to_h).run) if $PROGRAM_NAME == __FILE__ diff --git a/spec/support/keychain_bridge_stub.rb b/spec/support/keychain_bridge_stub.rb new file mode 100644 index 00000000..093f6aa6 --- /dev/null +++ b/spec/support/keychain_bridge_stub.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Store-backed resume bridges the macOS Keychain: unless the caller +# authenticates through the environment, materialization runs +# `security find-generic-password` for the config dir's entry. An example that +# reaches SessionResume.materialize_resume_session without stubbing that — +# any example pairing a session_store with resume — would read the developer's +# real credentials on macOS, copy them into a temp dir and depend on the state +# of the local Keychain. +# +# So the bridge is off for every example by default. The examples that test +# the bridge itself opt in with `keychain: true`, on the example or its group, +# and replace the layer below it (capture_with_timeout) instead: see +# spec/unit/session_resume_keychain_spec.rb, which also pins this default. +RSpec.configure do |config| + config.before do |example| + next if example.metadata[:keychain] + + allow(ClaudeAgentSDK::SessionResume).to receive(:read_keychain_credentials).and_return(nil) + end +end diff --git a/spec/support/scripted_cli.rb b/spec/support/scripted_cli.rb new file mode 100644 index 00000000..66360ede --- /dev/null +++ b/spec/support/scripted_cli.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'json' +require 'securerandom' + +# The CLI's end of the control protocol, in process. A spec opens a real +# Client on it, sends the control requests the CLI would send (hook_callback, +# can_use_tool, mcp_message) and gets back the frame the SDK wrote in reply: +# +# ScriptedCLI.session(options) do |cli| +# reply = cli.request(subtype: 'can_use_tool', tool_name: 'Bash', input: { command: 'ls' }) +# reply # => { 'subtype' => 'success', 'request_id' => '…', 'response' => { 'behavior' => 'allow', … } } +# end +# +# Nothing on the SDK side is stubbed or called privately: Client#connect +# builds the Query from the options a user writes, `initialize` is answered +# the way the CLI answers it, and each request goes through Query's read +# loop, its handler task and the hop to the user's callback. Frames cross in +# both directions as they cross SubprocessCLITransport: serialized to JSON +# and parsed back (Symbol keys towards the SDK, String keys on this side). +# +# This is the control channel only. For a child process that also speaks the +# message stream, see FakeClaude (spec/support/fake_claude.rb). +class ScriptedCLI + # How long #request waits for the SDK's answer. Only a regression that + # leaves a request unanswered ever waits this long. + REPLY_BOUND_SECONDS = 10 + + # What CLI 2.1.286 answers `initialize` with when it has no credentials, + # cut down to one entry per list. + INITIALIZE_RESPONSE = { + commands: [{ name: 'design', description: 'Grant or revoke Claude agent access to your Design projects', + argumentHint: 'consent | revoke', builtin: true }], + agents: [{ name: 'claude', description: "Catch-all for any task that doesn't fit a more specific agent." }], + output_style: 'default', + available_output_styles: %w[default Proactive Concise Explanatory Learning], + models: [{ value: 'default', resolvedModel: 'claude-opus-5-5', displayName: 'Default (recommended)', + description: 'Use the default model', supportsEffort: true, + supportedEffortLevels: %w[low medium high xhigh max], supportsAdaptiveThinking: true, + supportsFastMode: true, supportsAutoMode: true }], + account: { tokenSource: 'none', apiProvider: 'firstParty' }, + pid: 4242, + current_permission_mode: 'auto', + session_state: 'idle' + }.freeze + + # Opens a Client on a new ScriptedCLI, yields the ScriptedCLI (and the + # Client), disconnects. Returns the block's value. + def self.session(options = ClaudeAgentSDK::ClaudeAgentOptions.new) + cli = new + ClaudeAgentSDK::Client.open(options: options, transport_class: Transport, transport_args: { cli: cli }) do |client| + yield cli, client + end + end + + # Every frame the SDK wrote, in order, as the CLI parses them (String keys). + attr_reader :received + + def initialize + @to_sdk = Thread::Queue.new + @replies = Thread::Queue.new + @received = [] + end + + # Sends one control request and returns the body of the SDK's + # control_response: 'subtype', 'request_id' and 'response' (or 'error'). + def request(request, request_id: SecureRandom.uuid) + deliver(type: 'control_request', request_id: request_id, request: request) + reply = @replies.pop(timeout: REPLY_BOUND_SECONDS) + raise "the SDK did not answer the #{request[:subtype]} request within #{REPLY_BOUND_SECONDS}s" unless reply + + reply.fetch('response') + end + + # The `initialize` control request the SDK sent on connect. + def initialize_request + @received.find { |frame| frame.dig('request', 'subtype') == 'initialize' } + end + + # The callback ids the SDK registered for +event+ in `initialize`, which + # is how the CLI learns what to put in a hook_callback request. + def hook_callback_ids(event) + Array(initialize_request.dig('request', 'hooks', event)).flat_map { |matcher| matcher.fetch('hookCallbackIds') } + end + + # The three methods below are the transport's side. + + def next_frame + @to_sdk.pop + end + + def hang_up + @to_sdk.push(:eof) + end + + def take(data) + data.each_line do |line| + frame = JSON.parse(line) + @received << frame + case frame['type'] + when 'control_response' then @replies.push(frame) + when 'control_request' then answer(frame) + end + end + end + + private + + def deliver(frame) + @to_sdk.push(JSON.parse(JSON.generate(frame), symbolize_names: true)) + end + + def answer(frame) + payload = frame.dig('request', 'subtype') == 'initialize' ? INITIALIZE_RESPONSE : {} + deliver(type: 'control_response', + response: { subtype: 'success', request_id: frame.fetch('request_id'), response: payload }) + end + + # What Client instantiates: `transport_class.new(options, **transport_args)`. + class Transport < ClaudeAgentSDK::Transport + def initialize(_options, cli:) + super() + @cli = cli + @ready = false + end + + def connect + @ready = true + end + + def ready? + @ready + end + + def write(data) + @cli.take(data) + end + + def read_messages + loop do + frame = @cli.next_frame + break if frame == :eof + + yield frame + end + end + + def end_input; end + + def close + @ready = false + @cli.hang_up + end + end +end diff --git a/spec/unit/agents_hash_option_spec.rb b/spec/unit/agents_hash_option_spec.rb new file mode 100644 index 00000000..0d585393 --- /dev/null +++ b/spec/unit/agents_hash_option_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'json' + +# ClaudeAgentOptions#agents is signed +# Hash[String | Symbol, AgentDefinition | Hash[Symbol | String, untyped]] +# but the initialize request was built by calling readers on every value, so +# an agent given as a Hash raised NoMethodError out of connect. +# +# End to end through the real control protocol (no stubbing of Query): a fake +# Transport records each control request as the CLI's JSON parser sees it and +# answers it the way the CLI does. +RSpec.describe 'agents given as Hashes' do + let(:transport_class) do + Class.new(ClaudeAgentSDK::Transport) do + def initialize(_options, requests:) + super() + @requests = requests + @queue = Thread::Queue.new + end + + def connect = @ready = true + def ready? = @ready + def end_input; end + def close = @queue.push(:eof) + + def write(data) + message = JSON.parse(data, symbolize_names: true) + return unless message[:type] == 'control_request' + + @requests << JSON.parse(data).fetch('request') + response = { subtype: 'success', request_id: message[:request_id], response: {} } + @queue.push({ type: 'control_response', response: response }) + end + + def read_messages + loop do + message = @queue.pop + break if message == :eof + + yield message + end + end + end + end + + let(:requests) { [] } + + def client_with(agents) + ClaudeAgentSDK::Client.new( + options: ClaudeAgentSDK::ClaudeAgentOptions.new(agents: agents), + transport_class: transport_class, transport_args: { requests: requests } + ) + end + + # The `agents` object of the initialize request a session with these + # agents sends. + def agents_on_the_wire(agents) + requests.clear + Sync do + client = client_with(agents) + client.connect + ensure + client&.disconnect + end + requests.find { |request| request['subtype'] == 'initialize' }.fetch('agents') + end + + # What connecting with these agents raises, caught inside the reactor. + def connect_error(agents) + Sync do + client = client_with(agents) + client.connect + nil + rescue StandardError => e + e + ensure + client&.disconnect + end + end + + # Every attribute of AgentDefinition, with an inline MCP server config + # next to a server name. + attributes = { + description: 'Reviews pull requests', + prompt: 'You review code. Be specific.', + tools: %w[Read Grep], + disallowed_tools: %w[Bash], + model: 'sonnet', + skills: %w[review], + memory: 'project', + mcp_servers: ['docs', { 'tracker' => { type: 'http', url: 'https://example.com/mcp' } }], + initial_prompt: 'Start with the diff.', + max_turns: 4, + background: true, + effort: 'high', + permission_mode: 'plan' + }.freeze + + wire_agent = { + 'description' => 'Reviews pull requests', + 'prompt' => 'You review code. Be specific.', + 'tools' => %w[Read Grep], + 'disallowedTools' => %w[Bash], + 'model' => 'sonnet', + 'skills' => %w[review], + 'memory' => 'project', + 'mcpServers' => ['docs', { 'tracker' => { 'type' => 'http', 'url' => 'https://example.com/mcp' } }], + 'initialPrompt' => 'Start with the diff.', + 'maxTurns' => 4, + 'background' => true, + 'effort' => 'high', + 'permissionMode' => 'plan' + }.freeze + + it 'covers every attribute of AgentDefinition' do + expect(attributes.keys.map(&:to_s)).to match_array(ClaudeAgentSDK::AgentDefinition.attribute_names) + end + + it 'sends a typed AgentDefinition under its wire keys' do + typed = ClaudeAgentSDK::AgentDefinition.new(attributes) + + expect(agents_on_the_wire('reviewer' => typed)).to eq('reviewer' => wire_agent) + end + + { + 'Symbol keys' => attributes, + 'String keys' => attributes.transform_keys(&:to_s), + 'camelCase String keys' => wire_agent + }.each do |spelling, hash| + it "sends a Hash with #{spelling} like the AgentDefinition it stands for" do + expect(agents_on_the_wire('reviewer' => hash)).to eq('reviewer' => wire_agent) + end + end + + it 'takes Hash and typed agents side by side, under Symbol or String names' do + agents = { + reviewer: { description: 'Reviews pull requests', prompt: 'You review code.' }, + 'writer' => ClaudeAgentSDK::AgentDefinition.new(description: 'Writes docs', prompt: 'You write docs.') + } + + expect(agents_on_the_wire(agents)).to eq( + 'reviewer' => { 'description' => 'Reviews pull requests', 'prompt' => 'You review code.' }, + 'writer' => { 'description' => 'Writes docs', 'prompt' => 'You write docs.' } + ) + end + + it 'raises the strict-attribute error for a misspelled key, before anything is written' do + error = connect_error('reviewer' => { description: 'Reviews pull requests', promt: 'You review code.' }) + + expect(error).to be_a(ArgumentError) + expect(error.message).to start_with('ClaudeAgentSDK::AgentDefinition: unknown attribute :promt (known: ') + expect(requests).to be_empty + end +end diff --git a/spec/unit/callback_failure_containment_spec.rb b/spec/unit/callback_failure_containment_spec.rb new file mode 100644 index 00000000..97a9f66c --- /dev/null +++ b/spec/unit/callback_failure_containment_spec.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# A hook, a can_use_tool callback or an SDK MCP tool / resource / prompt +# handler can fail with an exception that is neither a StandardError nor a +# process exit: NotImplementedError and LoadError (both ScriptError), +# SystemStackError, SecurityError. No `rescue StandardError` on the way out +# matched those, so the control request was never answered and its handler +# task ended with an exception Async treats as fatal — which stopped the +# reactor and every other task (and session) on it. They are answered like +# any other callback failure now, and the session carries on. +# +# Deliberately not contained, so not in the matrix: NoMemoryError, a bare +# Exception subclass, and what spec/unit/callback_process_exit_spec.rb covers +# (process exits are answered and then re-raised; cancellation propagates). +RSpec.describe 'user callbacks failing outside StandardError' do + harness = CallbackExitHarness + + # The CLI side, scripted: hands the read loop one control request at a + # time and lets the handler task it spawned run to its end before the + # next, so the follow-up request reaches a read loop that has already + # lived through the failure. Records every frame the SDK writes. + let(:transport_class) do + Class.new(ClaudeAgentSDK::Transport) do + attr_reader :frames + + def initialize(requests) + super() + @requests = requests + @frames = [] + end + + def write(data) + @frames << JSON.parse(data) + end + + def read_messages + @requests.each do |request| + yield request + # Handler tasks are children of the task running the read loop. + Async::Task.current.children&.each(&:wait) + end + end + end + end + + # A request no user callback takes part in. + let(:follow_up_request) do + { type: 'control_request', request_id: 'req_next', request: CallbackExitHarness.mcp_request('tools/list', {}) } + end + + # One failing control request through Query#read_messages, as in a session, + # then the follow-up; next to them an unrelated task on the same reactor, + # parked until the session is done with both. + def run_session(path, mode, kind, wrapper: nil) + transport = transport_class.new([CallbackExitHarness.control_request(path), follow_up_request]) + query = CallbackExitHarness.build_query(path, mode, kind, transport, wrapper: wrapper) + outcome = { frames: transport.frames, escaped: nil, bystander_finished: false } + begin + Sync do |task| + gate = Thread::Queue.new + bystander = task.async do + gate.pop + outcome[:bystander_finished] = true + end + task.async { query.send(:read_messages) }.wait + gate << :session_done + bystander.wait + end + rescue ScriptError, SystemStackError, SecurityError => e + outcome[:escaped] = e # what stopped the reactor + end + outcome + end + + def control_response(request_id, fields) + { 'type' => 'control_response', + 'response' => { 'request_id' => request_id, 'requestId' => request_id }.merge(fields) } + end + + # The response an ordinary exception from the callback produces on each + # path (the shapes spec/unit/callback_process_exit_spec.rb pins too): an + # error control response for hooks and can_use_tool; for SDK MCP requests, + # inside a successful control response, an isError result (tools/call) or + # a JSON-RPC internal error (resources/read, prompts/get). + def failure_response(path, message) + mcp_response = + case path + when :call_tool then { 'result' => { 'content' => [{ 'type' => 'text', 'text' => message }], 'isError' => true } } + when :read_resource, :get_prompt then { 'error' => { 'code' => -32_603, 'message' => message } } + else return control_response('req_fail', 'subtype' => 'error', 'error' => message) + end + control_response('req_fail', 'subtype' => 'success', + 'response' => { 'mcp_response' => { 'jsonrpc' => '2.0', 'id' => 7 }.merge(mcp_response) }) + end + + def responses_to(outcome, request_id) + outcome[:frames].select { |frame| frame.dig('response', 'request_id') == request_id } + end + + # The tool names in each answer to the follow-up request: one answer, and + # a successful one, when the read loop outlived the failure. + def tools_listed_afterwards(outcome) + responses_to(outcome, 'req_next').map do |frame| + frame.dig('response', 'response', 'mcp_response', 'result', 'tools')&.map { |tool| tool['name'] } + end + end + + def expect_answered_and_running(outcome, path, message) + escaped = outcome[:escaped] + aggregate_failures do + expect(escaped).to be_nil, "the reactor was stopped by #{escaped.class}: #{escaped&.message}" + expect(responses_to(outcome, 'req_fail')).to eq([failure_response(path, message)]) + expect(tools_listed_afterwards(outcome)).to eq([['boom']]), 'the session did not answer the next request' + expect(outcome[:frames].length).to eq(2) + expect(outcome[:bystander_finished]).to be(true), 'an unrelated task on the same reactor did not finish' + end + end + + harness::PATHS.each do |path| + harness::MODES.each do |mode| + context "#{path} with #{mode} callback scheduling" do + harness::FAILURE_KINDS.each do |kind, (error_class, message)| + it "answers a #{error_class} like any other callback failure and keeps the session running" do + expect_answered_and_running(run_session(path, mode, kind), path, message) + end + end + + # The callback_wrapper runs around the callback, outside its body. + it 'does the same when the callback_wrapper raises instead of the callback' do + wrapper = ->(_invocation) { CallbackExitHarness.trigger(:not_implemented) } + + outcome = run_session(path, mode, :none, wrapper: wrapper) + + expect_answered_and_running(outcome, path, CallbackExitHarness::FAILURE_KINDS.fetch(:not_implemented).last) + end + + it 'answers an ordinary StandardError the same way (the response the others must match)' do + outcome = run_session(path, mode, :standard_error) + + expect_answered_and_running(outcome, path, CallbackExitHarness::ORDINARY_FAILURE) + end + end + end + end + + # Without a session there is no control request to answer; a tool + # handler's failure is still the in-band result the model can read. + describe 'SdkMcpServer tools called directly' do + harness::MODES.each do |mode| + harness::FAILURE_KINDS.each do |kind, (error_class, message)| + it "reports a #{error_class} from a #{mode} handler as an isError result of #call_tool" do + server = CallbackExitHarness.mcp_server(kind) + server.callback_scheduling = mode + + result = Sync { server.call_tool('boom', {}) } + + expect(result).to eq(content: [{ type: 'text', text: message }], isError: true) + end + + it "reports a #{error_class} from a #{mode} handler as an isError result of #handle_message" do + server = CallbackExitHarness.mcp_server(kind) + server.callback_scheduling = mode + request = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'boom', arguments: {} } } + + response = Sync { server.handle_message(request) } + + expect(response).to include(id: 1) + expect(response[:result]).to include(content: [{ type: 'text', text: message }], isError: true) + end + end + end + end + + describe 'FiberBoundary::CALLBACK_FAILURES' do + it 'covers what a failing callback raises' do + contained = [RuntimeError, NotImplementedError, LoadError, SyntaxError, SystemStackError, SecurityError] + + expect(contained).to all(satisfy { |raised| ClaudeAgentSDK::FiberBoundary::CALLBACK_FAILURES.any? { |listed| raised <= listed } }) + end + + # `rescue Exception` in its place would swallow cancellation and process + # exits; an answer built after NoMemoryError would likely fail again. + it 'leaves out cancellation, process exits and NoMemoryError' do + passed_on = [Async::Stop, ClaudeAgentSDK::FiberBoundary::InlineCancellation, SystemExit, Interrupt, + SignalException, NoMemoryError, Exception] + + expect(passed_on).to all(satisfy { |raised| ClaudeAgentSDK::FiberBoundary::CALLBACK_FAILURES.none? { |listed| raised <= listed } }) + end + end +end diff --git a/spec/unit/callback_process_exit_spec.rb b/spec/unit/callback_process_exit_spec.rb index d8218e38..1fcfe00c 100644 --- a/spec/unit/callback_process_exit_spec.rb +++ b/spec/unit/callback_process_exit_spec.rb @@ -107,6 +107,13 @@ def failure_response?(path, response) end end + # The answer a stopped handler task writes (Query#handle_control_request's + # Async::Stop branch): an error control response on every path, SDK MCP + # requests included. + def cancelled_response?(response) + response.fetch('response').values_at('subtype', 'error') == %w[error Cancelled] + end + harness::PATHS.each do |path| harness::MODES.each do |mode| context "#{path} with #{mode} callback scheduling" do @@ -128,12 +135,17 @@ def failure_response?(path, response) # the main thread, where MRI delivers OS signals. A real Ctrl-C / SIGTERM # landing in CPU-bound callback code must still end the process. # - # WHEN the signal interrupts is the async gem's business, not ours: newer - # releases (2.46) defer SIGTERM until the running task yields, so the - # callback completes and its normal answer goes out first; older ones - # (2.36) interrupt the callback mid-flight, which then gets the error - # answer. The invariant either way: exactly one answer, then the process - # ends by that signal — never swallowed, never left running. + # WHEN and HOW the signal interrupts is the business of Ruby and the + # async gem, not ours. Three answers have been observed for a SIGTERM: + # async 2.46 on Ruby 3.4 defers it until the running task yields, so the + # callback completes and its normal answer goes out first; async 2.36 + # interrupts the callback mid-flight, which then gets the error answer + # naming the signal; on Ruby 4.0 (async 2.46) the handler task is stopped + # instead and answers 'Cancelled'. The invariant in every case: exactly + # one answer, for this request, then the process ends by that signal — + # never swallowed, never left running. Only this example accepts + # 'Cancelled': an exception the callback raises itself (above) must + # still be reported by name. %i[sigint sigterm].each do |kind| it "lets a real #{kind.upcase} delivered during an inline #{path} callback terminate the process" do out, err, status = CallbackExitChildren.result([path, :inline, kind]) @@ -142,7 +154,9 @@ def failure_response?(path, response) expect_terminated_like_ruby(status, err, kind) written = responses(out) expect(written.length).to eq(1), "expected exactly one response, got #{written.inspect}" - if failure_response?(path, written.first) + if cancelled_response?(written.first) + expect(written.first.dig('response', 'request_id')).to eq('req_fail') + elsif failure_response?(path, written.first) expect_failure_response(path, written.first, CallbackExitHarness::KINDS.fetch(kind)[:message]) else expect(written.first.dig('response', 'subtype')).to eq('success') diff --git a/spec/unit/cli_installer_durability_spec.rb b/spec/unit/cli_installer_durability_spec.rb new file mode 100644 index 00000000..aa3a869f --- /dev/null +++ b/spec/unit/cli_installer_durability_spec.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'digest' +require 'json' +require 'tmpdir' + +# Durability of CLIInstaller's publish step. rename() is atomic for concurrent +# readers and across a process crash, but across a power loss it only orders +# metadata: unless the bytes were fsynced first, the published name can come +# back pointing at an empty or partial file — a zero-length executable +# `claude` beside a VERSION that names it, which lock-free discovery then +# hands out. Nothing was fsynced before. +# +# A power cut cannot be staged in a spec, so these examples spy on fsync and +# rename and check that the syncs happen, and where in the sequence. +RSpec.describe ClaudeAgentSDK::CLIInstaller, 'durability of the publish step' do + let(:base) { 'https://downloads.claude.ai/claude-code-releases' } + let(:http) { ClaudeAgentSDK::CLIInstaller::Http } + let(:metadata) { ClaudeAgentSDK::CLIInstaller::Metadata } + let(:dir) { @dir } + let(:binary_path) { File.join(dir, 'claude') } + # [:fsync, name, size at that moment], [:fsync_failed, name], + # [:rename, from, to] and [:rename_failed, from, to], in order; the random + # part of a temp name is replaced by <hex>. + let(:events) { [] } + + around do |example| + Dir.mktmpdir('cli-installer-durability') do |tmp| + @dir = File.join(tmp, 'vendor', 'claude') + example.run + end + end + + def label(path) + return 'the install directory' if File.expand_path(path) == dir + + File.basename(path).sub(/[0-9a-f]{16}/, '<hex>') + end + + # Records every fsync and every rename, then lets the real one run. + # +failing+ maps a label to the error raised instead: by the fsync of that + # file, or by the rename onto that name. + def spy_on_syncs_and_renames(failing: {}) + spy_on_fsync(failing) + spy_on_rename(failing) + end + + def spy_on_rename(failing) + log = events + labeller = method(:label) + allow(File).to receive(:rename).and_wrap_original do |original, from, to| + names = [labeller.call(from), labeller.call(to)] + if failing.key?(names.last) + log << [:rename_failed, *names] + raise failing[names.last] + end + + original.call(from, to).tap { log << [:rename, *names] } + end + end + + def spy_on_fsync(failing) + log = events + labeller = method(:label) + allow_any_instance_of(File).to receive(:fsync).and_wrap_original do |original, *args| + path = original.receiver.path + name = labeller.call(path) + if failing.key?(name) + log << [:fsync_failed, name] + raise failing[name] + end + + original.call(*args).tap { log << [:fsync, name, File.directory?(path) ? nil : File.size(path)] } + end + end + + # The manifest GET is stubbed at Http.fetch_text; the binary comes through + # the real Http.download_to (only the response is canned), so the file is + # written and synced by the code under test. + def stub_release(version:, body:) + allow(ClaudeAgentSDK::CLIInstaller::Platform).to receive(:detect).and_return('linux-x64') + allow(http).to receive(:fetch_text).with("#{base}/#{version}/manifest.json", any_args).and_return( + JSON.generate('platforms' => { 'linux-x64' => { 'checksum' => Digest::SHA256.hexdigest(body), + 'size' => body.bytesize } }) + ) + response = Object.new + response.define_singleton_method(:read_body) { |&chunk| body.scan(/.{1,7}/m).each(&chunk) } + allow(http).to receive(:with_response) { |_url, &handler| handler.call(response) } + end + + it 'syncs the download and the metadata before their renames, and the directory after the last one' do + body = 'not-really-280MB-of-claude' + stub_release(version: '2.1.220', body: body) + spy_on_syncs_and_renames + + expect(described_class.install(version: '2.1.220', dir: dir)).to eq(binary_path) + + metadata_size = "2.1.220\n#{Digest::SHA256.hexdigest(body)}\nlinux-x64\n".bytesize + expect(events).to eq( + [ + [:fsync, 'claude.download.<hex>', body.bytesize], + [:fsync, 'VERSION.<hex>.tmp', metadata_size], + [:rename, 'VERSION.<hex>.tmp', 'VERSION'], + [:rename, 'claude.download.<hex>', 'claude'], + [:fsync, 'the install directory', nil] + ] + ) + end + + it 'syncs nothing on the idempotent shortcut, which writes nothing' do + stub_release(version: '2.1.220', body: 'not-really-280MB-of-claude') + described_class.install(version: '2.1.220', dir: dir) + spy_on_syncs_and_renames + + described_class.install(version: '2.1.220', dir: dir) + + expect(events).to be_empty + end + + # After the rename nothing may fail the install: the binary is published and + # VERSION vouches for it. Platforms and filesystems that cannot fsync a + # directory say so in different ways. + [Errno::EINVAL, Errno::EACCES, Errno::EBADF, Errno::ENOTSUP, IOError, NotImplementedError].each do |error| + it "still succeeds when the directory cannot be synced (#{error})" do + stub_release(version: '2.1.220', body: 'not-really-280MB-of-claude') + spy_on_syncs_and_renames(failing: { 'the install directory' => error }) + + expect(described_class.install(version: '2.1.220', dir: dir)).to eq(binary_path) + expect(File.binread(binary_path)).to eq('not-really-280MB-of-claude') + expect(events.last(2)).to eq([[:rename, 'claude.download.<hex>', 'claude'], + [:fsync_failed, 'the install directory']]) + end + end + + it 'still succeeds when the directory cannot even be opened for the sync' do + stub_release(version: '2.1.220', body: 'not-really-280MB-of-claude') + spy_on_syncs_and_renames + allow(File).to receive(:open).and_call_original + allow(File).to receive(:open).with(dir, File::RDONLY).and_raise(Errno::EACCES.new(dir)) + + expect(described_class.install(version: '2.1.220', dir: dir)).to eq(binary_path) + expect(File).to have_received(:open).with(dir, File::RDONLY) + expect(events.last).to eq([:rename, 'claude.download.<hex>', 'claude']) + end + + # What a FIRST install that fails can leave behind, as docs/cli-installer.md + # states it. VERSION is recorded before the binary is renamed into place + # (that order is what protects a working install during an upgrade), so a + # failure at that last step leaves a VERSION with nothing beside it. + context 'when a first install fails' do + let(:body) { 'not-really-280MB-of-claude' } + + before { stub_release(version: '2.1.220', body: body) } + + it 'publishes no binary but leaves a recorded VERSION when the last step, the rename, fails' do + spy_on_syncs_and_renames(failing: { 'claude' => Errno::EIO }) + + expect { described_class.install(version: '2.1.220', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, /Errno::EIO/) + + expect(events.last(2)).to eq([[:rename, 'VERSION.<hex>.tmp', 'VERSION'], + [:rename_failed, 'claude.download.<hex>', 'claude']]) + expect(Dir.children(dir)).to contain_exactly('.install.lock', 'VERSION') + expect(metadata.read(dir)) + .to eq(version: '2.1.220', checksum: Digest::SHA256.hexdigest(body), platform: 'linux-x64') + # Lock-free discovery finds no binary there and moves on. + expect(described_class.installed_path(dir: dir)).to be_nil + end + + it 'is redone cleanly by the next install, which does not trust the leftover VERSION' do + spy_on_syncs_and_renames(failing: { 'claude' => Errno::EIO }) + expect { described_class.install(version: '2.1.220', dir: dir) }.to raise_error(ClaudeAgentSDK::CLIInstallError) + events.clear + spy_on_syncs_and_renames + + expect(described_class.install(version: '2.1.220', dir: dir)).to eq(binary_path) + + expect(events.map(&:first)).to eq(%i[fsync fsync rename rename fsync]) + expect(File.binread(binary_path)).to eq(body) + expect(Dir.children(dir)).to contain_exactly('.install.lock', 'VERSION', 'claude') + end + + it 'leaves only the directory and its lock file when an earlier step fails' do + spy_on_syncs_and_renames(failing: { 'claude.download.<hex>' => Errno::EIO }) + + expect { described_class.install(version: '2.1.220', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, /Errno::EIO/) + + expect(events).to eq([[:fsync_failed, 'claude.download.<hex>']]) + expect(Dir.children(dir)).to contain_exactly('.install.lock') + expect(described_class.installed_path(dir: dir)).to be_nil + end + end + + context 'with a working install in place' do + before do + stub_release(version: '2.1.220', body: 'the installed build') + described_class.install(version: '2.1.220', dir: dir) + stub_release(version: '2.1.226', body: 'a newer build') + end + + def expect_previous_install_intact + expect(File.binread(binary_path)).to eq('the installed build') + expect(metadata.read(dir)).to include(version: '2.1.220') + expect(Dir.children(dir)).to contain_exactly('.install.lock', 'VERSION', 'claude') + end + + it 'fails the upgrade, publishing nothing, when the download cannot be synced' do + spy_on_syncs_and_renames(failing: { 'claude.download.<hex>' => Errno::EIO }) + + expect { described_class.install(version: '2.1.226', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, /Errno::EIO/) + + expect(events).to eq([[:fsync_failed, 'claude.download.<hex>']]) + expect_previous_install_intact + end + + it 'fails the upgrade, publishing nothing, when the metadata cannot be synced' do + spy_on_syncs_and_renames(failing: { 'VERSION.<hex>.tmp' => Errno::EIO }) + + expect { described_class.install(version: '2.1.226', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, /Errno::EIO/) + + expect(events).to eq([[:fsync, 'claude.download.<hex>', 'a newer build'.bytesize], + [:fsync_failed, 'VERSION.<hex>.tmp']]) + expect_previous_install_intact + end + end +end diff --git a/spec/unit/cli_installer_error_message_spec.rb b/spec/unit/cli_installer_error_message_spec.rb new file mode 100644 index 00000000..d3672c74 --- /dev/null +++ b/spec/unit/cli_installer_error_message_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +# The message of the CLIInstallError that wraps a filesystem failure names the +# install directory. When resolving that directory is itself what failed (the +# default one, with the process's working directory deleted under it), the +# message used to read "… into : Errno::ENOENT: … getcwd". +RSpec.describe ClaudeAgentSDK::CLIInstaller, 'the message of a wrapped filesystem failure' do + it 'names the default directory when the working directory no longer exists' do + gone = Dir.mktmpdir('cli-installer-cwd') + + Dir.chdir(gone) do + Dir.rmdir(gone) + + expect { described_class.install(version: '2.1.220') } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| + expect(error.message).to start_with('Failed to install the Claude Code CLI into vendor/claude: Errno::ENOENT') + expect(error.cause).to be_a(Errno::ENOENT) + } + end + end + + it 'names the explicit directory otherwise' do + Dir.mktmpdir('cli-installer-message') do |dir| + blocked = File.join(dir, 'a-file') + File.write(blocked, 'not a directory') + + expect { described_class.install(version: '2.1.220', dir: File.join(blocked, 'claude')) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, /\AFailed to install the Claude Code CLI into #{Regexp.escape(blocked)}/) + end + end +end diff --git a/spec/unit/cli_installer_fiber_lock_spec.rb b/spec/unit/cli_installer_fiber_lock_spec.rb new file mode 100644 index 00000000..f880ca14 --- /dev/null +++ b/spec/unit/cli_installer_fiber_lock_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' +require 'rbconfig' +require 'tmpdir' + +# CLIInstaller.install called from two fibers of ONE reactor, into one +# directory. The install lock used to be a blocking flock(LOCK_EX), and +# File#flock has no Fiber-scheduler hook: the second fiber blocked the reactor +# THREAD while the first one — the lock holder — was parked in a +# scheduler-aware wait inside the critical section, so the holder was never +# resumed and neither install returned. +# +# Runs in a child process: when this regresses, the thread that calls install +# blocks forever, which in-process would hang the suite instead of failing it. +RSpec.describe ClaudeAgentSDK::CLIInstaller, 'called from two fibers of one reactor' do + # The child's own watchdog fires first and says where the reactor thread is + # stuck; the parent's bound only covers a child that cannot even do that. + # Both are far above the fraction of a second the installs take. + child_watchdog_seconds = 30 + parent_bound_seconds = 120 + + # ARGV: the install directory and the point inside the critical section + # where the lock holder waits for the second fiber ('download' or 'resolve'). + # HTTP is replaced at the Http module level, as in cli_installer_spec.rb. + child_script = <<~RUBY + require 'async' + require 'claude_agent_sdk/cli_installer' + require 'digest' + require 'json' + + dir, parking_point = ARGV + installer = ClaudeAgentSDK::CLIInstaller + body = 'not-really-280MB-of-claude' + manifest = JSON.generate( + 'platforms' => { 'linux-x64' => { 'checksum' => Digest::SHA256.hexdigest(body), 'size' => body.bytesize } } + ) + # Closed by the second fiber on its way to the lock. Until then the lock + # holder is parked on it — a scheduler-aware wait inside the critical + # section, which is what the real socket read is. Once closed, pop returns + # at once, so the second fiber never parks here itself. + gate = Thread::Queue.new + downloads = 0 + + installer::Platform.define_singleton_method(:detect) { 'linux-x64' } + installer::Http.define_singleton_method(:fetch_text) do |url, limit:| + if url.end_with?('/stable') + gate.pop if parking_point == 'resolve' + "2.1.220\\n" + elsif url.end_with?('/2.1.220/manifest.json') + manifest + else + raise "unexpected fetch_text(\#{url.inspect})" + end + end + installer::Http.define_singleton_method(:download_to) do |_url, path, max_bytes: nil| + downloads += 1 + gate.pop if parking_point == 'download' + File.binwrite(path, body) + path + end + + # A regression blocks the reactor thread inside File#flock, which releases + # the GVL — this thread still runs and can report where the main one is. + Thread.new do + sleep #{child_watchdog_seconds} + warn "DEADLOCK: neither install returned; main thread at \#{Thread.main.backtrace.first(2).join(' <- ')}" + exit!(3) + end + + # 'resolve': the pinned version is already installed, and both fibers ask + # for the dist-tag — nothing needs installing, yet the tag is resolved + # inside the lock, so the holder still waits on the network there. + installer.install(version: '2.1.220', dir: dir) if parking_point == 'resolve' + version = parking_point == 'resolve' ? 'stable' : '2.1.220' + + paths = Async do |task| + first = task.async { installer.install(version: version, dir: dir) } + second = task.async do + gate.close + installer.install(version: version, dir: dir) + end + [first.wait, second.wait] + end.wait + + puts "paths=\#{paths.inspect} downloads=\#{downloads}" + RUBY + + around do |example| + # Created (and removed) here rather than in the child, so a child that is + # killed mid-install leaves nothing behind. + Dir.mktmpdir('cli-installer-fibers') do |dir| + @dir = dir + example.run + end + end + + let(:dir) { @dir } + let(:binary_path) { File.join(dir, 'claude') } + + define_method(:run_two_fibers) do |parking_point| + lib_dir = File.expand_path('../../lib', __dir__) + Open3.popen3(RbConfig.ruby, '-I', lib_dir, '-e', child_script, dir, parking_point) do |stdin, stdout, stderr, waiter| + stdin.close + unless waiter.join(parent_bound_seconds) + Process.kill('KILL', waiter.pid) + waiter.join + end + [stdout.read, stderr.read, waiter.value] + end + end + + it 'finishes both installs when the lock holder is parked in its download' do + out, err, status = run_two_fibers('download') + + expect(status.exitstatus).to eq(0), "child: #{status.inspect}\n#{err}" + expect(out).to include("paths=#{[binary_path, binary_path].inspect} downloads=1") + expect(File.binread(binary_path)).to eq('not-really-280MB-of-claude') + expect(Dir.children(dir)).to contain_exactly('.install.lock', 'VERSION', 'claude') + end + + it 'finishes both installs of an installed dist-tag while the holder resolves it' do + out, err, status = run_two_fibers('resolve') + + expect(status.exitstatus).to eq(0), "child: #{status.inspect}\n#{err}" + # The one download is the setup install; neither fiber needed another. + expect(out).to include("paths=#{[binary_path, binary_path].inspect} downloads=1") + end +end diff --git a/spec/unit/cli_installer_http_spec.rb b/spec/unit/cli_installer_http_spec.rb new file mode 100644 index 00000000..407cf2d2 --- /dev/null +++ b/spec/unit/cli_installer_http_spec.rb @@ -0,0 +1,293 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'net/http' +require 'stringio' +require 'tmpdir' + +# CLIInstaller::Http itself. The other installer specs replace +# Http.fetch_text / Http.download_to wholesale; here the only seam is +# Net::HTTP.start, so everything above the socket is the real code: proxy +# resolution, the HTTPS-only check, redirects, status handling and body +# streaming. Responses are real Net::HTTPResponse objects, parsed from the raw +# bytes a server would send — stdlib only, no HTTP stubbing library. +RSpec.describe ClaudeAgentSDK::CLIInstaller::Http do + proxy_variables = %w[http_proxy HTTP_PROXY https_proxy HTTPS_PROXY all_proxy ALL_PROXY no_proxy NO_PROXY].freeze + + # The proxy settings of whoever runs the suite must not leak into an + # example, and what an example sets must not leak out. + around do |example| + saved = proxy_variables.to_h { |name| [name, ENV.fetch(name, nil)] } + proxy_variables.each { |name| ENV.delete(name) } + example.run + ensure + saved.each { |name, value| value.nil? ? ENV.delete(name) : ENV[name] = value } + end + + # One entry per Net::HTTP.start call, in order: where it connected, the + # explicit proxy arguments (none = Net::HTTP's own environment lookup), the + # session options, and the request sent over it. + let(:connections) { [] } + + def raw_response(status, headers = {}, body = '') + head = { 'Content-Length' => body.bytesize }.merge(headers).map { |name, value| "#{name}: #{value}\r\n" }.join + "HTTP/1.1 #{status}\r\n#{head}\r\n#{body}" + end + + # Answers the n-th connection with the n-th raw response, read the way + # Net::HTTP#transport_request reads one off the socket. + def stub_connections(*raw_responses) + allow(Net::HTTP).to receive(:start) do |address, port, *proxy, **options, &session| + raw = raw_responses.shift or raise "unexpected connection to #{address}" + connection = { address: address, port: port, proxy: proxy, options: options } + connections << connection + session.call(connection_answering(raw, connection)) + end + end + + def connection_answering(raw, connection) + http = Object.new + http.define_singleton_method(:request) do |request, &handler| + connection[:request] = request + socket = Net::BufferedIO.new(StringIO.new(raw)) + response = Net::HTTPResponse.read_new(socket) + response.decode_content = request.decode_content + response.reading_body(socket, request.response_body_permitted?) { handler.call(response) } + end + http + end + + describe 'proxy selection' do + # TEST-NET-1 literals: URI#find_proxy resolves the host before applying + # no_proxy, and a literal resolves without DNS. (Not a loopback address — + # find_proxy never proxies those.) + let(:url) { 'https://192.0.2.1/claude-code-releases/stable' } + let(:version_response) { raw_response('200 OK', { 'Content-Type' => 'text/plain' }, "2.1.285\n") } + + def proxy_used_for(url) + stub_connections(version_response) + expect(described_class.fetch_text(url, limit: 1024)).to eq("2.1.285\n") + connections.last[:proxy] + end + + it 'connects through the proxy HTTPS_PROXY names' do + ENV['HTTPS_PROXY'] = 'http://proxy.corp.example:3128' + + expect(proxy_used_for(url)).to eq(['proxy.corp.example', 3128, nil, nil]) + expect(connections.last).to include(address: '192.0.2.1', port: 443) + end + + it 'reads the lowercase https_proxy too' do + ENV['https_proxy'] = 'http://proxy.corp.example:3128' + + expect(proxy_used_for(url)).to eq(['proxy.corp.example', 3128, nil, nil]) + end + + it 'passes the proxy credentials, percent-decoded' do + ENV['HTTPS_PROXY'] = 'http://alice:p%40ss%3Aword@proxy.corp.example:3128' + + expect(proxy_used_for(url)).to eq(['proxy.corp.example', 3128, 'alice', 'p@ss:word']) + end + + it 'unbrackets an IPv6 proxy address and defaults the port' do + ENV['HTTPS_PROXY'] = 'http://[2001:db8::10]' + + expect(proxy_used_for(url)).to eq(['2001:db8::10', 80, nil, nil]) + end + + it 'passes no proxy when NO_PROXY lists the host' do + ENV['HTTPS_PROXY'] = 'http://proxy.corp.example:3128' + ENV['NO_PROXY'] = 'localhost,192.0.2.1' + + expect(proxy_used_for(url)).to eq([]) + end + + it 'passes no proxy when nothing is set' do + expect(proxy_used_for(url)).to eq([]) + end + + it 'leaves an environment that only sets http_proxy to Net::HTTP, as before' do + # No explicit arguments: Net::HTTP's own lookup reads http_proxy, which + # is how the installer reached a proxy before it read HTTPS_PROXY. + ENV['http_proxy'] = 'http://proxy.corp.example:3128' + + expect(proxy_used_for(url)).to eq([]) + end + + it 'does not consult ALL_PROXY' do + ENV['ALL_PROXY'] = 'http://proxy.corp.example:3128' + + expect(proxy_used_for(url)).to eq([]) + end + + it 'resolves the proxy again for every redirect hop' do + ENV['HTTPS_PROXY'] = 'http://proxy.corp.example:3128' + ENV['NO_PROXY'] = '192.0.2.99' + stub_connections(raw_response('302 Found', { 'Location' => 'https://192.0.2.99/claude' }), + raw_response('200 OK', {}, 'binary')) + + expect(described_class.fetch_text(url, limit: 1024)).to eq('binary') + expect(connections.map { |c| [c[:address], c[:proxy]] }) + .to eq([['192.0.2.1', ['proxy.corp.example', 3128, nil, nil]], ['192.0.2.99', []]]) + end + + # Values that were ignored while the installer did not read HTTPS_PROXY at + # all, and that Net::HTTP could not use as an HTTP proxy anyway. Passing + # them on would turn a download that works directly into a failure. + ['proxy.corp.example:3128', '127.0.0.1:3128', 'socks5://127.0.0.1:1080', + 'https://proxy.corp.example', 'http://proxy with spaces:3128', 'http://:3128'].each do |value| + it "still ignores HTTPS_PROXY=#{value}, which is not an http:// proxy URL" do + ENV['HTTPS_PROXY'] = value + + expect(proxy_used_for(url)).to eq([]) + end + end + end + + # With no proxy variable set, find_proxy answers before resolving anything, + # so these can name the real release host. + let(:release_url) { 'https://downloads.claude.ai/claude-code-releases/2.1.285/darwin-arm64/claude' } + + def redirect_to(location, body = '') + raw_response('302 Found', { 'Location' => location }, body) + end + + describe 'the HTTPS-only rule' do + it 'refuses an http:// URL before opening any connection' do + expect(Net::HTTP).not_to receive(:start) + + expect { described_class.fetch_text('http://downloads.claude.ai/claude-code-releases/stable', limit: 1024) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, + 'Refusing to fetch non-HTTPS URL: http://downloads.claude.ai/claude-code-releases/stable') + end + + it 'refuses a redirect from https to http without following it' do + stub_connections(redirect_to('http://mirror.example/claude'), raw_response('200 OK', {}, 'plaintext')) + + expect { described_class.fetch_text(release_url, limit: 1024) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, 'Refusing to fetch non-HTTPS URL: http://mirror.example/claude') + expect(connections.map { |c| c[:address] }).to eq(['downloads.claude.ai']) + end + + it 'opens every connection with TLS and peer verification, on the first hop and after a redirect' do + # use_ssl: true is what puts TLS on the wire: Net::HTTP would speak TLS + # to the host of an http:// URL too. The scheme check above keeps + # HTTP-shaped URLs out; it is not what encrypts the transfer. + stub_connections(redirect_to('https://cdn.example/claude'), raw_response('200 OK', {}, 'binary')) + + described_class.fetch_text(release_url, limit: 1024) + + expect(connections.map { |c| [c[:address], c[:port]] }).to eq([['downloads.claude.ai', 443], ['cdn.example', 443]]) + expect(connections.map { |c| c[:options] }).to all( + eq(use_ssl: true, open_timeout: described_class::OPEN_TIMEOUT_SECONDS, + read_timeout: described_class::READ_TIMEOUT_SECONDS) + ) + end + end + + describe 'redirects' do + it 'sends a GET for the URL and returns what the block makes of the response' do + stub_connections(raw_response('200 OK', { 'Content-Type' => 'text/plain' }, "2.1.285\n")) + + expect(described_class.fetch_text('https://downloads.claude.ai/claude-code-releases/stable', limit: 1024)) + .to eq("2.1.285\n") + request = connections.first[:request] + expect(request).to be_a(Net::HTTP::Get) + expect(request.path).to eq('/claude-code-releases/stable') + expect(request['host']).to eq('downloads.claude.ai') + end + + it 'follows up to five redirects' do + hops = (1..5).map { |n| redirect_to("https://hop#{n}.example/claude") } + stub_connections(*hops, raw_response('200 OK', {}, 'binary')) + + expect(described_class.fetch_text(release_url, limit: 1024)).to eq('binary') + expect(connections.map { |c| c[:address] }) + .to eq(%w[downloads.claude.ai hop1.example hop2.example hop3.example hop4.example hop5.example]) + end + + it 'raises "Too many redirects" on the sixth, after exactly six requests' do + hops = (1..8).map { |n| redirect_to("https://hop#{n}.example/claude") } + stub_connections(*hops) + + expect { described_class.fetch_text(release_url, limit: 1024) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, + 'Too many redirects while fetching https://hop5.example/claude') + expect(connections.size).to eq(6) + end + + it 'resolves a relative Location against the URL that sent it' do + stub_connections(redirect_to('/mirror/2.1.285/claude'), raw_response('200 OK', {}, 'binary')) + + described_class.fetch_text(release_url, limit: 1024) + + expect(connections.last[:address]).to eq('downloads.claude.ai') + expect(connections.last[:request].path).to eq('/mirror/2.1.285/claude') + end + + it 'raises on a redirect without a Location header' do + stub_connections(raw_response('302 Found')) + + expect { described_class.fetch_text(release_url, limit: 1024) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, "Redirect from #{release_url} is missing a Location header") + end + end + + describe 'failures' do + it 'raises with the status line of a 404' do + stub_connections(raw_response('404 Not Found', { 'Content-Type' => 'application/xml' }, '<Error>NoSuchKey</Error>')) + + expect { described_class.fetch_text(release_url, limit: 1024) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, "HTTP 404 Not Found for #{release_url}") + end + + it 'wraps a SocketError in CLIInstallError, keeping it as the cause' do + allow(Net::HTTP).to receive(:start).and_raise( + SocketError, 'getaddrinfo: nodename nor servname provided, or not known' + ) + + expect { described_class.fetch_text(release_url, limit: 1024) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| + expect(error.message).to eq( + "Failed to fetch #{release_url}: SocketError: getaddrinfo: nodename nor servname provided, or not known" + ) + expect(error.cause).to be_a(SocketError) + } + end + + it 'lets a CLIInstallError from the body handler through unwrapped' do + stub_connections(raw_response('200 OK', {}, 'x' * 2048)) + + expect { described_class.fetch_text(release_url, limit: 1024) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, "Response from #{release_url} exceeds the 1024-byte limit") + end + end + + describe 'what reaches the target file' do + around do |example| + Dir.mktmpdir('cli-installer-http') do |dir| + @target = File.join(dir, 'claude.download.0123456789abcdef') + example.run + end + end + + let(:target) { @target } + + it 'streams only the final 200 body, never a redirect body' do + chunked = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" \ + "6\r\nbinary\r\n7\r\n-bytes!\r\n0\r\n\r\n" + stub_connections(redirect_to('https://cdn.example/claude', '<html>Moved</html>'), chunked) + + expect(described_class.download_to(release_url, target, max_bytes: 13)).to eq(target) + expect(File.binread(target)).to eq('binary-bytes!') + end + + it 'creates no file for an error page' do + stub_connections(raw_response('503 Service Unavailable', {}, '<html>Try again later</html>')) + + expect { described_class.download_to(release_url, target) } + .to raise_error(ClaudeAgentSDK::CLIInstallError, "HTTP 503 Service Unavailable for #{release_url}") + expect(File.exist?(target)).to be false + end + end +end diff --git a/spec/unit/cli_installer_read_only_spec.rb b/spec/unit/cli_installer_read_only_spec.rb new file mode 100644 index 00000000..cfaba5fe --- /dev/null +++ b/spec/unit/cli_installer_read_only_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'digest' +require 'fileutils' +require 'json' +require 'tmpdir' + +# The offline, idempotent shortcut of CLIInstaller.install in a directory the +# process cannot write: an image built as root and run as another user, or a +# read-only root filesystem. The shortcut sits inside the install lock, and the +# lock file is opened read-write first — so a boot-time install_pinned raised +# EACCES although the pinned binary was installed and intact. +RSpec.describe ClaudeAgentSDK::CLIInstaller, 'in a read-only install directory' do + let(:base) { 'https://downloads.claude.ai/claude-code-releases' } + let(:binary_body) { 'not-really-280MB-of-claude' } + let(:http) { ClaudeAgentSDK::CLIInstaller::Http } + let(:dir) { @dir } + let(:binary_path) { File.join(dir, 'claude') } + let(:lock_path) { File.join(dir, '.install.lock') } + + around do |example| + Dir.mktmpdir('cli-installer-read-only') do |tmp| + @dir = File.join(tmp, 'vendor', 'claude') + example.run + ensure + # mktmpdir cannot remove a directory it may not write. + FileUtils.chmod(0o755, @dir) if File.directory?(@dir) + end + end + + # The two GET endpoints, as cli_installer_spec.rb stubs them: no HTTP + # library, the real Digest / Metadata / rename path. + def stub_release(version: '2.1.220', platform: 'linux-x64') + allow(ClaudeAgentSDK::CLIInstaller::Platform).to receive(:detect).and_return(platform) + allow(http).to receive(:fetch_text) do |url, **| + case url + when "#{base}/stable" then "#{version}\n" + when "#{base}/#{version}/manifest.json" + entry = { 'checksum' => Digest::SHA256.hexdigest(binary_body), 'size' => binary_body.bytesize } + JSON.generate('platforms' => { platform => entry }) + else raise "unexpected fetch_text(#{url.inspect})" + end + end + allow(http).to receive(:download_to) do |_url, path, **| + File.binwrite(path, binary_body) + path + end + end + + def expect_no_network + expect(http).not_to receive(:fetch_text) + expect(http).not_to receive(:download_to) + end + + def expect_permission_failure(error) + expect(error.message).to include("Failed to install the Claude Code CLI into #{dir}", 'Errno::EACCES') + expect(error.cause).to be_a(Errno::EACCES) + end + + before do + stub_release + described_class.install(version: '2.1.220', dir: dir) + end + + # chmod stands in for the foreign owner, so the process must be one that + # file modes apply to. + context 'when file modes deny writing' do + before { skip 'file modes do not restrict root' if Process.uid.zero? } + + # What the runtime user of an image built by another user sees: everything + # readable and executable, nothing writable. + def make_read_only(lock_file: :present) + lock_file == :present ? File.chmod(0o444, lock_path) : File.delete(lock_path) + File.chmod(0o555, dir) + end + + it 'returns the installed binary for its concrete version, offline and without the lock' do + make_read_only + expect_no_network + + expect(described_class.install(version: '2.1.220', dir: dir)).to eq(binary_path) + end + + it 'returns the installed binary when the directory has no lock file to open' do + # The install directory was copied into the image without its dotfile. + make_read_only(lock_file: :absent) + expect_no_network + + expect(described_class.install(version: '2.1.220', dir: dir)).to eq(binary_path) + expect(Dir.children(dir)).to contain_exactly('VERSION', 'claude') + end + + it 'serves install_pinned the same way' do + stub_const('ClaudeAgentSDK::CLIInstaller::PINNED_CLI_VERSION', '2.1.220') + make_read_only + expect_no_network + + expect(described_class.install_pinned(dir: dir)).to eq(binary_path) + end + + it 'raises for a dist-tag, which has to be resolved and may have to be published' do + make_read_only + expect_no_network + + expect { described_class.install(version: 'stable', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| expect_permission_failure(error) } + end + + it 'raises when the binary no longer matches its recorded checksum' do + File.binwrite(binary_path, 'truncated-or-swapped') + make_read_only + expect_no_network + + expect { described_class.install(version: '2.1.220', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| expect_permission_failure(error) } + end + + it 'raises when another version is requested than the one installed' do + make_read_only + expect_no_network + + expect { described_class.install(version: '2.1.226', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| expect_permission_failure(error) } + end + + it 'raises when the binary was installed for another platform' do + make_read_only + allow(ClaudeAgentSDK::CLIInstaller::Platform).to receive(:detect).and_return('linux-arm64-musl') + expect_no_network + + expect { described_class.install(version: '2.1.220', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| expect_permission_failure(error) } + end + + it 'raises when nothing is installed' do + FileUtils.rm_f([binary_path, File.join(dir, 'VERSION')]) + make_read_only + expect_no_network + + expect { described_class.install(version: '2.1.220', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| expect_permission_failure(error) } + end + end + + # Errors chmod cannot produce: a read-only filesystem (EROFS) and a denied + # operation (EPERM: an immutable file, a sandbox profile). Injected where + # the kernel would raise them — at the open of the lock file. + context 'when opening the lock file fails' do + def fail_lock_open(error) + allow(File).to receive(:open).and_call_original + allow(File).to receive(:open).with(lock_path, any_args).and_raise(error) + end + + [Errno::EROFS, Errno::EPERM, Errno::EACCES].each do |errno| + it "returns the installed binary for its concrete version on #{errno}" do + fail_lock_open(errno.new(lock_path)) + expect_no_network + + expect(described_class.install(version: '2.1.220', dir: dir)).to eq(binary_path) + end + + it "still raises for a dist-tag on #{errno}" do + fail_lock_open(errno.new(lock_path)) + expect_no_network + + expect { described_class.install(version: 'stable', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| expect(error.cause).to be_a(errno) } + end + end + + it 'raises on any other failure to open the lock file, even when the binary is intact' do + # A symlink planted at the lock path (opened NOFOLLOW) is not a + # read-only directory, and neither is running out of descriptors. + [Errno::ELOOP, Errno::EMFILE].each do |errno| + fail_lock_open(errno.new(lock_path)) + + expect { described_class.install(version: '2.1.220', dir: dir) } + .to raise_error(ClaudeAgentSDK::CLIInstallError) { |error| expect(error.cause).to be_a(errno) } + end + end + end +end diff --git a/spec/unit/cli_installer_spec.rb b/spec/unit/cli_installer_spec.rb index 343b4dc2..1f94172f 100644 --- a/spec/unit/cli_installer_spec.rb +++ b/spec/unit/cli_installer_spec.rb @@ -77,9 +77,11 @@ def eventually?(timeout: 5) true end - # True while +thread+ is parked inside File#flock (its innermost frame). - def blocked_in_flock?(thread) - thread.backtrace_locations&.first&.base_label == 'flock' + # True while +thread+ is waiting for the install lock: in with_install_lock's + # poll loop, asleep between attempts or inside the non-blocking flock call. + def waiting_for_install_lock?(thread) + innermost, caller = thread.backtrace_locations&.first(2)&.map(&:base_label) + %w[sleep flock].include?(innermost) && caller == 'with_install_lock' end describe '.default_dir' do @@ -680,11 +682,11 @@ def stub_response(*chunks) contended = nil allow(http).to receive(:download_to) do |_url, path, **| counter_mutex.synchronize { downloads += 1 } - # Hold the lock until the other installer is observably blocked in - # flock (bounded poll) rather than sleeping and hoping it got there. + # Hold the lock until the other installer is observably waiting for + # it (bounded poll) rather than sleeping and hoping it got there. # Without the lock the other thread would reach its own download # instead: the poll times out and downloads ends up 2. - contended = eventually? { threads.any? { |t| !t.equal?(Thread.current) && blocked_in_flock?(t) } } + contended = eventually? { threads.any? { |t| !t.equal?(Thread.current) && waiting_for_install_lock?(t) } } File.binwrite(path, binary_body) path end diff --git a/spec/unit/cli_path_pathname_spec.rb b/spec/unit/cli_path_pathname_spec.rb new file mode 100644 index 00000000..6b5bf483 --- /dev/null +++ b/spec/unit/cli_path_pathname_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'pathname' +require 'tmpdir' +require 'fileutils' + +# ClaudeAgentOptions#cli_path is signed `(String | Pathname)?`, and +# `Rails.root.join('vendor/claude/claude')` is the obvious value to pass. +# The version probe stringified it; the spawn did not. Real spawns here: an +# argv assertion alone would not have caught the TypeError. +RSpec.describe ClaudeAgentSDK::SubprocessCLITransport, 'cli_path given as a Pathname' do + after { described_class.active_processes.clear } + + around do |example| + previous = ENV.fetch('CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK', nil) + ENV.delete('CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK') + Dir.mktmpdir('cli-pathname-spec') do |dir| + @root = File.realpath(dir) + example.run + end + ensure + ENV['CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'] = previous if previous + end + + # The fake answers `-v` at once, but on a starved machine "at once" can + # exceed the probe's 2 s deadline, which would drop the probe's marker line. + before { stub_const("#{described_class}::VERSION_CHECK_TIMEOUT_SECONDS", 60) } + + attr_reader :root + + def marker = File.join(root, 'legs') + + # Records the version probe (`-v`) and the session spawn separately. + def install_fake_cli(relative_path) + path = File.join(root, relative_path) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, <<~SH) + #!/bin/sh + if [ "$1" = "-v" ]; then + echo 'probe' >> '#{marker}' + echo '2.1.286 (Claude Code)' + exit 0 + fi + echo 'spawn' >> '#{marker}' + SH + File.chmod(0o755, path) + path + end + + def run_session(transport) + transport.connect + transport.read_messages { |_frame| nil } + File.readlines(marker, chomp: true) + ensure + transport.close + end + + it 'spawns the CLI an absolute Pathname names' do + cli = Pathname.new(install_fake_cli('bin/claude')) + transport = described_class.new(ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: cli)) + + expect(run_session(transport)).to eq(%w[probe spawn]) + end + + it 'resolves a relative Pathname like a relative String: against the process cwd' do + install_fake_cli('app/bin/claude') + FileUtils.mkdir_p(File.join(root, 'target')) + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + cli_path: Pathname.new('bin/claude'), cwd: Pathname.new(File.join(root, 'target')) + ) + + legs = Dir.chdir(File.join(root, 'app')) { run_session(described_class.new(options)) } + + expect(legs).to eq(%w[probe spawn]) + end + + it 'hands CommandBuilder a String' do + cli = install_fake_cli('bin/claude') + transport = described_class.new(ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: Pathname.new(cli))) + + expect(transport.build_command.first).to be_a(String).and eq(cli) + end +end diff --git a/spec/unit/cli_path_resolution_spec.rb b/spec/unit/cli_path_resolution_spec.rb new file mode 100644 index 00000000..0e0b7b62 --- /dev/null +++ b/spec/unit/cli_path_resolution_spec.rb @@ -0,0 +1,467 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' + +# The version probe runs in the process's working directory; the session is +# spawned with `chdir: options.cwd`. A CLI path that is still relative when +# it reaches the spawn names one file for the probe and another for the +# session — whatever sits at that path inside the directory the agent was +# pointed at (CWE-427). These examples run real executables: every fake +# `claude` appends "<leg>:<its own path>" to a marker file, so the assertions +# are about which FILE ran for which leg, not about what Open3 was handed. +RSpec.describe ClaudeAgentSDK::SubprocessCLITransport, 'CLI path resolution' do + after { described_class.active_processes.clear } + + # `app` is the process cwd, `target` is options.cwd. Canonicalized: the + # macOS tmpdir lives behind the /var symlink, and Dir.pwd reports the + # resolved path. + around do |example| + Dir.mktmpdir('cli-path-spec') do |dir| + @root = File.realpath(dir) + FileUtils.mkdir_p([app, target]) + with_env('CLAUDE_CLI_PATH' => nil, 'CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK' => nil) { example.run } + end + end + + # Keep a vendored binary (CLIInstaller.root may point anywhere on a + # developer machine) out of the discovery examples. + before { allow(ClaudeAgentSDK::CLIInstaller).to receive(:installed_path).and_return(nil) } + + # The examples assert on the marker line the probe leg writes. The fakes + # answer `-v` at once, but on a starved machine "at once" can exceed the + # probe's 2 s deadline, which would silently drop that line. + before { stub_const("#{described_class}::VERSION_CHECK_TIMEOUT_SECONDS", 60) } + + attr_reader :root + + def app = File.join(root, 'app') + def target = File.join(root, 'target') + def marker = File.join(root, 'legs') + + def with_env(changes) + previous = changes.to_h { |key, _| [key, ENV.fetch(key, nil)] } + changes.each { |key, value| value.nil? ? ENV.delete(key) : (ENV[key] = value) } + yield + ensure + previous.each { |key, value| value.nil? ? ENV.delete(key) : (ENV[key] = value) } + end + + # PATH with +entry+ in front of whatever the host has. + def path_with(entry) + "#{entry}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH')}" + end + + # A stand-in `claude` at +relative_path+ under the tmp root. `-v` is the + # SDK's version probe; anything else is the session spawn. + def install_fake_cli(relative_path) + path = File.join(root, relative_path) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, <<~SH) + #!/bin/sh + if [ "$1" = "-v" ]; then + echo 'probe:#{relative_path}' >> '#{marker}' + echo '2.1.286 (Claude Code)' + exit 0 + fi + echo 'spawn:#{relative_path}' >> '#{marker}' + SH + File.chmod(0o755, path) + path + end + + def options(**values) + ClaudeAgentSDK::ClaudeAgentOptions.new(cwd: target, **values) + end + + def legs + File.exist?(marker) ? File.readlines(marker, chomp: true) : [] + end + + # Construct and connect from the process cwd, then read to EOF: the fake + # has exited, so both legs are on record. + def legs_executed(transport_class = described_class, **values) + Dir.chdir(app) do + transport = transport_class.new(options(**values)) + begin + transport.connect + transport.read_messages { |_frame| nil } + ensure + transport.close + end + end + legs + end + + # `link` -> `real/sub`, so `link/..` is `real` on the filesystem but the + # root itself when collapsed as text. Fakes on both sides tell them apart. + def symlinked_dotdot(name) + FileUtils.mkdir_p(File.join(root, 'real', 'sub')) + File.symlink(File.join(root, 'real', 'sub'), File.join(root, 'link')) + install_fake_cli("real/bin/#{name}") + install_fake_cli("bin/#{name}") + File.join(root, 'link', '..', 'bin') + end + + describe 'an explicit cli_path' do + it 'resolves a relative path against the process cwd, for the probe and the spawn' do + install_fake_cli('app/bin/claude') + install_fake_cli('target/bin/claude') + + expect(legs_executed(cli_path: 'bin/claude')).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + end + + it 'leaves an absolute path alone' do + cli = install_fake_cli('app/bin/claude') + install_fake_cli('target/bin/claude') + + expect(legs_executed(cli_path: cli)).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + end + + it 'expands a leading ~ to the home directory, like CLAUDE_CLI_PATH' do + install_fake_cli('app/bin/claude') + + with_env('HOME' => app) do + expect(legs_executed(cli_path: '~/bin/claude')).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + end + end + + it 'reports a missing file as CLINotFoundError at connect, not at construction' do + install_fake_cli('target/bin/missing') + transport = Dir.chdir(app) { described_class.new(options(cli_path: 'bin/missing')) } + + expect { Dir.chdir(app) { transport.connect } } + .to raise_error(ClaudeAgentSDK::CLINotFoundError, %r{Claude Code not found at: .*bin/missing}) + expect(legs).to be_empty + end + + it 'reports a ~user path it cannot expand as CLINotFoundError at connect' do + transport = Dir.chdir(app) { described_class.new(options(cli_path: '~no-such-user-for-this-spec/bin/claude')) } + + expect { Dir.chdir(app) { transport.connect } }.to raise_error(ClaudeAgentSDK::CLINotFoundError) + end + end + + # Names no host has on PATH: if the lookup regressed, an example must fail + # with CLINotFoundError, not fall through to the developer's real `claude` + # (which would then wait for a prompt on stdin and hang the run). + describe 'an explicit bare name' do + it 'is found through a relative PATH entry in the process cwd, for the probe and the spawn' do + install_fake_cli('app/bin/fake-claude-cli') + install_fake_cli('target/bin/fake-claude-cli') + + with_env('PATH' => path_with('bin')) do + expect(legs_executed(cli_path: 'fake-claude-cli')) + .to eq(%w[probe:app/bin/fake-claude-cli spawn:app/bin/fake-claude-cli]) + end + end + + it 'treats `.` and an empty PATH entry as the process cwd' do + install_fake_cli('app/fake-claude-cli') + install_fake_cli('target/fake-claude-cli') + + aggregate_failures do + ['.', ''].each do |entry| + FileUtils.rm_f(marker) + with_env('PATH' => path_with(entry)) do + expect(legs_executed(cli_path: 'fake-claude-cli')) + .to eq(%w[probe:app/fake-claude-cli spawn:app/fake-claude-cli]), "PATH entry #{entry.inspect}: #{legs.inspect}" + end + end + end + end + + # Ruby's spawn tests for an executable regular file; a directory of the + # same name earlier on PATH (executable? is true for directories) is + # passed over. + it 'takes the first executable regular file on PATH, not a directory of that name' do + FileUtils.mkdir_p(File.join(root, 'dirs', 'fake-claude-cli')) + install_fake_cli('tools/fake-claude-cli') + search = [File.join(root, 'dirs'), File.join(root, 'tools'), ENV.fetch('PATH')].join(File::PATH_SEPARATOR) + + with_env('PATH' => search) do + expect(legs_executed(cli_path: 'fake-claude-cli')) + .to eq(%w[probe:tools/fake-claude-cli spawn:tools/fake-claude-cli]) + end + end + + it 'treats a trailing empty PATH entry as the process cwd' do + install_fake_cli('app/claude-wrapper') + install_fake_cli('target/claude-wrapper') + + with_env('PATH' => "#{ENV.fetch('PATH')}#{File::PATH_SEPARATOR}") do + expect(legs_executed(cli_path: 'claude-wrapper')) + .to eq(%w[probe:app/claude-wrapper spawn:app/claude-wrapper]) + end + end + + # As spawn's own lookup did for a bare name: a `~/` entry is the home + # directory (HOME is the tmp root here), not `~` under the cwd. The same + # rule as for discovery, pinned there too. + it 'expands a `~/` PATH entry against HOME, not against the process cwd' do + install_fake_cli('app/~/bin/fake-claude-cli') + install_fake_cli('bin/fake-claude-cli') + + with_env('PATH' => path_with('~/bin'), 'HOME' => root) do + expect(legs_executed(cli_path: 'fake-claude-cli')) + .to eq(%w[probe:bin/fake-claude-cli spawn:bin/fake-claude-cli]) + end + end + + # spawn searched the PATH it was about to give the child, so that is the + # PATH which decides here too — and the probe now runs the same file. + it 'is searched on the PATH from options.env, for the probe and the spawn' do + install_fake_cli('tools/claude-wrapper') + env = { 'PATH' => path_with(File.join(root, 'tools')) } + + expect(legs_executed(cli_path: 'claude-wrapper', env: env)) + .to eq(%w[probe:tools/claude-wrapper spawn:tools/claude-wrapper]) + end + + it 'anchors a relative entry of the options.env PATH to the process cwd as well' do + install_fake_cli('app/bin/fake-claude-cli') + install_fake_cli('target/bin/fake-claude-cli') + + expect(legs_executed(cli_path: 'fake-claude-cli', env: { PATH: path_with('bin') })) + .to eq(%w[probe:app/bin/fake-claude-cli spawn:app/bin/fake-claude-cli]) + end + + it 'reports a name that is not on PATH as CLINotFoundError at connect, not at construction' do + transport = Dir.chdir(app) { described_class.new(options(cli_path: 'claude-that-is-not-installed')) } + + expect { Dir.chdir(app) { transport.connect } }.to raise_error( + ClaudeAgentSDK::CLINotFoundError, 'Claude Code not found at: claude-that-is-not-installed' + ) + end + + # A name the SDK could not settle must not reach spawn: spawn searches + # PATH on its own, in the parent, and then runs a relative hit after + # chdir-ing into options.cwd. Here the SDK's lookup is made to miss the + # process-cwd file (File.file? is its test); spawn's own lookup, done in + # C, would still find it and run target/bin/unsettled-claude. + it 'never hands an unresolved name to spawn' do + install_fake_cli('app/bin/unsettled-claude') + install_fake_cli('target/bin/unsettled-claude') + allow(File).to receive(:file?).and_call_original + allow(File).to receive(:file?).with(File.join(app, 'bin', 'unsettled-claude')).and_return(false) + + with_env('PATH' => path_with('bin')) do + transport = Dir.chdir(app) { described_class.new(options(cli_path: 'unsettled-claude')) } + + expect { Dir.chdir(app) { transport.connect } }.to raise_error(ClaudeAgentSDK::CLINotFoundError) + ensure + transport&.close + end + expect(legs).to be_empty + end + + it 'is looked up again at connect, as spawn did: a CLI installed after construction is found' do + with_env('PATH' => path_with(File.join(root, 'tools'))) do + transport = Dir.chdir(app) { described_class.new(options(cli_path: 'claude-installed-later')) } + install_fake_cli('tools/claude-installed-later') + + begin + Dir.chdir(app) do + transport.connect + transport.read_messages { |_frame| nil } + end + ensure + transport.close + end + end + + expect(legs).to eq(%w[probe:tools/claude-installed-later spawn:tools/claude-installed-later]) + end + end + + # Discovery looks for `claude` itself, on the process's PATH. HOME at the + # tmp root keeps the well-known `~/...` install locations empty, so a + # regression in the PATH step fails here instead of reaching the + # developer's real CLI. + describe 'discovery (no cli_path)' do + it 'runs the hit of a relative PATH entry from the process cwd, for the probe and the spawn' do + install_fake_cli('app/bin/claude') + install_fake_cli('target/bin/claude') + + with_env('PATH' => path_with('bin'), 'HOME' => root) do + expect(legs_executed).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + end + end + + it 'runs the hit of a `.` PATH entry from the process cwd' do + install_fake_cli('app/claude') + install_fake_cli('target/claude') + + with_env('PATH' => path_with('.'), 'HOME' => root) do + expect(legs_executed).to eq(%w[probe:app/claude spawn:app/claude]) + end + end + + # An empty entry is the current directory to a PATH lookup, and the easy + # one to lose in a lookup written by hand (a split that drops empty + # strings is enough). The first entry names nothing, so only the + # trailing empty one can find `claude` here. + it 'treats a trailing empty PATH entry as the process cwd' do + install_fake_cli('app/claude') + install_fake_cli('target/claude') + search = "#{File.join(root, 'no-such-directory')}#{File::PATH_SEPARATOR}" + + with_env('PATH' => search, 'HOME' => root) do + expect(legs_executed).to eq(%w[probe:app/claude spawn:app/claude]) + end + end + + # A PATH entry that is `~` or starts with `~/` is the home directory to + # Ruby's own command lookup (system, spawn, Open3; sh and bash agree), + # not a directory called `~` under the cwd — only `which` and execvp read + # it literally. The SDK spawns through Open3, so it resolves the name the + # way Ruby would: on purpose, this is not one of the relative entries + # anchored to the process cwd. HOME is the tmp root here. + it "expands a `~/` PATH entry against HOME, as Ruby's own lookup does" do + install_fake_cli('app/~/bin/claude') + install_fake_cli('bin/claude') + search = ['~/bin', '/usr/bin', '/bin'].join(File::PATH_SEPARATOR) + + with_env('PATH' => search, 'HOME' => root) do + expect(legs_executed).to eq(%w[probe:bin/claude spawn:bin/claude]) + end + end + + # `which` is a program like any other: looked up on PATH — here through + # the relative `bin` entry — and free to answer anything. This one names + # the binary inside options.cwd. Discovery searches PATH itself, so it + # neither runs `which` nor has an answer to believe. + it 'does not run or believe a `which` found on PATH' do + install_fake_cli('app/bin/claude') + install_fake_cli('target/bin/claude') + lying_which = File.join(app, 'bin', 'which') + File.write(lying_which, <<~SH) + #!/bin/sh + echo 'which-ran' >> '#{marker}' + echo '#{File.join(target, 'bin', 'claude')}' + SH + File.chmod(0o755, lying_which) + search = ['bin', '/usr/bin', '/bin'].join(File::PATH_SEPARATOR) + + with_env('PATH' => search, 'HOME' => root) do + expect(legs_executed).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + end + end + + # A PATH in options.env is the session's. Discovery searches the PATH + # `which claude` used to see, the process's; only an explicit bare + # cli_path is looked up on the session's. + it 'searches the process PATH, not the one options.env gives the session' do + install_fake_cli('app/bin/claude') + install_fake_cli('tools/claude') + session_env = { 'PATH' => path_with(File.join(root, 'tools')) } + + with_env('PATH' => path_with('bin'), 'HOME' => root) do + expect(legs_executed(env: session_env)).to eq(%w[probe:app/bin/claude spawn:app/bin/claude]) + end + end + + it 'puts the anchored absolute path in the argv before connecting' do + install_fake_cli('app/bin/claude') + + transport = with_env('PATH' => path_with('bin'), 'HOME' => root) do + Dir.chdir(app) { described_class.new(options) } + end + + expect(transport.build_command.first).to eq(File.join(app, 'bin', 'claude')) + end + end + + # #build_command is not public API, but a transport that runs the CLI + # through another program (docker exec, ssh) has no other hook, and its + # cli_path names a file on the far side. The refusal to spawn an unsettled + # path is about the SDK's own argv; an argv like this is spawned as built. + describe 'a subclass that wraps the CLI in another program' do + it 'still spawns its argv when cli_path is a bare name this host does not have' do + wrapper = File.join(root, 'wrapper') + File.write(wrapper, "#!/bin/sh\necho \"wrapped:$1\" >> '#{marker}'\n") + File.chmod(0o755, wrapper) + wrapping = Class.new(described_class) do + define_method(:build_command) { [wrapper, *super()] } + end + transport = Dir.chdir(app) { wrapping.new(options(cli_path: 'claude-on-the-far-side')) } + + begin + transport.connect + transport.read_messages { |_frame| nil } + ensure + transport.close + end + + expect(legs).to eq(['wrapped:claude-on-the-far-side']) + end + end + + # spawn resolved `link/..` through the filesystem; a path rewritten as + # text (File.expand_path) would name another file when `link` is a + # symlink. Paths are anchored, never normalized. + describe 'a symlink followed by `..`' do + it 'runs an absolute cli_path the way the filesystem resolves it' do + bin = symlinked_dotdot('fake-claude-cli') + + expect(legs_executed(cli_path: File.join(bin, 'fake-claude-cli'))) + .to eq(%w[probe:real/bin/fake-claude-cli spawn:real/bin/fake-claude-cli]) + end + + it 'searches a PATH entry the way the filesystem resolves it' do + bin = symlinked_dotdot('fake-claude-cli') + + with_env('PATH' => path_with(bin)) do + expect(legs_executed(cli_path: 'fake-claude-cli')) + .to eq(%w[probe:real/bin/fake-claude-cli spawn:real/bin/fake-claude-cli]) + end + end + + it 'discovers `claude` through such a PATH entry and keeps the entry as written' do + bin = symlinked_dotdot('claude') + + # HOME at the tmp root: no well-known install location to fall back on. + transport = with_env('PATH' => path_with(bin), 'HOME' => root) do + Dir.chdir(app) { described_class.new(options) } + end + + expect(transport.build_command.first).to eq(File.join(bin, 'claude')) + end + end + + describe 'inputs that worked before this resolution existed' do + it 'treats any falsy cli_path as "discover"', rbs_incompatible: 'cli_path: false is outside the signature' do + env_cli = install_fake_cli('tools/claude-from-env') + + with_env('CLAUDE_CLI_PATH' => env_cli) do + expect(legs_executed(cli_path: false)).to eq(%w[probe:tools/claude-from-env spawn:tools/claude-from-env]) + end + end + + it "settles a bare name from a subclass's #find_cli like an explicit cli_path" do + install_fake_cli('tools/claude-from-subclass') + discovering = Class.new(described_class) { define_method(:find_cli) { 'claude-from-subclass' } } + + with_env('PATH' => path_with(File.join(root, 'tools'))) do + expect(legs_executed(discovering)) + .to eq(%w[probe:tools/claude-from-subclass spawn:tools/claude-from-subclass]) + end + end + + it 'searches a PATH that is not valid UTF-8 byte by byte' do + install_fake_cli('tools/claude-bytewise') + not_utf8 = "#{root}/\xFFdir".force_encoding(Encoding::UTF_8) + env = { 'PATH' => "#{not_utf8}#{File::PATH_SEPARATOR}#{File.join(root, 'tools')}" } + + expect(legs_executed(cli_path: 'claude-bytewise', env: env)) + .to eq(%w[probe:tools/claude-bytewise spawn:tools/claude-bytewise]) + end + + it 'reports a cli_path with a NUL byte as a CLIConnectionError, not a raw ArgumentError' do + transport = Dir.chdir(app) { described_class.new(options(cli_path: "bin/cl\0aude")) } + + expect { Dir.chdir(app) { transport.connect } }.to raise_error(ClaudeAgentSDK::CLIConnectionError) + end + end +end diff --git a/spec/unit/client_receive_after_stream_end_spec.rb b/spec/unit/client_receive_after_stream_end_spec.rb new file mode 100644 index 00000000..330e57c1 --- /dev/null +++ b/spec/unit/client_receive_after_stream_end_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'timeout' + +# Once the CLI's stdout has ended — the process exited, cleanly or not — the +# read loop is gone and nothing is ever written to the message queue again. +# The read loop leaves ONE end-of-stream marker behind, so the end has to be +# remembered for every later Client#receive_response / #receive_messages: +# forgotten once the first reader consumed the marker, the next one waits on +# an empty queue that has no producer, for as long as the process lives. +# +# Each call below runs under a bounded wait, so a call that parks fails the +# example instead of hanging the suite. A call that ends does so without +# suspending, long before that bound could matter. +RSpec.describe ClaudeAgentSDK::Client, 'receiving after the CLI stream has ended' do + # Queue-backed stand-in for the CLI process: +on_user_message+ scripts what + # it sends back for a prompt, and how its stdout ends. + let(:transport_class) do + Class.new do + def initialize(_options, on_user_message:) + @on_user_message = on_user_message + @stdout = Thread::Queue.new + end + + def connect; end + + def ready? + true + end + + def end_input; end + + def close + exit_cleanly + end + + def write(line) + frame = JSON.parse(line, symbolize_names: true) + if frame[:type] == 'control_request' && frame.dig(:request, :subtype) == 'initialize' + send_frame(type: 'control_response', + response: { subtype: 'success', request_id: frame[:request_id], + response: { commands: [], agents: [], output_style: 'default', models: [], + pid: 4242, session_state: 'idle', capabilities: [] }, + pending_permission_requests: [], pending_user_dialog_requests: [] }) + elsif frame[:type] == 'user' + @on_user_message.call(self) + end + end + + def send_frame(frame) + @stdout << frame + end + + # stdout reaches EOF after exit status 0. + def exit_cleanly + @stdout << :eof + end + + # What SubprocessCLITransport#read_messages raises at EOF when the CLI + # exited non-zero. + def crash(exit_code:, stderr:) + @stdout << ClaudeAgentSDK::ProcessError.new("Command failed with exit code #{exit_code}", + exit_code: exit_code, stderr: stderr) + end + + def read_messages + loop do + item = @stdout.pop + break if item == :eof + raise item if item.is_a?(Exception) + + yield item + end + end + end + end + + let(:session_id) { 'c0ffee00-0000-4000-8000-000000000001' } + + def assistant_frame + { type: 'assistant', parent_tool_use_id: nil, session_id: session_id, uuid: SecureRandom.uuid, + message: { id: 'msg_01', type: 'message', role: 'assistant', model: 'claude-haiku-4-5-20251001', + content: [{ type: 'text', text: 'Four.' }], stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 3 } } } + end + + def result_frame + sample_result_message.merge(session_id: session_id, result: 'Four.', stop_reason: 'end_turn', + terminal_reason: 'completed', uuid: SecureRandom.uuid) + end + + # One turn against a CLI scripted by +on_user_message+; yields the client + # once the prompt is written and the CLI's reaction is queued. + def with_session(on_user_message) + Async do |task| + client = described_class.new(transport_class: transport_class, + transport_args: { on_user_message: on_user_message }) + client.connect + client.query('What is 2 + 2?') + yield client, task + ensure + client&.disconnect + end.wait + end + + # What one receive call did: the message classes it yielded, or :parked + # when it was still waiting at the bound. + def receive_within_deadline(task, client, method) + seen = [] + task.with_timeout(5) { client.public_send(method) { |message| seen << message.class } } + seen + rescue Async::TimeoutError + :parked + end + + let(:cli_exits_after_the_turn) do + lambda do |cli| + cli.send_frame(assistant_frame) + cli.send_frame(result_frame) + cli.exit_cleanly + end + end + + let(:cli_crashes_mid_turn) do + lambda do |cli| + cli.send_frame(assistant_frame) + cli.crash(exit_code: 1, stderr: 'Killed') + end + end + + %i[receive_response receive_messages].each do |method| + describe "##{method}" do + it 'ends at once on every call after a clean end of stream' do + with_session(cli_exits_after_the_turn) do |client, task| + calls = Array.new(3) { receive_within_deadline(task, client, method) } + + expect(calls).to eq([[ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage], [], []]) + end + end + + it 'raises the stream error once, then ends at once on every later call' do + with_session(cli_crashes_mid_turn) do |client, task| + seen_before_the_crash = [] + expect do + task.with_timeout(5) { client.public_send(method) { |message| seen_before_the_crash << message.class } } + end.to raise_error(ClaudeAgentSDK::ProcessError, /exit code 1/) + expect(seen_before_the_crash).to eq([ClaudeAgentSDK::AssistantMessage]) + + expect(Array.new(2) { receive_within_deadline(task, client, method) }).to eq([[], []]) + end + end + end + end + + # A Client can outlive its reactor: once the CLI has exited, the read loop + # is over, the `Async { }` that connected finishes, and the application may + # still call receive_* from plain Ruby. The end of the stream has to be + # remembered there too, where nothing can be put back on the queue (async + # < 2.29 cannot enqueue outside a task). + describe 'from plain Ruby, after the reactor has finished' do + def finished_session(on_user_message) + client = described_class.new(transport_class: transport_class, + transport_args: { on_user_message: on_user_message }) + Async do + client.connect + client.query('What is 2 + 2?') + end.wait # the CLI exited: the read loop ended, and the reactor with it + client + end + + # As #receive above; there is no task here, so the bound is a plain Timeout. + def receive_without_a_reactor(client, method) + seen = [] + Timeout.timeout(5) { client.public_send(method) { |message| seen << message.class } } + seen + rescue Timeout::Error + :parked + end + + %i[receive_response receive_messages].each do |method| + it "ends ##{method} at once on every call after a clean end of stream" do + client = finished_session(cli_exits_after_the_turn) + calls = Array.new(3) { receive_without_a_reactor(client, method) } + + expect(calls).to eq([[ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage], [], []]) + end + + it "raises the stream error from ##{method} once, then ends at once on every later call" do + client = finished_session(cli_crashes_mid_turn) + seen_before_the_crash = [] + expect do + Timeout.timeout(5) { client.public_send(method) { |message| seen_before_the_crash << message.class } } + end.to raise_error(ClaudeAgentSDK::ProcessError, /exit code 1/) + expect(seen_before_the_crash).to eq([ClaudeAgentSDK::AssistantMessage]) + + expect(Array.new(2) { receive_without_a_reactor(client, method) }).to eq([[], []]) + end + end + end +end diff --git a/spec/unit/client_teardown_under_deadline_spec.rb b/spec/unit/client_teardown_under_deadline_spec.rb new file mode 100644 index 00000000..830a7000 --- /dev/null +++ b/spec/unit/client_teardown_under_deadline_spec.rb @@ -0,0 +1,320 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'fileutils' +require 'securerandom' +require 'tmpdir' + +# Client#disconnect notifies on_close, and a failing Client#connect notifies +# on_error, BEFORE they tear the session down. Whatever is raised into the +# caller's fiber while it waits for that observer must not skip the teardown: +# the transport is closed, the read loop ends and the materialized resume dir +# (it holds a credentials copy) is removed, and only then does the exception +# reach the caller. +# +# What reaches the caller differs by scheduling. With the default :thread +# scheduling a task.with_timeout expiry propagates. With :inline the observer +# runs on the caller's fiber, so that same expiry lands inside the observer and +# is contained with the observer's own failures (a known residue). What does +# get through under :inline is a cancellation that is not a StandardError: +# FiberBoundary's cooperative timeouts (an inline hook's timeout) and +# Async::Stop. +# +# The observers block on a queue until the example releases them, so the +# deadlines can only expire while they wait. disconnect reaches its observer +# without suspending. connect suspends before it reaches on_error (resume +# materialization, the handshake), so there the real task.with_timeout is +# armed only around the observer notification: no slow setup can use it up. +RSpec.describe ClaudeAgentSDK::Client, 'teardown when the caller is interrupted during an observer' do + # In-memory stand-in for the CLI process. It answers `initialize` the way + # CLI 2.1.286 answers control requests (payload trimmed), or rejects it + # with the error frame shape the CLI uses for a refused request. + let(:fake_cli_class) do + Class.new do + attr_reader :config_dir + + def initialize(options, reject_initialize: false) + @config_dir = options.env['CLAUDE_CONFIG_DIR'] # the materialized resume dir + @reject_initialize = reject_initialize + @stdout = Thread::Queue.new + @closed = false + @read_loop_ended = false + end + + def connect; end + + def ready? + !@closed + end + + def end_input; end + + def closed? + @closed + end + + def read_loop_ended? + @read_loop_ended + end + + def inspect + "#<fake CLI closed=#{@closed} read_loop_ended=#{@read_loop_ended}>" + end + + def close + @closed = true + @stdout << :eof + end + + def write(line) + frame = JSON.parse(line, symbolize_names: true) + return unless frame[:type] == 'control_request' && frame.dig(:request, :subtype) == 'initialize' + + @stdout << { type: 'control_response', response: initialize_response(frame[:request_id]) } + end + + def read_messages + while (frame = @stdout.pop) != :eof + yield frame + end + ensure + @read_loop_ended = true + end + + private + + def initialize_response(request_id) + if @reject_initialize + { subtype: 'error', request_id: request_id, error: 'Invalid initialize request' } + else + { subtype: 'success', request_id: request_id, + response: { commands: [], agents: [], output_style: 'default', models: [], pid: 4242, + session_state: 'idle', capabilities: [] }, + pending_permission_requests: [], pending_user_dialog_requests: [] } + end + end + end + end + + let(:cwd) { Dir.mktmpdir('client-teardown-cwd-') } + let(:user_config_dir) { Dir.mktmpdir('client-teardown-config-') } # keeps the host's Keychain out of it + let(:session_id) { SecureRandom.uuid } + let(:store) do + ClaudeAgentSDK::InMemorySessionStore.new.tap do |store| + store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id }, + [{ 'type' => 'user', 'uuid' => SecureRandom.uuid, 'message' => { 'content' => 'hi' } }]) + end + end + let(:release) { Thread::Queue.new } + let(:entered) { Thread::Queue.new } + let(:handed) { [] } + + after do + release.close + FileUtils.remove_entry(cwd) if File.directory?(cwd) + FileUtils.remove_entry(user_config_dir) if File.directory?(user_config_dir) + end + + # Client materializes a store-backed resume only for SubprocessCLITransport + # and its subclasses, so the class it is given is one; its .new hands back + # the in-memory stand-in. No subprocess is spawned. + def transport_class(created) + fake = fake_cli_class + Class.new(ClaudeAgentSDK::SubprocessCLITransport) do + define_singleton_method(:new) { |options, **kwargs| fake.new(options, **kwargs).tap { |cli| created << cli } } + end + end + + # An observer that stays in +hook+ until the example releases it. on_error + # waits only for the handshake rejection: handed anything else it returns + # at once, and the example's gate (#expect_on_error_reached) fails. + def blocking_observer(hook) + queues = { entered: entered, release: release, handed: handed } + Class.new do + include ClaudeAgentSDK::Observer + + define_method(hook) do |*args| + queues[:handed] << (args.first || :called) + next if hook == :on_error && !args.first.message.include?('Invalid initialize request') + + queues[:entered] << true + queues[:release].pop + end + end.new + end + + def client_for(hook, scheduling:, reject_initialize: false) + created = [] + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + session_store: store, resume: session_id, cwd: cwd, env: { 'CLAUDE_CONFIG_DIR' => user_config_dir }, + observers: [blocking_observer(hook)], callback_scheduling: scheduling + ) + client = described_class.new(options: options, transport_class: transport_class(created), + transport_args: { reject_initialize: reject_initialize }) + [client, created] + end + + # What the caller got from the call: :returned, or the exception it raised. + def outcome_of + yield + :returned + rescue Exception => e # rubocop:disable Lint/RescueException -- the deadline may be a bare Exception + e + end + + def expect_torn_down(client, cli) + aggregate_failures do + expect(cli).to be_closed + expect(cli).to be_read_loop_ended + expect(File.exist?(cli.config_dir)).to be(false) # the materialized resume dir is gone + expect { client.query('again') }.to raise_error(ClaudeAgentSDK::CLIConnectionError, /Not connected/) + end + end + + # The cooperative-timeout class FiberBoundary uses for inline callbacks. + def inline_cancellation + Class.new(ClaudeAgentSDK::FiberBoundary::InlineCancellation) + end + + describe 'Client#disconnect while on_close runs' do + def connected_client(scheduling) + client, created = client_for(:on_close, scheduling: scheduling) + client.connect + [client, created.fetch(0)] + end + + it 'tears the session down, then raises the expired deadline (:thread)' do + Sync do |task| + client, cli = connected_client(:thread) + outcome = outcome_of { task.with_timeout(0.05) { client.disconnect } } + + expect(outcome).to be_a(Async::TimeoutError) + expect_torn_down(client, cli) + ensure + release.close + client&.disconnect + end + end + + it 'tears the session down, then raises an inline cooperative cancellation (:inline)' do + Sync do |task| + client, cli = connected_client(:inline) + cancellation = inline_cancellation + outcome = outcome_of { task.with_timeout(0.05, cancellation) { client.disconnect } } + + expect(outcome).to be_a(cancellation) + expect_torn_down(client, cli) + ensure + release.close + client&.disconnect + end + end + + it 'tears the session down when the caller task is stopped (:thread)' do + Sync do |task| + client, cli = connected_client(:thread) + caller_task = task.async { client.disconnect } + entered.pop # on_close is running + caller_task.stop + caller_task.wait + + expect_torn_down(client, cli) + ensure + release.close + client&.disconnect + end + end + + it 'tears the session down while the observer contains a deadline it ran into (:inline, residue)' do + Sync do |task| + client, cli = connected_client(:inline) + outcome = outcome_of { task.with_timeout(0.05) { client.disconnect } } + + expect(outcome).to eq(:returned) # the expiry landed inside the observer, which contains it + expect_torn_down(client, cli) + ensure + release.close + client&.disconnect + end + end + end + + describe 'a failing Client#connect while on_error runs' do + # Arms a real task.with_timeout on the connecting fiber for exactly the + # time Client spends notifying on_error. + def arm_deadline_around_on_error(client, seconds, *exception_class) + allow(client).to receive(:notify_error).and_wrap_original do |notify, error| + Async::Task.current.with_timeout(seconds, *exception_class) { notify.call(error) } + end + end + + # The gate of every example here: on_error ran, with the handshake rejection. + def expect_on_error_reached + expect(handed).to match([have_attributes(class: StandardError, message: 'Invalid initialize request')]) + end + + it 'tears the partial session down, then raises the expired deadline (:thread)' do + Sync do + client, created = client_for(:on_error, scheduling: :thread, reject_initialize: true) + arm_deadline_around_on_error(client, 0.05) + outcome = outcome_of { client.connect } + + expect_on_error_reached + expect(outcome).to be_a(Async::TimeoutError) + expect_torn_down(client, created.fetch(0)) + ensure + release.close + client&.disconnect + end + end + + it 'tears the partial session down, then raises an inline cooperative cancellation (:inline)' do + Sync do + client, created = client_for(:on_error, scheduling: :inline, reject_initialize: true) + cancellation = inline_cancellation + arm_deadline_around_on_error(client, 0.05, cancellation) + outcome = outcome_of { client.connect } + + expect_on_error_reached + expect(outcome).to be_a(cancellation) + expect_torn_down(client, created.fetch(0)) + ensure + release.close + client&.disconnect + end + end + + it 'tears the partial session down when the caller task is stopped (:thread)' do + Sync do |task| + client, created = client_for(:on_error, scheduling: :thread, reject_initialize: true) + caller_task = task.async { client.connect } + entered.pop # on_error is running with the handshake rejection + caller_task.stop + caller_task.wait + + expect_on_error_reached + expect_torn_down(client, created.fetch(0)) + ensure + release.close + client&.disconnect + end + end + + it 'tears the partial session down and raises the connect error while the observer contains a deadline ' \ + '(:inline, residue)' do + Sync do + client, created = client_for(:on_error, scheduling: :inline, reject_initialize: true) + arm_deadline_around_on_error(client, 0.05) + outcome = outcome_of { client.connect } + + expect_on_error_reached + expect(outcome).to be_a(StandardError).and(have_attributes(message: 'Invalid initialize request')) + expect_torn_down(client, created.fetch(0)) + ensure + release.close + client&.disconnect + end + end + end +end diff --git a/spec/unit/close_from_another_thread_spec.rb b/spec/unit/close_from_another_thread_spec.rb new file mode 100644 index 00000000..410994e1 --- /dev/null +++ b/spec/unit/close_from_another_thread_spec.rb @@ -0,0 +1,397 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'timeout' + +# Query#close — and so Client#disconnect — may be called from a thread other +# than the one whose reactor owns the session: an application thread that +# cancels a session, or a tool handler / hook / can_use_tool callback on its +# :thread-mode worker. Async::Task#stop needs the owning reactor, so such a +# caller hands the close to a task on that reactor and waits for it. +# +# The task that takes the request is an idle watcher, and it is `transient` +# so that a session nobody closes never keeps its reactor alive. A transient +# task is also the one kind a reactor does not wait for. The close stops the +# read loop, the end of the stream wakes the session's own task, and once +# that task has nothing left to do the reactor is finished: it used to wind +# down and stop the watcher at the first point where the transport's teardown +# waited for the CLI. SubprocessCLITransport then does what it does for a +# cancelled close — SIGTERM — instead of closing stdin and giving the CLI the +# grace period it needs for its last session write, and the caller was +# released before the child was reaped. The close now runs in a task the +# reactor waits for. +# +# No clock orders anything below. The one bound, around each example, only +# turns a lost wake-up into a failure instead of a hung suite. +RSpec.describe ClaudeAgentSDK::Query, 'closed from another thread' do + # Stand-in for SubprocessCLITransport, with the part of its #close that + # matters here: stdin EOF, then a WAIT for the CLI to exit — a suspension + # point on the reactor, like wait_process_with_timeout — and SIGTERM from + # the ensure when the close is unwound while it waits. The journal reads + # like the CLI's own event log. + let(:transport_class) do + Class.new do + attr_reader :journal, :teardown_task, :teardown_task_was_transient + + def initialize(_options = nil, **_transport_args) + @stdout = Thread::Queue.new + @journal = [] + # Closed queues as latches: #pop parks a fiber or a thread until + # #close, and returns at once ever after. + @stdin_closed = Thread::Queue.new + @exited = Thread::Queue.new + @closing = false + end + + def connect; end + + def ready? + !@closing + end + + # What CLI 2.1.286 answers to `initialize` (payload trimmed). + def write(line) + raise ClaudeAgentSDK::CLIConnectionError, 'ProcessTransport is not ready for writing' if @closing + + frame = JSON.parse(line, symbolize_names: true) + return unless frame[:type] == 'control_request' && frame.dig(:request, :subtype) == 'initialize' + + deliver(type: 'control_response', + response: { subtype: 'success', request_id: frame[:request_id], + response: { commands: [], agents: [], output_style: 'default', models: [], + pid: 4242, session_state: 'idle', capabilities: [] }, + pending_permission_requests: [], pending_user_dialog_requests: [] }) + end + + def deliver(frame) + @stdout << frame + end + + def read_messages + while (frame = @stdout.pop) + yield frame + end + end + + def end_input + close_stdin + end + + def close + return if @closing + + @closing = true + @teardown_task = Async::Task.current? + @teardown_task_was_transient = @teardown_task&.transient? + graceful = false + begin + close_stdin + @exited.pop # parks the closing task until the CLI is gone + @journal << :cli_exited + graceful = true + ensure + @journal << :sigterm unless graceful + @stdout.close + end + end + + # Parks the caller until the teardown has closed stdin. + def wait_for_stdin_eof + @stdin_closed.pop + end + + def exit_cli + @exited.close + end + + # What the CLI does about stdin EOF: it exits. + def exit_on_stdin_eof + wait_for_stdin_eof + exit_cli + end + + # The CLI's stdout ends: the read loop is over, the process is not. + def end_stdout + @stdout.close + end + + private + + def close_stdin + return if @stdin_closed.closed? + + @journal << :stdin_eof + @stdin_closed.close + end + end + end + + let(:transport) { transport_class.new } + let(:query) { described_class.new(transport: transport, is_streaming_mode: true) } + let(:threads) { [] } + + around do |example| + Timeout.timeout(60) { example.run } + ensure + threads.each { |thread| reap(thread) } + end + + def in_thread(&block) + thread = Thread.new do + Thread.current.report_on_exception = false + block.call + end + threads << thread + thread + end + + # A failed example can leave a thread parked; one that raised has already + # failed the example through its own join. + def reap(thread) + thread.kill + thread.join + rescue StandardError + nil + end + + # The session on a reactor of its own. Nothing was asked of the CLI, so its + # task parks until the close ends the stream — and then has nothing left to + # do. Its last act scripts the CLI (exit on stdin EOF) from the reactor's + # own thread. That puts the wake-up of the teardown, which is waiting for + # that exit, behind the end of the task: a reactor that winds down as soon + # as the task is over stops the teardown before it can see the exit. + def session_with_nothing_left_to_do(parked) + in_thread do + Sync do + query.start + parked << true + query.receive_messages { |_message| nil } + transport.exit_on_stdin_eof + end + end + end + + describe 'when the session task has nothing left to do' do + it 'closes stdin and waits for the CLI to exit before it returns' do + parked = Thread::Queue.new + session = session_with_nothing_left_to_do(parked) + parked.pop + + query.close + transport.journal << :close_returned + session.join + + expect(transport.journal).to eq(%i[stdin_eof cli_exited close_returned]) + end + + it 'runs the teardown on a task the reactor waits for' do + parked = Thread::Queue.new + session = session_with_nothing_left_to_do(parked) + parked.pop + + query.close + session.join + + expect(transport.teardown_task_was_transient).to be(false) + end + + # The watcher is a child of the task that started the session. When that + # task and the read loop are both over, async moves it up the tree — here + # all the way to the reactor, which unrelated work keeps alive. A task + # spawned through the reactor from there would become a child of the + # watcher, and a reactor only counts its own children. + it 'does so after the watcher has been re-parented to the reactor' do + started = Thread::Queue.new + stream_ended = Thread::Queue.new + session = in_thread do + Fiber.set_scheduler(Async::Scheduler.new) + Fiber.schedule { query.start } # over at once; the read loop keeps it in the tree + Fiber.schedule do # unrelated work on the same reactor + started << true + query.receive_messages { |_message| nil } + stream_ended << true + transport.exit_on_stdin_eof + end + ensure + Fiber.set_scheduler(nil) # runs the reactor until its tasks have finished + end + started.pop + transport.end_stdout + stream_ended.pop + expect(query.instance_variable_get(:@close_watcher).parent).to be_a(Async::Scheduler) + + query.close + transport.journal << :close_returned + session.join + + expect(transport.journal).to eq(%i[stdin_eof cli_exited close_returned]) + end + end + + # What the watcher's `ensure` guaranteed still holds: a teardown that is + # itself cancelled — the transport TERMs the CLI then — never strands the + # caller. + it 'still releases the caller when the teardown itself is stopped' do + parked = Thread::Queue.new + session = in_thread do + Sync do |task| + query.start + parked << true + query.receive_messages { |_message| nil } + transport.wait_for_stdin_eof + task.stop # the application stops the session's tasks, the teardown among them + end + end + parked.pop + + query.close + transport.journal << :close_returned + session.join + + expect(transport.journal).to eq(%i[stdin_eof sigterm close_returned]) + end + + # A waiting caller checks every 100 ms that the reactor-side task answering + # it is still alive, and falls back to a direct close when it is not (the + # reactor is gone). While a teardown is in flight that check has to see the + # task running it. The session's own task stays busy in these examples, so + # no reactor winds down: the teardown is simply slow. + describe 'while the teardown is still waiting for the CLI' do + # Reports, as the asking Thread, every check whether +task+ is alive. + def report_liveness_checks(task, signals) + task.define_singleton_method(:alive?) do + signals << Thread.current + super() + end + end + + def busy_session(hold) + started = Thread::Queue.new + session = in_thread do + Sync do + query.start + started << true + hold.pop + end + end + started.pop + session + end + + def closing_thread(signals) + in_thread do + query.close + transport.journal << :close_returned + signals << :returned + end + end + + it 'does not return before the CLI has exited, however long that takes' do + hold = Thread::Queue.new + session = busy_session(hold) + signals = Thread::Queue.new + closer = closing_thread(signals) + transport.wait_for_stdin_eof + report_liveness_checks(transport.teardown_task, signals) + + # Until the caller has checked twice — or has (wrongly) returned. + checks = 0 + while checks < 2 + signal = signals.pop + break if signal == :returned + + checks += 1 if signal == closer + end + transport.exit_cli + closer.join + hold << true + session.join + + expect(transport.journal).to eq(%i[stdin_eof cli_exited close_returned]) + end + + it 'keeps a second caller waiting for the same teardown' do + hold = Thread::Queue.new + session = busy_session(hold) + signals = Thread::Queue.new + first = closing_thread(signals) + transport.wait_for_stdin_eof + report_liveness_checks(transport.teardown_task, signals) + second = closing_thread(signals) + + # Until the second caller has seen the teardown in flight — or has + # (wrongly) returned. + loop do + signal = signals.pop + break if signal == :returned || signal == second + end + transport.exit_cli + [first, second].each(&:join) + hold << true + session.join + + expect(transport.journal).to eq(%i[stdin_eof cli_exited close_returned close_returned]) + end + end + + describe 'through Client#disconnect' do + # Client builds its transport itself; hand it the scripted one. + let(:client_transport_class) do + scripted = transport + Class.new { define_singleton_method(:new) { |*_args, **_kwargs| scripted } } + end + + it 'returns to a plain thread once the CLI has exited' do + client = ClaudeAgentSDK::Client.new(transport_class: client_transport_class) + parked = Thread::Queue.new + session = in_thread do + Sync do + client.connect + parked << true + client.receive_response { |_message| nil } + transport.exit_on_stdin_eof + end + end + parked.pop + + client.disconnect + transport.journal << :disconnect_returned + session.join + + expect(transport.journal).to eq(%i[stdin_eof cli_exited disconnect_returned]) + end + + # A :thread-mode callback runs on a worker thread; the task that waits + # for it is a child of the read loop the close stops. + it 'returns to a :thread-mode callback once the CLI has exited' do + client = nil + returned = Thread::Queue.new + can_use_tool = lambda do |_tool_name, _input, _context| + client.disconnect + transport.journal << :disconnect_returned + ClaudeAgentSDK::PermissionResultAllow.new + ensure + returned << true + end + options = ClaudeAgentSDK::ClaudeAgentOptions.new(callback_scheduling: :thread, can_use_tool: can_use_tool) + client = ClaudeAgentSDK::Client.new(options: options, transport_class: client_transport_class) + session = in_thread do + Sync do + client.connect + transport.deliver(type: 'control_request', request_id: 'f3a1c2d4-0b7e-4c1a-9d52-6e8f0a1b2c3d', + request: { subtype: 'can_use_tool', tool_name: 'Bash', + input: { command: 'git status', description: 'Show working tree status' }, + permission_suggestions: [], tool_use_id: 'toolu_01Hq7XvKc2mRt9LwZ3nYb5Ds' }) + client.receive_response { |_message| nil } + transport.exit_on_stdin_eof + end + end + + returned.pop + session.join + + expect(transport.journal).to eq(%i[stdin_eof cli_exited disconnect_returned]) + end + end +end diff --git a/spec/unit/command_builder_nil_options_spec.rb b/spec/unit/command_builder_nil_options_spec.rb new file mode 100644 index 00000000..8390291b --- /dev/null +++ b/spec/unit/command_builder_nil_options_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# allowed_tools, disallowed_tools, add_dirs and extra_args are filled with +# their defaults by the constructor, and their signatures are nilable: "nil +# only when set to nil afterwards". `options.dup_with(allowed_tools: nil)` or +# `options.extra_args = nil` — the natural way to clear one — kept the nil, +# and the builder then called a method on it. +RSpec.describe ClaudeAgentSDK::CommandBuilder do + def argv(options) + described_class.new('/usr/bin/claude', options).build + end + + populated = { + allowed_tools: ['Read'], + disallowed_tools: ['Bash'], + add_dirs: ['/work/shared'], + extra_args: { 'debug-to-stderr' => nil } + }.freeze + + # What each option contributes to the command when it is set. + contributions = { + allowed_tools: ['--allowedTools', 'Read'], + disallowed_tools: ['--disallowedTools', 'Bash'], + add_dirs: ['--add-dir', '/work/shared'], + extra_args: ['--debug-to-stderr'] + }.freeze + + { + '#dup_with' => ->(options, name) { options.dup_with(name => nil) }, + 'its writer' => ->(options, name) { options.tap { |opts| opts.public_send(:"#{name}=", nil) } } + }.each do |way, clear| + populated.each_key do |name| + it "builds the command without #{name} once it is set to nil through #{way}" do + options = clear.call(ClaudeAgentSDK::ClaudeAgentOptions.new(**populated), name) + expected = argv(ClaudeAgentSDK::ClaudeAgentOptions.new(**populated.except(name))) + + expect(options.public_send(name)).to be_nil + expect(argv(options)).to eq(expected) + expect(expected).not_to include(*contributions.fetch(name)) + expect(expected).to include(*contributions.except(name).values.flatten) + end + end + end + + it 'builds the default command when all four are nil, and leaves them nil' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new + populated.each_key { |name| options.public_send(:"#{name}=", nil) } + + expect(argv(options)).to eq(argv(ClaudeAgentSDK::ClaudeAgentOptions.new)) + expect(populated.keys.map { |name| options.public_send(name) }).to all(be_nil) + end + + it 'still adds the Skill rule that skills implies when allowed_tools is nil' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new(skills: %w[pdf]).dup_with(allowed_tools: nil) + cmd = argv(options) + + expect(cmd[cmd.index('--allowedTools') + 1]).to eq('Skill(pdf)') + end +end diff --git a/spec/unit/command_builder_settings_sandbox_spec.rb b/spec/unit/command_builder_settings_sandbox_spec.rb new file mode 100644 index 00000000..28a09041 --- /dev/null +++ b/spec/unit/command_builder_settings_sandbox_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'tmpdir' + +# The `sandbox:` option is folded into the --settings value so that it can +# override a sandbox section the settings already carry (`sandbox: false` +# against settings that enable it). Settings read from JSON (a JSON String, a +# settings file, a Hash from JSON.parse) spell that section with a String +# key; the option was stored under a Symbol key next to it, so the generated +# JSON had the key twice. json 2.x emits both, and the override only works as +# long as the CLI's parser keeps the last one; json 3.x raises +# JSON::GeneratorError out of connect. The examples count the key in the +# argument text, so they fail on either json major. +RSpec.describe ClaudeAgentSDK::CommandBuilder do + settings_document = { + 'permissions' => { 'deny' => ['WebFetch'] }, + 'sandbox' => { 'enabled' => true, 'network' => { 'allowedDomains' => ['example.com'] } } + }.freeze + + # The forms `settings:` may take, each yielding one that carries the + # document above. + settings_sources = { + 'a JSON String' => ->(&use) { use.call(JSON.generate(settings_document)) }, + 'a String-keyed Hash' => ->(&use) { use.call(JSON.parse(JSON.generate(settings_document))) }, + 'a Symbol-keyed Hash' => ->(&use) { use.call(JSON.parse(JSON.generate(settings_document), symbolize_names: true)) }, + 'a Hash with the section under both key types' => lambda { |&use| + use.call(JSON.parse(JSON.generate(settings_document)).merge(sandbox: { enabled: true })) + }, + 'the path of a settings file' => lambda { |&use| + Dir.mktmpdir('settings-sandbox') do |dir| + path = File.join(dir, 'settings.json') + File.write(path, JSON.generate(settings_document)) + use.call(path) + end + } + }.freeze + + # A value of the `sandbox:` option, and the section the CLI must receive. + sandbox_options = { + 'false' => [false, false], + 'a Hash' => [{ enabled: false }, { 'enabled' => false }], + 'a SandboxSettings' => [ + ClaudeAgentSDK::SandboxSettings.new(enabled: true, excluded_commands: ['git']), + { 'enabled' => true, 'excludedCommands' => ['git'] } + ] + }.freeze + + # The --settings argument built for these options. + def settings_argument(**options) + cmd = described_class.new('/usr/bin/claude', ClaudeAgentSDK::ClaudeAgentOptions.new(**options)).build + cmd[cmd.index('--settings') + 1] + end + + settings_sources.each do |source, with_settings| + sandbox_options.each do |option, (sandbox, section)| + it "writes the sandbox key once for settings given as #{source} and sandbox: #{option}" do + with_settings.call do |settings| + argument = settings_argument(settings: settings, sandbox: sandbox) + + expect(argument.scan('"sandbox"').size).to eq(1) + expect(JSON.parse(argument)).to eq('permissions' => { 'deny' => ['WebFetch'] }, 'sandbox' => section) + end + end + end + end + + it 'leaves the settings Hash the caller passed untouched' do + settings = JSON.parse(JSON.generate(settings_document)) + + settings_argument(settings: settings, sandbox: false) + + expect(settings).to eq(settings_document) + end + + it 'keeps the sandbox section of the settings when the sandbox option is not set' do + argument = settings_argument(settings: JSON.parse(JSON.generate(settings_document))) + + expect(JSON.parse(argument)).to eq(settings_document) + end +end diff --git a/spec/unit/control_error_encoding_spec.rb b/spec/unit/control_error_encoding_spec.rb new file mode 100644 index 00000000..e2c0ad26 --- /dev/null +++ b/spec/unit/control_error_encoding_spec.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' +require 'rbconfig' + +# The process-exit cells of this file end their process, so each one runs in +# a child (spec/support/callback_exit_harness.rb): all of them in parallel, +# once, the first time an example asks for a result. +module EncodedExitChildren + # [path, scheduling, kind]: an error control response (hook) and both + # MCP-shaped answers, for every exit in CallbackExitHarness::ENCODED_EXITS. + CELLS = %i[hook read_resource call_tool] + .product(CallbackExitHarness::MODES, CallbackExitHarness::ENCODED_EXITS.keys).freeze + + def self.result(cell) + results.fetch(cell) + end + + def self.results + @results ||= CELLS.zip(CELLS.map { |cell| Thread.new { run(cell) } }.map(&:value)).to_h + end + + # Read as bytes: the answers carry non-ASCII text, and how a text-mode read + # would tag it depends on the locale. + def self.run(cell) + lib_dir = File.expand_path('../../lib', __dir__) + harness_file = File.expand_path('../support/callback_exit_harness.rb', __dir__) + out, err, status = Open3.capture3(RbConfig.ruby, '-I', lib_dir, '-r', 'claude_agent_sdk', '-r', harness_file, + '-e', 'CallbackExitHarness.main(ARGV)', *cell.map(&:to_s), binmode: true) + [out.force_encoding(Encoding::UTF_8), err, status] + end +end + +# A callback's exception message need not be valid UTF-8: `byteslice` cuts a +# multibyte character, an error text embeds the raw bytes of a subprocess or +# an HTTP body, a driver reports in its own encoding. Building the error +# response from such a message raised JSON::GeneratorError — inside the +# rescue clause that was answering the request, where nothing catches it. +# The request was never answered, and on the process-exit path the +# GeneratorError also replaced the SystemExit that has to end the process. +# A process exit with valid text in an encoding that is not ASCII-compatible +# (UTF-16) failed one step earlier, with Encoding::CompatibilityError, when +# the exception's class name was put in front of the message. +RSpec.describe 'control error responses for messages that are not valid UTF-8' do + # Records every frame the SDK writes. + let(:transport_class) do + Class.new(ClaudeAgentSDK::Transport) do + attr_reader :frames + + def initialize + super + @frames = [] + end + + def write(data) + @frames << JSON.parse(data) + end + end + end + + # The frames written for one hook / can_use_tool request whose callback + # raises +error+. + def frames_for(path, mode, error) + callback = ->(*) { raise error } + transport = transport_class.new + query = ClaudeAgentSDK::Query.new(transport: transport, is_streaming_mode: true, + can_use_tool: callback, callback_scheduling: mode) + query.instance_variable_set(:@hook_callbacks, { 'hook' => callback }) + Sync { query.send(:handle_control_request, CallbackExitHarness.control_request(path)) } + transport.frames + end + + def control_response(fields) + { 'type' => 'control_response', + 'response' => { 'request_id' => 'req_fail', 'requestId' => 'req_fail' }.merge(fields) } + end + + def error_response(text) + control_response('subtype' => 'error', 'error' => text) + end + + # label => [the message as raised, the text the CLI is told] + unencodable = { + 'cut inside a multibyte character' => ['tool output: 日本語'.byteslice(0, 15), "tool output: \uFFFD"], + 'BINARY-tagged with bytes that are not UTF-8' => ["upstream replied: caf\xE9".b, "upstream replied: caf\uFFFD"], + 'US-ASCII-tagged with a high byte' => [(+"caf\xE9").force_encoding(Encoding::US_ASCII), "caf\uFFFD"], + 'invalid in its own non-UTF-8 encoding' => [(+"bad \x82").force_encoding(Encoding::Shift_JIS), "bad \uFFFD"], + 'in an encoding Ruby has no converter for' => [(+'no converter').force_encoding(Encoding::UTF_7), 'no converter'] + } + # Text the SDK delivered before as well: it must arrive undamaged, i.e. + # transcoded from its declared encoding rather than scrubbed as if UTF-8. + encodable = { + 'BINARY-tagged holding valid UTF-8' => ['upstream replied: café'.b, 'upstream replied: café'], + 'valid ISO-8859-1' => [(+"caf\xE9").force_encoding(Encoding::ISO_8859_1), 'café'], + 'valid Shift_JIS' => ['テスト'.encode(Encoding::Shift_JIS), 'テスト'] + } + + %i[hook can_use_tool].each do |path| + CallbackExitHarness::MODES.each do |mode| + context "#{path} with #{mode} callback scheduling" do + unencodable.each do |label, (raised, told)| + it "answers once, replacing the bad bytes, when the message is #{label}" do + expect(frames_for(path, mode, RuntimeError.new(raised))).to eq([error_response(told)]) + end + end + + encodable.each do |label, (raised, told)| + it "sends the text itself when the message is #{label}" do + expect(frames_for(path, mode, RuntimeError.new(raised))).to eq([error_response(told)]) + end + end + end + end + end + + # The second line of defense: whatever makes JSON.generate reject the + # response, the request is still answered. + context 'when the response cannot be generated all the same' do + # The first JSON.generate fails as it does for an unencodable String; + # later calls run for real. + def fail_first_generate + attempts = 0 + allow(JSON).to receive(:generate).and_wrap_original do |original, *args, **options| + attempts += 1 + raise JSON::GeneratorError, 'source sequence is illegal/malformed utf-8' if attempts == 1 + + original.call(*args, **options) + end + end + + it 'answers a hook with a fixed ASCII text' do + fail_first_generate + + frames = frames_for(:hook, :inline, RuntimeError.new('ordinary failure')) + + expect(frames).to eq([error_response('Control request failed; its error message could not be encoded as JSON')]) + expect(frames.first.dig('response', 'error')).to be_ascii_only + end + + it 'answers an SDK MCP request with an error response when its in-band answer cannot be generated' do + fail_first_generate + transport = transport_class.new + query = CallbackExitHarness.build_query(:read_resource, :inline, :not_implemented, transport) + + Sync { query.send(:handle_control_request, CallbackExitHarness.control_request(:read_resource)) } + + expect(transport.frames).to eq([error_response(CallbackExitHarness::FAILURE_KINDS.fetch(:not_implemented).last)]) + end + end + + # Issue #119's promise, kept whatever the exit's message holds: the CLI is + # answered, then the ORIGINAL exception ends the process. The answer is + # the one an ordinary failure gets on each path — an error control response + # (hook), a JSON-RPC internal error (resources/read) or an isError result + # (tools/call) inside a successful control response. + describe 'a process exit raised with such a message' do + def responses(out) + prefix = CallbackExitHarness::RESPONSE + out.lines.map(&:chomp).select { |line| line.start_with?(prefix) }.map { |line| JSON.parse(line.delete_prefix(prefix)) } + end + + def failure_response(path, message) + mcp_response = + case path + when :call_tool then { 'result' => { 'content' => [{ 'type' => 'text', 'text' => message }], 'isError' => true } } + when :read_resource then { 'error' => { 'code' => -32_603, 'message' => message } } + else return error_response(message) + end + control_response('subtype' => 'success', + 'response' => { 'mcp_response' => { 'jsonrpc' => '2.0', 'id' => 7 }.merge(mcp_response) }) + end + + messages = { + exit_invalid_utf8: 'cut inside a multibyte character (the stray byte is replaced)', + exit_utf16: 'valid UTF-16LE text (transcoded, so it stays readable)' + } + + EncodedExitChildren::CELLS.each do |path, mode, kind| + it "answers #{path} once (#{mode} scheduling), then exits with the original SystemExit: #{messages.fetch(kind)}" do + out, err, status = EncodedExitChildren.result([path, mode, kind]) + exit_kind = CallbackExitHarness::ENCODED_EXITS.fetch(kind) + + expect(out).not_to include(CallbackExitHarness::SURVIVED) + expect(status.exitstatus).to eq(exit_kind[:exitstatus]), "child: #{status.inspect}\n#{err}" + expect(responses(out)).to eq([failure_response(path, exit_kind[:message])]) + end + end + + # In-process from here on, so :inline and Interrupt only, the dispatch + # inside `rescue Exception` (as in callback_process_exit_spec.rb): RSpec + # does not rescue Interrupt, so a leak has to become an expectation. + it 're-raises the very exception the callback raised, its message untouched' do + original = Interrupt.new('arrêt demandé'.encode(Encoding::UTF_16LE)) + transport = transport_class.new + query = ClaudeAgentSDK::Query.new(transport: transport, is_streaming_mode: true, callback_scheduling: :inline) + query.instance_variable_set(:@hook_callbacks, { 'hook' => ->(*) { raise original } }) + + raised = begin + Sync { query.send(:handle_control_request, CallbackExitHarness.control_request(:hook)) } + nil + rescue Exception => e # rubocop:disable Lint/RescueException + e + end + + # Compared without letting RSpec print the UTF-16 text on a failure. + expect(raised.equal?(original)).to be(true), "expected the callback's own Interrupt back, got #{raised.class}" + message = original.message + expect([message.encoding, message.encode(Encoding::UTF_8)]).to eq([Encoding::UTF_16LE, 'arrêt demandé']) + expect(transport.frames).to eq([error_response('Interrupt: arrêt demandé')]) + end + end + + # Query puts the class name in front of the message itself, because the + # message has to be normalized first. The format is the one + # FiberBoundary.process_exit_message gives the carrier a callback_wrapper + # sees; this keeps the two from drifting apart. + describe 'the text a process exit is reported with' do + def frames_for_exit(error) + transport = transport_class.new + query = ClaudeAgentSDK::Query.new(transport: transport, is_streaming_mode: true) + query.send(:respond_to_process_exit, 'req_fail', CallbackExitHarness.control_request(:hook)[:request], error) + transport.frames + end + + [ + SystemExit.new(3, 'exit'), SystemExit.new(3, 'bye'), SystemExit.new(3, 'arrêt à 5 %'), + Interrupt.new, Interrupt.new(''), SignalException.new('TERM') + ].each do |error| + it "is FiberBoundary.process_exit_message's for #{error.class} with the message #{error.message.inspect}" do + expect(frames_for_exit(error)).to eq([error_response(ClaudeAgentSDK::FiberBoundary.process_exit_message(error))]) + end + end + + it 'has the same form when the message had to be transcoded' do + error = SystemExit.new(3, 'café'.encode(Encoding::UTF_16LE)) + + expect(frames_for_exit(error)).to eq([error_response('SystemExit: café')]) + end + end +end diff --git a/spec/unit/docs_configuration_spec.rb b/spec/unit/docs_configuration_spec.rb new file mode 100644 index 00000000..3ead599a --- /dev/null +++ b/spec/unit/docs_configuration_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# docs/configuration.md, "Turning auto-memory off", gives a host two switches +# that keep Claude Code's auto-memory out of its sessions: a variable in `env` +# and a key in `settings`. Either one reaches the CLI only through the +# transport. The default transport applies both itself; a custom transport is +# handed the options and has to pass them on. The page once said that only +# `env` needs passing on and that `settings` travels by itself, so a session +# on a custom transport stayed unisolated without a word of warning. +RSpec.describe 'docs/configuration.md on turning auto-memory off' do + let(:root) { File.expand_path('../..', __dir__) } + let(:page) { File.read(File.join(root, 'docs/configuration.md')) } + let(:options_class) { ClaudeAgentSDK::ClaudeAgentOptions } + let(:switches) { { env: { 'CLAUDE_CODE_DISABLE_AUTO_MEMORY' => '1' }, settings: { autoMemoryEnabled: false } } } + + # A custom transport with the five required methods. It answers control + # requests and keeps the options the SDK constructed it with. + let(:transport_class) do + Class.new do + class << self + attr_accessor :options + end + + def initialize(options, **) + self.class.options = options + @frames = Thread::Queue.new + end + + def connect; end + + def write(data) + data.each_line do |line| + frame = JSON.parse(line, symbolize_names: true) + next unless frame[:type] == 'control_request' + + @frames << { type: 'control_response', + response: { subtype: 'success', request_id: frame[:request_id], response: {} } } + end + end + + def read_messages + while (frame = @frames.pop) != :end + yield frame + end + end + + def end_input; end + + def close + @frames << :end + end + end + end + + # The examples build options from the two switches alone, so no configured + # defaults may be left over from another example. + around do |example| + ClaudeAgentSDK.reset_configuration + example.run + ensure + ClaudeAgentSDK.reset_configuration + end + + # The list items of the section, each on one line. Fenced code is skipped. + def bullets + section = page[/^### Turning auto-memory off\n(.*?)^\#{2,3} /m, 1].to_s.gsub(/^```.*?^```\n/m, '') + section.scan(/^- .*(?:\n {2}\S.*)*/).map { |item| item.gsub(/\s+/, ' ') } + end + + def command_line(**options) + ClaudeAgentSDK::CommandBuilder.new('claude', options_class.new(**options)).build + end + + it 'keeps the default transport and a custom transport apart, and asks the custom one for both switches', + :aggregate_failures do + default = bullets.select { |bullet| bullet.include?('`SubprocessCLITransport`') } + custom = bullets.select { |bullet| bullet.include?('custom transport') } + + expect([default.size, custom.size]).to eq([1, 1]) + expect(default).not_to eq(custom) + expect(default.first).to include('`env`', '`settings`', '`--settings`') + expect(custom.first).to include('neither switch reaches that CLI unless the transport passes it through', + '`env`', '`settings`', 'the session is not isolated') + end + + # What the default transport's half of the page rests on. That the transport + # puts `env` into the environment of the CLI it starts is pinned by + # subprocess_cli_transport_spec.rb ("environment variable handling"). + it 'builds settings into the command line as --settings, and nothing for env' do + with_settings = command_line(settings: switches[:settings]) + + expect(with_settings.each_cons(2)).to include(['--settings', '{"autoMemoryEnabled":false}']) + expect(command_line(env: switches[:env])).to eq(command_line) + end + + # What the custom transport's half rests on: the SDK constructs the + # transport with the options and does nothing else with either switch. + it 'hands a custom transport both switches and applies neither itself' do + environment = ENV.to_h + expect(ClaudeAgentSDK::CommandBuilder).not_to receive(:new) + + ClaudeAgentSDK::Client.open(options: options_class.new(**switches), transport_class: transport_class) do |_client| + nil + end + + expect(transport_class.options.env).to eq(switches[:env]) + expect(transport_class.options.settings).to eq(switches[:settings]) + expect(ENV.to_h).to eq(environment) + end +end diff --git a/spec/unit/docs_links_spec.rb b/spec/unit/docs_links_spec.rb new file mode 100644 index 00000000..ef05fcf9 --- /dev/null +++ b/spec/unit/docs_links_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'bundler' + +# The Markdown files in the gem are read in three places: on GitHub, inside +# the installed gem, and (README.md, as the landing page) on rubydoc.info. A +# relative link works in all of them only if its target ships in the gem, and +# on rubydoc.info a relative link in the README never works: it resolves under +# rubydoc.info/gems/claude-agent-sdk/. No network access here: only the +# repository's own files are checked. +RSpec.describe 'links in the packaged Markdown files' do + let(:root) { File.expand_path('../..', __dir__) } + let(:packaged) { Bundler.load_gemspec_uncached(File.join(root, 'claude-agent-sdk.gemspec')).files } + let(:markdown_files) { packaged.grep(/\.md\z/) } + + # Inline links and images, and reference definitions, outside fenced code, + # each as { file:, line:, target: }. + def links_in(file) + links = [] + in_fence = false + File.readlines(File.join(root, file), chomp: true).each_with_index do |line, index| + in_fence = !in_fence if line.match?(/\A\s*```/) + next if in_fence + + # Code spans can hold link-shaped text (`hash[key](arg)`); a link's own + # text may be a code span, so the span is blanked, not removed. + prose = line.gsub(/`[^`]*`/) { |span| 'x' * span.length } + # The link text may hold one level of brackets: a linked badge is + # [![alt](image)](target), and both targets count. + targets = prose.scan(/!?\[(?:[^\]\[]|\[[^\]]*\])*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/).flatten + + prose.scan(/\A\[[^\]]+\]:\s*(\S+)/).flatten + targets.each { |target| links << { file: file, line: index + 1, target: target } } + end + links + end + + def relative?(target) + !target.match?(/\A(?:[a-z][a-z0-9+.-]*:|#)/i) + end + + # The path a relative link resolves to, from the repository root. + def resolve(link) + path = link[:target].split('#', 2).first + File.expand_path(path, File.dirname(File.join(root, link[:file]))).delete_prefix("#{root}/") + end + + # GitHub's heading anchors: lowercase, punctuation dropped, spaces to + # hyphens, a numeric suffix for a repeated heading. + def anchors_in(file) + seen = Hash.new(0) + in_fence = false + File.readlines(File.join(root, file), chomp: true).filter_map do |line| + in_fence = !in_fence if line.match?(/\A\s*```/) + next if in_fence || !(heading = line[/\A\#{1,6}\s+(.*?)\s*#*\z/, 1]) + + text = heading.gsub(/\[([^\]]*)\]\([^)]*\)/, '\1').downcase + slug = text.gsub(/[^\p{L}\p{N}\- _]/u, '').tr(' ', '-') + seen[slug] += 1 + seen[slug] == 1 ? slug : "#{slug}-#{seen[slug] - 1}" + end + end + + def relative_links + markdown_files.flat_map { |file| links_in(file) }.select { |link| relative?(link[:target]) } + end + + it 'finds the Markdown files and their links' do + expect(markdown_files).to include('README.md', 'CHANGELOG.md', 'docs/client.md') + expect(markdown_files.flat_map { |file| links_in(file) }.size).to be > 100 + end + + it 'has no relative link or image in README.md, the rubydoc.info landing page' do + relative = links_in('README.md').select { |link| relative?(link[:target]) } + + expect(relative.map { |link| "README.md:#{link[:line]} #{link[:target]}" }).to eq([]) + end + + it 'points every relative link at a file that ships in the gem' do + unpackaged = relative_links.reject { |link| packaged.include?(resolve(link)) } + + expect(unpackaged.map { |link| "#{link[:file]}:#{link[:line]} #{link[:target]}" }).to eq([]) + end + + it 'points every anchor at a heading that exists' do + links = markdown_files.flat_map { |file| links_in(file) }.select { |link| link[:target].include?('#') } + broken = links.filter_map do |link| + target = link[:target] + next unless target.start_with?('#') || relative?(target) + + target_file = target.start_with?('#') ? link[:file] : resolve(link) + next unless target_file.end_with?('.md') && File.file?(File.join(root, target_file)) + + "#{link[:file]}:#{link[:line]} #{target}" unless anchors_in(target_file).include?(target.split('#', 2).last) + end + + expect(broken).to eq([]) + end +end diff --git a/spec/unit/docs_options_spec.rb b/spec/unit/docs_options_spec.rb new file mode 100644 index 00000000..f441d809 --- /dev/null +++ b/spec/unit/docs_options_spec.rb @@ -0,0 +1,457 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# docs/options.md is the reference for ClaudeAgentOptions: one table row per +# public attribute, with the option's type, its default and what it turns +# into. Every cell is checked against the code, so that a row with a made-up +# type, default, flag or initialize field fails: +# +# Type the constructor's keyword type in sig/ +# Default a bare ClaudeAgentOptions.new, and a marker in the cell where an +# explicit nil does not give that default +# Sent ... for sample values of the option: the flags CommandBuilder adds, +# the initialize fields a Client sends, and the variables the +# transport puts into the CLI's environment +RSpec.describe 'docs/options.md' do + let(:root) { File.expand_path('../..', __dir__) } + let(:page_path) { File.join(root, 'docs/options.md') } + let(:page) { File.exist?(page_path) ? File.read(page_path) : '' } + let(:options_class) { ClaudeAgentSDK::ClaudeAgentOptions } + + # What a Default cell says when an explicit nil does not give the default. + let(:explicit_nil_marker) { 'explicit `nil` stays `nil`' } + + # The values each option is tried with: { option: value }, or + # [context, { option: value }] where the option needs another one set. + let(:samples) do + sdk = ClaudeAgentSDK + first, second, third = %w[0 1 2].map { |digit| "550e8400-e29b-41d4-a716-44665544000#{digit}" } + callback = ->(*) {} + { + 'system_prompt' => [{ system_prompt: 'Be brief.' }, + { system_prompt: sdk::SystemPromptFile.new(path: '/srv/app/prompt.md') }, + { system_prompt: sdk::SystemPromptCustom.new(prompt: 'Be brief.', snapshot: true) }, + { system_prompt: sdk::SystemPromptPreset.new(preset: 'claude_code', append: 'Be brief.', + exclude_dynamic_sections: true) }], + 'model' => [{ model: 'claude-sonnet-5-5' }], + 'fallback_model' => [{ fallback_model: 'claude-haiku-4-5' }], + 'advisor_model' => [{ advisor_model: 'claude-opus-5-5' }], + 'max_turns' => [{ max_turns: 3 }], + 'max_budget_usd' => [{ max_budget_usd: 0.5 }], + 'task_budget' => [{ task_budget: { total: 50_000 } }], + 'thinking' => [{ thinking: sdk::ThinkingConfigAdaptive.new }, + { thinking: sdk::ThinkingConfigAdaptive.new(display: 'summarized') }, + { thinking: sdk::ThinkingConfigEnabled.new(budget_tokens: 2000) }, + { thinking: sdk::ThinkingConfigDisabled.new }], + 'effort' => [{ effort: 'high' }], + 'max_thinking_tokens' => [{ max_thinking_tokens: 2000 }], + 'betas' => [{ betas: ['context-1m-2025-08-07'] }], + 'output_format' => [{ output_format: { type: 'json_schema', schema: { type: 'object' } } }], + 'tools' => [{ tools: ['Read'] }, { tools: [] }, { tools: sdk::ToolsPreset.new(preset: 'claude_code') }], + 'allowed_tools' => [{ allowed_tools: ['Read'] }], + 'disallowed_tools' => [{ disallowed_tools: ['Bash'] }], + 'permission_mode' => [{ permission_mode: 'acceptEdits' }], + 'can_use_tool' => [{ can_use_tool: callback }], + 'permission_prompt_tool_name' => [{ permission_prompt_tool_name: 'mcp__permissions__ask' }], + 'hooks' => [{ hooks: { 'PreToolUse' => [sdk::HookMatcher.new(matcher: 'Bash', hooks: [callback])] } }], + 'skills' => [{ skills: 'all' }, { skills: ['pdf'] }], + 'sandbox' => [{ sandbox: { enabled: true } }], + 'mcp_servers' => [{ mcp_servers: { docs: { type: 'http', url: 'https://example.com/mcp' } } }], + 'strict_mcp_config' => [{ strict_mcp_config: true }], + 'agents' => [{ agents: { reviewer: sdk::AgentDefinition.new(description: 'Reviews code', + prompt: 'Review the diff.') } }], + 'plugins' => [{ plugins: [{ type: 'local', path: '/srv/app/plugin' }] }], + 'settings' => [{ settings: { 'autoMemoryEnabled' => false } }], + 'setting_sources' => [{ setting_sources: ['project'] }, { setting_sources: [] }], + 'add_dirs' => [{ add_dirs: ['/srv/shared'] }], + 'bare' => [{ bare: true }], + 'verbatim_prompts' => [{ verbatim_prompts: true }], + 'resume' => [{ resume: first }], + 'continue_conversation' => [{ continue_conversation: true }], + 'fork_session' => [{ fork_session: true }], + 'session_id' => [{ session_id: first }], + 'resume_session_at' => [[{ resume: first }, { resume_session_at: second }]], + 'resume_drops_turn' => [[{ resume: first, resume_session_at: second }, { resume_drops_turn: third }]], + 'enable_file_checkpointing' => [{ enable_file_checkpointing: true }], + 'session_store' => [{ session_store: sdk::InMemorySessionStore.new }], + 'session_store_flush' => [{ session_store_flush: 'eager' }], + 'load_timeout_ms' => [{ load_timeout_ms: 1000 }], + 'include_partial_messages' => [{ include_partial_messages: true }], + 'include_hook_events' => [{ include_hook_events: true }], + 'forward_subagent_text' => [{ forward_subagent_text: true }], + 'agent_progress_summaries' => [{ agent_progress_summaries: true }, { agent_progress_summaries: false }], + 'cli_path' => [{ cli_path: '/opt/claude/bin/claude' }], + 'cwd' => [{ cwd: '/srv/app' }], + 'env' => [{ env: { 'ANTHROPIC_BASE_URL' => 'https://proxy.example.com' } }], + 'user' => [{ user: 'deploy' }], + # The flag names are the caller's; the page writes them as `--<flag>`. + 'extra_args' => [{ extra_args: { 'any-flag' => 'value', 'any-switch' => nil } }], + 'stderr' => [{ stderr: callback }], + 'debug_stderr' => [{ debug_stderr: $stderr }], + 'max_buffer_size' => [{ max_buffer_size: 2_000_000 }], + 'observers' => [{ observers: [Object.new.extend(sdk::Observer)] }], + 'callback_scheduling' => [{ callback_scheduling: 'inline' }], + 'callback_wrapper' => [{ callback_wrapper: :call.to_proc }] + } + end + + # The fields of the initialize request each option sets; the options that + # are not listed set none. Hand-written, and checked against the code below. + let(:initialize_fields) do + { 'system_prompt' => %w[excludeDynamicSections systemPromptSnapshot], 'hooks' => %w[hooks], + 'agents' => %w[agents], 'skills' => %w[skills], 'forward_subagent_text' => %w[forwardSubagentText], + 'agent_progress_summaries' => %w[agentProgressSummaries] } + end + + # A stand-in for the CLI that answers every control request and keeps the + # requests it was sent. + let(:recording_transport_class) do + Class.new do + def self.requests + @requests ||= [] + end + + def initialize(*) + @frames = Thread::Queue.new + end + + def connect; end + + def write(data) + data.each_line do |line| + frame = JSON.parse(line, symbolize_names: true) + next unless frame[:type] == 'control_request' + + self.class.requests << frame[:request] + @frames << { type: 'control_response', + response: { subtype: 'success', request_id: frame[:request_id], response: {} } } + end + end + + def read_messages + while (frame = @frames.pop) != :end + yield frame + end + end + + def end_input; end + + def close + @frames << :end + end + end + end + + # The page documents a bare ClaudeAgentOptions.new, so no configured + # defaults may be left over from another example. + around do |example| + ClaudeAgentSDK.reset_configuration + example.run + ensure + ClaudeAgentSDK.reset_configuration + end + + # Attributes whose declaration carries a YARD `@api private` tag are not + # public API (CONTRIBUTING.md, "What is public API") and need no entry. + def private_attributes(source) + declaration = /((?:^[ \t]*#.*\n)+)[ \t]*attr_(?:accessor|reader|writer)[ \t]+((?::\w+,?\s*)+)/ + source.scan(declaration).flat_map do |comment, names| + comment.match?(/^\s*#\s*@api private\s*$/) ? names.scan(/:(\w+)/).flatten : [] + end + end + + def public_attributes + source = File.read(File.join(root, 'lib/claude_agent_sdk/types/options.rb')) + options_class.attribute_names - private_attributes(source) + end + + # The cells of a Markdown table row. An escaped pipe (`\|`) is part of its cell. + def cells(line) + parts = line.split(/(?<!\\)\|/, -1).map(&:strip) + parts.size > 2 && parts.first.empty? && parts.last.empty? ? parts[1..-2] : [] + end + + # The rows of the option tables, as { name:, type:, default:, sent: }: the + # rows with four cells whose first one is a name in backticks. + def table_rows + page.each_line(chomp: true).filter_map do |line| + row = cells(line) + next unless row.size == 4 && (name = row[0][/\A`(\w+)`\z/, 1]) + + { name: name, type: row[1][/\A`(.*)`\z/, 1].to_s.gsub('\|', '|'), default: row[2], sent: row[3] } + end + end + + def rows + table_rows.select { |row| public_attributes.include?(row[:name]) } + end + + # The constructor's keyword types in sig/, the way the page writes them: + # without the optional mark, and without the parentheses around a union. + def constructor_types + sig = File.read(File.join(root, 'sig/claude_agent_sdk/types/options.rbs')) + keywords = sig[/^ class ClaudeAgentOptions\b.*?^ *def initialize: \(\n(.*?)^ *\) -> void/m, 1].to_s + keywords.scan(/^ *\?(\w+): (.+?),?$/).to_h { |name, type| [name, unparenthesized(type.delete_suffix('?'))] } + end + + def unparenthesized(type) + inner = type[/\A\((.*)\)\z/, 1] + inner && balanced?(inner) ? inner : type + end + + def balanced?(text) + depth = 0 + text.each_char do |char| + depth += { '(' => 1, ')' => -1 }.fetch(char, 0) + return false if depth.negative? + end + depth.zero? + end + + def sample_pairs(name) + samples.fetch(name, []).map { |sample| sample.is_a?(Array) ? sample : [{}, sample] } + end + + def command_line(options) + ClaudeAgentSDK::CommandBuilder.new('claude', ClaudeAgentSDK.configure_can_use_tool(options)).build + end + + # The command line as [flag, value] pairs; `--flag=value` counts as one pair. + def flag_pairs(options) + arguments = command_line(options).drop(1) + arguments.each_with_index.filter_map do |argument, index| + next unless argument.start_with?('--') + + flag, value = argument.split('=', 2) + following = arguments[index + 1] + value ||= following unless following.nil? || following.start_with?('--') + [flag, value] + end + end + + # The flags whose presence or value the sample changes. + def flags_changed_by(context, sample) + without = flag_pairs(options_class.new(**context)) + with = flag_pairs(options_class.new(**context, **sample)) + (without | with).reject { |pair| without.count(pair) == with.count(pair) }.map(&:first) + end + + def flags_sent_for(name) + flags = sample_pairs(name).flat_map { |context, sample| flags_changed_by(context, sample) } + (name == 'extra_args' ? flags.map { '--<flag>' } : flags).uniq.sort + end + + # The flags a cell names in its code spans: `--flag`, `--flag <value>`, + # `--flag=<value>`. + def flags_in(cell) + cell.scan(/`([^`]*)`/).flatten.flat_map { |code| code.scan(/(?<![\w-])--(?:<flag>|[a-z][\w-]*)/i) }.uniq.sort + end + + # The [flag, value] pairs a cell gives with a literal value, such as + # `--tools default` or `--tools ""`, and not with a <placeholder>. + def literal_pairs_in(cell) + cell.scan(/`(--[a-z][\w-]*) ([^`<]+)`/i).map { |flag, value| [flag, value == '""' ? '' : value] } + end + + def pairs_sent_for(name) + sample_pairs(name).flat_map { |context, sample| flag_pairs(options_class.new(**context, **sample)) } + end + + # The fields of the initialize request a Client sends for these options, + # beyond the bare request ({ subtype:, hooks: nil, agents: nil }). + def initialize_fields_sent(options) + transport_class = recording_transport_class + transport_class.requests.clear + ClaudeAgentSDK::Client.open(options: options, transport_class: transport_class) { |_client| nil } + request = transport_class.requests.find { |candidate| candidate[:subtype] == 'initialize' } + request.except(:subtype).compact.keys.map(&:to_s) + end + + it 'has one table row for every public ClaudeAgentOptions attribute, and for nothing else' do + expect(public_attributes.size).to be >= 55 + expect(table_rows.map { |row| row[:name] }).to match_array(public_attributes) + end + + it 'recognizes an attribute tagged @api private as not needing an entry' do + source = <<~RUBY + class ClaudeAgentOptions < Type + attr_accessor :model, :cwd + + # Where the transport keeps its scratch files. + # + # @api private + attr_accessor :scratch_dir, + :scratch_mode + + # Deliver every prompt as written. + attr_reader :verbatim_prompts + end + RUBY + + expect(private_attributes(source)).to eq(%w[scratch_dir scratch_mode]) + end + + it 'gives every option the type its constructor keyword has in sig/' do + wrong = rows.filter_map do |row| + accepted = constructor_types[row[:name]] + "#{row[:name]}: documented #{row[:type].inspect}, constructor #{accepted.inspect}" unless row[:type] == accepted + end + + expect(wrong).to eq([]) + end + + it 'explains each type alias in the Type column with the members of its definition in sig/' do + sig = Dir[File.join(root, 'sig/**/*.rbs')].map { |file| File.read(file) }.join("\n") + aliases = rows.flat_map { |row| row[:type].scan(/(?<![:\w"])[a-z]\w*/) }.uniq - %w[bool untyped] + wrong = aliases.filter_map do |name| + definition = sig[/^ *type #{name} = (.*?)\n *\n/m, 1].to_s + members = definition.gsub(/\[[^\]]*\]/, '').scan(/[A-Z]\w*/) + explanation = page.each_line.map { |line| cells(line) }.find { |row| row.first == "`#{name}`" }.to_a.last.to_s + documented = explanation.scan(/`([A-Z]\w*)`/).flatten + explanation.scan(/\bHash\b/).uniq + "#{name}: documented #{documented.sort.inspect}, defined #{members.sort.inspect}" unless documented.sort == members.sort + end + + expect(aliases).not_to be_empty + expect(wrong).to eq([]) + end + + it 'gives every option the default a bare ClaudeAgentOptions.new has' do + wrong = rows.filter_map do |row| + actual = options_class.new.public_send(row[:name]).inspect + documented = row[:default][/\A`([^`]*)`/, 1] + "#{row[:name]}: documented #{documented.inspect}, actual #{actual.inspect}" unless documented == actual + end + + expect(wrong).to eq([]) + end + + it 'marks exactly the options whose explicit nil stays nil, which still act as false' do + stays_nil = public_attributes.reject do |name| + options_class.new(name.to_sym => nil).public_send(name) == options_class.new.public_send(name) + end + marked = rows.select { |row| row[:default].include?(explicit_nil_marker) }.map { |row| row[:name] } + changes_the_command_line = public_attributes.reject do |name| + command_line(options_class.new(name.to_sym => nil)) == command_line(options_class.new) + end + + expect(marked).to match_array(stays_nil) + expect(stays_nil.map { |name| options_class.new(name.to_sym => nil).public_send(name) }.uniq).to eq([nil]) + expect(stays_nil.map { |name| options_class.new(name.to_sym => nil).public_send(:"#{name}?") }.uniq).to eq([false]) + expect(changes_the_command_line).to eq([]) + end + + # The table under "Leaving an option out, and passing nil". + it 'resolves a left-out option, an explicit nil and a value the way the page tabulates it' do + without_defaults = options_class.new(env: nil, fork_session: nil) + ClaudeAgentSDK.configure do |config| + config.default_options = { env: { 'A' => '1' }, fork_session: true, model: 'claude-opus-5-5' } + end + left_out = options_class.new + with_nil = options_class.new(env: nil, fork_session: nil, model: nil, include_partial_messages: nil) + with_value = options_class.new(env: { 'B' => '2' }, fork_session: false, model: 'claude-haiku-4-5') + + expect([without_defaults.env, without_defaults.fork_session]).to eq([{}, nil]) + expect([left_out.env, left_out.fork_session, left_out.model]).to eq([{ 'A' => '1' }, true, 'claude-opus-5-5']) + expect([with_nil.env, with_nil.fork_session, with_nil.model]).to eq([{ 'A' => '1' }, true, 'claude-opus-5-5']) + expect(with_nil.include_partial_messages).to be_nil # no default is configured for it + expect([with_value.env, with_value.fork_session, with_value.model]) + .to eq([{ 'A' => '1', 'B' => '2' }, false, 'claude-haiku-4-5']) + end + + it 'stores a String callback_scheduling as its Symbol and reads nil as the default, as the row says' do + sent = rows.find { |row| row[:name] == 'callback_scheduling' }.to_h.fetch(:sent, '') + + expect(options_class.new(callback_scheduling: 'inline').callback_scheduling).to be(:inline) + expect(options_class.new(callback_scheduling: 'thread').callback_scheduling).to be(:thread) + expect(options_class.new(callback_scheduling: nil).callback_scheduling).to be(:thread) + expect(sent).to include('A String is stored as its Symbol', '`nil` means the default') + end + + # The setter calls to_sym on whatever responds to it and rejects what does + # not come out as :thread or :inline, so the row may not say that every + # value other than a String or a Symbol raises. + it 'accepts what converts to :thread or :inline and rejects the rest, as the row says', + rbs_incompatible: 'passes out-of-signature input to test its rejection' do + sent = rows.find { |row| row[:name] == 'callback_scheduling' }.to_h.fetch(:sent, '') + convertible = Struct.new(:to_sym).new(:inline) + + expect(options_class.new(callback_scheduling: convertible).callback_scheduling).to be(:inline) + ['fiber', :fiber, 5].each do |value| + expect { options_class.new(callback_scheduling: value) } + .to raise_error(ArgumentError, /callback_scheduling must be one of/) + end + expect(sent).to include('A value that does not convert (through `to_sym`) to `:thread` or `:inline` raises') + end + + it 'names, for every option, the flags CommandBuilder sends for it and no other' do + wrong = rows.filter_map do |row| + documented = flags_in(row[:sent]) + sent = flags_sent_for(row[:name]) + "#{row[:name]}: documented #{documented.inspect}, sent #{sent.inspect}" unless documented == sent + end + + expect(samples.keys).to match_array(public_attributes) + expect(wrong).to eq([]) + end + + it 'gives a flag a literal value only where CommandBuilder sends that value' do + literal = rows.flat_map { |row| literal_pairs_in(row[:sent]).map { |pair| [row[:name], pair] } } + wrong = literal.filter_map do |name, pair| + "#{name}: #{pair.join(' ').inspect} is not sent for any of its samples" unless pairs_sent_for(name).include?(pair) + end + + expect(literal.size).to be >= 5 + expect(wrong).to eq([]) + end + + it 'names, for every option, the initialize fields it sets and no other' do + wrong = rows.filter_map do |row| + documented = row[:sent].scan(/`initialize\.(\w+)`/).flatten.sort + sent = initialize_fields.fetch(row[:name], []).sort + "#{row[:name]}: documented #{documented.inspect}, sent #{sent.inspect}" unless documented == sent + end + + expect(wrong).to eq([]) + end + + # initialize_fields is hand-written, so it is checked twice: each entry + # against the request a Client sends for the option's samples, and all of + # them together against the fields Query can put into that request. + it 'expects the initialize fields a Client sends, and Query has no others' do + sent = initialize_fields.keys.to_h do |name| + requests = sample_pairs(name).map { |context, sample| options_class.new(**context, **sample) } + [name, requests.flat_map { |options| initialize_fields_sent(options) }.uniq.sort] + end + query = File.read(File.join(root, 'lib/claude_agent_sdk/query.rb')) + request = query[/^ *request = \{\n *subtype: 'initialize',\n(.*?)send_control_request\(request\)/m, 1].to_s + known_to_query = request.scan(/^ *(\w+): /).flatten + request.scan(/request\[:(\w+)\]/).flatten + + expect(initialize_fields_sent(options_class.new)).to eq([]) + expect(sent).to eq(initialize_fields.transform_values(&:sort)) + expect(known_to_query).to match_array(initialize_fields.values.flatten) + end + + # The transport builds the CLI's environment only while it starts the CLI, + # so the variables it sets for an option are read from its source. + it 'names, for every option, the environment variables the transport sets for it and no other' do + transport = File.read(File.join(root, 'lib/claude_agent_sdk/subprocess_cli_transport.rb')) + set_for = transport.scan(/process_env\['(\w+)'\] = '\w+' if @options\.(\w+)/) + .group_by(&:last).transform_values { |pairs| pairs.map(&:first) } + wrong = rows.filter_map do |row| + documented = row[:sent].scan(/`([A-Z][A-Z0-9_]+)=[^`]*`/).flatten + set = set_for.fetch(row[:name], []) + "#{row[:name]}: documented #{documented.inspect}, set #{set.inspect}" unless documented == set + end + + expect(set_for).not_to be_empty + expect(wrong).to eq([]) + end + + it 'is linked from the README documentation table' do + readme = File.read(File.join(root, 'README.md')) + + expect(readme).to match(%r{\]\([^)]*docs/options\.md\)}) + end +end diff --git a/spec/unit/docs_reference_spec.rb b/spec/unit/docs_reference_spec.rb new file mode 100644 index 00000000..03dfa3e7 --- /dev/null +++ b/spec/unit/docs_reference_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# docs/ is part of the SemVer contract (public API = docs/ plus the YARD docs +# without `@api private`), so the reference pages have to name what the code +# defines. The checks run from the code to the page: a class the SDK can hand +# to a consumer must be in the reference. Prose is not scanned for names; the +# only page-to-code check is on table rows that start with a constant name. +RSpec.describe 'the type and error references' do + let(:root) { File.expand_path('../..', __dir__) } + let(:types_doc) { File.read(File.join(root, 'docs/types.md')) } + let(:errors_doc) { File.read(File.join(root, 'docs/errors.md')) } + + def short_name(klass) + klass.name.split('::').last + end + + def sdk_classes + ClaudeAgentSDK.constants.map { |name| ClaudeAgentSDK.const_get(name) }.grep(Class) + end + + # Every class the message parser can hand to a consumer: the Type subclasses + # message_parser.rb names (its system-subtype table included), plus every + # Type subclass called *Message / *Block, so the list cannot shrink + # unnoticed if the dispatch moves out of that file. + def produced_classes + parser_source = File.read(File.join(root, 'lib/claude_agent_sdk/message_parser.rb')) + sdk_classes.select do |klass| + next false unless klass < ClaudeAgentSDK::Type + + name = short_name(klass) + name.end_with?('Message', 'Block') || parser_source.match?(/\b#{name}\b/) + end + end + + def block_classes + produced_classes.select { |klass| short_name(klass).end_with?('Block') } + end + + def message_classes + produced_classes - block_classes + end + + # The members of a `Name = A | B | ...` union inside a fenced block. + def documented_union(name) + types_doc[/^#{name} = (.*?)^```/m, 1].to_s.scan(/[A-Z]\w+/) + end + + it 'names every message and content-block class the parser can produce in docs/types.md' do + missing = produced_classes.map { |klass| short_name(klass) }.reject { |name| types_doc.match?(/\b#{name}\b/) } + + expect(produced_classes.size).to be >= 36 + expect(missing).to be_empty, "docs/types.md does not mention: #{missing.sort.join(', ')}" + end + + it 'lists every top-level message class in the Message union of docs/types.md' do + top_level = message_classes.reject { |klass| klass < ClaudeAgentSDK::SystemMessage } + + expect(documented_union('Message')).to match_array(top_level.map { |klass| short_name(klass) }) + end + + it 'lists every content-block class in the ContentBlock union of docs/types.md' do + expect(documented_union('ContentBlock')).to match_array(block_classes.map { |klass| short_name(klass) }) + end + + it 'starts the table rows of docs/types.md only with classes and constants that exist' do + names = types_doc.scan(/^\| `([A-Z]\w+)` \|/).flatten + unknown = names.reject { |name| ClaudeAgentSDK.const_defined?(name) } + + expect(names).not_to be_empty + expect(unknown).to be_empty, "docs/types.md lists names ClaudeAgentSDK does not define: #{unknown.join(', ')}" + end + + it 'has a reference entry and a table row in docs/errors.md for every error class' do + errors = sdk_classes.select { |klass| klass <= ClaudeAgentSDK::ClaudeSDKError }.map { |klass| short_name(klass) } + without_entry = errors.reject { |name| errors_doc.match?(/^class #{name}\b/) } + without_row = errors.reject { |name| errors_doc.match?(/^\| `#{name}` \|/) } + + expect(errors).to include('ClaudeSDKError', 'ProcessError', 'ResultError') + expect(without_entry).to be_empty, "docs/errors.md has no reference entry for: #{without_entry.join(', ')}" + expect(without_row).to be_empty, "docs/errors.md has no table row for: #{without_row.join(', ')}" + end + + # docs/types.md: SystemMessage#data is the whole frame, unless the frame + # carries a `data` value of its own that is neither nil nor false (the class + # assigns it with `||=`, so key presence alone does not decide). + it 'parses #data of a system frame the way docs/types.md describes it' do + frame = { type: 'system', subtype: 'status', uuid: 'u1', session_id: 's1', status: 'compacting' } + data_of = ->(extra) { ClaudeAgentSDK::MessageParser.parse(frame.merge(extra)).data } + + expect(data_of.call({})).to eq(frame) + expect(data_of.call(data: { marker: 1 })).to eq(marker: 1) + expect(data_of.call(data: {})).to eq({}) + expect(data_of.call(data: nil)).to eq(frame.merge(data: nil)) + prose = types_doc.gsub(/\s+/, ' ') # the sentences may wrap anywhere + expect(prose).to include('unless the frame carries a `data` value of its own that is neither `nil` nor `false`') + expect(prose).to include('A frame whose `data` is `nil` or `false` reads like one without the key') + expect(types_doc).to include("the frame's own `data` value when that is neither nil nor false, otherwise the whole frame") + end + + it 'parses a system frame whose data is false like one without the key, as docs/types.md says', + rbs_incompatible: 'parses a frame whose data is false, outside the signature of SystemMessage#data=' do + frame = { type: 'system', subtype: 'status', uuid: 'u1', session_id: 's1', status: 'compacting', data: false } + + expect(ClaudeAgentSDK::MessageParser.parse(frame).data).to eq(frame) + end + + # docs/types.md: RateLimitEvent#data is always the whole event, whether or + # not the event has a `data` key. + it 'parses #data of a rate-limit event the way docs/types.md describes it' do + event = { type: 'rate_limit_event', uuid: 'rl_123', session_id: 'sess_456', + rate_limit_info: { status: 'allowed_warning', resetsAt: 1_700_000_000, rateLimitType: 'five_hour' } } + with_data = event.merge(data: { marker: 1 }) + + expect(ClaudeAgentSDK::MessageParser.parse(event).data).to eq(event) + expect(ClaudeAgentSDK::MessageParser.parse(with_data).data).to eq(with_data) + expect(types_doc.gsub(/\s+/, ' ')).to include('`RateLimitEvent#data` is always the whole event') + end + + # The example announces how many message types it handles; both the number + # and the `when` branches are checked against the classes. + it 'handles every typed message class in examples/message_types_example.rb, and counts them' do + example = File.read(File.join(root, 'examples/message_types_example.rb')) + typed = message_classes.map { |klass| short_name(klass) } - ['SystemMessage'] + handled = example.scan(/^\s*when ClaudeAgentSDK::(\w+)\s*$/).flatten + + expect(typed - handled).to eq([]) + expect(example[/Handling all (\d+) SDK message types/, 1]).to eq(typed.size.to_s) + end +end diff --git a/spec/unit/docs_transport_contract_spec.rb b/spec/unit/docs_transport_contract_spec.rb new file mode 100644 index 00000000..4835a689 --- /dev/null +++ b/spec/unit/docs_transport_contract_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# The custom-transport contract is written down in three places: the +# _Transport interface in sig/, the table in docs/client.md and one sentence +# of the bundled skill. They once listed five, six and four methods, and a +# transport written from the four-method list hung query(). The interface in +# sig/ is the reference; the other two must name the same methods, and a +# transport with exactly those methods must work. +RSpec.describe 'the custom transport contract' do + let(:root) { File.expand_path('../..', __dir__) } + + let(:interface_methods) do + sig = File.read(File.join(root, 'sig/claude_agent_sdk/transport.rbs')) + sig[/^ interface _Transport\n(.*?)^ end/m, 1].scan(/def (\w+\??):/).flatten + end + + # A scripted stand-in for the CLI with the interface's methods and nothing + # else. It answers the initialize request, and each user message with the + # frames a turn produces (system/init, assistant, result). + let(:minimal_transport_class) do + Class.new do + def initialize(*) + @frames = Thread::Queue.new + end + + def connect; end + + def write(data) + data.each_line do |line| + frame = JSON.parse(line, symbolize_names: true) + case frame[:type] + when 'control_request' then answer_control_request(frame) + when 'user' then answer_user_message + end + end + end + + def read_messages + while (frame = @frames.pop) != :end + yield frame + end + end + + def end_input + @frames << :end + end + + def close + @frames << :end + end + + private + + def answer_control_request(frame) + @frames << { type: 'control_response', + response: { subtype: 'success', request_id: frame[:request_id], response: {} } } + end + + def answer_user_message + session_id = '11111111-1111-4111-8111-111111111111' + @frames << { type: 'system', subtype: 'init', session_id: session_id, uuid: 'init-1', + model: 'claude-haiku-4-5', cwd: '/srv/app', tools: %w[Read Bash], + claude_code_version: '2.1.285', permissionMode: 'default' } + @frames << { type: 'assistant', session_id: session_id, uuid: 'assistant-1', parent_tool_use_id: nil, + message: { id: 'msg_1', role: 'assistant', model: 'claude-haiku-4-5', + content: [{ type: 'text', text: 'Hello.' }], + usage: { input_tokens: 10, output_tokens: 5 } } } + @frames << { type: 'result', subtype: 'success', is_error: false, duration_ms: 2100, + duration_api_ms: 1800, num_turns: 1, session_id: session_id, total_cost_usd: 0.0031, + result: 'Hello.', usage: { input_tokens: 10, output_tokens: 5 }, uuid: 'result-1' } + end + end + end + + it 'has five methods in sig/, none of them ready?' do + expect(interface_methods).to match_array(%w[connect write read_messages end_input close]) + end + + it 'lists the same methods in the docs/client.md table' do + section = File.read(File.join(root, 'docs/client.md'))[/^## Custom Transport\n(.*?)^###? /m, 1] + + expect(section.scan(/^\| `(\w+\??)/).flatten).to match_array(interface_methods) + end + + it 'lists the same methods in both copies of the skill' do + %w[skills plugins/claude-agent-ruby/skills/claude-agent-ruby].each do |tree| + text = File.read(File.join(root, tree, 'references/options.md')) + required = text[/A custom transport must implement ([^(.]*)/, 1].to_s + + expect(required.scan(/`(\w+\??)`/).flatten).to match_array(interface_methods), "in #{tree}" + end + end + + it 'serves query and Client with a transport that has only those methods' do + expect(minimal_transport_class.public_instance_methods(false)).to match_array(interface_methods.map(&:to_sym)) + + from_query = [] + ClaudeAgentSDK.query(prompt: 'Hello', transport: minimal_transport_class.new) { |message| from_query << message } + from_client = [] + ClaudeAgentSDK::Client.open(transport_class: minimal_transport_class) do |client| + client.query('Hello') + client.receive_response { |message| from_client << message } + end + + [from_query, from_client].each do |messages| + expect(messages.map(&:class)).to eq( + [ClaudeAgentSDK::InitMessage, ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage] + ) + expect(messages.last.result).to eq('Hello.') + end + end +end diff --git a/spec/unit/fiber_boundary_join_spec.rb b/spec/unit/fiber_boundary_join_spec.rb new file mode 100644 index 00000000..49838ba4 --- /dev/null +++ b/spec/unit/fiber_boundary_join_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' + +# FiberBoundary.invoke waits for its worker thread with Thread#join. Under a +# fiber scheduler, join parks the fiber, and MRI treats ANY wakeup that finds +# the thread still alive as "timed out": join returns nil (and Thread#value +# then returns nil too) although nothing has finished. Such a wakeup does not +# have to belong to this join. When a hop's thread ends it queues a wakeup +# for the waiting fiber; if an exception (Async::Stop, a deadline) is raised +# into the fiber before that wakeup is consumed, the wakeup stays queued and +# resumes whatever the fiber waits on next — the next hop. +# +# So only "the thread has finished" or "the deadline has really passed" may +# end the wait. +RSpec.describe ClaudeAgentSDK::FiberBoundary, 'waiting for the worker thread' do + def monotonic + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + # Runs the given hop on a fiber that has a stale wakeup queued for it. The + # hop gets two queues: it must push to +started+ from its worker and then + # block that worker on +release+.pop. Returns whether the hop was over + # before its worker was released, and what it returned or raised. + # + # No clock is involved: every step waits on a queue or on thread state. + def hop_after_stale_wakeup # rubocop:disable Metrics/AbcSize -- one linear interleaving, annotated step by step + first_started = Thread::Queue.new + first_release = Thread::Queue.new + started = Thread::Queue.new + release = Thread::Queue.new + outcome = Thread::Queue.new + + Sync do |root| + child = root.async do + ClaudeAgentSDK.offload do + first_started << Thread.current + first_release.pop + end + ensure + # The second hop of the same fiber, entered while the first hop's + # wakeup is still queued. + outcome << begin + yield started, release + rescue StandardError => e + e + end + end + + first_thread = root.with_timeout(10) { first_started.pop } + first_release << true + # The first worker ends and queues the wakeup for the child's fiber. + # This fiber keeps the reactor thread meanwhile, so the child is not + # resumed yet. + Thread.pass while first_thread.alive? + stopper = root.async { child.stop } # Async::Stop into the not-yet-resumed fiber: it unwinds into the ensure + root.with_timeout(10) { started.pop } # the second worker is running, and blocked + 3.times { root.yield } # the stale wakeup, queued before all of this, has had its turn + + over_before_release = !outcome.empty? + release << true + stopper.wait + [over_before_release, root.with_timeout(10) { outcome.pop }] + end + end + + it 'does not return from an unbounded hop (ClaudeAgentSDK.offload) while its block is still running' do + over_before_release, result = hop_after_stale_wakeup do |started, release| + ClaudeAgentSDK.offload do + started << true + release.pop + :finished + end + end + + aggregate_failures do + expect(over_before_release).to be(false) + expect(result).to eq(:finished) + end + end + + it 'does not report a timeout for a bounded hop whose deadline has not passed' do + over_before_release, result = hop_after_stale_wakeup do |started, release| + described_class.invoke(timeout: 60) do + started << true + release.pop + :finished + end + end + + aggregate_failures do + expect(over_before_release).to be(false) + expect(result).to eq(:finished) + end + end + + describe 'a deadline that really passes' do + let(:release) { Thread::Queue.new } + + after { release.close } # the abandoned worker's pop returns and its thread ends + + # The worker outlives the deadline by a wide margin but not forever: on a + # Ruby whose Thread#join(limit) cannot expire under a scheduler (3.2.0) + # the example then fails after that margin instead of hanging the suite. + def outlive_the_deadline + release.pop(timeout: 5) + end + + it 'raises JoinTimeout inside a reactor, and not before the deadline' do + started = monotonic + + expect do + Sync { described_class.invoke(timeout: 0.05) { outlive_the_deadline } } + end.to raise_error(described_class::JoinTimeout, 'timed out after 0.05s') + expect(monotonic - started).to be > 0.049 + end + + it 'raises JoinTimeout outside a reactor, and not before the deadline' do + started = monotonic + + expect do + described_class.invoke(timeout: 0.05) { outlive_the_deadline } + end.to raise_error(described_class::JoinTimeout, 'timed out after 0.05s') + expect(monotonic - started).to be > 0.049 + end + end + + it 'returns the value of a bounded hop that finishes in time' do + expect(Sync { described_class.invoke(timeout: 60) { :in_time } }).to eq(:in_time) + expect(described_class.invoke(timeout: 60) { :in_time }).to eq(:in_time) + end +end diff --git a/spec/unit/gemspec_spec.rb b/spec/unit/gemspec_spec.rb index 2b80f821..49620f6d 100644 --- a/spec/unit/gemspec_spec.rb +++ b/spec/unit/gemspec_spec.rb @@ -8,7 +8,7 @@ let(:gemspec_path) { File.join(root, 'claude-agent-sdk.gemspec') } def git_tracked_files - IO.popen(%w[git ls-files -z lib sig docs README.md LICENSE CHANGELOG.md UPGRADING-1.0.md], + IO.popen(%w[git ls-files -z lib sig docs README.md LICENSE CHANGELOG.md UPGRADING-1.0.md .yardopts], chdir: root, err: File::NULL, &:read).split("\x0") .reject { |path| path.start_with?('docs/history/') } rescue SystemCallError @@ -35,4 +35,27 @@ def git_tracked_files expect(files).to include('sig/claude_agent_sdk.rbs', 'sig/claude_agent_sdk/types/options.rbs') end end + + # rubydoc.info builds a gem's documentation from the packaged files. Without + # .yardopts it runs YARD with its defaults: nothing tagged `@api private` is + # hidden (the tag is this gem's SemVer boundary) and the extra files are not + # rendered. + it 'ships .yardopts, through git and without it' do + from_git = Bundler.load_gemspec_uncached(gemspec_path).files + allow(IO).to receive(:popen).and_raise(Errno::ENOENT, 'git') + fallback = Bundler.load_gemspec_uncached(gemspec_path).files + + [from_git, fallback].each do |files| + expect(files).to include('.yardopts') + end + end + + it 'packages every extra file .yardopts tells YARD to render' do + # Everything after the lone `-` line is an extra file (README, CHANGELOG, ...). + extra_files = File.readlines(File.join(root, '.yardopts'), chomp: true) + .drop_while { |line| line != '-' }.drop(1).reject(&:empty?) + + expect(extra_files).not_to be_empty + expect(Bundler.load_gemspec_uncached(gemspec_path).files).to include(*extra_files) + end end diff --git a/spec/unit/hook_output_normalization_spec.rb b/spec/unit/hook_output_normalization_spec.rb new file mode 100644 index 00000000..c96baa63 --- /dev/null +++ b/spec/unit/hook_output_normalization_spec.rb @@ -0,0 +1,480 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'json' + +# A hook callback may return a Hash instead of a typed output; the shipped +# signature says "a Hash (snake_case or camelCase keys)". The SDK used to +# rename five top-level keys, only when they were Symbols, and forward a +# nested hook_specific_output Hash untouched. A PreToolUse deny written in +# Ruby spelling therefore reached the CLI under keys it does not read, the +# object was ignored, and the tool ran. +# +# Every example asserts on the control_response frame the SDK writes for a +# hook_callback request, never on the private converter. +RSpec.describe 'hook output normalization' do + # The hook_callback request CLI 2.1.286 writes for a PreToolUse hook + # (recorded from a real session; only the home directory is shortened), as + # the transport hands it to Query: parsed with Symbol keys. The reply is + # built from the callback's return value alone, so the examples for other + # events reuse this envelope with their own event name. + def hook_callback_request(event) + request = JSON.parse(<<~FRAME, symbolize_names: true) + {"type":"control_request","request_id":"a0ae4d7c-684f-4cd2-a36a-e5e7f1e7e928","request":{"subtype":"hook_callback","callback_id":"hook_0","input":{"session_id":"097e32d1-723a-42b4-a59b-f816b0d9ac84","transcript_path":"/home/dev/.claude/projects/-work-app/097e32d1-723a-42b4-a59b-f816b0d9ac84.jsonl","cwd":"/work/app","scratchpad_dir":"/private/tmp/claude-501/-work-app/097e32d1-723a-42b4-a59b-f816b0d9ac84/scratchpad","prompt_id":"efe95091-ba7c-4f07-8d76-e376ef8f0c92","permission_mode":"default","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf build","description":"Remove the build directory"},"tool_use_id":"toolu_01PsVteovwuBVRcCCBHw4EC7"},"tool_use_id":"toolu_01PsVteovwuBVRcCCBHw4EC7"}} + FRAME + request[:request][:input][:hook_event_name] = event + request + end + + # Answers the request with +output+ (what the hook callback returns) and + # gives back the one line the SDK wrote to the CLI. + def reply_line(output, event: 'PreToolUse') + writes = [] + transport = instance_double(ClaudeAgentSDK::Transport) + allow(transport).to receive(:write) { |data| writes << data } + query = ClaudeAgentSDK::Query.new(transport: transport, is_streaming_mode: true) + query.instance_variable_set(:@hook_callbacks, { 'hook_0' => ->(_input, _tool_use_id, _context) { output } }) + + Async { query.send(:handle_control_request, hook_callback_request(event)) }.wait + + expect(writes.size).to eq(1) + writes.first + end + + # The hook output as the CLI reads it: the `response` object of a + # successful control_response. + def reply(output, **) + frame = JSON.parse(reply_line(output, **)) + expect(frame['response']).to include('subtype' => 'success', 'request_id' => 'a0ae4d7c-684f-4cd2-a36a-e5e7f1e7e928') + frame['response'].fetch('response') + end + + # What JSON makes of a Ruby value (Symbol keys and values become Strings). + def on_the_wire(value) + JSON.parse(JSON.generate(value)) + end + + describe 'a PreToolUse deny' do + typed = ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: ClaudeAgentSDK::PreToolUseHookSpecificOutput.new( + permission_decision: 'deny', permission_decision_reason: 'rm -rf is not allowed here' + ) + ) + + it 'reaches the CLI in wire spelling when the output is typed' do + expect(JSON.parse(reply_line(typed))).to eq( + 'type' => 'control_response', + 'response' => { + 'subtype' => 'success', + 'request_id' => 'a0ae4d7c-684f-4cd2-a36a-e5e7f1e7e928', + 'requestId' => 'a0ae4d7c-684f-4cd2-a36a-e5e7f1e7e928', + 'response' => { + 'continue' => true, + 'hookSpecificOutput' => { + 'hookEventName' => 'PreToolUse', + 'permissionDecision' => 'deny', + 'permissionDecisionReason' => 'rm -rf is not allowed here' + } + } + } + ) + end + + { + 'snake_case Symbol keys, nested ones included' => { + continue_: true, + hook_specific_output: { + hook_event_name: 'PreToolUse', permission_decision: 'deny', + permission_decision_reason: 'rm -rf is not allowed here' + } + }, + 'String keys at the top level and camelCase inside' => { + 'continue' => true, + 'hook_specific_output' => { + hookEventName: 'PreToolUse', permissionDecision: 'deny', + permissionDecisionReason: 'rm -rf is not allowed here' + } + }, + 'a typed output that holds a snake_case Hash' => ClaudeAgentSDK::SyncHookJSONOutput.new( + hook_specific_output: { + hook_event_name: 'PreToolUse', permission_decision: 'deny', + permission_decision_reason: 'rm -rf is not allowed here' + } + ), + 'snake_case String keys throughout' => { + 'continue_' => true, + 'hook_specific_output' => { + 'hook_event_name' => 'PreToolUse', 'permission_decision' => 'deny', + 'permission_decision_reason' => 'rm -rf is not allowed here' + } + }, + 'camelCase Symbol keys (the documented form)' => { + continue: true, + hookSpecificOutput: { + hookEventName: 'PreToolUse', permissionDecision: 'deny', + permissionDecisionReason: 'rm -rf is not allowed here' + } + }, + 'a Hash that holds a typed hook_specific_output' => { + continue_: true, + hook_specific_output: ClaudeAgentSDK::PreToolUseHookSpecificOutput.new( + permission_decision: 'deny', permission_decision_reason: 'rm -rf is not allowed here' + ) + } + }.each do |form, output| + it "writes the same control_response for #{form}" do + expect(JSON.parse(reply_line(output))).to eq(JSON.parse(reply_line(typed))) + end + end + end + + # Every field of every typed hook output class: attribute => [wire key, + # value]. The wire keys are spelled out here, not derived from the SDK, so + # a renamed key on either side fails the round trip below. + specific_outputs = { + 'SetupHookSpecificOutput' => { + hook_event_name: %w[hookEventName Setup], + additional_context: ['additionalContext', 'Dependencies are installed.'] + }, + 'PreToolUseHookSpecificOutput' => { + hook_event_name: %w[hookEventName PreToolUse], + permission_decision: %w[permissionDecision ask], + permission_decision_reason: ['permissionDecisionReason', 'Touches the lockfile.'], + updated_input: ['updatedInput', { command: 'bundle install --frozen', run_in_background: false }], + additional_context: ['additionalContext', 'Gemfile.lock is under review.'] + }, + 'PostToolUseHookSpecificOutput' => { + hook_event_name: %w[hookEventName PostToolUse], + additional_context: ['additionalContext', 'Secrets were redacted.'], + updated_tool_output: ['updatedToolOutput', { stdout: '[redacted]', stderr: '', exit_code: 0 }], + updated_mcp_tool_output: ['updatedMCPToolOutput', + { content: [{ type: 'text', text: '[redacted]' }], is_error: false }] + }, + 'PostToolUseFailureHookSpecificOutput' => { + hook_event_name: %w[hookEventName PostToolUseFailure], + additional_context: ['additionalContext', 'The registry is down; do not retry.'] + }, + 'UserPromptSubmitHookSpecificOutput' => { + hook_event_name: %w[hookEventName UserPromptSubmit], + additional_context: ['additionalContext', 'The user is on the staging cluster.'] + }, + 'NotificationHookSpecificOutput' => { + hook_event_name: %w[hookEventName Notification], + additional_context: ['additionalContext', 'Forwarded to the on-call channel.'] + }, + 'SubagentStartHookSpecificOutput' => { + hook_event_name: %w[hookEventName SubagentStart], + additional_context: ['additionalContext', 'Only read from the replica.'] + }, + 'PermissionRequestHookSpecificOutput' => { + hook_event_name: %w[hookEventName PermissionRequest], + decision: ['decision', { behavior: 'allow', updatedInput: { command: 'ls -la' } }] + }, + 'SessionStartHookSpecificOutput' => { + hook_event_name: %w[hookEventName SessionStart], + additional_context: ['additionalContext', 'Branch: main, clean tree.'] + }, + 'PermissionDeniedHookSpecificOutput' => { + hook_event_name: %w[hookEventName PermissionDenied], + retry: ['retry', true] + }, + 'CwdChangedHookSpecificOutput' => { + hook_event_name: %w[hookEventName CwdChanged], + watch_paths: ['watchPaths', %w[/work/app/config /work/app/.env]] + }, + 'FileChangedHookSpecificOutput' => { + hook_event_name: %w[hookEventName FileChanged], + watch_paths: ['watchPaths', %w[/work/app/config/routes.rb]] + } + }.freeze + + top_level_outputs = { + 'SyncHookJSONOutput' => { + continue: ['continue', false], + suppress_output: ['suppressOutput', true], + stop_reason: ['stopReason', 'The budget is exhausted.'], + decision: %w[decision block], + system_message: ['systemMessage', 'Stopped by the deploy freeze.'], + reason: ['reason', 'The test suite is failing.'], + hook_specific_output: ['hookSpecificOutput', + { hookEventName: 'PostToolUse', additionalContext: 'Lint failed.' }] + }, + 'AsyncHookJSONOutput' => { + async: ['async', true], + async_timeout: ['asyncTimeout', 5000] + } + }.freeze + + # The spellings a Hash may use for those fields. + spellings = { + 'snake_case Symbol keys' => ->(fields) { fields.transform_values(&:last) }, + 'snake_case String keys' => ->(fields) { fields.to_h { |name, (_wire, value)| [name.to_s, value] } }, + 'camelCase Symbol keys' => ->(fields) { fields.to_h { |_name, (wire, value)| [wire.to_sym, value] } }, + 'camelCase String keys' => ->(fields) { fields.to_h { |_name, (wire, value)| [wire, value] } } + }.freeze + attributes_of = spellings.fetch('snake_case Symbol keys') + wire_form_of = spellings.fetch('camelCase String keys') + + describe 'every field of every typed hook output class' do + output_classes = ClaudeAgentSDK.constants.grep(/Hook(?:SpecificOutput|JSONOutput)\z/).map(&:to_s) + rows = specific_outputs.merge(top_level_outputs) + + it 'has a row for every class' do + expect(rows.keys).to match_array(output_classes) + end + + it 'has a field for every attribute' do + expect(rows.transform_values { |fields| fields.keys.map(&:to_s).sort }) + .to eq(rows.to_h { |class_name, _fields| [class_name, ClaudeAgentSDK.const_get(class_name).attribute_names] }) + end + + specific_outputs.each do |class_name, fields| + describe "ClaudeAgentSDK::#{class_name}" do + let(:klass) { ClaudeAgentSDK.const_get(class_name) } + let(:event) { fields.fetch(:hook_event_name).last } + let(:expected) { { 'continue' => true, 'hookSpecificOutput' => on_the_wire(wire_form_of.call(fields)) } } + + # The forms the enclosing output may take around one nested value. + def enclosing(nested) + { + 'typed output' => ClaudeAgentSDK::SyncHookJSONOutput.new(hook_specific_output: nested), + 'snake_case Symbol keys' => { continue_: true, hook_specific_output: nested }, + 'snake_case String keys' => { 'continue_' => true, 'hook_specific_output' => nested }, + 'camelCase Symbol keys' => { continue: true, hookSpecificOutput: nested }, + 'camelCase String keys' => { 'continue' => true, 'hookSpecificOutput' => nested } + } + end + + def replies_to(forms, event) + forms.transform_values { |output| reply(output, event: event) } + end + + it 'is written under its wire keys when typed' do + forms = enclosing(klass.new(attributes_of.call(fields))) + + expect(replies_to(forms, event)).to eq(forms.transform_values { expected }) + end + + spellings.each do |spelling, hash_in| + it "is written the same way from a Hash with #{spelling}" do + forms = enclosing(hash_in.call(fields)) + + expect(replies_to(forms, event)).to eq(forms.transform_values { expected }) + end + end + end + end + + top_level_outputs.each do |class_name, fields| + describe "ClaudeAgentSDK::#{class_name}" do + let(:expected) { on_the_wire(wire_form_of.call(fields)) } + + it 'is written under its wire keys when typed' do + expect(reply(ClaudeAgentSDK.const_get(class_name).new(attributes_of.call(fields)))).to eq(expected) + end + + spellings.each do |spelling, hash_in| + it "is written the same way from a Hash with #{spelling}" do + expect(reply(hash_in.call(fields))).to eq(expected) + end + end + end + end + end + + # The vocabulary is a hand-written table next to the typed classes. Walk + # every class so the two cannot drift: a new attribute, or a renamed wire + # key in a #to_h, fails here until the table follows. + describe 'ClaudeAgentSDK::HookOutputKeys' do + { 'TOP_LEVEL' => [top_level_outputs, %w[async_ continue_]], + 'HOOK_SPECIFIC' => [specific_outputs, []] }.each do |table_name, (rows, ruby_safe_spellings)| + describe table_name do + let(:table) { ClaudeAgentSDK::HookOutputKeys.const_get(table_name) } + + it 'gives every attribute of every typed output class the key its #to_h emits' do + emitted = rows.to_h do |class_name, fields| + [class_name, ClaudeAgentSDK.const_get(class_name).new(attributes_of.call(fields)).to_h.keys.map(&:to_s).sort] + end + mapped = rows.to_h do |class_name, _fields| + names = ClaudeAgentSDK.const_get(class_name).attribute_names + [class_name, names.map { |name| table.fetch(name, name) }.sort] + end + + expect(mapped).to eq(emitted) + end + + it 'lists nothing but those attributes and the Ruby-safe spellings of keywords' do + attributes = rows.keys.flat_map { |class_name| ClaudeAgentSDK.const_get(class_name).attribute_names } + + expect(table.keys - attributes).to match_array(ruby_safe_spellings) + end + end + end + end + + describe 'a key outside the vocabulary' do + it 'is sent as written, at the top level and inside hook_specific_output' do + output = { + hook_specific_output: { + hook_event_name: 'PreToolUse', + permission_decision: 'allow', + sessionTitle: 'Nightly build', + future_field: { inner_key: true }, + 'x-vendor' => 1 + }, + futureField: { some_key: 1 }, + 'future_field' => 'kept' + } + + expect(reply(output)).to eq( + 'hookSpecificOutput' => { + 'hookEventName' => 'PreToolUse', + 'permissionDecision' => 'allow', + 'sessionTitle' => 'Nightly build', + 'future_field' => { 'inner_key' => true }, + 'x-vendor' => 1 + }, + 'futureField' => { 'some_key' => 1 }, + 'future_field' => 'kept' + ) + end + + it 'includes a name the SDK only knows at the other level' do + output = { + permission_decision: 'deny', + hook_specific_output: { hook_event_name: 'PreToolUse', suppress_output: true, stop_reason: 'x' } + } + + expect(reply(output)).to eq( + 'permission_decision' => 'deny', + 'hookSpecificOutput' => { 'hookEventName' => 'PreToolUse', 'suppress_output' => true, 'stop_reason' => 'x' } + ) + end + end + + describe 'the value of a field' do + # Tool input and tool output are the tool's own payloads: a key in there + # that happens to be spelled like a hook field is not one. + tool_payload = { + file_path: '/work/app/config.rb', old_string: 'a', new_string: 'b', + hook_event_name: 'kept', 'permission_decision' => 'kept', suppress_output: true, + nested: { additional_context: 'kept', watch_paths: ['kept'] } + }.freeze + + { + 'updated_input' => ['PreToolUse', :updated_input, 'updatedInput'], + 'updatedInput' => ['PreToolUse', :updatedInput, 'updatedInput'], + 'updated_tool_output' => %w[PostToolUse updated_tool_output updatedToolOutput], + 'updatedToolOutput' => %w[PostToolUse updatedToolOutput updatedToolOutput], + 'updated_mcp_tool_output' => ['PostToolUse', :updated_mcp_tool_output, 'updatedMCPToolOutput'], + 'updatedMCPToolOutput' => ['PostToolUse', :updatedMCPToolOutput, 'updatedMCPToolOutput'] + }.each do |spelling, (event, key, wire_key)| + it "is not rewritten under #{spelling}" do + output = { hook_specific_output: { hook_event_name: event, key => tool_payload } } + + expect(reply(output, event: event)).to eq( + 'hookSpecificOutput' => { 'hookEventName' => event, wire_key => on_the_wire(tool_payload) } + ) + end + end + + it 'is not rewritten for the decision of a PermissionRequest output' do + decision = { behavior: 'allow', updated_input: { command: 'ls' }, 'updatedPermissions' => [{ rule_content: 'x' }] } + output = { hook_specific_output: { hook_event_name: 'PermissionRequest', decision: decision } } + + expect(reply(output, event: 'PermissionRequest')).to eq( + 'hookSpecificOutput' => { 'hookEventName' => 'PermissionRequest', 'decision' => on_the_wire(decision) } + ) + end + end + + describe 'a Hash that spells one field both ways' do + allow_then_deny = { hook_event_name: 'PreToolUse', permission_decision: 'allow', permissionDecision: 'deny' } + deny_then_allow = { hookEventName: 'PreToolUse', permissionDecision: 'deny', permission_decision: 'allow' } + denied = { 'hookSpecificOutput' => { 'hookEventName' => 'PreToolUse', 'permissionDecision' => 'deny' } } + + it 'sends the camelCase spelling inside hook_specific_output, whichever comes first' do + outputs = [allow_then_deny, deny_then_allow, allow_then_deny.transform_keys(&:to_s)] + + expect(outputs.map { |nested| reply({ hook_specific_output: nested }) }).to eq([denied] * 3) + end + + it 'sends the camelCase spelling at the top level, whichever comes first' do + allowed = { hookEventName: 'PreToolUse', permissionDecision: 'allow' } + wins = { hookEventName: 'PreToolUse', permissionDecision: 'deny' } + outputs = [ + { hook_specific_output: allowed, hookSpecificOutput: wins }, + { hookSpecificOutput: wins, hook_specific_output: allowed }, + { 'hook_specific_output' => allowed, 'hookSpecificOutput' => wins } + ] + + expect(outputs.map { |output| reply(output) }).to eq([denied] * 3) + end + + it 'sends continue rather than continue_, and async rather than async_' do + outputs = [{ continue_: true, continue: false }, { continue: false, continue_: true }, + { async_: false, 'async' => true }, { 'async' => true, async_: false }] + + expect(outputs.map { |output| reply(output) }) + .to eq([{ 'continue' => false }, { 'continue' => false }, { 'async' => true }, { 'async' => true }]) + end + + it 'writes the field once' do + line = reply_line({ hook_specific_output: allow_then_deny, hookSpecificOutput: deny_then_allow }) + + expect(line.scan('"hookSpecificOutput"').size).to eq(1) + expect(line.scan('"permissionDecision"').size).to eq(1) + expect(line).not_to include('hook_specific_output', 'permission_decision') + end + + it 'takes the later one when a Symbol and a String spell the same key' do + nested = { hookEventName: 'PreToolUse', permissionDecision: 'allow', 'permissionDecision' => 'deny' } + line = reply_line({ hookSpecificOutput: nested }) + + expect(JSON.parse(line).dig('response', 'response')).to eq(denied) + expect(line.scan('"permissionDecision"').size).to eq(1) + end + end + + describe 'the Ruby-safe spellings of keywords' do + it 'still stand for continue and async, as a Symbol or a String' do + outputs = [{ continue_: false }, { 'continue_' => false }, { async_: true }, { 'async_' => true }] + + expect(outputs.map { |output| reply(output) }) + .to eq([{ 'continue' => false }, { 'continue' => false }, { 'async' => true }, { 'async' => true }]) + end + end + + describe 'the Hash a callback returned' do + it 'is left untouched, so a frozen constant can be returned on every call' do + output = { + continue_: true, + hook_specific_output: { hook_event_name: 'PreToolUse', permission_decision: 'deny' }.freeze + }.freeze + denied = { 'continue' => true, + 'hookSpecificOutput' => { 'hookEventName' => 'PreToolUse', 'permissionDecision' => 'deny' } } + + expect([reply(output), reply(output)]).to eq([denied, denied]) + expect(output).to eq( + continue_: true, hook_specific_output: { hook_event_name: 'PreToolUse', permission_decision: 'deny' } + ) + end + end + + describe 'a return value that is not a Hash' do + it 'still answers {} for nil, true and a String' do + expect([nil, true, 'deny'].map { |output| reply(output) }).to eq([{}, {}, {}]) + end + + it 'is normalized like a Hash when it responds to #to_h' do + output = Struct.new(:continue_, :hook_specific_output) + .new(false, { hook_event_name: 'PreToolUse', permission_decision: 'deny' }) + + expect(reply(output)).to eq( + 'continue' => false, + 'hookSpecificOutput' => { 'hookEventName' => 'PreToolUse', 'permissionDecision' => 'deny' } + ) + end + end +end diff --git a/spec/unit/hooks_event_key_merge_spec.rb b/spec/unit/hooks_event_key_merge_spec.rb new file mode 100644 index 00000000..5227e984 --- /dev/null +++ b/spec/unit/hooks_event_key_merge_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'json' + +# ClaudeAgentOptions#hooks is keyed by event name, written as a String or as +# a Symbol. When one event appeared under both key types (two parts of an +# app each adding their own guard, say) the second list silently replaced the +# first, so one of the hooks was never registered and never ran. +# +# End to end through the real control protocol: a fake Transport answers the +# initialize request the way the CLI does, delivers the frames an example +# hands it, and reports every frame the SDK writes. +RSpec.describe 'hooks registered under a String and a Symbol key for one event' do + let(:transport_class) do + Class.new(ClaudeAgentSDK::Transport) do + def initialize(_options, inbound:, written:) + super() + @inbound = inbound + @written = written + end + + def connect = @ready = true + def ready? = @ready + def end_input; end + def close = @inbound.push(:eof) + + def write(data) + frame = JSON.parse(data, symbolize_names: true) + if frame.dig(:request, :subtype) == 'initialize' + response = { subtype: 'success', request_id: frame[:request_id], response: {} } + @inbound.push({ type: 'control_response', response: response }) + end + @written.push(frame) + end + + def read_messages + loop do + message = @inbound.pop + break if message == :eof + + yield message + end + end + end + end + + let(:inbound) { Thread::Queue.new } + let(:written) { Thread::Queue.new } + let(:called) { [] } + + # A matcher whose one hook records that it ran. + def matcher(name, tool, **) + hook = lambda do |_input, _tool_use_id, _context| + called << name + {} + end + ClaudeAgentSDK::HookMatcher.new(matcher: tool, hooks: [hook], **) + end + + # Connects a session with these hooks and runs the block inside it. + def session(hooks) + Sync do + client = ClaudeAgentSDK::Client.new( + options: ClaudeAgentSDK::ClaudeAgentOptions.new(hooks: hooks), + transport_class: transport_class, transport_args: { inbound: inbound, written: written } + ) + client.connect + yield + ensure + client&.disconnect + end + end + + # The next frame the SDK wrote (nil if none arrives: a bounded wait, so a + # regression fails the example instead of hanging the suite). + def next_written + written.pop(timeout: 5) + end + + # The hooks object of the initialize request. + def registered_hooks + next_written.dig(:request, :hooks) + end + + # The hook_callback request CLI 2.1.286 writes for a PreToolUse hook + # (recorded; only the home directory is shortened), for one callback id. + def hook_callback(callback_id) + request = JSON.parse(<<~FRAME, symbolize_names: true) + {"type":"control_request","request_id":"a0ae4d7c-684f-4cd2-a36a-e5e7f1e7e928","request":{"subtype":"hook_callback","callback_id":"hook_0","input":{"session_id":"097e32d1-723a-42b4-a59b-f816b0d9ac84","transcript_path":"/home/dev/.claude/projects/-work-app/097e32d1-723a-42b4-a59b-f816b0d9ac84.jsonl","cwd":"/work/app","scratchpad_dir":"/private/tmp/claude-501/-work-app/097e32d1-723a-42b4-a59b-f816b0d9ac84/scratchpad","prompt_id":"efe95091-ba7c-4f07-8d76-e376ef8f0c92","permission_mode":"default","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf build","description":"Remove the build directory"},"tool_use_id":"toolu_01PsVteovwuBVRcCCBHw4EC7"},"tool_use_id":"toolu_01PsVteovwuBVRcCCBHw4EC7"}} + FRAME + request[:request_id] = "request-for-#{callback_id}" + request[:request][:callback_id] = callback_id + request + end + + it 'registers the matchers of both keys, in the order they were written' do + hooks = { 'PreToolUse' => [matcher('bash_guard', 'Bash')], PreToolUse: [matcher('write_guard', 'Write|Edit')] } + + session(hooks) do + expect(registered_hooks).to eq( + PreToolUse: [ + { matcher: 'Bash', hookCallbackIds: ['hook_0'] }, + { matcher: 'Write|Edit', hookCallbackIds: ['hook_1'] } + ] + ) + end + end + + it 'runs the hooks of both keys' do + hooks = { PreToolUse: [matcher('write_guard', 'Write|Edit')], 'PreToolUse' => [matcher('bash_guard', 'Bash')] } + + replies = session(hooks) do + next_written # the initialize request + %w[hook_0 hook_1].map do |callback_id| + inbound.push(hook_callback(callback_id)) + next_written + end + end + + expect(called).to eq(%w[write_guard bash_guard]) + expect(replies.map { |reply| reply[:response].slice(:subtype, :request_id) }).to eq( + [{ subtype: 'success', request_id: 'request-for-hook_0' }, + { subtype: 'success', request_id: 'request-for-hook_1' }] + ) + end + + it 'keeps each matcher its own timeout and leaves other events alone' do + hooks = { + 'PreToolUse' => [matcher('bash_guard', 'Bash', timeout: 30)], + 'PostToolUse' => [matcher('audit', nil)], + PreToolUse: [matcher('write_guard', 'Write|Edit')] + } + + session(hooks) do + expect(registered_hooks).to eq( + PreToolUse: [ + { matcher: 'Bash', hookCallbackIds: ['hook_0'], timeout: 30 }, + { matcher: 'Write|Edit', hookCallbackIds: ['hook_1'] } + ], + PostToolUse: [{ matcher: nil, hookCallbackIds: ['hook_2'] }] + ) + end + end + + { 'an empty list' => [], 'nil' => nil }.each do |label, nothing| + it "still ignores #{label} under the other key" do + session('PreToolUse' => [matcher('bash_guard', 'Bash')], PreToolUse: nothing) do + expect(registered_hooks).to eq(PreToolUse: [{ matcher: 'Bash', hookCallbackIds: ['hook_0'] }]) + end + end + end +end diff --git a/spec/unit/instrumentation/otel_spec.rb b/spec/unit/instrumentation/otel_spec.rb index 56d5a83f..1bab0279 100644 --- a/spec/unit/instrumentation/otel_spec.rb +++ b/spec/unit/instrumentation/otel_spec.rb @@ -670,9 +670,33 @@ def finish expect(root_span.status.code).to eq(OpenTelemetry::Trace::Status::ERROR) end - it 'is a no-op when no root span exists' do + it 'gives an error that arrives before any InitMessage a finished session span of its own' do fresh_observer = described_class.new - expect { fresh_observer.on_error(RuntimeError.new('test')) }.not_to raise_error + spans_before = created_spans.length + + fresh_observer.on_error(RuntimeError.new('test')) + + spans = created_spans[spans_before..] + expect(spans.map(&:name)).to eq(['claude_agent.session']) + expect(spans.first).to have_attributes(finished: true) + expect(spans.first.status).to have_attributes(code: OpenTelemetry::Trace::Status::ERROR, description: 'test') + expect(spans.first.events.map { |event| event[:name] }).to eq(['exception']) + end + + # The CLI exits non-zero after an error result, so query() raises a + # ResultError when the trace has already ended. That trace reports the + # failure; a second session span for it would be a duplicate. + it 'adds no span for an error that follows a finished trace' do + observer.on_message( + ClaudeAgentSDK::ResultMessage.new( + subtype: 'error_during_execution', is_error: true, duration_ms: 1, duration_api_ms: 1, + num_turns: 1, session_id: 'sess-1', usage: {} + ) + ) + observer.on_error(ClaudeAgentSDK::ProcessError.new('Claude Code returned an error result: boom', exit_code: 1)) + + expect(created_spans.map(&:name)).to eq(['claude_agent.session']) + expect(created_spans.first.events).to be_empty end end @@ -1096,4 +1120,342 @@ def deliver_result(tool_use_id, content) expect(span.finished).to be(true) end end + + # The CLI sends one `assistant` frame PER CONTENT BLOCK, and every frame of + # one API response repeats that response's message id and usage snapshot. + # The hand-built messages above hold a whole response in one message with + # no id, a shape the CLI does not send, so these examples replay a recorded + # turn instead (spec/fixtures/cli_frames/README.md). + describe 'token usage on generation spans, from recorded CLI frames' do + let(:frames) do + path = File.expand_path('../../fixtures/cli_frames/tool_turn.jsonl', __dir__) + File.readlines(path).map { |line| JSON.parse(line, symbolize_names: true) } + end + let(:assistant_frames) { frames.select { |frame| frame[:type] == 'assistant' } } + let(:token_keys) { %i[input_tokens output_tokens cache_creation_input_tokens cache_read_input_tokens] } + + # Hands frames to the observer the way a session does: Query drops the + # `sdk_host_only` state frames, everything else is parsed and observed. + def replay(frames) + frames.each do |frame| + next if frame[:subtype] == 'session_state_changed' && frame[:sdk_host_only] == true + + observer.on_message(ClaudeAgentSDK::MessageParser.parse(frame)) + end + end + + def recorded_init + ClaudeAgentSDK::MessageParser.parse(frames.find { |frame| frame[:subtype] == 'init' }) + end + + def recorded_result + ClaudeAgentSDK::MessageParser.parse(frames.find { |frame| frame[:type] == 'result' }) + end + + # A recorded assistant frame with another message id / usage, for frame + # orders the recording does not contain. + def assistant_frame(id:, usage: assistant_frames.last.dig(:message, :usage)) + frame = assistant_frames.last + ClaudeAgentSDK::MessageParser.parse(frame.merge(message: frame[:message].merge(id: id, usage: usage))) + end + + def generation_spans + created_spans.select { |span| span.name == 'claude_agent.generation' } + end + + def token_usage(span) + token_keys.select { |key| span.attributes.key?("gen_ai.usage.#{key}") } + .to_h { |key| [key, span.attributes["gen_ai.usage.#{key}"]] } + end + + it 'replays a turn in which each API response spans two frames that repeat one usage snapshot' do + by_message_id = assistant_frames.group_by { |frame| frame.dig(:message, :id) } + + expect(by_message_id.keys).to all(start_with('msg_')) + expect(by_message_id.values.map(&:length)).to eq([2, 2]) + expect(assistant_frames.map { |frame| frame.dig(:message, :content).map { |block| block[:type] } }) + .to eq([%w[thinking], %w[tool_use], %w[thinking], %w[text]]) + by_message_id.each_value do |group| + expect(group.map { |frame| frame.dig(:message, :usage) }.uniq.length).to eq(1) + end + end + + it 'still emits one generation span per assistant frame' do + replay(frames) + + expect(generation_spans.length).to eq(4) + expect(generation_spans).to all(have_attributes(finished: true)) + end + + it 'attaches the usage of an API response to the first of its generation spans only' do + replay(frames) + + first_frames = assistant_frames.uniq { |frame| frame.dig(:message, :id) } + expected = assistant_frames.map do |frame| + first_frames.any? { |first| first.equal?(frame) } ? frame.dig(:message, :usage).slice(*token_keys) : {} + end + expect(expected.map(&:empty?)).to eq([false, true, false, true]) + expect(generation_spans.map { |span| token_usage(span) }).to eq(expected) + end + + it 'reports the same input-side token usage on generation spans as the ResultMessage' do + replay(frames) + + input_side = %i[input_tokens cache_read_input_tokens cache_creation_input_tokens] + summed = input_side.to_h { |key| [key, generation_spans.sum { |span| token_usage(span).fetch(key, 0) }] } + + expect(summed).to eq(input_tokens: 18, cache_read_input_tokens: 45_028, cache_creation_input_tokens: 4928) + expect(summed).to eq(recorded_result.usage.slice(*input_side)) + end + + # Every frame carries the snapshot taken when the response started, so a + # generation span cannot know the response's final output count; the + # ResultMessage, and with it the session span, can. + it 'leaves the authoritative totals, output tokens included, to the session span' do + replay(frames) + + session_span = created_spans.find { |span| span.name == 'claude_agent.session' } + expect(token_usage(session_span)).to eq(recorded_result.usage.slice(*token_keys)) + expect(token_usage(session_span)).to include(output_tokens: 161) + expect(generation_spans.sum { |span| token_usage(span).fetch(:output_tokens, 0) }).to eq(4 + 2) + end + + it 'opens the tool span on the tool_use frame and closes it on the tool result frame' do + replay(frames) + + tool_span = created_spans.find { |span| span.name == 'claude_agent.tool.Bash' } + expect(tool_span).to have_attributes(finished: true) + expect(tool_span.attributes).to include( + 'input.value' => '{"command":"echo placeholder","description":"placeholder"}', + 'output.value' => 'placeholder' + ) + end + + # Constructed order: two subagents answering at once interleave the + # frames of their responses. + it 'tells the frames of interleaved API responses apart by message id' do + observer.on_message(recorded_init) + %w[msg_a msg_b msg_a msg_b].each { |id| observer.on_message(assistant_frame(id: id)) } + + expect(generation_spans.map { |span| token_usage(span).empty? }).to eq([false, false, true, true]) + end + + it 'keeps the usage for a later frame when the first frame of a response carries none' do + observer.on_message(recorded_init) + observer.on_message(assistant_frame(id: 'msg_a', usage: nil)) + observer.on_message(assistant_frame(id: 'msg_a')) + observer.on_message(assistant_frame(id: 'msg_a')) + + expect(generation_spans.map { |span| token_usage(span).empty? }).to eq([true, false, true]) + end + + it 'keeps usage on every span when messages carry no id to group them by' do + observer.on_message(recorded_init) + 2.times { observer.on_message(assistant_frame(id: nil)) } + + expect(generation_spans.map { |span| token_usage(span).empty? }).to eq([false, false]) + end + + it 'remembers message ids for one trace only' do + observer.on_message(recorded_init) + observer.on_message(assistant_frame(id: 'msg_a')) + observer.on_message(recorded_result) # the trace ends + observer.on_message(recorded_init) + observer.on_message(assistant_frame(id: 'msg_a')) + observer.on_message(recorded_init) # superseded without a result + observer.on_message(assistant_frame(id: 'msg_a')) + observer.on_close + observer.on_message(recorded_init) + observer.on_message(assistant_frame(id: 'msg_a')) + + expect(generation_spans.map { |span| token_usage(span).empty? }).to eq([false, false, false, false]) + end + end + + # A CLI that cannot be found or started, an initialize that fails or times + # out, a process that dies before its first frame: the session never sends + # an InitMessage, so there is no trace to record the error on. + describe 'a session that fails before its first InitMessage' do + let(:error) { ClaudeAgentSDK::CLINotFoundError.new('Claude Code not found at: /nonexistent/claude') } + let(:init_message) do + ClaudeAgentSDK::InitMessage.new(subtype: 'init', session_id: 'sess-1', model: 'claude-sonnet-4') + end + let(:result_message) do + ClaudeAgentSDK::ResultMessage.new( + subtype: 'success', is_error: false, duration_ms: 1, duration_api_ms: 1, num_turns: 1, + session_id: 'sess-1', usage: {} + ) + end + + before do + # As the outer capture, and counting how often each span is finished. + allow_any_instance_of(OpenTelemetry::MockTracer).to receive(:start_span) do |_tracer, name, **kwargs| + span = OpenTelemetry::MockSpan.new(name, kwargs[:attributes] || {}) + allow(span).to receive(:finish).and_call_original + created_spans << span + span + end + end + + def error_span?(span) + span.status&.code == OpenTelemetry::Trace::Status::ERROR && span.events.map { |event| event[:name] } == ['exception'] + end + + it 'exports a session span with the exception, error status and the prompt, and nothing an init would add' do + observer.on_user_prompt('Fix the bug') + observer.on_error(error) + + expect(created_spans.map(&:name)).to eq(['claude_agent.session']) + span = created_spans.first + expect(span.attributes).to eq( + 'gen_ai.system' => 'anthropic', 'openinference.span.kind' => 'AGENT', + 'langfuse.observation.type' => 'agent', 'input.mime_type' => 'text/plain', + 'output.mime_type' => 'text/plain', 'input.value' => 'Fix the bug' + ) + expect(span.status).to have_attributes(code: OpenTelemetry::Trace::Status::ERROR, description: error.message) + expect(span.events).to eq( + [{ name: 'exception', attributes: { 'exception.type' => 'ClaudeAgentSDK::CLINotFoundError', + 'exception.message' => error.message } }] + ) + end + + # Client#connect notifies on_error and never on_close when it fails + # before the handshake; an unfinished span is never exported. + it 'finishes that span in on_error, without waiting for an on_close' do + observer.on_error(error) + + expect(created_spans.first).to have_attributes(finished: true) + end + + it 'does not finish it a second time when on_close follows, as it does in query()' do + observer.on_error(error) + observer.on_close + + expect(created_spans.length).to eq(1) + expect(created_spans.first).to have_received(:finish).once + end + + it 'adds the default attributes of the observer' do + tagged = described_class.new('langfuse.session.id' => 'checkout-42', 'user.id' => 'user-7') + tagged.on_error(error) + + expect(created_spans.first.attributes).to include('langfuse.session.id' => 'checkout-42', 'user.id' => 'user-7') + end + + it 'does not carry the prompt of the failed session into the next trace' do + observer.on_user_prompt('Failed prompt') + observer.on_error(error) + observer.on_message(init_message) # a retry on the same observer; no on_close came in between + + expect(created_spans.map { |span| span.attributes['input.value'] }).to eq(['Failed prompt', nil]) + end + + it 'labels a retry after the failure with its own prompt' do + observer.on_user_prompt('Failed prompt') + observer.on_error(error) + observer.on_close + observer.on_user_prompt('Retry prompt') + observer.on_message(init_message) + + expect(created_spans.map { |span| span.attributes['input.value'] }).to eq(['Failed prompt', 'Retry prompt']) + expect(created_spans.map { |span| error_span?(span) }).to eq([true, false]) + end + + it 'gives every error that surfaces before the first InitMessage its own finished span' do + observer.on_error(error) + observer.on_error(ClaudeAgentSDK::CLIConnectionError.new('Not connected')) + + expect(created_spans.map { |span| span.status.description }).to eq([error.message, 'Not connected']) + expect(created_spans).to all(have_attributes(finished: true)) + end + + it 'reports a failed start again once the observer has been closed' do + observer.on_message(init_message) + observer.on_message(result_message) + observer.on_close + observer.on_error(error) + + expect(created_spans.map { |span| error_span?(span) }).to eq([false, true]) + expect(created_spans).to all(have_attributes(finished: true)) + end + + it 'stays silent for an error between two traces of one session' do + observer.on_message(init_message) + observer.on_message(result_message) + observer.on_error(ClaudeAgentSDK::CLIConnectionError.new('Not connected')) + observer.on_message(init_message) + + expect(created_spans.map { |span| error_span?(span) }).to eq([false, false]) + end + + describe 'through the public entry points' do + # The real transport, pointed at a CLI that is not there. + let(:options) { ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [observer], cli_path: '/nonexistent/claude') } + + it 'leaves one finished error span when query() cannot start the CLI' do + context = { span: Object.new } + + OpenTelemetry::Context.with_current(context) do + expect { ClaudeAgentSDK.query(prompt: 'hello', options: options) { |_message| nil } } + .to raise_error(ClaudeAgentSDK::CLINotFoundError) + end + + expect(created_spans.map(&:name)).to eq(['claude_agent.session']) + expect(error_span?(created_spans.first)).to be(true) + expect(created_spans.first).to have_attributes(finished: true, parent_context: context) + expect(created_spans.first).to have_received(:finish).once + end + + it 'leaves one finished error span when Client#connect cannot start the CLI, though no on_close follows' do + notified = [] + recorder = Object.new.extend(ClaudeAgentSDK::Observer) + recorder.define_singleton_method(:on_error) { |_error| notified << :on_error } + recorder.define_singleton_method(:on_close) { notified << :on_close } + both = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [recorder, observer], cli_path: options.cli_path) + context = { span: Object.new } + + OpenTelemetry::Context.with_current(context) do + expect { ClaudeAgentSDK::Client.open(options: both) { |_client| nil } } + .to raise_error(ClaudeAgentSDK::CLINotFoundError) + end + + expect(notified).to eq([:on_error]) + expect(created_spans.map(&:name)).to eq(['claude_agent.session']) + expect(error_span?(created_spans.first)).to be(true) + expect(created_spans.first).to have_attributes(finished: true, parent_context: context) + expect(created_spans.first).to have_received(:finish).once + end + + it 'records the prompt when the CLI dies after the prompt was written and before any InitMessage' do + incoming = Async::Queue.new + transport = instance_double(ClaudeAgentSDK::SubprocessCLITransport, connect: true, close: nil, end_input: nil) + allow(transport).to receive(:write) do |json| + message = JSON.parse(json, symbolize_names: true) + case message[:type] + when 'control_request' + incoming.enqueue(type: 'control_response', + response: { subtype: 'success', request_id: message[:request_id], response: {} }) + when 'user' + incoming.enqueue(:exit) + end + end + allow(transport).to receive(:read_messages) do |&receive| + loop do + frame = incoming.dequeue + raise ClaudeAgentSDK::ProcessError.new('Command failed', exit_code: 1, stderr: 'boom') if frame == :exit + + receive.call(frame) + end + end + + expect { ClaudeAgentSDK.query(prompt: 'hello', options: options, transport: transport) { |_message| nil } } + .to raise_error(ClaudeAgentSDK::ProcessError, /exit code: 1/) + + expect(created_spans.map(&:name)).to eq(['claude_agent.session']) + expect(error_span?(created_spans.first)).to be(true) + expect(created_spans.first.attributes).to include('input.value' => 'hello') + expect(created_spans.first).to have_received(:finish).once + end + end + end end diff --git a/spec/unit/materialized_resume_spec.rb b/spec/unit/materialized_resume_spec.rb new file mode 100644 index 00000000..7f2f6c99 --- /dev/null +++ b/spec/unit/materialized_resume_spec.rb @@ -0,0 +1,783 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'tmpdir' +require 'fileutils' +require 'stringio' + +RSpec.describe ClaudeAgentSDK::MaterializedResume do + # When the mirror dropped batches the materialized config dir is kept, since + # it holds the only copy of the dropped turns. It must then hold nothing but + # those transcripts, and getting there must not delete or change anything + # outside it, whatever is done to the directory in the meantime: the CLI's + # tools can write into it, sandboxed ones included. + describe '#preserve_transcripts' do + # One scratch directory per example holds the temp config dir, the private + # directory the SDK moves it into (created next to it) and the directories + # nothing may touch. Nothing lands in the real $TMPDIR. + let(:scratch) { Dir.mktmpdir('preserve-spec-') } + let(:config_dir) { File.join(scratch, 'claude-resume-under-test') } + let(:outside) { File.join(scratch, 'outside') } + let(:sid) { '11111111-1111-4111-8111-111111111111' } + let(:materialized) { described_class.new(config_dir: config_dir, resume_session_id: sid) } + + # What the SDK seeds from the caller's .claude.json: it can carry MCP + # header secrets. + let(:claude_json) do + JSON.generate('oauthAccount' => { 'emailAddress' => 'user@example.invalid' }, + 'mcpServers' => { 'internal' => { 'type' => 'http', 'url' => 'https://mcp.example.invalid', + 'headers' => { 'Authorization' => 'Bearer MCP-HEADER-SECRET' } } }) + end + + # The four files the SDK seeds, then what a real CLI (2.1.286) left beside + # them after a store-backed resume — at startup it saves the seeded + # .claude.json as backups/.claude.json.backup.<epoch ms> before rewriting + # it — and a file name nobody has heard of yet. + let(:secrets) do + { + '.credentials.json' => '{"claudeAiOauth":{"accessToken":"ACCESS-TOKEN"}}', + '.claude.json' => claude_json, + 'settings.json' => '{"env":{"INTERNAL_API_KEY":"SETTINGS-SECRET"}}', + 'cowork_settings.json' => '{"env":{"INTERNAL_API_KEY":"SETTINGS-SECRET"}}', + 'backups/.claude.json.backup.1790881328533' => claude_json, + 'cache/model-catalog/x-cc.json' => '{}', + 'written-by-a-later-cli.json' => claude_json + } + end + let(:transcripts) do + { + "projects/-proj/#{sid}.jsonl" => %({"type":"user","uuid":"u1","sessionId":"#{sid}"}\n), + "projects/-proj/#{sid}/subagents/agent-a1.jsonl" => %({"type":"user","uuid":"s1","isSidechain":true}\n), + "projects/-proj/#{sid}/subagents/agent-a1.meta.json" => '{"agentType":"worker"}' + } + end + + before do + Dir.mkdir(config_dir, 0o700) + write_tree(config_dir, secrets.merge(transcripts)) + Dir.mkdir(File.join(config_dir, 'sessions')) # the CLI leaves this one empty + write_tree(outside, 'not-ours.txt' => 'keep me') + File.symlink(outside, File.join(config_dir, 'linked-elsewhere')) + materialized # built now, like the SDK does: it records which directory this is + end + + # Stubs are still in place while this runs. A wrapper that waits for a + # call its example never got to must not make its swap now, in the middle + # of the teardown: the scratch directory would stay behind in $TMPDIR. + after do + { File => %i[lstat open rename unlink], Dir => %i[children rmdir] }.each do |receiver, wrapped| + wrapped.each { |name| allow(receiver).to receive(name).and_call_original } + end + unlock(scratch) + FileUtils.remove_entry(scratch) + end + + def write_tree(root, files) + files.each do |relative, content| + path = File.join(root, relative) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + end + end + + # Give every real directory under +path+ its owner bits back (never + # through a symlink), so the scratch tree can be removed whatever an + # example left read-only. + def unlock(path) + return unless File.lstat(path).directory? + + File.chmod(0o700, path) + Dir.children(path).each { |name| unlock(File.join(path, name)) } + end + + def stderr_of + original = $stderr + $stderr = StringIO.new + yield + $stderr.string + ensure + $stderr = original + end + + # relative path => content, for every regular file under +dir+. + def tree(dir) + files = Dir.glob('**/*', File::FNM_DOTMATCH, base: dir).select { |relative| File.file?(File.join(dir, relative)) } + files.sort.to_h { |relative| [relative, File.read(File.join(dir, relative))] } + end + + def mode_of(path) + format('%o', File.stat(path).mode & 0o777) + end + + # Runs preserve_transcripts and returns its warning. + def preserve + stderr_of { materialized.preserve_transcripts } + end + + # Runs preserve_transcripts while +method+ of +receiver+ raises something + # that is not a StandardError — what a cancellation (Async::Stop) or a + # signal looks like — and returns the warning. The wrapper is disarmed + # afterwards: the examples and the teardown use the same methods. + def preserve_interrupted_at(receiver, method) + cancellation = Class.new(Exception) # rubocop:disable Lint/InheritException -- see above + interrupting = true + allow(receiver).to receive(method).and_wrap_original do |original, *args| + raise cancellation if interrupting + + original.call(*args) + end + stderr_of { expect { materialized.preserve_transcripts }.to raise_error(cancellation) } + ensure + interrupting = false + end + + # Where the directory lives once it was moved, and the private directory + # it was moved into. + def preserved + materialized.config_dir + end + + def staging + File.dirname(preserved) + end + + let(:skipped) { /transcript mirror dropped batches.*Scrubbing was skipped and nothing was deleted/ } + + # Dir.children answers in the filesystem's order, and that order differs + # from one system to the next: an example that passed on APFS failed on + # ext4. Every example that wraps Dir.children runs with the names in each + # of these orders, so that none owes its result to the machine it runs on. + listing_orders = { + 'sorted' => :sort.to_proc, + 'in reverse order' => ->(names) { names.sort.reverse }, + 'shuffled' => ->(names) { names.sort.shuffle(random: Random.new(RSpec.configuration.seed)) } + }.freeze + + # Makes Dir.children answer with the names in +order+. A block, if given, is + # called with the path and those names before they are returned. + def list_children(order) + allow(Dir).to receive(:children).and_wrap_original do |original, path| + order.call(original.call(path)).tap { |names| yield path, names if block_given? } + end + end + + def in_trash?(path) + File.basename(File.dirname(path)).start_with?('scrub-') + end + + it 'moves the directory into a private one next to it, keeps projects/ and deletes every other entry' do + warning = preserve + + expect(File.symlink?(config_dir) || File.exist?(config_dir)).to be(false) + expect(File.basename(staging)).to start_with('claude-preserved-resume-') + expect(Dir.children(scratch)).to contain_exactly(File.basename(staging), 'outside') + expect(mode_of(staging)).to eq('700') + expect(Dir.children(staging)).to eq(['claude-resume-under-test']) + expect(Dir.children(preserved)).to eq(['projects']) + expect(tree(preserved)).to eq(transcripts) + expect(warning).to match(/transcript mirror dropped batches.*Preserving the session transcript under /) + expect(warning).to match(/under #{Regexp.escape(File.join(preserved, 'projects'))} .*then remove #{Regexp.escape(staging)}\./) + expect(warning).not_to match(/Scrubbing/) + end + + it 'removes a symlink without following it' do + preserve + + expect(Dir.children(preserved)).to eq(['projects']) + expect(tree(outside)).to eq('not-ours.txt' => 'keep me') + end + + it 'still reports the incomplete store copy, without raising, when the directory is already gone' do + FileUtils.remove_entry(config_dir) + + expect(preserve).to match(skipped) + expect(Dir.children(scratch)).to eq(['outside']) # and leaves no empty private directory behind + end + + it 'deletes nothing, and leaves no empty directory behind, when the directory cannot be moved' do + allow(File).to receive(:rename).and_raise(Errno::EACCES, config_dir) + + warning = preserve + + expect(tree(config_dir)).to eq(secrets.merge(transcripts)) + expect(Dir.children(scratch)).to contain_exactly('claude-resume-under-test', 'outside') + expect(warning).to match(skipped) + expect(warning).to include("#{config_dir} could not be moved aside (Permission denied") + end + + # Cut short by something that is not a StandardError (a cancellation, a + # signal), it still has to say what to do: #cleanup leaves the directory + # alone from then on. Where the transcript is, that copies which were not + # removed yet may be left, and which directory to remove once the + # transcript is imported — the private one if the directory was moved + # there, the temp dir itself if it was not. (Its parent would be $TMPDIR.) + context 'when it is interrupted' do + # The whole interrupted notice, for a transcript under +transcript_dir+ + # and +to_remove+ as the directory the user is told to remove. + def interrupted(transcript_dir, to_remove) + to_remove = Regexp.escape(to_remove) + Regexp.new( + 'transcript mirror dropped batches.* Scrubbing was interrupted; the session transcript is under ' \ + "#{Regexp.escape(File.join(transcript_dir, 'projects'))}\\. " \ + "Copies of your credentials and settings .* may be left under #{to_remove} — " \ + "import the transcript into your session store, then remove #{to_remove}\\.$" + ) + end + + it 'names the private directory to remove once the directory was moved there' do + warning = preserve_interrupted_at(File, :unlink) + + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + expect(File.basename(staging)).to start_with('claude-preserved-resume-') + expect(warning).to match(interrupted(preserved, staging)) + expect(warning).not_to match(/remove #{Regexp.escape(preserved)}\./) + end + + it 'names the temp dir itself when the directory had not been moved yet' do + warning = preserve_interrupted_at(File, :lstat) + + expect(preserved).to eq(config_dir) + expect(Dir.children(scratch)).to contain_exactly('claude-resume-under-test', 'outside') + expect(warning).to match(interrupted(config_dir, config_dir)) + expect(warning).not_to match(/remove #{Regexp.escape(scratch)}\./) + end + end + + # A teardown can run twice. Client#disconnect lets go of its + # MaterializedResume only once preserve_transcripts returned; cut short, a + # later disconnect finds the query handler gone — nothing remembers that + # the mirror dropped batches — and calls #cleanup on the same object. What + # was kept is the only copy of the dropped turns. + context 'when #cleanup is called afterwards' do + it 'leaves the preserved directory alone' do + preserve + + materialized.cleanup + + expect(tree(preserved)).to eq(transcripts) + end + + it 'leaves the transcript in place when the scrub had been interrupted' do + preserve_interrupted_at(File, :unlink) + + materialized.cleanup + + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + end + + it 'deletes nothing when it had been interrupted before the directory was moved' do + preserve_interrupted_at(File, :lstat) + + materialized.cleanup + + expect(preserved).to eq(config_dir) + expect(tree(config_dir)).to eq(secrets.merge(transcripts)) + end + end + + # An entry that cannot be removed must not pass for scrubbed: backups/ can + # hold the seeded .claude.json, MCP header secrets included. + context 'when an entry resists deletion' do + let(:backups) { File.join(config_dir, 'backups') } + + it 'makes a read-only directory of its own writable and removes it' do + File.chmod(0o500, backups) # listable, not writable: it cannot be moved, its file cannot be unlinked + + warning = preserve + + expect(Dir.children(preserved)).to eq(['projects']) + expect(Dir.children(staging)).to eq(['claude-resume-under-test']) + expect(warning).to match(/Preserving the session transcript/) + expect(warning).not_to match(/Scrubbing failed/) + end + + it 'makes a directory accessible that it could take out but not empty' do + File.chmod(0o600, backups) # writable, so it can be moved; not searchable, so nothing in it can be + + warning = preserve + + expect(Dir.children(preserved)).to eq(['projects']) + expect(Dir.children(staging)).to eq(['claude-resume-under-test']) + expect(warning).not_to match(/Scrubbing failed/) + end + + it 'neither follows nor changes a symlink while it makes a directory writable' do + File.symlink(outside, File.join(backups, 'elsewhere')) + File.chmod(0o555, outside) + File.chmod(0o500, backups) + + preserve + + expect(Dir.children(preserved)).to eq(['projects']) + expect(mode_of(outside)).to eq('555') + expect(tree(outside)).to eq('not-ours.txt' => 'keep me') + end + + it 'does not chmod a directory that another user owns' do + File.chmod(0o500, backups) + allow_any_instance_of(File::Stat).to receive(:owned?).and_return(false) + + warning = preserve + + expect(mode_of(File.join(preserved, 'backups'))).to eq('500') + expect(warning).to match(/Scrubbing failed: could not remove backups \(Operation not permitted/) + end + + it 'says scrubbing failed, and names what is left, when it cannot take an entry out' do + allow(File).to receive(:rename).and_wrap_original do |original, from, to| + raise Errno::EACCES, from if File.basename(from) == 'backups' + + original.call(from, to) + end + + warning = preserve + + expect(Dir.children(preserved)).to contain_exactly('backups', 'projects') + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + expect(warning).to match(/Preserving the session transcript.* Scrubbing failed: could not remove backups \(Permission denied/) + expect(warning).to match(/What is left is under #{Regexp.escape(staging)}/) + end + + it 'names an entry it had already taken out but could not finish removing' do + taken = nil + allow(File).to receive(:rename).and_wrap_original do |original, from, to| + taken = to if File.basename(from) == 'backups' + original.call(from, to) + end + refusing = true + allow(Dir).to receive(:rmdir).and_wrap_original do |original, path| + raise Errno::EACCES, path if refusing && path == taken + + original.call(path) + end + + warning = preserve + refusing = false + + expect(Dir.children(preserved)).to eq(['projects']) # it is no longer next to the transcript ... + expect(File).to exist(taken) # ... but it is not gone either + expect(warning).to match(/Scrubbing failed: could not remove backups \(Permission denied.*What is left is under #{Regexp.escape(staging)}/) + end + end + + # Something that still holds a directory open can write into it after it + # was moved into the trash. A listing cannot tell: the entry may arrive + # right after it, and the listing may have been empty (on ext4 the empty + # sessions/ was the first directory listed; on APFS it never was). rmdir + # is what notices, and the directory is then emptied again. + listing_orders.each do |how, order| + context "when an entry arrives in a directory that is being emptied (entries listed #{how})" do + # Writes one file into the first directory of the trash whose listing + # the block accepts, right after that listing. + def late_arrival(order) + { arrived: false }.tap do |late| + list_children(order) do |path, names| + next if late[:arrived] || !in_trash?(path) || !yield(names) + + late[:arrived] = true + File.write(File.join(path, 'late.json'), claude_json) + end + end + end + + def expect_nothing_left(warning) + expect(Dir.children(preserved)).to eq(['projects']) + expect(Dir.children(staging)).to eq(['claude-resume-under-test']) + expect(warning).not_to match(/Scrubbing/) + end + + it 'takes it out too, after a listing that had entries' do + late = late_arrival(order, &:any?) + + warning = preserve + + expect(late[:arrived]).to be(true) + expect_nothing_left(warning) + end + + it 'takes it out too, after a listing that was empty' do + late = late_arrival(order, &:empty?) + + warning = preserve + + expect(late[:arrived]).to be(true) + expect_nothing_left(warning) + end + + it 'does the same when rmdir answers EEXIST for a directory that is not empty, as some systems do' do + allow(Dir).to receive(:rmdir).and_wrap_original do |original, path| + raise Errno::EEXIST, path unless Dir.empty?(path) + + original.call(path) + end + late = late_arrival(order, &:empty?) + + warning = preserve + + expect(late[:arrived]).to be(true) + expect_nothing_left(warning) + end + + it 'gives up after EMPTYING_PASSES passes when entries keep arriving, and says what is left' do + kept_busy = nil + arrivals = 0 + list_children(order) do |path, _names| + # The ceiling is for a scrub that would never give up. + next unless in_trash?(path) && (kept_busy ||= path) == path && arrivals < 10 + + File.write(File.join(path, "late-#{arrivals += 1}.json"), claude_json) + end + + warning = preserve + + expect(arrivals).to eq(ClaudeAgentSDK::SessionResume::Scrubber::EMPTYING_PASSES) + expect(warning).to match(/Scrubbing failed: could not remove \S+ \(Directory not empty/) + expect(Dir.children(staging)).to contain_exactly('claude-resume-under-test', a_string_starting_with('scrub-')) + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + end + end + end + + # Teardown runs on the reactor, and a fiber's stack is small. A scrub that + # recursed once per directory level ran out of it a few hundred levels + # down — one `mkdir -p` makes such a tree — and SystemStackError is not a + # StandardError: it left preserve_transcripts. + context 'when a tree is very deep' do + # A path can only be so long, so the chain is built in pieces of at most + # 150 levels: each new piece takes the chain so far under its deepest + # directory. Returns the top of the chain. + def deep_chain(levels) + chain = nil + levels.fdiv(150).ceil.times do |index| + top = File.join(scratch, "piece-#{index}") + deepest = File.join(top, Array.new([150, levels - (index * 150)].min - 1, 'd')) + FileUtils.mkdir_p(deepest) + chain ? File.rename(chain, File.join(deepest, 'd')) : File.write(File.join(deepest, 'secret.json'), claude_json) + chain = top + end + chain + end + + # The teardown above cannot take such a tree down: unlock and FileUtils + # recurse, and the paths get too long. So whatever a failing example left + # is removed here first, without either: every entry is renamed to the + # top of the scratch directory before it is looked at. + after do + flattened = 0 + pending = Dir.children(scratch).map { |name| File.join(scratch, name) } + until pending.empty? + path = pending.pop + next File.unlink(path) unless File.lstat(path).directory? + + Dir.children(path).each do |child| + pending << File.join(scratch, "flat-#{flattened += 1}") + File.rename(File.join(path, child), pending.last) + end + Dir.rmdir(path) + end + end + + it 'scrubs a chain of 1000 directories on the reactor' do + File.rename(deep_chain(1000), File.join(config_dir, 'deep')) + + warning = Sync { preserve } + + expect(Dir.children(preserved)).to eq(['projects']) + expect(Dir.children(staging)).to eq(['claude-resume-under-test']) # and nothing is left in the trash + expect(tree(preserved)).to eq(transcripts) + expect(warning).not_to match(/Scrubbing/) + end + end + + # A read-only root cannot be moved. Skipping the scrub would leave the + # credential copies behind, so the root is repaired first — through a + # handle that has to be the directory the SDK created. + context 'when the directory itself is read-only' do + it 'makes it writable, moves it and scrubs it' do + File.chmod(0o500, config_dir) + + warning = preserve + + expect(Dir.children(preserved)).to eq(['projects']) + expect(tree(preserved)).to eq(transcripts) + expect(warning).to match(/Preserving the session transcript/) + expect(warning).not_to match(/Scrubbing/) + end + + it 'skips the scrub, deleting nothing, when it cannot be made writable' do + skip 'a superuser can open any directory' if Process.euid.zero? + + File.chmod(0o300, config_dir) # its owner cannot even open it + + warning = preserve + + File.chmod(0o700, config_dir) + expect(tree(config_dir)).to eq(secrets.merge(transcripts)) + expect(Dir.children(scratch)).to contain_exactly('claude-resume-under-test', 'outside') + expect(warning).to match(skipped) + end + end + + # Anything that knows CLAUDE_CONFIG_DIR can replace the temp dir before + # teardown. "Delete every entry but projects/" must then delete nothing, + # rather than the entries of whatever the path leads to now. + # + # The original directory is always renamed aside, never deleted, before + # its replacement appears: a freshly created directory may otherwise reuse + # its inode number and look like the same directory. + context 'when the directory is no longer the one the SDK created' do + let(:unrelated) { File.join(scratch, 'unrelated') } + let(:aside) { File.join(scratch, 'the-sdk-directory-moved-aside') } + + before do + write_tree(unrelated, 'projects/-elsewhere/x.jsonl' => "{}\n", 'innocent/keep.txt' => 'synthetic', + '.claude.json' => '{}') + end + + it 'deletes nothing when the path has become a symlink: it moves the link, not its target' do + File.rename(config_dir, aside) + File.symlink(unrelated, config_dir) + File.chmod(0o555, unrelated) + before_scrub = tree(unrelated) + + warning = preserve + + expect(tree(unrelated)).to eq(before_scrub) + expect(mode_of(unrelated)).to eq('555') + expect(tree(aside)).to eq(secrets.merge(transcripts)) + expect(File.symlink?(preserved)).to be(true) + expect(File.readlink(preserved)).to eq(unrelated) + expect(warning).to match(skipped) + expect(warning).to include(preserved) + end + + it 'deletes nothing in a different directory at the same path' do + File.rename(config_dir, aside) + FileUtils.cp_r(unrelated, config_dir) + before_scrub = tree(config_dir) + + warning = preserve + + expect(tree(preserved)).to eq(before_scrub) + expect(warning).to match(skipped) + end + + it 'does not make a read-only directory writable when it is not the one the SDK created' do + File.rename(config_dir, aside) + FileUtils.cp_r(unrelated, config_dir) + File.chmod(0o500, config_dir) + + warning = preserve + + expect(mode_of(config_dir)).to eq('500') # still where it was, as it was + expect(Dir.children(scratch)).to contain_exactly('claude-resume-under-test', 'outside', 'unrelated', + File.basename(aside)) + expect(warning).to match(skipped) + end + + it 'deletes nothing when it is handed a symlink as the directory' do + link = File.join(scratch, 'replaced-config') + File.symlink(unrelated, link) + handed_a_link = described_class.new(config_dir: link, resume_session_id: sid) + before_scrub = tree(unrelated) + + warning = stderr_of { handed_a_link.preserve_transcripts } + + expect(tree(unrelated)).to eq(before_scrub) + expect(warning).to match(skipped) + end + + # What counts is the directory materialize_resume_session created, not + # whatever is at the path by the time the MaterializedResume is built. + it 'compares with the directory as created, even if it was replaced during materialization' do + created = [] + allow(Dir).to receive(:mktmpdir).and_wrap_original do |original, *args, **options, &block| + next original.call(*args, **options, &block) unless args == ['claude-resume-'] + + original.call('claude-resume-', scratch).tap { |dir| created << dir } + end + replace_temp_dir = lambda do + File.rename(created.last, aside) + FileUtils.cp_r(unrelated, created.last) + end + store = Class.new(ClaudeAgentSDK::InMemorySessionStore) do + define_method(:list_subkeys) do |_key| + replace_temp_dir.call # runs after the transcript and the seed files are written + [] + end + end.new + project_dir = File.join(scratch, 'project') + caller_config_dir = File.join(scratch, 'caller-config') # empty: nothing seeded from the developer's own + [project_dir, caller_config_dir].each { |dir| FileUtils.mkdir_p(dir) } + store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(project_dir), 'session_id' => sid }, + [{ 'type' => 'user', 'uuid' => 'u1', 'sessionId' => sid, 'message' => { 'role' => 'user', 'content' => 'hi' } }]) + + from_the_store = ClaudeAgentSDK::SessionResume.materialize_resume_session( + ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: sid, cwd: project_dir, + env: { 'CLAUDE_CONFIG_DIR' => caller_config_dir }) + ) + before_scrub = tree(from_the_store.config_dir) + + warning = stderr_of { from_the_store.preserve_transcripts } + + expect(tree(from_the_store.config_dir)).to eq(before_scrub) + expect(warning).to match(skipped) + end + end + + # The same, with the swap timed against the cleanup itself. Each example + # puts it between two steps of preserve_transcripts by wrapping the call + # that comes next; nothing depends on a clock or on another thread. + context 'when something is swapped while the cleanup runs' do + let(:unrelated) { File.join(scratch, 'unrelated') } + let(:aside) { File.join(scratch, 'the-sdk-directory-moved-aside') } + let(:backups) { File.join(config_dir, 'backups') } + + before do + # An entry for every name the scrub lists, whatever order it walks + # them in, and for what is inside backups/: each one deleted through + # a swapped path would be a file the SDK never wrote. + names = Dir.children(config_dir) - %w[projects linked-elsewhere] + write_tree(unrelated, names.to_h { |name| [name == 'backups' ? 'backups/kept.txt' : name, 'synthetic'] } + .merge('.claude.json.backup.1790881328533' => 'synthetic')) + end + + it 'deletes nothing when the directory becomes a symlink right before it is moved' do + swapped = false + allow(File).to receive(:rename).and_wrap_original do |original, from, to| + if from == config_dir && !swapped + swapped = true + original.call(config_dir, aside) + File.symlink(unrelated, config_dir) + end + original.call(from, to) + end + before_scrub = tree(unrelated) + + warning = preserve + + expect(swapped).to be(true) + expect(tree(unrelated)).to eq(before_scrub) + expect(tree(aside)).to eq(secrets.merge(transcripts)) + expect(warning).to match(skipped) + end + + it 'never follows a symlink planted at the old path once the directory was moved' do + planted = false + allow(File).to receive(:rename).and_wrap_original do |original, from, to| + if File.basename(File.dirname(to)).start_with?('scrub-') && !planted # right before the first removal + planted = true + File.symlink(unrelated, config_dir) + end + original.call(from, to) + end + before_scrub = tree(unrelated) + + warning = preserve + + expect(planted).to be(true) + expect(tree(unrelated)).to eq(before_scrub) + expect(Dir.children(preserved)).to eq(['projects']) + expect(tree(preserved)).to eq(transcripts) + expect(warning).to match(/Preserving the session transcript under #{Regexp.escape(File.join(preserved, 'projects'))}/) + expect(warning).not_to match(/Scrubbing/) + end + + it 'unlinks, and does not follow, an entry that becomes a symlink right before it is removed' do + swapped = false + allow(File).to receive(:rename).and_wrap_original do |original, from, to| + if File.basename(from) == 'backups' && !swapped + swapped = true + original.call(from, "#{from}-aside") + File.symlink(unrelated, from) + end + original.call(from, to) + end + before_scrub = tree(unrelated) + + warning = preserve + + expect(swapped).to be(true) + expect(tree(unrelated)).to eq(before_scrub) + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + # The real backups/ is still there under its new name, and the warning says so. + expect(Dir.children(preserved)).to contain_exactly('backups-aside', 'projects') + expect(warning).to match(/Scrubbing failed: could not remove backups-aside/) + end + + # A read-only backups/ has to be made writable. Between the look at it + # and the chmod it is swapped for a symlink to a directory elsewhere: + # that directory must keep its mode and its files, and the scrub stops. + listing_orders.each do |how, order| + context "with a read-only backups/ (entries listed #{how})" do + # The names the scrub got for the directory itself, in the order it + # walked them: that it stopped at backups/ shows in what came after. + let(:listed) { [] } + + before do + File.chmod(0o500, backups) + File.chmod(0o555, outside) + list_children(order) do |path, names| + listed.replace(names) if listed.empty? && File.basename(path) == 'claude-resume-under-test' + end + end + + # Gone: what was listed before backups/. Still there: what was listed + # after it, projects/, the symlink the swap put at backups and the real + # directory under the name the swap gave it. + def expect_the_scrub_stopped_at_backups + untouched = listed.drop(listed.index('backups') + 1) + expect(Dir.children(preserved)).to match_array((untouched + %w[projects backups backups-aside]).uniq) + end + + it 'leaves an outside directory alone when backups/ is swapped between the lstat and the chmod' do + swapped = false + allow(File).to receive(:lstat).and_wrap_original do |original, path| + stat = original.call(path) + if !swapped && File.basename(path) == 'backups' && stat.directory? && stat.mode.nobits?(0o200) + swapped = true + File.rename(path, "#{path}-aside") + File.symlink(outside, path) + end + stat + end + + warning = preserve + + expect(swapped).to be(true) + expect(mode_of(outside)).to eq('555') + expect(tree(outside)).to eq('not-ours.txt' => 'keep me') + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + expect(warning).to match(/Preserving the session transcript.* Scrubbing failed: could not remove .*backups/) + expect(warning).to match(/backups became a symlink/) + expect_the_scrub_stopped_at_backups + end + + it 'stops, leaving an outside directory alone, when backups/ is swapped right after the chmod' do + swapped = false + allow(File).to receive(:open).and_wrap_original do |original, *args, **options, &block| + result = original.call(*args, **options, &block) + if !swapped && File.basename(args.first.to_s) == 'backups' + swapped = true + File.rename(args.first, "#{args.first}-aside") + File.symlink(outside, args.first) + end + result + end + + warning = preserve + + expect(swapped).to be(true) + expect(mode_of(outside)).to eq('555') + expect(tree(outside)).to eq('not-ours.txt' => 'keep me') + expect(tree(preserved).slice(*transcripts.keys)).to eq(transcripts) + expect(warning).to match(/Scrubbing failed: could not remove .*backups/) + expect(warning).to match(/backups was replaced while it was being made writable/) + expect_the_scrub_stopped_at_backups + end + end + end + end + end +end diff --git a/spec/unit/observer_containment_spec.rb b/spec/unit/observer_containment_spec.rb new file mode 100644 index 00000000..b6bae146 --- /dev/null +++ b/spec/unit/observer_containment_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' + +# ClaudeAgentSDK.notify_observers contains what an observer (or the +# callback_wrapper around it) raises, so observers can never break a session. +# It must contain exactly that and nothing else: the calling fiber WAITS for +# the observer's thread hop, and an exception raised into a waiting fiber is +# not the observer's. A caller's deadline (`task.with_timeout`, +# `Timeout.timeout`) arrives precisely that way. +RSpec.describe ClaudeAgentSDK, '.notify_observers' do + # Queue-backed stand-in for the CLI: answers the handshake, answers every + # prompt with one assistant message and its result, and ends its stdout + # when stdin is closed. + let(:transport_class) do + Class.new do + attr_reader :frames_handled + + def initialize + @stdout = Thread::Queue.new + @frames_handled = 0 + end + + def connect; end + + def ready? + true + end + + def end_input + @stdout << :eof + end + + def close + @stdout << :eof + end + + def write(line) + frame = JSON.parse(line, symbolize_names: true) + if frame[:type] == 'control_request' && frame.dig(:request, :subtype) == 'initialize' + @stdout << { type: 'control_response', + response: { subtype: 'success', request_id: frame[:request_id], + response: { commands: [], agents: [], output_style: 'default', models: [], + pid: 4242, session_state: 'idle', capabilities: [] }, + pending_permission_requests: [], pending_user_dialog_requests: [] } } + elsif frame[:type] == 'user' + @stdout << { type: 'assistant', parent_tool_use_id: nil, session_id: 'c0ffee00-0000-4000-8000-000000000002', + message: { id: 'msg_01', type: 'message', role: 'assistant', + model: 'claude-haiku-4-5-20251001', content: [{ type: 'text', text: 'Four.' }], + stop_reason: 'end_turn', usage: { input_tokens: 10, output_tokens: 3 } } } + @stdout << { type: 'result', subtype: 'success', is_error: false, duration_ms: 1500, + duration_api_ms: 1000, num_turns: 1, result: 'Four.', stop_reason: 'end_turn', + session_id: 'c0ffee00-0000-4000-8000-000000000002', total_cost_usd: 0.001234, + usage: { input_tokens: 10, output_tokens: 3 }, terminal_reason: 'completed' } + end + end + + def read_messages + while (frame = @stdout.pop) != :eof + yield frame + @frames_handled += 1 # the read loop is done with this frame + end + end + end + end + + let(:transport) { transport_class.new } + + # Client builds its transport itself: hand it the one the example holds. + def class_returning(instance) + Class.new { define_singleton_method(:new) { |*_args, **_kwargs| instance } } + end + + def observer_with(**handlers) + Class.new do + include ClaudeAgentSDK::Observer + + handlers.each { |name, body| define_method(name, &body) } + end.new + end + + describe "a caller's deadline while an observer is running" do + it 'is raised to the caller, and the turn does not carry on' do + release = Thread::Queue.new + slow_observer = observer_with(on_message: ->(_message) { release.pop }) # runs until the example lets it go + options = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [slow_observer]) + events = Thread::Queue.new + + Async do |root| + client = ClaudeAgentSDK::Client.new(options: options, transport_class: class_returning(transport)) + client.connect + client.query('What is 2 + 2?') + # The whole turn is queued before the deadline starts (handshake + # response, assistant, result), so the receive below reaches the + # observer's hop without suspending: the deadline cannot expire + # anywhere but there, however slow the machine is. + root.with_timeout(10) { root.yield until transport.frames_handled == 3 } + + receiver = root.async do |task| + task.with_timeout(0.01) do + client.receive_response { |message| events << [:block_called, message.class] } + end + events << [:receive_returned] + rescue Async::TimeoutError + events << [:deadline_raised] + end + + expect(events.pop).to eq([:deadline_raised]) + receiver.wait + expect(events).to be_empty # the message block was never called + ensure + release.close # the observer's pop returns, its thread ends + client&.disconnect + end.wait + end + end + + describe 'what an observer or its wrapper raises' do + let(:message) { ClaudeAgentSDK::MessageParser.parse(sample_assistant_message) } + + def notify(observers, scheduling:, wrapper: nil) + Async do + described_class.notify_observers(observers, :on_message, message, scheduling: scheduling, wrapper: wrapper) + end.wait + end + + %i[thread inline].each do |scheduling| + context "with callback_scheduling: :#{scheduling}" do + it 'is contained for a StandardError and for a ScriptError, and later observers still run' do + notified = [] + observers = [ + observer_with(on_message: ->(_message) { raise 'observer crashed' }), + observer_with(on_message: ->(_message) { raise NotImplementedError, 'observer stub' }), + observer_with(on_message: ->(received) { notified << received }) + ] + + expect { notify(observers, scheduling: scheduling) }.not_to raise_error + expect(notified).to eq([message]) + end + + it 'is contained when the callback_wrapper itself fails, and later observers still run' do + notified = [] + calls = 0 + failing_once = lambda do |invocation| + calls += 1 + raise 'wrapper failed' if calls == 1 + + invocation.call + end + observers = Array.new(2) { observer_with(on_message: ->(received) { notified << received }) } + + expect { notify(observers, scheduling: scheduling, wrapper: failing_once) }.not_to raise_error + expect(notified).to eq([message]) # the first observer's wrapper failed, the second ran + end + end + end + + it 'still delivers every message when the wrapper fails around an observer during query()' do + calls = 0 + # query() wraps, in order: on_user_prompt, on_message (assistant), the + # block, on_message (result), the block, on_close. The second call is + # the observer's on_message for the first message. + fails_around_on_message = lambda do |invocation| + calls += 1 + raise 'wrapper failed around the observer' if calls == 2 + + invocation.call + end + options = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [observer_with], + callback_wrapper: fails_around_on_message) + seen = [] + + described_class.query(prompt: 'What is 2 + 2?', options: options, transport: transport) do |received| + seen << received.class + end + + expect(seen).to eq([ClaudeAgentSDK::AssistantMessage, ClaudeAgentSDK::ResultMessage]) + expect(calls).to eq(6) + end + end +end diff --git a/spec/unit/option_hash_symbol_type_spec.rb b/spec/unit/option_hash_symbol_type_spec.rb new file mode 100644 index 00000000..dcbf7c7c --- /dev/null +++ b/spec/unit/option_hash_symbol_type_spec.rb @@ -0,0 +1,308 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'json' + +# system_prompt, tools and output_format take a Hash tagged with a `type`. +# The tag was compared with String literals, so the Ruby spelling +# `type: :preset` matched no branch: +# +# tools: { type: :preset, preset: :claude_code } sent the Hash's JSON +# text as a tool NAME, +# so the session had no +# tools at all +# system_prompt: { type: :custom, prompt: '...' } sent no flag: the +# prompt was dropped +# output_format: { type: :json_schema, schema: ... } sent the wrapper as +# the schema, which the +# CLI rejects +# +# and a Hash with `type` under one key style and `schema` under the other +# turned structured output off. The same Hashes with a String tag worked, and +# other options (thinking, MCP server configs) already took a Symbol tag. +RSpec.describe 'an option Hash tagged with a Symbol type' do + # The command line built for these options. + def argv(**options) + ClaudeAgentSDK::CommandBuilder.new('/usr/bin/claude', ClaudeAgentSDK::ClaudeAgentOptions.new(**options)).build + end + + # The value that follows +flag+ on that command line (nil without the flag). + def flag_value(cmd, flag) + index = cmd.index(flag) + index && cmd[index + 1] + end + + schema = { + type: 'object', + properties: { + verdict: { type: 'string', enum: %w[approve reject] }, + reasons: { type: 'array', items: { type: 'string' } } + }, + required: ['verdict'] + }.freeze + + # option => { form => [the Hash with a String tag, the same Hash in other spellings] } + forms = { + system_prompt: { + 'a custom prompt' => [ + { type: 'custom', prompt: 'You are a terse reviewer.' }, + { + 'a Symbol type' => { type: :custom, prompt: 'You are a terse reviewer.' }, + 'a Symbol type under String keys' => { 'type' => :custom, 'prompt' => 'You are a terse reviewer.' } + } + ], + 'a prompt file' => [ + { type: 'file', path: 'prompts/reviewer.md' }, + { + 'a Symbol type' => { type: :file, path: 'prompts/reviewer.md' }, + 'a Symbol type under String keys' => { 'type' => :file, 'path' => 'prompts/reviewer.md' } + } + ], + 'the preset with an appended prompt' => [ + { type: 'preset', preset: 'claude_code', append: 'Answer in one paragraph.' }, + { + 'a Symbol type and preset' => { type: :preset, preset: :claude_code, append: 'Answer in one paragraph.' }, + 'a Symbol type and preset under String keys' => { + 'type' => :preset, 'preset' => :claude_code, 'append' => 'Answer in one paragraph.' + } + } + ] + }, + tools: { + 'the preset' => [ + { type: 'preset', preset: 'claude_code' }, + { + 'a Symbol type and preset' => { type: :preset, preset: :claude_code }, + 'a Symbol type and preset under String keys' => { 'type' => :preset, 'preset' => :claude_code } + } + ] + }, + output_format: { + 'a JSON schema' => [ + { type: 'json_schema', schema: schema }, + { + 'a Symbol type' => { type: :json_schema, schema: schema }, + 'a Symbol type under String keys' => { 'type' => :json_schema, 'schema' => schema }, + 'a String-keyed type and a Symbol-keyed schema' => { 'type' => 'json_schema', schema: schema }, + 'a Symbol-keyed type and a String-keyed schema' => { type: 'json_schema', 'schema' => schema }, + 'a Symbol type and a String-keyed schema' => { type: :json_schema, 'schema' => schema } + } + ] + } + }.freeze + + describe 'the command line' do + it 'carries the custom prompt, the prompt file, the appended prompt, the tools preset and the schema ' \ + 'for a String type' do + custom, file, preset = forms.fetch(:system_prompt).values.map(&:first) + tools = forms.dig(:tools, 'the preset').first + output_format = forms.dig(:output_format, 'a JSON schema').first + + expect(flag_value(argv(system_prompt: custom), '--system-prompt')).to eq('You are a terse reviewer.') + expect(flag_value(argv(system_prompt: file), '--system-prompt-file')).to eq('prompts/reviewer.md') + expect(flag_value(argv(system_prompt: preset), '--append-system-prompt')).to eq('Answer in one paragraph.') + expect(argv(system_prompt: preset)).not_to include('--system-prompt') + expect(flag_value(argv(tools: tools), '--tools')).to eq('default') + expect(JSON.parse(flag_value(argv(output_format: output_format), '--json-schema'))).to eq(JSON.parse(JSON.generate(schema))) + end + + forms.each do |option, by_form| + by_form.each do |form, (string_typed, spellings)| + spellings.each do |spelling, hash| + it "is the same for #{option} given as #{form} with #{spelling}" do + expect(argv(option => hash)).to eq(argv(option => string_typed)) + end + end + end + end + + it 'is the one the typed classes build' do + typed = { + system_prompt: [ + ClaudeAgentSDK::SystemPromptCustom.new(prompt: 'You are a terse reviewer.'), + ClaudeAgentSDK::SystemPromptFile.new(path: 'prompts/reviewer.md'), + ClaudeAgentSDK::SystemPromptPreset.new(preset: 'claude_code', append: 'Answer in one paragraph.') + ], + tools: [ClaudeAgentSDK::ToolsPreset.new(preset: 'claude_code')] + } + symbol_typed = { + system_prompt: forms.fetch(:system_prompt).values.map { |(_string_typed, spellings)| spellings.values.first }, + tools: [forms.dig(:tools, 'the preset').last.values.first] + } + + built = symbol_typed.to_h { |option, hashes| [option, hashes.map { |hash| argv(option => hash) }] } + + expect(built).to eq(typed.to_h { |option, values| [option, values.map { |value| argv(option => value) }] }) + end + + # A custom prompt without its text must not fall through to the default + # Claude Code prompt; the String spelling already raised here. + it 'rejects a custom system prompt without a prompt, whichever way the type is written' do + errors = [{ type: 'custom' }, { type: :custom }, { 'type' => :custom, 'snapshot' => false }].map do |system_prompt| + argv(system_prompt: system_prompt) + rescue ArgumentError => e + e.message + end + + expect(errors).to eq(["system_prompt of type 'custom' requires a :prompt String"] * 3) + end + end + + # plugins: entries carry a `type` too ('local', or 'plugin', its older + # name). A Symbol there was refused: "Unsupported plugin type: :local". + describe 'a plugin Hash' do + it 'builds the same command line with a Symbol type' do + string_typed = argv(plugins: [{ type: 'local', path: '/srv/app/plugins/review' }]) + symbol_typed = [ + { type: :local, path: '/srv/app/plugins/review' }, + { 'type' => :local, 'path' => '/srv/app/plugins/review' }, + { type: :plugin, path: '/srv/app/plugins/review' } + ].map { |plugin| argv(plugins: [plugin]) } + + expect(flag_value(string_typed, '--plugin-dir')).to eq('/srv/app/plugins/review') + expect(symbol_typed).to eq([string_typed] * 3) + end + + it 'still refuses a type that is not a plugin type, naming it as written' do + messages = [{ type: 'remote', path: '/x' }, { type: :remote, path: '/x' }, { path: '/x' }].map do |plugin| + argv(plugins: [plugin]) + rescue ArgumentError => e + e.message + end + + expect(messages).to eq(['Unsupported plugin type: "remote"', 'Unsupported plugin type: :remote', + 'Unsupported plugin type: nil']) + end + end + + describe 'a type the SDK does not know' do + it 'still sends no system prompt flag at all' do + [{ type: 'bogus', prompt: 'x' }, { type: :bogus, prompt: 'x' }, { prompt: 'x' }, { type: nil }].each do |system_prompt| + expect(argv(system_prompt: system_prompt)) + .not_to include('--system-prompt', '--system-prompt-file', '--append-system-prompt') + end + end + + it 'still sends a tools Hash as JSON text' do + expect([{ type: 'bogus', names: ['Read'] }, { type: :bogus, names: ['Read'] }, { names: ['Read'] }] + .map { |tools| flag_value(argv(tools: tools), '--tools') }) + .to eq(['{"type":"bogus","names":["Read"]}', '{"type":"bogus","names":["Read"]}', '{"names":["Read"]}']) + end + + it 'still sends an output_format Hash as the schema itself' do + bare_schema = { type: 'object', properties: { verdict: { type: 'string' } } } + + expect([bare_schema, bare_schema.merge(type: :object), { 'type' => 'text' }] + .map { |output_format| flag_value(argv(output_format: output_format), '--json-schema') }) + .to eq([JSON.generate(bare_schema), JSON.generate(bare_schema), '{"type":"text"}']) + end + end + + # exclude_dynamic_sections and snapshot travel on the initialize request, + # not on the command line, and are read off the same Hash. End to end + # through the real control protocol: a fake Transport records each control + # request as the CLI's JSON parser sees it and answers it. + describe 'the initialize request' do + let(:transport_class) do + Class.new(ClaudeAgentSDK::Transport) do + def initialize(_options, requests:) + super() + @requests = requests + @queue = Thread::Queue.new + end + + def connect = @ready = true + def ready? = @ready + def end_input; end + def close = @queue.push(:eof) + + def write(data) + message = JSON.parse(data, symbolize_names: true) + return unless message[:type] == 'control_request' + + @requests << JSON.parse(data).fetch('request') + response = { subtype: 'success', request_id: message[:request_id], response: {} } + @queue.push({ type: 'control_response', response: response }) + end + + def read_messages + loop do + message = @queue.pop + break if message == :eof + + yield message + end + end + end + end + + # What a session with this system prompt asks for on initialize. + def prompt_fields_on_the_wire(system_prompt) + requests = [] + Sync do + client = ClaudeAgentSDK::Client.new( + options: ClaudeAgentSDK::ClaudeAgentOptions.new(system_prompt: system_prompt), + transport_class: transport_class, transport_args: { requests: requests } + ) + client.connect + ensure + client&.disconnect + end + requests.find { |request| request['subtype'] == 'initialize' }.slice('excludeDynamicSections', 'systemPromptSnapshot') + end + + { + 'the preset' => [ + { type: 'preset', preset: 'claude_code', exclude_dynamic_sections: true, snapshot: false }, + { 'excludeDynamicSections' => true, 'systemPromptSnapshot' => false }, + { + 'a Symbol type' => { type: :preset, preset: :claude_code, exclude_dynamic_sections: true, snapshot: false }, + 'a Symbol type under String keys' => { + 'type' => :preset, 'preset' => :claude_code, 'exclude_dynamic_sections' => true, 'snapshot' => false + } + } + ], + 'a custom prompt' => [ + { type: 'custom', prompt: 'You are a terse reviewer.', snapshot: false }, + { 'systemPromptSnapshot' => false }, + { + 'a Symbol type' => { type: :custom, prompt: 'You are a terse reviewer.', snapshot: false }, + 'a Symbol type under String keys' => { + 'type' => :custom, 'prompt' => 'You are a terse reviewer.', 'snapshot' => false + } + } + ] + }.each do |form, (string_typed, fields, spellings)| + it "carries the prompt fields of #{form} with a String type" do + expect(prompt_fields_on_the_wire(string_typed)).to eq(fields) + end + + spellings.each do |spelling, hash| + it "carries the same fields for #{form} with #{spelling}" do + expect(prompt_fields_on_the_wire(hash)).to eq(fields) + end + end + end + + it 'still carries neither field for a prompt file or a type the SDK does not know' do + prompts = [ + { type: 'file', path: 'prompts/reviewer.md', snapshot: false, exclude_dynamic_sections: true }, + { type: :file, path: 'prompts/reviewer.md', snapshot: false, exclude_dynamic_sections: true }, + { type: :bogus, snapshot: false, exclude_dynamic_sections: true }, + { snapshot: false, exclude_dynamic_sections: true } + ] + + expect(prompts.map { |system_prompt| prompt_fields_on_the_wire(system_prompt) }).to eq([{}] * 4) + end + + it 'still leaves exclude_dynamic_sections to the preset: a custom prompt does not carry it' do + prompts = [ + { type: 'custom', prompt: 'You are a terse reviewer.', exclude_dynamic_sections: true }, + { type: :custom, prompt: 'You are a terse reviewer.', exclude_dynamic_sections: true } + ] + + expect(prompts.map { |system_prompt| prompt_fields_on_the_wire(system_prompt) }).to eq([{}] * 2) + end + end +end diff --git a/spec/unit/permission_update_wire_spec.rb b/spec/unit/permission_update_wire_spec.rb new file mode 100644 index 00000000..2bbdbba6 --- /dev/null +++ b/spec/unit/permission_update_wire_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'json' + +# The CLI validates the updatedPermissions array of a can_use_tool reply as +# one unit. CLI 2.1.286: a rule is { toolName: string, ruleContent?: string } +# (absent, never null) and every update needs a destination. One entry that +# does not fit and the whole array is dropped, with nothing but a line in the +# CLI's debug log — the tool call is still allowed, the "always allow" never +# sticks, and the callback is asked again on every call. +RSpec.describe ClaudeAgentSDK::PermissionUpdate do + # The suggestion the CLI builds for a tool it allows as a whole (WebSearch, + # every MCP tool): a rule without ruleContent. + whole_tool_suggestion = { + type: 'addRules', + rules: [{ toolName: 'WebSearch' }], + behavior: 'allow', + destination: 'localSettings' + }.freeze + + describe '#to_h' do + it 'leaves ruleContent out of a rule that has no content' do + update = described_class.new( + type: 'addRules', behavior: 'allow', destination: 'session', + rules: [ClaudeAgentSDK::PermissionRuleValue.new(tool_name: 'WebSearch')] + ) + + expect(update.to_h).to eq( + type: 'addRules', destination: 'session', rules: [{ toolName: 'WebSearch' }], behavior: 'allow' + ) + end + + it 'keeps ruleContent for a rule that has content, an empty String included' do + update = described_class.new( + type: 'replaceRules', behavior: 'deny', destination: 'projectSettings', + rules: [{ tool_name: 'Bash', rule_content: 'git push:*' }, { tool_name: 'Read', rule_content: '' }, + { tool_name: 'mcp__docs__search' }] + ) + + expect(update.to_h[:rules]).to eq( + [{ toolName: 'Bash', ruleContent: 'git push:*' }, { toolName: 'Read', ruleContent: '' }, + { toolName: 'mcp__docs__search' }] + ) + end + + it 'round-trips the suggestion the CLI sends for a whole-tool rule' do + hydrated = described_class.from_hash(whole_tool_suggestion) + + expect(hydrated.rules.first.rule_content).to be_nil + expect(hydrated.to_h).to eq(whole_tool_suggestion) + expect(JSON.generate(hydrated.to_h)).not_to include('ruleContent', 'null') + end + + { + 'addRules' => { behavior: 'allow', rules: [{ tool_name: 'Bash', rule_content: 'ls' }] }, + 'replaceRules' => { behavior: 'ask', rules: [{ tool_name: 'Write' }] }, + 'removeRules' => { behavior: 'deny', rules: [{ tool_name: 'WebFetch' }] }, + 'setMode' => { mode: 'acceptEdits' }, + 'addDirectories' => { directories: ['/work/shared'] }, + 'removeDirectories' => { directories: ['/work/shared'] } + }.each do |type, fields| + it "sends #{type} without a destination to the session" do + update = described_class.new(type: type, **fields) + + expect(update.destination).to be_nil + expect(update.to_h[:destination]).to eq('session') + end + end + + it 'keeps the destination the update names' do + sent = ClaudeAgentSDK::PERMISSION_UPDATE_DESTINATIONS.map do |destination| + described_class.new(type: 'setMode', mode: 'plan', destination: destination).to_h[:destination] + end + + expect(sent).to eq(ClaudeAgentSDK::PERMISSION_UPDATE_DESTINATIONS) + end + + it 'leaves an update without a type as it was' do + expect(described_class.new.to_h).to eq(type: nil) + expect(described_class.new(destination: 'userSettings').to_h).to eq(type: nil, destination: 'userSettings') + end + end + + # The documented loop: hand context.suggestions back as updated_permissions. + describe 'echoed back through can_use_tool' do + # A can_use_tool request as CLI 2.1.286 writes it (recorded; the temporary + # directory is shortened), as the transport hands it to Query. + def can_use_tool_request(suggestions) + request = JSON.parse(<<~FRAME, symbolize_names: true) + {"type":"control_request","request_id":"59125456-7f10-41d7-8f57-31700867e194","request":{"subtype":"can_use_tool","tool_name":"Bash","display_name":"Bash","input":{"command":"touch lane-e-probe.txt","description":"Create empty file lane-e-probe.txt"},"description":"Create empty file lane-e-probe.txt","permission_suggestions":[{"type":"addRules","rules":[{"toolName":"Bash","ruleContent":"touch lane-e-probe.txt"}],"behavior":"allow","destination":"localSettings"},{"type":"addDirectories","directories":["/private/var/folders/3y/T/lane-e-20261002"],"destination":"session"},{"type":"setMode","mode":"acceptEdits","destination":"session"}],"blocked_path":"/private/var/folders/3y/T/lane-e-20261002/lane-e-probe.txt","tool_use_id":"toolu_01NzfL6jnh8N6VM4uxq7ZjMX"}} + FRAME + request[:request][:permission_suggestions] = suggestions if suggestions + request + end + + # Answers the request by allowing the call with every suggestion, and + # gives back the one line the SDK wrote to the CLI. + def reply_line(suggestions = nil) + writes = [] + transport = instance_double(ClaudeAgentSDK::Transport) + allow(transport).to receive(:write) { |data| writes << data } + always_allow = lambda do |_tool_name, _input, context| + ClaudeAgentSDK::PermissionResultAllow.new(updated_permissions: context.suggestions) + end + query = ClaudeAgentSDK::Query.new(transport: transport, is_streaming_mode: true, can_use_tool: always_allow) + + Async { query.send(:handle_control_request, can_use_tool_request(suggestions)) }.wait + + expect(writes.size).to eq(1) + writes.first + end + + def updated_permissions(line) + JSON.parse(line, symbolize_names: true).dig(:response, :response, :updatedPermissions) + end + + it 'returns the suggestions of a recorded request unchanged' do + sent = can_use_tool_request(nil).dig(:request, :permission_suggestions) + + expect(sent.map { |suggestion| suggestion[:type] }).to eq(%w[addRules addDirectories setMode]) + expect(updated_permissions(reply_line)).to eq(sent) + end + + it 'returns a whole-tool rule without a null ruleContent' do + line = reply_line([whole_tool_suggestion]) + + expect(updated_permissions(line)).to eq([whole_tool_suggestion]) + expect(line).not_to include('ruleContent', 'null') + end + + it 'writes no null into an array that mixes rules with and without content' do + suggestions = can_use_tool_request(nil).dig(:request, :permission_suggestions) + [whole_tool_suggestion] + line = reply_line(suggestions) + + expect(updated_permissions(line)).to eq(suggestions) + expect(line).not_to include('null') + end + end +end diff --git a/spec/unit/query_control_waiter_ownership_spec.rb b/spec/unit/query_control_waiter_ownership_spec.rb new file mode 100644 index 00000000..8667c0eb --- /dev/null +++ b/spec/unit/query_control_waiter_ownership_spec.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' + +# Outbound control requests (interrupt, set_model, ...) wait for the CLI's +# response on a waiter that the session's read loop signals. Which waiter is +# safe depends on where the caller runs, not on whether it has an Async task: +# only a fiber of the reactor that owns the Query checks the result slot and +# parks without the read loop being able to run in between. A fiber on +# ANOTHER thread's reactor (`Sync { client.interrupt }` on a request thread +# while the session lives on a background reactor, or a `Sync { }` inside a +# :thread-mode callback) races the read loop exactly like a plain thread, so +# it needs the level-triggered ThreadWaiter too. +# +# No clock takes part in the ordering below. The control-request timeout is +# only the cap that turns a lost wakeup into a failing example. +RSpec.describe ClaudeAgentSDK::Query, 'control requests from a fiber on another reactor' do + # In-memory stand-in for the CLI. Thread::Queue everywhere: the caller + # writes from one thread while the owning reactor reads on another. + let(:transport_class) do + Class.new do + attr_reader :held + + def initialize + @incoming = Thread::Queue.new + @handled = Thread::Queue.new + @held = Thread::Queue.new + @hold_interrupt_response = false + end + + def hold_interrupt_response! + @hold_interrupt_response = true + end + + def connect; end + + def ready? + true + end + + def end_input; end + + def close + @incoming << nil + end + + def deliver(frame) + @incoming << frame + end + + # What CLI 2.1.286 answers to `initialize` (payload trimmed) and to + # `interrupt`. + def write(line) + request = JSON.parse(line, symbolize_names: true) + return unless request[:type] == 'control_request' + + case request.dig(:request, :subtype) + when 'initialize' + deliver(type: 'control_response', + response: { subtype: 'success', request_id: request[:request_id], + response: { commands: [], agents: [], output_style: 'default', models: [], + pid: 4242, session_state: 'idle', capabilities: [] }, + pending_permission_requests: [], pending_user_dialog_requests: [] }) + when 'interrupt' + response = { type: 'control_response', + response: { subtype: 'success', request_id: request[:request_id], + response: { still_queued: [] } } } + @hold_interrupt_response ? @held << response : deliver(response) + end + end + + def read_messages + while (frame = @incoming.pop) + yield frame + @handled << frame # the read loop is done with this frame + end + ensure + @handled << :read_loop_ended + end + + # Blocks until the read loop has finished handling +frame+. + def wait_until_handled(frame) + loop do + seen = @handled.pop + return if seen.equal?(frame) + raise 'the read loop ended before it handled the frame' if seen == :read_loop_ended + end + end + end + end + + # A result-slot Hash that can hold its next #key? caller right after the + # check, as a thread preempted between "is my response there?" and "park". + let(:holding_slots_class) do + Class.new(Hash) do + attr_reader :checked + + def hold_next_check! + @checked = Thread::Queue.new + @release = Thread::Queue.new + @armed = true + end + + def release! + @release << true + end + + def key?(request_id) + present = super + if @armed + @armed = false + @checked << present + @release.pop + end + present + end + end + end + + let(:transport) { transport_class.new } + let(:query) { described_class.new(transport: transport, is_streaming_mode: true) } + + before do + # The cap for a response that never wakes its sender (1200 s by default). + allow(query).to receive(:control_request_timeout_seconds).and_return(10) + end + + # Runs the session — read loop included — on a reactor of its own, on a + # background thread, for the duration of the block. + def with_session_on_its_own_reactor + ready = Thread::Queue.new + finish = Thread::Queue.new + owner = Thread.new do + Thread.current.report_on_exception = false + Sync do + query.start + query.initialize_protocol + ready << :ready + finish.pop + ensure + query.close + end + ensure + ready << :owner_exited # never leaves the example waiting for a session that did not start + end + raise 'the session did not start' unless ready.pop == :ready + + yield + ensure + finish << :done + owner.join # re-raises whatever ended the owning thread + end + + # Calls the control method from a reactor of its own on a new thread. The + # thread's value is the response, or the error the call raised. + def interrupt_from_another_reactor(announce_exit_on: nil) + Thread.new do + Sync { query.interrupt } + rescue StandardError => e + e + ensure + announce_exit_on&.push(:sender_exited) + end + end + + def read_task + query.instance_variable_get(:@task) + end + + it 'answers a control method called inside Sync on another thread and keeps the session running' do + with_session_on_its_own_reactor do + # The caller has an Async task, but on a reactor that is not the Query's. + answer = interrupt_from_another_reactor.value + aggregate_failures do + expect(answer).to eq({ still_queued: [] }) + expect(read_task).to be_alive # signalling the caller did not take the read loop down + end + end + end + + it 'does not lose the response routed between the sender checking its slot and parking' do + slots = holding_slots_class.new + query.instance_variable_set(:@pending_control_results, slots) + transport.hold_interrupt_response! + + with_session_on_its_own_reactor do + slots.hold_next_check! + sender = interrupt_from_another_reactor(announce_exit_on: slots.checked) + + expect(slots.checked.pop).to be(false) # the sender looked: no response yet + response = transport.held.pop + transport.deliver(response) # the CLI's response arrives ... + transport.wait_until_handled(response) # ... and the read loop has stored and signalled it + slots.release! # only now does the sender go on to park + + answer = sender.value + aggregate_failures do + expect(answer).to eq({ still_queued: [] }) + expect(read_task).to be_alive + end + ensure + slots.release! + sender&.join + end + end + + # The deadline is still chosen by "has an Async task": for such a caller it + # is the cooperative timeout, now cancelling a wait on the ThreadWaiter. + it 'still times out a caller on another reactor whose response never arrives' do + transport.hold_interrupt_response! # the response is never delivered + + with_session_on_its_own_reactor do + # Only from here on: the handshake above keeps its generous cap. + allow(query).to receive(:control_request_timeout_seconds).and_return(0.2) + answer = interrupt_from_another_reactor.value + + aggregate_failures do + expect(answer).to be_a(ClaudeAgentSDK::ControlRequestTimeoutError) + expect(answer.message).to match(/interrupt/) + expect(read_task).to be_alive + expect(query.instance_variable_get(:@pending_control_responses)).to be_empty + expect(query.instance_variable_get(:@pending_control_results)).to be_empty + end + end + end +end diff --git a/spec/unit/query_end_run_order_spec.rb b/spec/unit/query_end_run_order_spec.rb new file mode 100644 index 00000000..4ba86227 --- /dev/null +++ b/spec/unit/query_end_run_order_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'async/queue' + +# Query#end_run has one suspension point: stopping the ceiling sleeper hands +# the reactor to whatever else is ready. A stream_input task that is ready at +# that moment writes its next message, and the run that message belongs to is +# decided by what it sees: an ended run is reopened as a fresh one, a run +# that is still open is joined. So the run must already count as ended when +# end_run reaches that suspension point, or the message joins the run that is +# about to end and stdin closes before its own run produced a frame. +# +# No clock takes part in the ordering below: two enqueues make the read loop +# and the stream task ready, in that order, and the example then only yields +# to the reactor until the state it waits for is there. +RSpec.describe ClaudeAgentSDK::Query, 'ending a run while the next streamed message is ready' do + # Queue-backed stand-in for the CLI: frames are fed to the read loop one by + # one, and every stdin close records how many messages had been written. + let(:transport_class) do + Class.new do + attr_reader :frames, :writes, :stdin_closed_after, :frames_handled + + def initialize + @frames = Async::Queue.new + @writes = [] + @stdin_closed_after = [] + @frames_handled = 0 + end + + def connect; end + + def ready? + true + end + + def read_messages + while (frame = @frames.dequeue) != :eof + yield frame + @frames_handled += 1 # the read loop is done with this frame + end + end + + def write(line) + @writes << line + end + + def end_input + @stdin_closed_after << @writes.length + end + + def close + @frames.enqueue(:eof) + end + end + end + + # The frames CLI 2.1.286 sends around a turn when the transport asked for + # session state: "running", the turn, its result, then "idle". + def session_state(value) + { type: 'system', subtype: 'session_state_changed', state: value, sdk_host_only: true, + uuid: SecureRandom.uuid, session_id: 'b1c2d3e4-0000-4000-8000-000000000001' } + end + + def turn_result + sample_result_message.merge(result: 'done', stop_reason: 'end_turn', terminal_reason: 'completed') + end + + def user_message(content) + { type: 'user', message: { role: 'user', content: content }, parent_tool_use_id: nil, session_id: '' } + end + + def reactor_settles(task, limit: 10) + task.with_timeout(limit) { task.yield until yield } + end + + it 'keeps stdin open for a message written while the previous run was ending' do + transport = transport_class.new + gate = Async::Queue.new + prompts = Enumerator.new do |yielder| + yielder << user_message('first') + gate.dequeue + yielder << user_message('second') + end + # A hook keeps the control channel in use, so stdin waits for the run's end. + query = described_class.new(transport: transport, is_streaming_mode: true, + hooks: { 'PreToolUse' => [{ matcher: 'Bash', hooks: [proc {}] }] }) + + Async do |task| + query.start + streamer = task.async { query.stream_input(prompts) } # writes "first", parks on the gate + transport.frames.enqueue(session_state('running')) + transport.frames.enqueue(turn_result) + reactor_settles(task) { transport.frames_handled == 2 } + # The precondition of the whole example: the result armed the ceiling + # sleeper, so ending the run has a task to stop — the suspension point. + expect(query.instance_variable_get(:@run_end_ceiling_task)).not_to be_nil + + transport.frames.enqueue(session_state('idle')) # the read loop becomes ready first ... + gate.enqueue(:go) # ... and the stream task second + reactor_settles(task) { transport.writes.length == 2 && transport.frames_handled == 3 } + 3.times { task.yield } # whatever the ended run woke gets its turn + + # "second" owes a run of its own, and none of that run's frames exists yet. + expect(transport.stdin_closed_after).to be_empty + + transport.frames.enqueue(session_state('running')) + transport.frames.enqueue(turn_result) + transport.frames.enqueue(session_state('idle')) + task.with_timeout(10) { streamer.wait } + expect(transport.stdin_closed_after).to eq([2]) + ensure + query.close + end.wait + end +end diff --git a/spec/unit/query_failure_logging_spec.rb b/spec/unit/query_failure_logging_spec.rb new file mode 100644 index 00000000..30bcfbaa --- /dev/null +++ b/spec/unit/query_failure_logging_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'tempfile' + +# query() runs its body in a task and waits for it. A failure of that task is +# raised to the caller by the wait, so it is a handled failure: Async must not +# also log it as "Task may have ended with unhandled exception." (a warn line +# carrying the message and a backtrace, on stderr, even when the application +# rescued the error). Outside a reactor the task has always finished before +# anyone waits for it, which is exactly the case Async logs. +RSpec.describe ClaudeAgentSDK, 'failures of query() / ask()' do + # Queue-backed stand-in for the CLI. +connect_error+ makes #connect raise; + # +on_user_message+ scripts what the CLI does with the prompt. + let(:transport_class) do + Class.new do + def initialize(connect_error: nil, on_user_message: nil) + @connect_error = connect_error + @on_user_message = on_user_message + @stdout = Thread::Queue.new + end + + def connect + raise @connect_error if @connect_error + end + + def ready? + true + end + + def end_input + @stdout << :eof # the CLI exits once stdin is closed + end + + def close + @stdout << :eof + end + + def write(line) + frame = JSON.parse(line, symbolize_names: true) + if frame[:type] == 'control_request' && frame.dig(:request, :subtype) == 'initialize' + send_frame(type: 'control_response', + response: { subtype: 'success', request_id: frame[:request_id], + response: { commands: [], agents: [], output_style: 'default', models: [], + pid: 4242, session_state: 'idle', capabilities: [] }, + pending_permission_requests: [], pending_user_dialog_requests: [] }) + elsif frame[:type] == 'user' + @on_user_message&.call(self) + end + end + + def send_frame(frame) + @stdout << frame + end + + # What SubprocessCLITransport#read_messages raises at EOF when the CLI + # exited non-zero. + def crash(exit_code:) + @stdout << ClaudeAgentSDK::ProcessError.new("Command failed with exit code #{exit_code}", + exit_code: exit_code, stderr: 'boom') + end + + def read_messages + loop do + item = @stdout.pop + break if item == :eof + raise item if item.is_a?(Exception) + + yield item + end + end + end + end + + let(:unhandled_task_warning) { 'Task may have ended with unhandled exception' } + let(:connect_error) { ClaudeAgentSDK::CLIConnectionError.new('Failed to start Claude Code: no such file') } + let(:session_id) { 'c0ffee00-0000-4000-8000-000000000003' } + + let(:answers_the_prompt) do + lambda do |cli| + cli.send_frame(type: 'assistant', parent_tool_use_id: nil, session_id: session_id, + message: { id: 'msg_01', type: 'message', role: 'assistant', + model: 'claude-haiku-4-5-20251001', content: [{ type: 'text', text: 'Four.' }], + stop_reason: 'end_turn', usage: { input_tokens: 10, output_tokens: 3 } }) + cli.send_frame(sample_result_message.merge(session_id: session_id, result: 'Four.', + stop_reason: 'end_turn', terminal_reason: 'completed')) + end + end + + let(:crashes_on_the_prompt) { ->(cli) { cli.crash(exit_code: 1) } } + + # Runs the block with file descriptor 2 pointed at a file and returns what + # was written to it. spec_helper has bound the console logger (through + # which Async warns) to the real $stderr object, so swapping $stderr for a + # StringIO would capture nothing; redirecting its descriptor captures every + # writer, on every console version. + def stderr_during + log = Tempfile.new('query-failure-log') + original = $stderr.dup + $stderr.reopen(log) + begin + yield + ensure + $stderr.flush + $stderr.reopen(original) + original.close + end + File.read(log.path) + ensure + log&.close! + end + + # The block must raise +error_class+ to its caller, as an application that + # rescues it would see, without Async reporting the same failure itself. + def expect_handled_failure(error_class, message) + raised = nil + log = stderr_during do + yield + rescue StandardError => e + raised = e + end + + aggregate_failures do + expect(raised).to be_a(error_class) + expect(raised&.message).to match(message) + expect(log).not_to include(unhandled_task_warning) + end + end + + context 'when called from plain synchronous code' do + it 'raises a connect failure to the caller and logs nothing as unhandled' do + transport = transport_class.new(connect_error: connect_error) + + expect_handled_failure(ClaudeAgentSDK::CLIConnectionError, /Failed to start Claude Code/) do + described_class.query(prompt: 'What is 2 + 2?', transport: transport) { |_message| nil } + end + end + + it 'raises a CLI crash to the caller and logs nothing as unhandled' do + transport = transport_class.new(on_user_message: crashes_on_the_prompt) + + expect_handled_failure(ClaudeAgentSDK::ProcessError, /exit code 1/) do + described_class.query(prompt: 'What is 2 + 2?', transport: transport) { |_message| nil } + end + end + + it "raises the error of the caller's own block and logs nothing as unhandled" do + transport = transport_class.new(on_user_message: answers_the_prompt) + + expect_handled_failure(ArgumentError, /my block failed/) do + described_class.query(prompt: 'What is 2 + 2?', transport: transport) { raise ArgumentError, 'my block failed' } + end + end + + it 'raises from ask() and logs nothing as unhandled' do + transport = transport_class.new(on_user_message: crashes_on_the_prompt) + + expect_handled_failure(ClaudeAgentSDK::ProcessError, /exit code 1/) do + described_class.ask('What is 2 + 2?', transport: transport) + end + end + end + + context 'when called inside an existing reactor' do + it 'raises a connect failure to the caller and logs nothing as unhandled' do + transport = transport_class.new(connect_error: connect_error) + + expect_handled_failure(ClaudeAgentSDK::CLIConnectionError, /Failed to start Claude Code/) do + Sync { described_class.query(prompt: 'What is 2 + 2?', transport: transport) { |_message| nil } } + end + end + end + + describe 'a successful call' do + def break_at_the_result(transport) + described_class.query(prompt: 'What is 2 + 2?', transport: transport) do |message| + break :stopped_at_the_result if message.is_a?(ClaudeAgentSDK::ResultMessage) + end + end + + it 'still returns the final ResultMessage from ask()' do + result = described_class.ask('What is 2 + 2?', transport: transport_class.new(on_user_message: answers_the_prompt)) + + expect(result).to be_a(ClaudeAgentSDK::ResultMessage) + expect(result.result).to eq('Four.') + end + + it "still returns the block's break value from query(), outside and inside a reactor" do + outside = break_at_the_result(transport_class.new(on_user_message: answers_the_prompt)) + inside = Sync { break_at_the_result(transport_class.new(on_user_message: answers_the_prompt)) } + + expect([outside, inside]).to eq(%i[stopped_at_the_result stopped_at_the_result]) + end + end +end diff --git a/spec/unit/query_hook_input_spec.rb b/spec/unit/query_hook_input_spec.rb new file mode 100644 index 00000000..7281fc30 --- /dev/null +++ b/spec/unit/query_hook_input_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# What a hook callback is handed, for every hook event: the typed input the +# SDK builds from a `hook_callback` control request, checked reader by +# reader. A field mapped from the wrong key, or dropped, used to pass the +# whole suite for 19 of the 27 events. +# +# One row per event in ClaudeAgentSDK::HOOK_EVENTS. An event without a row, +# and a reader its row gives no value to, both fail here — so the table has +# to be extended when an event or a reader is added. +RSpec.describe ClaudeAgentSDK::Query do + describe 'the typed input of a hook_callback request' do + # The event's own fields, by the names the CLI uses on the wire, with the + # JSON type each one has there. The fields every event carries are in + # #base_fields. + fields = { + 'PreToolUse' => { tool_name: :string, tool_input: :object, tool_use_id: :string, + agent_id: :string, agent_type: :string }, + 'PostToolUse' => { tool_name: :string, tool_input: :object, tool_response: :object, tool_use_id: :string, + agent_id: :string, agent_type: :string }, + 'PostToolUseFailure' => { tool_name: :string, tool_input: :object, tool_use_id: :string, error: :string, + is_interrupt: :boolean, agent_id: :string, agent_type: :string }, + 'Notification' => { message: :string, title: :string, notification_type: :string }, + 'UserPromptSubmit' => { prompt: :string }, + 'SessionStart' => { source: :string, agent_type: :string, model: :string }, + 'SessionEnd' => { reason: :string }, + 'Stop' => { stop_hook_active: :boolean, last_assistant_message: :string, background_tasks: :objects, + session_crons: :objects }, + 'StopFailure' => { error: :string, error_details: :string, last_assistant_message: :string }, + 'SubagentStart' => { agent_id: :string, agent_type: :string }, + 'SubagentStop' => { stop_hook_active: :boolean, agent_id: :string, agent_transcript_path: :string, + agent_type: :string, last_assistant_message: :string, background_tasks: :objects, + session_crons: :objects }, + 'PreCompact' => { trigger: :string, custom_instructions: :string }, + 'PostCompact' => { trigger: :string, compact_summary: :string }, + 'PermissionRequest' => { tool_name: :string, tool_input: :object, permission_suggestions: :objects, + agent_id: :string, agent_type: :string }, + 'PermissionDenied' => { tool_name: :string, tool_input: :object, tool_use_id: :string, reason: :string, + agent_id: :string, agent_type: :string }, + 'Setup' => { trigger: :string }, + 'TeammateIdle' => { teammate_name: :string, team_name: :string }, + 'TaskCreated' => { task_id: :string, task_subject: :string, task_description: :string, + teammate_name: :string, team_name: :string }, + 'TaskCompleted' => { task_id: :string, task_subject: :string, task_description: :string, + teammate_name: :string, team_name: :string }, + 'Elicitation' => { mcp_server_name: :string, message: :string, mode: :string, url: :string, + elicitation_id: :string, requested_schema: :object }, + 'ElicitationResult' => { mcp_server_name: :string, elicitation_id: :string, mode: :string, action: :string, + content: :object }, + 'ConfigChange' => { source: :string, file_path: :string }, + 'WorktreeCreate' => { name: :string }, + 'WorktreeRemove' => { worktree_path: :string }, + 'InstructionsLoaded' => { file_path: :string, memory_type: :string, load_reason: :string, globs: :strings, + trigger_file_path: :string }, + 'CwdChanged' => { old_cwd: :string, new_cwd: :string }, + 'FileChanged' => { file_path: :string, event: :string } + }.freeze + + # A value of the field's JSON type that names the field, so no two fields + # of a frame carry the same one and a field read from the wrong key shows. + def sentinel(event, field, type) + marker = "#{field} of #{event}" + case type + when :string then marker + when :strings then [marker] + when :object then { marker: marker } + when :objects then [{ marker: marker }] + when :boolean then true # the inputs default it to false or nil + else raise ArgumentError, "unknown wire type #{type.inspect} for #{marker}" + end + end + + # What every hook input carries. scratchpad_dir and prompt_id are on the + # frames CLI 2.1.286 sends; the typed inputs have no reader for them. + def base_fields(event) + { session_id: "session_id of #{event}", transcript_path: "transcript_path of #{event}", + cwd: "cwd of #{event}", scratchpad_dir: "scratchpad_dir of #{event}", prompt_id: "prompt_id of #{event}", + permission_mode: "permission_mode of #{event}", hook_event_name: event } + end + + # Registers one hook for +event+, sends it one hook_callback request the + # way the CLI does and returns what the callback was called with, plus + # the SDK's reply. + def dispatch_hook(event, input) + calls = [] + hook = lambda do |hook_input, tool_use_id, context| + calls << { input: hook_input, tool_use_id: tool_use_id, context: context } + {} + end + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + hooks: { event => [ClaudeAgentSDK::HookMatcher.new(hooks: [hook])] } + ) + reply = ScriptedCLI.session(options) do |cli| + cli.request({ subtype: 'hook_callback', callback_id: cli.hook_callback_ids(event).fetch(0), + tool_use_id: "request tool_use_id of #{event}", input: input }, + request_id: "request_id of #{event}") + end + [calls, reply] + end + + it 'has a row for every event in HOOK_EVENTS, and no other' do + expect(fields.keys).to match_array(ClaudeAgentSDK::HOOK_EVENTS) + end + + ClaudeAgentSDK::HOOK_EVENTS.each do |event| + it "hands a #{event} hook every field of the frame through its typed input" do + own = fields.fetch(event) { raise "no row for #{event}: add its wire fields to the table above" } + input = base_fields(event).merge(own.to_h { |field, type| [field, sentinel(event, field, type)] }) + input_class = ClaudeAgentSDK.const_get("#{event}HookInput") + + calls, reply = dispatch_hook(event, input) + + expect(calls.length).to eq(1) + hook_input = calls.first[:input] + expect(hook_input).to be_an_instance_of(input_class) + # Every reader the class declares, not only the ones the row names: a + # reader without a value in the frame is a row that fell behind. + input_class.attribute_names.each do |reader| + expected = input.fetch(reader.to_sym) do + raise "#{input_class} has a reader ##{reader} the #{event} row gives no value: add the wire field" + end + actual = hook_input.public_send(reader) + expect(actual).to eq(expected), "#{input_class}##{reader}: expected #{expected.inspect}, got #{actual.inspect}" + end + # ...and no field in the row the class cannot read, which would + # leave that column of the table unchecked. + expect(input_class.attribute_names).to include(*own.keys.map(&:to_s)) + + expect(calls.first[:tool_use_id]).to eq("request tool_use_id of #{event}") + expect(calls.first[:context]).to be_a(ClaudeAgentSDK::HookContext) + expect(calls.first[:context].request_id).to eq("request_id of #{event}") + expect(reply).to include('subtype' => 'success', 'request_id' => "request_id of #{event}") + end + end + end +end diff --git a/spec/unit/query_permission_result_spec.rb b/spec/unit/query_permission_result_spec.rb new file mode 100644 index 00000000..7ed2a9fa --- /dev/null +++ b/spec/unit/query_permission_result_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# The fail-closed branch of the permission round trip: a can_use_tool +# callback that returns anything but a PermissionResult must not be read as a +# decision. The CLI gets an error control response, never `behavior: allow` — +# `true`, `nil` and a Hash are what a callback written from memory returns, +# and answering any of them with an allow would run the tool unasked. +RSpec.describe ClaudeAgentSDK::Query do + describe 'a can_use_tool request whose callback does not return a PermissionResult' do + # The request the CLI sends before a tool call it has no rule for. + let(:request) do + { subtype: 'can_use_tool', tool_name: 'Bash', input: { command: 'rm -rf build' }, + permission_suggestions: [{ type: 'addRules', behavior: 'allow', destination: 'session', + rules: [{ toolName: 'Bash', ruleContent: 'rm -rf build' }] }], + tool_use_id: 'toolu_01A2b3C4d5E6f7G8h9J0kLmN' } + end + + # Returns the SDK's reply and every frame it wrote during the session. + def ask_permission(callback) + options = ClaudeAgentSDK::ClaudeAgentOptions.new(can_use_tool: callback) + ScriptedCLI.session(options) do |cli| + [cli.request(request, request_id: 'req_permission'), cli.received] + end + end + + { + 'true' => true, + 'nil' => nil, + 'a Hash shaped like an allow' => { behavior: 'allow', updatedInput: { command: 'rm -rf build' } } + }.each do |label, returned| + it "answers #{label} with an error, never an allow" do + asked = [] + callback = lambda do |tool_name, input, _context| + asked << [tool_name, input] + returned + end + + reply, written = ask_permission(callback) + + expect(asked).to eq([['Bash', { command: 'rm -rf build' }]]) + expect(reply).to include('subtype' => 'error', 'request_id' => 'req_permission') + expect(reply.fetch('error')) + .to eq("Tool permission callback must return PermissionResult, got #{returned.class}") + expect(reply).not_to have_key('response') + expect(JSON.generate(written)).not_to include('"behavior"') + end + end + + # The same request through the same channel, answered by a real + # PermissionResult: without it, a harness that turned every request into + # an error would make the examples above pass for the wrong reason. + it 'answers a PermissionResultAllow with an allow' do + reply, = ask_permission(->(_tool_name, _input, _context) { ClaudeAgentSDK::PermissionResultAllow.new }) + + expect(reply).to include('subtype' => 'success', 'request_id' => 'req_permission') + expect(reply.fetch('response')).to eq('behavior' => 'allow', 'updatedInput' => { 'command' => 'rm -rf build' }) + end + end +end diff --git a/spec/unit/query_run_end_spec.rb b/spec/unit/query_run_end_spec.rb index b3ebf880..815c26e5 100644 --- a/spec/unit/query_run_end_spec.rb +++ b/spec/unit/query_run_end_spec.rb @@ -7,20 +7,30 @@ # #1190/#1279): at the CLI's "idle" session state after a result, bounded # between turns by CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS, and at the first # result with no tracked task in flight from a CLI that sends no state. +# +# Nothing here waits on a clock to "let the read loop catch up": frames are +# followed by a barrier (#with_query), and the ceiling is fired by hand +# (#ceiling_passes) in every example but one. RSpec.describe ClaudeAgentSDK::Query do + # The transport double. Frames come from +queue+; a Thread::Queue among + # them is a barrier, which is answered instead of delivered (#with_query). + # +written+ receives every line the SDK writes. def queue_fed_transport(queue) ended = [] + written = Thread::Queue.new transport = mock_transport allow(transport).to receive(:end_input) { ended << true } + allow(transport).to receive(:write) { |line| written << line } allow(transport).to receive(:read_messages) do |&blk| loop do msg = queue.dequeue break if msg == :eof + next msg.push(:reached) if msg.is_a?(Thread::Queue) blk.call(msg) end end - [transport, ended] + [transport, ended, written] end def hooks_config @@ -57,24 +67,64 @@ def user_message(content = 'hi') { type: 'user', message: { role: 'user', content: content }, session_id: '' } end - # Runs +body+ with a started query whose read loop is fed from a queue. - # `feed` enqueues frames and yields so the read loop processes them. + # Runs +body+ with a started query whose read loop is fed from a queue, and + # yields the query, `feed`, the end_input calls so far, the task and the + # queue of written lines. + # + # `feed.call(*frames)` returns once the read loop is done with the frames + # and what they woke has run. It enqueues a barrier behind them, which the + # transport double only takes off the queue after the block for the frame + # before it has returned — so every side effect of every frame is in place, + # including the ones that suspend the read loop half-way (arming and + # stopping the ceiling sleeper both yield). Being told that a frame was + # dequeued would not be enough. Then every task that is ready gets a few + # turns on the reactor: a task the frames woke (the stdin-closing waiter) + # needs two to reach end_input, as it yields once if it has a ceiling + # sleeper to stop; five is that with room to spare, and still no clock. + # + # `feed.call` with no frames is the same barrier with nothing in front of + # it: it lets what the example itself made ready (a gate it opened, a + # ceiling it fired) run. def with_query(**kwargs) queue = Async::Queue.new - transport, ended = queue_fed_transport(queue) + transport, ended, written = queue_fed_transport(queue) query = build_query(transport, **kwargs) Async do |task| query.start feed = lambda do |*frames| - frames.each { |frame| queue.enqueue(frame) } - task.sleep 0.01 + barrier = Thread::Queue.new + [*frames, barrier].each { |frame| queue.enqueue(frame) } + raise 'the read loop never got through the frames it was fed' unless barrier.pop(timeout: 10) + + 5.times { task.yield } end - yield query, feed, ended, task + yield query, feed, ended, task, written ensure query.close + release_parked_tasks(task) end.wait end + # Closing the query ends the run for good, which releases every task the + # example left parked on it (a wait_for_result_and_end_input, a + # stream_input). Bounded, and what is still parked is stopped: a task that + # close did not release would keep this reactor alive forever, and the + # example would hang the suite instead of failing. + def release_parked_tasks(task) + parked = [] + task.children&.each { |child| parked << child } + task.with_timeout(10) { task.yield while parked.any?(&:alive?) } + rescue Async::TimeoutError + parked.each(&:stop) + raise + end + + # Waits for a task that is expected to finish now. The bound is what a + # regression runs into; nothing healthy comes near it. + def finish(task, awaited) + task.with_timeout(10) { awaited.wait } + end + describe 'stdin stays open until idle' do it 'keeps stdin open past a result when a task settled just before it, and closes at idle (#1190)' do with_query do |query, feed, ended, task| @@ -86,7 +136,7 @@ def with_query(**kwargs) expect(ended).to be_empty feed.call(state('idle')) - task.with_timeout(2) { waiter.wait } + finish(task, waiter) expect(ended).not_to be_empty end end @@ -112,7 +162,7 @@ def with_query(**kwargs) expect(ended).to be_empty feed.call(state('idle', sdk_host_only: false)) - task.with_timeout(2) { waiter.wait } + finish(task, waiter) expect(ended).not_to be_empty end end @@ -121,7 +171,7 @@ def with_query(**kwargs) with_query do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } feed.call(result) - task.with_timeout(2) { waiter.wait } + finish(task, waiter) expect(ended).not_to be_empty end end @@ -133,7 +183,7 @@ def with_query(**kwargs) expect(ended).to be_empty # idle before any result does not end the run feed.call(result) - task.with_timeout(2) { waiter.wait } + finish(task, waiter) expect(ended).not_to be_empty end end @@ -145,7 +195,7 @@ def with_query(**kwargs) expect(ended).to be_empty feed.call(task_done('bg-1'), state('running'), main_assistant, result, state('idle')) - task.with_timeout(2) { waiter.wait } + finish(task, waiter) expect(ended).not_to be_empty end end @@ -157,7 +207,7 @@ def with_query(**kwargs) expect(ended).to be_empty feed.call(state('idle')) - task.with_timeout(2) { streamer.wait } + finish(task, streamer) expect(ended).not_to be_empty end end @@ -170,15 +220,43 @@ def with_query(**kwargs) y << user_message('two') end - with_query do |query, feed, ended, task| + with_query do |query, feed, ended, task, written| + streamer = task.async { query.stream_input(prompts) } + expect(written.pop(timeout: 10)).to include('one') + feed.call(state('running'), result, state('idle')) # the first message's run ends + gate.enqueue(:go) + expect(written.pop(timeout: 10)).to include('two') + feed.call # the stream is exhausted: the streamer now waits for message two's run + expect(ended).to be_empty # which has not even started + + feed.call(state('running'), result, state('idle')) + finish(task, streamer) + expect(ended).not_to be_empty + end + end + + # The idle of the run that just ended can still be on its way when the + # next message goes out. It belongs to the earlier run: the new message + # owes a result of its own before an idle can end its run. + it 'does not let a late idle of the previous run end the run of the next message' do + gate = Async::Queue.new + prompts = Enumerator.new do |y| + y << user_message('one') + gate.dequeue + y << user_message('two') + end + + with_query do |query, feed, ended, task, written| streamer = task.async { query.stream_input(prompts) } + expect(written.pop(timeout: 10)).to include('one') feed.call(state('running'), result, state('idle')) # the first message's run ends gate.enqueue(:go) - task.sleep 0.01 - expect(ended).to be_empty # the second message's run has not even started + expect(written.pop(timeout: 10)).to include('two') + feed.call(state('idle')) # a late duplicate, before message two produced anything + expect(ended).to be_empty feed.call(state('running'), result, state('idle')) - task.with_timeout(2) { streamer.wait } + finish(task, streamer) expect(ended).not_to be_empty end end @@ -196,7 +274,7 @@ def with_query(**kwargs) expect(ended).to be_empty feed.call(state('idle')) - task.with_timeout(2) { streamer.wait } + finish(task, streamer) expect(ended).not_to be_empty end end @@ -214,11 +292,11 @@ def with_query(**kwargs) feed.call(state('running'), result, state('idle')) # ended while the stream is still open feed.call(state(wake)) # a finished background task woke the CLI gate.enqueue(:go) - task.sleep 0.01 + feed.call # the stream is exhausted: the streamer waits for the reopened run expect(ended).to be_empty feed.call(state('running'), main_assistant, result, state('idle')) - task.with_timeout(2) { streamer.wait } + finish(task, streamer) expect(ended).not_to be_empty end end @@ -226,136 +304,219 @@ def with_query(**kwargs) end describe 'run-end ceiling' do - let(:ceiling) { { run_end_ceiling_ms: 50 } } + # The ceiling is a sleeper task that calls end_run_at_ceiling(generation) + # when its sleep is over. These examples leave the default ceiling of ten + # minutes in place, so the real sleeper never wakes, and make that call + # themselves: what is under test is which sleeper is armed when, and what + # one that wakes is allowed to do — not how long the sleep takes. The one + # example that sleeps through a real ceiling is the first. + def ceiling_armed?(query) + !query.instance_variable_get(:@run_end_ceiling_task).nil? + end + + # The generation the armed sleeper holds (it stands down when it wakes + # with a stale one). + def ceiling_generation(query) + query.instance_variable_get(:@run_end_ceiling_generation) + end + + # What the sleeper armed at +generation+ does when its sleep is over; + # +feed+ then lets what that woke run. + def ceiling_passes(query, generation, feed) + query.send(:end_run_at_ceiling, generation) + feed.call + end it 'ends the run when no idle arrives within the ceiling after a result' do - with_query(**ceiling) do |query, feed, ended, task| + with_query(run_end_ceiling_ms: 300) do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } feed.call(state('running'), result) - expect(ended).to be_empty - task.with_timeout(2) { waiter.wait } + # No idle follows: only the ceiling, a real one here, ends this run. + finish(task, waiter) expect(ended).not_to be_empty end end + it 'does not end the run at a result while the CLI still reports running' do + with_query do |query, feed, ended, task| + task.async { query.wait_for_result_and_end_input } + feed.call(state('running'), result) + + expect(ended).to be_empty + expect(ceiling_armed?(query)).to be(true) # the clock for the wait between turns + end + end + it 'is stopped by a main-thread turn and re-armed by its result' do - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } - feed.call(state('running'), result, main_assistant) - task.sleep 0.15 + feed.call(state('running'), result) + stopped = ceiling_generation(query) + feed.call(main_assistant) + expect(ceiling_armed?(query)).to be(false) + + ceiling_passes(query, stopped, feed) # the stopped sleeper was already waking up expect(ended).to be_empty feed.call(result) - task.with_timeout(2) { waiter.wait } + expect(ceiling_armed?(query)).to be(true) + ceiling_passes(query, ceiling_generation(query), feed) + finish(task, waiter) expect(ended).not_to be_empty end end it 'is not stopped by subagent messages' do - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } - feed.call(state('running'), result, main_assistant.merge(parent_tool_use_id: 'toolu_1')) - task.with_timeout(2) { waiter.wait } + feed.call(state('running'), result) + armed = ceiling_generation(query) + feed.call(main_assistant.merge(parent_tool_use_id: 'toolu_1')) + expect(ceiling_armed?(query)).to be(true) + expect(ceiling_generation(query)).to eq(armed) # the same sleeper, not a new one + + ceiling_passes(query, armed, feed) + finish(task, waiter) expect(ended).not_to be_empty end end it 'is stopped by requires_action and re-armed by the running that follows' do - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } - feed.call(state('running'), result, state('requires_action')) - task.sleep 0.15 + feed.call(state('running'), result) + stopped = ceiling_generation(query) + feed.call(state('requires_action')) + expect(ceiling_armed?(query)).to be(false) + + ceiling_passes(query, stopped, feed) expect(ended).to be_empty feed.call(state('running')) - task.with_timeout(2) { waiter.wait } + expect(ceiling_armed?(query)).to be(true) + ceiling_passes(query, ceiling_generation(query), feed) + finish(task, waiter) expect(ended).not_to be_empty end end + # "running" before any result is a turn starting, not the wait between + # turns that the ceiling bounds. + it 'is not armed by running before the first result' do + with_query do |query, feed, ended, task| + task.async { query.wait_for_result_and_end_input } + feed.call(state('running')) + + expect(ceiling_armed?(query)).to be(false) + expect(ended).to be_empty + end + end + it 'is not armed mid-turn' do - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| task.async { query.wait_for_result_and_end_input } feed.call(state('running'), result, main_assistant, task_started('bg-1'), task_done('bg-1'), state('running')) - task.sleep 0.15 + + expect(ceiling_armed?(query)).to be(false) expect(ended).to be_empty end end it 'is not armed by a result that arrives while the SDK is answering a request' do - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } feed.call(state('requires_action'), result) - task.sleep 0.15 + expect(ceiling_armed?(query)).to be(false) expect(ended).to be_empty feed.call(state('running')) - task.with_timeout(2) { waiter.wait } + expect(ceiling_armed?(query)).to be(true) + ceiling_passes(query, ceiling_generation(query), feed) + finish(task, waiter) expect(ended).not_to be_empty end end it 'leaves a tracked agent alone and starts over once it settles' do - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } feed.call(state('running'), task_started('bg-1'), result) - task.sleep 0.15 + + ceiling_passes(query, ceiling_generation(query), feed) # with bg-1 still running expect(ended).to be_empty + expect(ceiling_armed?(query)).to be(false) feed.call(task_done('bg-1')) - task.with_timeout(2) { waiter.wait } + expect(ceiling_armed?(query)).to be(true) + ceiling_passes(query, ceiling_generation(query), feed) + finish(task, waiter) expect(ended).not_to be_empty end end it 'does not close stdin under a control request the SDK is still answering' do - log = [] - queue = Async::Queue.new - transport = mock_transport - allow(transport).to receive(:write) { |data| log << [:write, data] } - allow(transport).to receive(:end_input) { log << [:end_input] } - allow(transport).to receive(:read_messages) do |&blk| - loop { blk.call(queue.dequeue) } - end - slow_hook = lambda do |_input, _tool_use_id, _context| - sleep 0.25 # on its worker thread, several ceilings long + entered = Thread::Queue.new + release = Thread::Queue.new + parked_hook = lambda do |_input, _tool_use_id, _context| + entered << true + release.pop # on its worker thread, for as long as the example wants {} end - query = build_query(transport, run_end_ceiling_ms: 50) - query.instance_variable_set(:@hook_callbacks, { 'hook_0' => slow_hook }) + hook_request = { type: 'control_request', request_id: 'req_1', + request: { subtype: 'hook_callback', callback_id: 'hook_0', tool_use_id: nil, + input: { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: {}, + session_id: 's', cwd: '/tmp' } } } - Async do |task| - query.start + with_query do |query, feed, ended, task, written| + query.instance_variable_set(:@hook_callbacks, { 'hook_0' => parked_hook }) waiter = task.async { query.wait_for_result_and_end_input } - [state('running'), result, - { type: 'control_request', request_id: 'req_1', - request: { subtype: 'hook_callback', callback_id: 'hook_0', tool_use_id: nil, - input: { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: {}, - session_id: 's', cwd: '/tmp' } } }].each { |frame| queue.enqueue(frame) } - task.with_timeout(3) { waiter.wait } + feed.call(state('running'), result, hook_request) + expect(entered.pop(timeout: 10)).to be(true) + + ceiling_passes(query, ceiling_generation(query), feed) # while the hook is still running + expect(ended).to be_empty + expect(ceiling_armed?(query)).to be(true) # the clock starts over instead + + release << true + expect(written.pop(timeout: 10)).to include('req_1') # the reply went out, + expect(ended).to be_empty # on a stdin that was still open + feed.call + + ceiling_passes(query, ceiling_generation(query), feed) + finish(task, waiter) + expect(ended).not_to be_empty ensure - query.close - end.wait + release << true # never leave the worker thread parked + end + end + + it 'arms no ceiling when the ceiling is 0, and waits for idle' do + with_query(run_end_ceiling_ms: 0) do |query, feed, ended, task| + waiter = task.async { query.wait_for_result_and_end_input } + feed.call(state('running'), result) + expect(ceiling_armed?(query)).to be(false) + expect(ended).to be_empty - reply = log.index { |entry| entry[0] == :write && entry[1].include?('req_1') } - expect(reply).not_to be_nil - expect(log.index([:end_input])).to be > reply + feed.call(state('idle')) + finish(task, waiter) + expect(ended).not_to be_empty + end end - [0, 10**12].each do |ms| - it "waits for idle with a ceiling of #{ms}" do - with_query(run_end_ceiling_ms: ms) do |query, feed, ended, task| - waiter = task.async { query.wait_for_result_and_end_input } - feed.call(state('running'), result) - task.sleep 0.1 - expect(ended).to be_empty + # 10**12 ms is past what a timer can take; the ceiling is capped + # (MAX_RUN_END_CEILING_MS), so arming it must neither raise nor fire. + it 'waits for idle with a ceiling of 10**12' do + with_query(run_end_ceiling_ms: 10**12) do |query, feed, ended, task| + waiter = task.async { query.wait_for_result_and_end_input } + feed.call(state('running'), result) + expect(ceiling_armed?(query)).to be(true) + expect(ended).to be_empty - feed.call(state('idle')) - task.with_timeout(2) { waiter.wait } - expect(ended).not_to be_empty - end + feed.call(state('idle')) + finish(task, waiter) + expect(ended).not_to be_empty end end @@ -366,30 +527,30 @@ def with_query(**kwargs) gate.dequeue end - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| streamer = task.async { query.stream_input(prompts) } feed.call(state('running'), result) - task.sleep 0.1 # the ceiling ends the run while the stream is still open + ceiling_passes(query, ceiling_generation(query), feed) # ends the run while the stream is still open feed.call(main_assistant) gate.enqueue(:go) - task.sleep 0.01 + feed.call # the stream is exhausted: the streamer waits for the reopened run expect(ended).to be_empty feed.call(result, state('idle')) - task.with_timeout(2) { streamer.wait } + finish(task, streamer) expect(ended).not_to be_empty end end it 'arms no ceiling once stdin is closed' do - with_query(**ceiling) do |query, feed, ended, task| + with_query do |query, feed, ended, task| waiter = task.async { query.wait_for_result_and_end_input } feed.call(state('running'), result, state('idle')) - task.with_timeout(2) { waiter.wait } + finish(task, waiter) expect(ended.length).to eq(1) feed.call(state('running'), main_assistant, result) - expect(query.instance_variable_get(:@run_end_ceiling_task)).to be_nil + expect(ceiling_armed?(query)).to be(false) end end @@ -407,7 +568,7 @@ def with_query(**kwargs) [state('running'), result, :eof].each { |frame| queue.enqueue(frame) } waiter.wait end.wait - end.join(5) + end.join(30) expect(finished).to be_truthy expect(ended).not_to be_empty @@ -475,6 +636,12 @@ def with_query(**kwargs) end RSpec.describe ClaudeAgentSDK::SubprocessCLITransport do + # #connect registers the stubbed Process::Waiter in the process-wide + # at-exit registry and these examples never #close: drop it here, so the + # double does not outlive its example (see the suite-wide check in + # spec_helper.rb). + after { described_class.active_processes_mutex.synchronize { described_class.active_processes.clear } } + def connect_and_capture_env(options) transport = described_class.new('hi', options) allow(transport).to receive(:check_claude_version) diff --git a/spec/unit/query_sdk_mcp_messages_spec.rb b/spec/unit/query_sdk_mcp_messages_spec.rb new file mode 100644 index 00000000..b2c4a629 --- /dev/null +++ b/spec/unit/query_sdk_mcp_messages_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# The JSON-RPC conversation the CLI has with an SDK MCP server, which travels +# as `mcp_message` control requests: each example sends one request the way +# the CLI does and checks the frame the SDK writes back. Before this file +# only tools/call and notifications/initialized were ever executed by the +# suite, so an `initialize` answer without protocolVersion — which makes the +# real CLI report the whole in-process server as failed — passed it. +# +# The initialize, notifications/initialized and tools/list requests are the +# frames CLI 2.1.286 sends when it connects to an SDK server; the other +# methods use the same envelope with the parameters the MCP specification +# gives them. +RSpec.describe ClaudeAgentSDK::Query do + describe 'mcp_message control requests for an SDK MCP server' do + let(:tool_calls) { [] } + let(:prompt_calls) { [] } + + let(:add_tool) do + calls = tool_calls + ClaudeAgentSDK.create_tool('add', 'Add two numbers', { a: :number, b: :number }) do |args| + calls << args + (args[:a] + args[:b]).to_s + end + end + + let(:config_resource) do + ClaudeAgentSDK.create_resource(uri: 'config://app', name: 'App Config', description: 'Current settings', + mime_type: 'text/plain') do + { contents: [{ uri: 'config://app', mimeType: 'text/plain', text: 'debug=false' }] } + end + end + + let(:review_prompt) do + calls = prompt_calls + ClaudeAgentSDK.create_prompt(name: 'review', description: 'Code review', + arguments: [{ name: 'focus', description: 'What to look at', + required: true }]) do |args| + calls << args + { messages: [{ role: 'user', content: { type: 'text', text: "Review #{args[:focus]}" } }] } + end + end + + let(:full_server) do + ClaudeAgentSDK.create_sdk_mcp_server(name: 'calc', tools: [add_tool], resources: [config_resource], + prompts: [review_prompt]) + end + + # What CLI 2.1.286 sends as the first message to every SDK server. + let(:initialize_message) do + { method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, + clientInfo: { name: 'claude-code', title: 'Claude Code', + description: "Anthropic's agentic coding tool", + websiteUrl: 'https://claude.com/claude-code', version: '2.1.286' } }, + jsonrpc: '2.0', id: 0 } + end + + # Sends +message+ to the server registered as +server_name+ and returns + # the JSON-RPC response the SDK wrote, after checking the envelope: an + # mcp_message is always answered with a successful control response, + # whatever the JSON-RPC outcome inside it. + def mcp_exchange(message, servers: { 'calc' => full_server }, server_name: 'calc') + options = ClaudeAgentSDK::ClaudeAgentOptions.new(mcp_servers: servers) + reply = ScriptedCLI.session(options) do |cli| + cli.request({ subtype: 'mcp_message', server_name: server_name, message: message }, request_id: 'req_mcp') + end + expect(reply).to include('subtype' => 'success', 'request_id' => 'req_mcp') + expect(reply.fetch('response').keys).to eq(['mcp_response']) + reply.dig('response', 'mcp_response') + end + + describe 'initialize' do + it 'answers with the protocol version, the capabilities of what the server offers, and its identity' do + expect(mcp_exchange(initialize_message)).to eq( + 'jsonrpc' => '2.0', 'id' => 0, + 'result' => { 'protocolVersion' => '2024-11-05', + 'capabilities' => { 'tools' => {}, 'resources' => {}, 'prompts' => {} }, + 'serverInfo' => { 'name' => 'calc', 'version' => '1.0.0' } } + ) + end + + it 'advertises only tools for a server that has only tools' do + tools_only = ClaudeAgentSDK.create_sdk_mcp_server(name: 'calc', version: '2.3.0', tools: [add_tool]) + + result = mcp_exchange(initialize_message, servers: { 'calc' => tools_only }).fetch('result') + + expect(result.fetch('capabilities')).to eq('tools' => {}) + expect(result.fetch('serverInfo')).to eq('name' => 'calc', 'version' => '2.3.0') + expect(result.fetch('protocolVersion')).to eq('2024-11-05') + end + + it 'does not advertise tools for a server that has none' do + no_tools = ClaudeAgentSDK.create_sdk_mcp_server(name: 'calc', resources: [config_resource]) + + result = mcp_exchange(initialize_message, servers: { 'calc' => no_tools }).fetch('result') + + expect(result.fetch('capabilities')).to eq('resources' => {}) + end + end + + it 'acknowledges notifications/initialized' do + response = mcp_exchange({ jsonrpc: '2.0', method: 'notifications/initialized' }) + + expect(response).to eq('jsonrpc' => '2.0', 'result' => {}) + end + + it 'lists the tools with their JSON schemas for tools/list' do + response = mcp_exchange({ method: 'tools/list', jsonrpc: '2.0', id: 1 }) + + expect(response).to eq( + 'jsonrpc' => '2.0', 'id' => 1, + 'result' => { 'tools' => [{ 'name' => 'add', 'description' => 'Add two numbers', + 'inputSchema' => { 'type' => 'object', + 'properties' => { 'a' => { 'type' => 'number' }, + 'b' => { 'type' => 'number' } }, + 'required' => %w[a b] } }] } + ) + end + + it 'runs the tool for tools/call and returns its content' do + response = mcp_exchange({ method: 'tools/call', params: { name: 'add', arguments: { a: 2, b: 3 } }, + jsonrpc: '2.0', id: 2 }) + + expect(tool_calls).to eq([{ a: 2, b: 3 }]) + expect(response).to include('jsonrpc' => '2.0', 'id' => 2) + expect(response).not_to have_key('error') + expect(response.dig('result', 'content')).to eq([{ 'type' => 'text', 'text' => '5' }]) + expect(response.dig('result', 'isError')).not_to be(true) + end + + it 'lists the resources for resources/list' do + response = mcp_exchange({ method: 'resources/list', jsonrpc: '2.0', id: 3 }) + + expect(response).to eq( + 'jsonrpc' => '2.0', 'id' => 3, + 'result' => { 'resources' => [{ 'uri' => 'config://app', 'name' => 'App Config', + 'description' => 'Current settings', 'mimeType' => 'text/plain' }] } + ) + end + + it 'reads the resource named by params.uri for resources/read' do + response = mcp_exchange({ method: 'resources/read', params: { uri: 'config://app' }, jsonrpc: '2.0', id: 4 }) + + expect(response).to eq( + 'jsonrpc' => '2.0', 'id' => 4, + 'result' => { 'contents' => [{ 'uri' => 'config://app', 'mimeType' => 'text/plain', + 'text' => 'debug=false' }] } + ) + end + + it 'answers resources/read without a uri with a JSON-RPC internal error' do + response = mcp_exchange({ method: 'resources/read', params: {}, jsonrpc: '2.0', id: 5 }) + + expect(response).to eq('jsonrpc' => '2.0', 'id' => 5, + 'error' => { 'code' => -32_603, 'message' => 'Missing uri parameter for resources/read' }) + end + + it 'lists the prompts with their arguments for prompts/list' do + response = mcp_exchange({ method: 'prompts/list', jsonrpc: '2.0', id: 6 }) + + expect(response).to eq( + 'jsonrpc' => '2.0', 'id' => 6, + 'result' => { 'prompts' => [{ 'name' => 'review', 'description' => 'Code review', + 'arguments' => [{ 'name' => 'focus', 'description' => 'What to look at', + 'required' => true }] }] } + ) + end + + it 'renders the prompt named by params.name with params.arguments for prompts/get' do + response = mcp_exchange({ method: 'prompts/get', params: { name: 'review', arguments: { focus: 'the diff' } }, + jsonrpc: '2.0', id: 7 }) + + expect(prompt_calls).to eq([{ focus: 'the diff' }]) + expect(response).to eq( + 'jsonrpc' => '2.0', 'id' => 7, + 'result' => { 'messages' => [{ 'role' => 'user', + 'content' => { 'type' => 'text', 'text' => 'Review the diff' } }] } + ) + end + + it 'answers prompts/get without a name with a JSON-RPC internal error' do + response = mcp_exchange({ method: 'prompts/get', params: { arguments: { focus: 'the diff' } }, + jsonrpc: '2.0', id: 8 }) + + expect(prompt_calls).to be_empty + expect(response).to eq('jsonrpc' => '2.0', 'id' => 8, + 'error' => { 'code' => -32_603, 'message' => 'Missing name parameter for prompts/get' }) + end + + it 'answers a method it does not implement with method-not-found' do + response = mcp_exchange({ method: 'completion/complete', params: {}, jsonrpc: '2.0', id: 9 }) + + expect(response).to eq('jsonrpc' => '2.0', 'id' => 9, + 'error' => { 'code' => -32_601, 'message' => "Method 'completion/complete' not found" }) + end + + it 'answers a request for a server it does not have with method-not-found' do + response = mcp_exchange({ method: 'tools/list', jsonrpc: '2.0', id: 10 }, server_name: 'other') + + expect(response).to eq('jsonrpc' => '2.0', 'id' => 10, + 'error' => { 'code' => -32_601, 'message' => "Server 'other' not found" }) + end + end +end diff --git a/spec/unit/query_spec.rb b/spec/unit/query_spec.rb index c992297c..0a620fd3 100644 --- a/spec/unit/query_spec.rb +++ b/spec/unit/query_spec.rb @@ -220,9 +220,13 @@ def hooks_config expect(ended).to be_empty queue.enqueue(:crash) - waiter.wait + # Bounded, and the waiter is stopped below: the regression leaves it + # parked forever, which must fail this example rather than hang the + # suite (a parked task keeps the reactor alive). + task.with_timeout(10) { waiter.wait } expect(ended).not_to be_empty ensure + waiter&.stop query.close end.wait end diff --git a/spec/unit/release_lockstep_spec.rb b/spec/unit/release_lockstep_spec.rb new file mode 100644 index 00000000..52618755 --- /dev/null +++ b/spec/unit/release_lockstep_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# Version strings and copies that no code path reads, so nothing fails when +# one of them is forgotten in a release or a CLI pin bump. Each example pins +# one of them to its source of truth. +RSpec.describe 'release lockstep' do + let(:root) { File.expand_path('../..', __dir__) } + + def read_json(path) + JSON.parse(File.read(File.join(root, path))) + end + + # Relative path => content, for every file under dir. + def tree(dir) + base = File.join(root, dir) + Dir.glob('**/*', File::FNM_DOTMATCH, base: base) + .select { |path| File.file?(File.join(base, path)) } + .to_h { |path| [path, File.binread(File.join(base, path))] } + end + + it 'gives the Claude Code plugin the gem version' do + plugin = read_json('plugins/claude-agent-ruby/.claude-plugin/plugin.json') + + expect(plugin['version']).to eq(ClaudeAgentSDK::VERSION) + end + + # The CLI takes a plugin's version from its plugin.json; a `version` on the + # marketplace entry is ignored when the two differ, and `claude plugin + # validate --strict` rejects the mismatch. One place to bump is enough. + it 'declares no version on the marketplace entry' do + marketplace = read_json('.claude-plugin/marketplace.json') + entry = marketplace.fetch('plugins').find { |plugin| plugin['name'] == 'claude-agent-ruby' } + + expect(entry).not_to be_nil + expect(entry).not_to have_key('version') + end + + # The skill is published twice: at the repository root and inside the + # plugin. An edit to one copy only would ship two different skills. + it 'keeps the two copies of the skill identical' do + root_copy = tree('skills') + plugin_copy = tree('plugins/claude-agent-ruby/skills/claude-agent-ruby') + + expect(root_copy).not_to be_empty + expect(plugin_copy.keys).to match_array(root_copy.keys) + expect(plugin_copy.reject { |path, content| root_copy[path] == content }.keys).to eq([]) + end + + # The CLI version to install is CLIInstaller::PINNED_CLI_VERSION, which + # moves with every pin bump; a literal in the README is stale the day after + # it is written. The minimum requirement (2.0.0) is the only CLI version + # the README states. + it 'names no CLI release (2.1.x) in the README' do + readme = File.read(File.join(root, 'README.md')) + + expect(readme.scan(/\b2\.1\.\d+\b/)).to eq([]) + end +end diff --git a/spec/unit/sandbox_hash_keys_spec.rb b/spec/unit/sandbox_hash_keys_spec.rb new file mode 100644 index 00000000..cb234b20 --- /dev/null +++ b/spec/unit/sandbox_hash_keys_spec.rb @@ -0,0 +1,653 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# `sandbox:` takes a typed SandboxSettings or a Hash, and a SandboxSettings +# takes a typed or a Hash `network` / `filesystem`. The typed classes have +# snake_case attributes and write the camelCase keys the CLI reads; a Hash +# used to be forwarded as written. The CLI's sandbox schema keeps unknown keys +# at the top level and strips them below it, so `deny_read`, `denied_domains` +# or `auto_allow_bash_if_sandboxed` were accepted without an error and never +# applied: the session ran with a weaker sandbox than the one written. +# +# The examples assert on the --settings argument the CLI is started with (and +# on SandboxSettings#to_h, the public form of the same section). +RSpec.describe 'sandbox settings written as a Hash' do + # The --settings argument built for this `sandbox:` option. + def settings_argument(sandbox, **options) + cmd = ClaudeAgentSDK::CommandBuilder.new( + '/usr/bin/claude', ClaudeAgentSDK::ClaudeAgentOptions.new(sandbox: sandbox, **options) + ).build + cmd[cmd.index('--settings') + 1] + end + + # Its sandbox section, as the CLI's JSON parser reads it. + def sandbox_section(sandbox) + JSON.parse(settings_argument(sandbox)).fetch('sandbox') + end + + # What JSON makes of a Ruby value (Symbol keys become Strings). + def on_the_wire(value) + JSON.parse(JSON.generate(value)) + end + + describe 'the same sandbox, written three ways' do + typed = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, auto_allow_bash_if_sandboxed: false, excluded_commands: ['docker'], + network: ClaudeAgentSDK::SandboxNetworkConfig.new(denied_domains: ['evil.example']), + filesystem: ClaudeAgentSDK::SandboxFilesystemConfig.new(deny_read: ['/private/etc/ssh']) + ) + snake_case_hash = { + enabled: true, auto_allow_bash_if_sandboxed: false, excluded_commands: ['docker'], + network: { denied_domains: ['evil.example'] }, + filesystem: { deny_read: ['/private/etc/ssh'] } + } + typed_outer_hash_inner = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, auto_allow_bash_if_sandboxed: false, excluded_commands: ['docker'], + network: { denied_domains: ['evil.example'] }, + filesystem: { deny_read: ['/private/etc/ssh'] } + ) + + it 'reaches the CLI under its own keys when every level is typed' do + expect(settings_argument(typed)).to eq( + '{"sandbox":{"enabled":true,"autoAllowBashIfSandboxed":false,"excludedCommands":["docker"],' \ + '"network":{"deniedDomains":["evil.example"]},"filesystem":{"denyRead":["/private/etc/ssh"]}}}' + ) + end + + it 'builds the identical --settings value from a snake_case Hash' do + expect(settings_argument(snake_case_hash)).to eq(settings_argument(typed)) + end + + it 'builds the identical --settings value from a typed SandboxSettings holding snake_case Hashes' do + expect(settings_argument(typed_outer_hash_inner)).to eq(settings_argument(typed)) + end + + it 'gives SandboxSettings#to_h the keys a typed network and filesystem give it' do + expect(typed_outer_hash_inner.to_h).to eq(typed.to_h) + end + + it 'builds it from a snake_case Hash with String keys, such as one read from YAML or JSON' do + expect(settings_argument(JSON.parse(JSON.generate(snake_case_hash)))).to eq(settings_argument(typed)) + end + + it 'still merges into settings that carry their own sandbox section' do + settings = '{"permissions":{"deny":["WebFetch"]},"sandbox":{"enabled":false}}' + + expect(JSON.parse(settings_argument(snake_case_hash, settings: settings))).to eq( + 'permissions' => { 'deny' => ['WebFetch'] }, 'sandbox' => on_the_wire(typed.to_h) + ) + end + end + + # Every field of the three sandbox classes: attribute => [wire key, value]. + # The wire keys are spelled out here, not derived from the SDK, so a renamed + # key on either side fails the examples below. + network_fields = { + allowed_domains: ['allowedDomains', %w[rubygems.org *.github.com]], + denied_domains: ['deniedDomains', %w[evil.example]], + allow_managed_domains_only: ['allowManagedDomainsOnly', false], + allow_unix_sockets: ['allowUnixSockets', %w[/var/run/docker.sock]], + allow_all_unix_sockets: ['allowAllUnixSockets', false], + allow_local_binding: ['allowLocalBinding', true], + allow_mach_lookup: ['allowMachLookup', %w[com.apple.coreservices.launchservicesd]], + http_proxy_port: ['httpProxyPort', 8080], + socks_proxy_port: ['socksProxyPort', 1080] + }.freeze + + filesystem_fields = { + allow_write: ['allowWrite', %w[/work/app/tmp]], + deny_write: ['denyWrite', %w[/work/app/config]], + deny_read: ['denyRead', %w[/private/etc/ssh ~/.aws]], + allow_read: ['allowRead', %w[/work/app]], + allow_managed_read_paths_only: ['allowManagedReadPathsOnly', false] + }.freeze + + # `network` and `filesystem` are attributes too; each form below fills them + # in with the nested section in the spelling it is about. + top_level_fields = { + enabled: ['enabled', true], + fail_if_unavailable: ['failIfUnavailable', true], + auto_allow_bash_if_sandboxed: ['autoAllowBashIfSandboxed', false], + excluded_commands: ['excludedCommands', %w[docker]], + allow_unsandboxed_commands: ['allowUnsandboxedCommands', false], + network: ['network', nil], + filesystem: ['filesystem', nil], + ignore_violations: ['ignoreViolations', { 'file' => ['/tmp/*'], 'network' => ['localhost'] }], + enable_weaker_nested_sandbox: ['enableWeakerNestedSandbox', false], + enable_weaker_network_isolation: ['enableWeakerNetworkIsolation', false], + ripgrep: ['ripgrep', { 'command' => '/usr/bin/rg', 'args' => ['--hidden'] }] + }.freeze + + # The spellings a Hash may use for those fields. + spellings = { + 'snake_case Symbol keys' => ->(fields) { fields.transform_values(&:last) }, + 'snake_case String keys' => ->(fields) { fields.to_h { |name, (_wire, value)| [name.to_s, value] } }, + 'camelCase Symbol keys' => ->(fields) { fields.to_h { |_name, (wire, value)| [wire.to_sym, value] } }, + 'camelCase String keys' => ->(fields) { fields.to_h { |_name, (wire, value)| [wire, value] } } + }.freeze + attributes_of = spellings.fetch('snake_case Symbol keys') + wire_form_of = spellings.fetch('camelCase String keys') + + # The top-level fields around one network and one filesystem section. + around_sections = lambda do |network, filesystem| + top_level_fields.merge(network: ['network', network], filesystem: ['filesystem', filesystem]) + end + + fields_of = { + 'SandboxSettings' => top_level_fields, + 'SandboxNetworkConfig' => network_fields, + 'SandboxFilesystemConfig' => filesystem_fields + }.freeze + + # What CLI 2.1.287's sandbox schema accepts under each key that has a + # snake_case spelling: attribute => kind. Read from the schema in the CLI + # binary and checked against the running CLI, key by key, with get_settings: + # + # boolean true or false + # strings an Array of Strings + # mach_services an Array of Strings; a "*" only as the last character + # port a number (the CLI takes any it can read; the SDK asks for + # what a port is, an Integer from 0 to 65535) + # string_lists a Hash whose values are Arrays of Strings + # + # Spelled out here, not derived from the SDK. enabled, network, filesystem + # and ripgrep are missing on purpose: their name is their wire key. + kinds = { + 'SandboxSettings' => { + fail_if_unavailable: :boolean, auto_allow_bash_if_sandboxed: :boolean, excluded_commands: :strings, + allow_unsandboxed_commands: :boolean, ignore_violations: :string_lists, + enable_weaker_nested_sandbox: :boolean, enable_weaker_network_isolation: :boolean + }, + 'SandboxNetworkConfig' => { + allowed_domains: :strings, denied_domains: :strings, allow_managed_domains_only: :boolean, + allow_unix_sockets: :strings, allow_all_unix_sockets: :boolean, allow_local_binding: :boolean, + allow_mach_lookup: :mach_services, http_proxy_port: :port, socks_proxy_port: :port + }, + 'SandboxFilesystemConfig' => { + allow_write: :strings, deny_write: :strings, deny_read: :strings, allow_read: :strings, + allow_managed_read_paths_only: :boolean + } + }.freeze + + # For each kind, values outside it: a snake_case key holding one is not + # renamed. The CLI rejects them under the wire key, and one rejected value + # makes it discard the whole --settings value, sandbox and permissions + # included. That goes for 10**400 too: it is an Integer, and a number the + # CLI cannot read (it fails the schema as a non-finite value). 65_536 and -1 + # are the exception, numbers the CLI takes: they are outside the kind + # because a port is 0..65535, and a bound that is too tight costs nothing. + ill_shaped = { + boolean: ['true', 0, []], + strings: ['docker', ['docker', 1], [nil], true, { 'docker' => true }], + mach_services: ['com.apple.audio', ['com.*.helper'], ['*.helper'], ['com.apple.**'], [1]], + port: ['8080', true, [8080], 10**400, 65_536, -1], + string_lists: [{ 'file' => '/tmp/*' }, { 'file' => [1] }, { 'file' => { 'read' => [] } }, ['/tmp/*'], 'file'] + }.freeze + + # A sandbox Hash with +fields+ at the level of the class: the top, or inside + # the section the class stands for. + at_level_of = { + 'SandboxSettings' => ->(fields) { { enabled: true }.merge(fields) }, + 'SandboxNetworkConfig' => ->(fields) { { enabled: true, network: fields } }, + 'SandboxFilesystemConfig' => ->(fields) { { enabled: true, filesystem: fields } } + }.freeze + + describe 'every field of every sandbox class' do + # The section the CLI must receive, whichever way it was written. + wire = wire_form_of.call( + around_sections.call(wire_form_of.call(network_fields), wire_form_of.call(filesystem_fields)) + ) + + it 'has a field for every attribute' do + expect( + 'SandboxSettings' => top_level_fields.keys.map(&:to_s).sort, + 'SandboxNetworkConfig' => network_fields.keys.map(&:to_s).sort, + 'SandboxFilesystemConfig' => filesystem_fields.keys.map(&:to_s).sort + ).to eq( + 'SandboxSettings' => ClaudeAgentSDK::SandboxSettings.attribute_names, + 'SandboxNetworkConfig' => ClaudeAgentSDK::SandboxNetworkConfig.attribute_names, + 'SandboxFilesystemConfig' => ClaudeAgentSDK::SandboxFilesystemConfig.attribute_names + ) + end + + it 'is written under its wire key when every level is typed' do + typed = ClaudeAgentSDK::SandboxSettings.new( + attributes_of.call( + around_sections.call( + ClaudeAgentSDK::SandboxNetworkConfig.new(attributes_of.call(network_fields)), + ClaudeAgentSDK::SandboxFilesystemConfig.new(attributes_of.call(filesystem_fields)) + ) + ) + ) + + expect(sandbox_section(typed)).to eq(wire) + end + + spellings.each do |inner_spelling, inner| + it "is written the same way from a typed SandboxSettings holding Hashes with #{inner_spelling}" do + sandbox = ClaudeAgentSDK::SandboxSettings.new( + attributes_of.call(around_sections.call(inner.call(network_fields), inner.call(filesystem_fields))) + ) + + expect(sandbox_section(sandbox)).to eq(wire) + end + + spellings.each do |outer_spelling, outer| + it "is written the same way from a Hash with #{outer_spelling} holding Hashes with #{inner_spelling}" do + sandbox = outer.call(around_sections.call(inner.call(network_fields), inner.call(filesystem_fields))) + + expect(sandbox_section(sandbox)).to eq(wire) + end + end + end + end + + # The vocabulary is a hand-written table next to the typed classes. Walk + # every attribute of every class so the two cannot drift: a new attribute, + # or a renamed wire key in a #to_h, fails here until the table follows. + describe 'ClaudeAgentSDK::SandboxKeys' do + { + 'TOP_LEVEL' => ['SandboxSettings', around_sections.call({ deniedDomains: %w[evil.example] }, { denyRead: %w[~/.aws] })], + 'NETWORK' => ['SandboxNetworkConfig', network_fields], + 'FILESYSTEM' => ['SandboxFilesystemConfig', filesystem_fields] + }.each do |table_name, (class_name, fields)| + describe table_name do + let(:table) { ClaudeAgentSDK::SandboxKeys.const_get(table_name) } + let(:klass) { ClaudeAgentSDK.const_get(class_name) } + + it "maps every #{class_name} attribute to the key its #to_h emits for it" do + emitted = klass.attribute_names.to_h do |name| + [name, klass.new(name => fields.fetch(name.to_sym).last).to_h.keys] + end + + expect(table.transform_values { |wire_key| [wire_key] }).to eq(emitted) + end + + it 'agrees with the wire keys this file spells out' do + expect(table).to eq(fields.to_h { |name, (wire_key, _value)| [name.to_s, wire_key.to_sym] }) + end + end + end + + describe 'SHAPES' do + it 'has a kind for every attribute whose name is not its wire key, and for nothing else' do + with_a_spelling_of_their_own = fields_of.transform_values do |fields| + fields.reject { |name, (wire_key, _value)| name.to_s == wire_key }.keys + end + + expect(kinds.transform_values(&:keys)).to eq(with_a_spelling_of_their_own) + end + + it 'agrees with the kinds this file spells out' do + spelled_out = kinds.flat_map do |class_name, by_attribute| + by_attribute.map { |attribute, kind| [fields_of.fetch(class_name).fetch(attribute).first.to_sym, kind] } + end + + expect(ClaudeAgentSDK::SandboxKeys::SHAPES).to eq(spelled_out.to_h) + end + + it 'has values outside every kind it uses' do + expect(ill_shaped.keys).to match_array(kinds.values.flat_map(&:values).uniq) + end + end + end + + describe 'a key the typed classes do not model' do + # allowAppleEvents, network.strictAllowlist and filesystem.disabled are + # fields of CLI 2.1.287's sandbox schema that no typed class has yet; a + # Hash is the only way to send them. + it 'is sent as written, at every level' do + sandbox = { + enabled: true, + allowAppleEvents: true, + future_field: { inner_key: 1 }, + 'x-vendor' => nil, + network: { denied_domains: ['evil.example'], strictAllowlist: true, future_field: 1 }, + filesystem: { deny_read: ['~/.aws'], 'disabled' => false, 'future_field' => [2] } + } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'allowAppleEvents' => true, + 'future_field' => { 'inner_key' => 1 }, + 'x-vendor' => nil, + 'network' => { 'deniedDomains' => ['evil.example'], 'strictAllowlist' => true, 'future_field' => 1 }, + 'filesystem' => { 'denyRead' => ['~/.aws'], 'disabled' => false, 'future_field' => [2] } + ) + end + + it 'is sent as written inside a typed SandboxSettings too' do + sandbox = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, + network: { denied_domains: ['evil.example'], strictAllowlist: true }, + filesystem: { 'deny_read' => ['~/.aws'], 'disabled' => false } + ) + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'network' => { 'deniedDomains' => ['evil.example'], 'strictAllowlist' => true }, + 'filesystem' => { 'denyRead' => ['~/.aws'], 'disabled' => false } + ) + expect(sandbox.to_h).to eq( + enabled: true, + network: { deniedDomains: ['evil.example'], strictAllowlist: true }, + filesystem: { denyRead: ['~/.aws'], 'disabled' => false } + ) + end + + it 'includes a name the SDK only knows at another level' do + sandbox = { + enabled: true, + deny_read: ['~/.aws'], + network: { excluded_commands: ['docker'], deny_read: ['~/.aws'] }, + filesystem: { denied_domains: ['evil.example'], auto_allow_bash_if_sandboxed: false } + } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'deny_read' => ['~/.aws'], + 'network' => { 'excluded_commands' => ['docker'], 'deny_read' => ['~/.aws'] }, + 'filesystem' => { 'denied_domains' => ['evil.example'], 'auto_allow_bash_if_sandboxed' => false } + ) + end + end + + describe 'the value of a field' do + # ignore_violations and ripgrep hold Hashes of their own, and no typed + # class models what is inside them: a key in there that happens to be + # spelled like a sandbox field is not one. + it 'is not rewritten' do + inner = { command: '/usr/bin/rg', args: ['--hidden'], deny_read: 'kept', 'allowed_domains' => 'kept' } + sandbox = { + enabled: true, + ripgrep: inner, + ignore_violations: { 'excluded_commands' => ['/tmp/*'], denied_domains: ['kept'], network: ['localhost'] }, + network: { allow_unix_sockets: ['/var/run/docker.sock'], allow_mach_lookup: ['com.apple.coresimulator.*'] } + } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'ripgrep' => on_the_wire(inner), + 'ignoreViolations' => { 'excluded_commands' => ['/tmp/*'], 'denied_domains' => ['kept'], 'network' => ['localhost'] }, + 'network' => { 'allowUnixSockets' => ['/var/run/docker.sock'], 'allowMachLookup' => ['com.apple.coresimulator.*'] } + ) + end + end + + describe 'a Hash that spells one field both ways' do + it 'sends the camelCase spelling at the top level, whichever comes first' do + sandboxes = [ + { enabled: true, auto_allow_bash_if_sandboxed: true, autoAllowBashIfSandboxed: false }, + { enabled: true, autoAllowBashIfSandboxed: false, auto_allow_bash_if_sandboxed: true }, + { 'enabled' => true, 'auto_allow_bash_if_sandboxed' => true, 'autoAllowBashIfSandboxed' => false }, + { enabled: true, 'autoAllowBashIfSandboxed' => false, auto_allow_bash_if_sandboxed: true } + ] + + expect(sandboxes.map { |sandbox| sandbox_section(sandbox) }) + .to eq([{ 'enabled' => true, 'autoAllowBashIfSandboxed' => false }] * 4) + end + + it 'sends the camelCase spelling inside network and filesystem, whichever comes first' do + sandboxes = [ + { network: { denied_domains: [], deniedDomains: ['evil.example'] }, + filesystem: { denyRead: ['/private/etc/ssh', '~/.aws'], deny_read: [] } }, + { 'network' => { 'deniedDomains' => ['evil.example'], 'denied_domains' => [] }, + 'filesystem' => { 'deny_read' => [], 'denyRead' => ['/private/etc/ssh', '~/.aws'] } }, + ClaudeAgentSDK::SandboxSettings.new( + network: { deniedDomains: ['evil.example'], denied_domains: [] }, + filesystem: { deny_read: [], denyRead: ['/private/etc/ssh', '~/.aws'] } + ) + ] + expected = { + 'network' => { 'deniedDomains' => ['evil.example'] }, + 'filesystem' => { 'denyRead' => ['/private/etc/ssh', '~/.aws'] } + } + + expect(sandboxes.map { |sandbox| sandbox_section(sandbox) }).to eq([expected] * 3) + end + + it 'writes the field once' do + argument = settings_argument( + { excluded_commands: ['git'], excludedCommands: ['docker'], + network: { deniedDomains: ['evil.example'], denied_domains: ['other.example'] } } + ) + + expect(argument.scan('"excludedCommands"').size).to eq(1) + expect(argument.scan('"deniedDomains"').size).to eq(1) + expect(argument).not_to include('excluded_commands', 'denied_domains') + end + + # json 3.x raises on a Hash that carries one key as a Symbol and as a + # String; json 2.x writes it twice. + it 'writes a key once when a Symbol and a String spell it, taking the later one' do + argument = settings_argument( + { enabled: false, 'enabled' => true, + 'network' => { 'deniedDomains' => ['other.example'] }, + network: { deniedDomains: [], 'deniedDomains' => ['evil.example'] } } + ) + + expect(JSON.parse(argument).fetch('sandbox')).to eq( + 'enabled' => true, 'network' => { 'deniedDomains' => ['evil.example'] } + ) + expect(%w[enabled network deniedDomains].map { |key| argument.scan("\"#{key}\"").size }).to eq([1, 1, 1]) + end + + it 'takes the later one of a snake_case Symbol and a snake_case String too' do + sandbox = { + 'excluded_commands' => ['git'], excluded_commands: ['docker'], + filesystem: { deny_read: [], 'deny_read' => ['~/.aws'] } + } + + expect(sandbox_section(sandbox)).to eq('excludedCommands' => ['docker'], 'filesystem' => { 'denyRead' => ['~/.aws'] }) + end + end + + # The typed classes leave a nil attribute out of #to_h. A Hash stands for + # the typed value with the same fields, so a known key holding nil is left + # out as well, in either spelling. It matters: CLI 2.1.287 rejects null + # under every one of these keys ("sandbox.excludedCommands: Expected array, + # but received null") and then drops the whole --settings value, sandbox + # included. Written in snake_case the same key used to be one the CLI did + # not know and ignored. + describe 'a nil value under a known key' do + typed = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, excluded_commands: nil, auto_allow_bash_if_sandboxed: nil, filesystem: nil, + network: ClaudeAgentSDK::SandboxNetworkConfig.new(denied_domains: ['evil.example'], allowed_domains: nil) + ) + expected = { 'enabled' => true, 'network' => { 'deniedDomains' => ['evil.example'] } } + + it 'is left out by the typed classes' do + expect(sandbox_section(typed)).to eq(expected) + end + + { + 'snake_case keys' => { + enabled: true, excluded_commands: nil, auto_allow_bash_if_sandboxed: nil, filesystem: nil, + network: { denied_domains: ['evil.example'], allowed_domains: nil } + }, + 'camelCase keys' => { + enabled: true, excludedCommands: nil, autoAllowBashIfSandboxed: nil, filesystem: nil, + network: { deniedDomains: ['evil.example'], allowedDomains: nil } + }, + 'String keys' => { + 'enabled' => true, 'excluded_commands' => nil, 'autoAllowBashIfSandboxed' => nil, 'filesystem' => nil, + 'network' => { 'denied_domains' => ['evil.example'], 'allowedDomains' => nil } + }, + 'a typed SandboxSettings holding a Hash' => ClaudeAgentSDK::SandboxSettings.new( + enabled: true, network: { denied_domains: ['evil.example'], allowed_domains: nil, 'allowLocalBinding' => nil } + ) + }.each do |form, sandbox| + it "is left out of a Hash with #{form}" do + expect(sandbox_section(sandbox)).to eq(expected) + end + end + + it 'does not stand in for a value the other spelling carries' do + sandbox = { filesystem: { deny_read: ['~/.aws'], denyRead: nil }, excludedCommands: nil, excluded_commands: ['docker'] } + + expect(sandbox_section(sandbox)).to eq('filesystem' => { 'denyRead' => ['~/.aws'] }, 'excludedCommands' => ['docker']) + end + + it 'keeps false, which is a value' do + sandbox = { enabled: false, auto_allow_bash_if_sandboxed: false, network: { allow_local_binding: false } } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => false, 'autoAllowBashIfSandboxed' => false, 'network' => { 'allowLocalBinding' => false } + ) + end + end + + # A snake_case key is one the CLI does not know: it ignores it, value and + # all. Under its wire name the same value is validated, and one value that + # fails the CLI's schema makes it discard the whole --settings value (the + # sandbox, and the permissions passed next to it); the SDK is not told. So a + # snake_case key is renamed only when its value has the shape the CLI + # accepts for that key. Otherwise it goes out as written and stays inert, + # as it always was: renaming must never cost a session its sandbox. + describe 'a snake_case key whose value the CLI would reject' do + # allowUnixSockets takes an Array of socket paths; the boolean switch is + # allow_all_unix_sockets. The shipped signature of allow_unix_sockets + # allows a bool all the same, which makes `true` the value most likely to + # be written here. Sent as allowUnixSockets: true it would leave the + # session with no sandbox at all. + it 'sends allow_unix_sockets: true as written, not as allowUnixSockets' do + sandbox = { enabled: true, network: { allow_unix_sockets: true, denied_domains: ['evil.example'] } } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, 'network' => { 'allow_unix_sockets' => true, 'deniedDomains' => ['evil.example'] } + ) + end + + kinds.each do |class_name, by_attribute| + by_attribute.each do |attribute, kind| + it "is sent as written for #{attribute} (#{kind}), under a Symbol or a String key" do + sandboxes = ill_shaped.fetch(kind).flat_map do |value| + [attribute, attribute.to_s].map { |key| at_level_of.fetch(class_name).call(key => value) } + end + + expect(sandboxes.map { |sandbox| sandbox_section(sandbox) }).to eq(sandboxes.map { |sandbox| on_the_wire(sandbox) }) + end + end + end + + it 'is left as written inside a typed SandboxSettings too' do + sandbox = ClaudeAgentSDK::SandboxSettings.new( + enabled: true, network: { allow_unix_sockets: true }, filesystem: { 'deny_read' => '~/.aws' } + ) + + expect(sandbox.to_h).to eq( + enabled: true, network: { allow_unix_sockets: true }, filesystem: { 'deny_read' => '~/.aws' } + ) + end + + it 'stays next to the same field written in a form the CLI accepts, and never replaces it' do + sandbox = { + excluded_commands: 'docker', excludedCommands: ['git'], + filesystem: { deny_read: ['~/.aws'], 'deny_read' => '~/.ssh' } + } + + expect(sandbox_section(sandbox)).to eq( + 'excluded_commands' => 'docker', 'excludedCommands' => ['git'], + 'filesystem' => { 'denyRead' => ['~/.aws'], 'deny_read' => '~/.ssh' } + ) + end + + it 'is still renamed for the values the CLI accepts at the edges' do + sandbox = { + excluded_commands: [], ignore_violations: {}, + network: { allowed_domains: ['*.example.com', ''], allow_mach_lookup: ['*', 'com.apple.coresimulator.*'], + http_proxy_port: 0, socks_proxy_port: 65_535 }, + filesystem: { deny_read: ['/work/**/*.pem'] } + } + + expect(sandbox_section(sandbox)).to eq( + 'excludedCommands' => [], 'ignoreViolations' => {}, + 'network' => { 'allowedDomains' => ['*.example.com', ''], 'allowMachLookup' => ['*', 'com.apple.coresimulator.*'], + 'httpProxyPort' => 0, 'socksProxyPort' => 65_535 }, + 'filesystem' => { 'denyRead' => ['/work/**/*.pem'] } + ) + end + + # A proxy port is renamed for what a port can be, whichever of the two + # attributes it is and whichever class its key has. Beyond that range the + # CLI still takes a number it can read, but not one it cannot: an Integer + # such as 10**400 reaches it as a non-finite value, and it discards the + # whole --settings value over that (the examples for the port kind above). + %i[http_proxy_port socks_proxy_port].each do |attribute| + it "is renamed for #{attribute} at 0 and at 65535, under a Symbol or a String key" do + wire_key = network_fields.fetch(attribute).first + sections = [0, 65_535].flat_map do |port| + [attribute, attribute.to_s].map { |key| sandbox_section({ enabled: true, network: { key => port } }) } + end + + expect(sections) + .to eq([0, 0, 65_535, 65_535].map { |port| { 'enabled' => true, 'network' => { wire_key => port } } }) + end + end + + # The check is only about renaming. A key the caller wrote in the CLI's + # own spelling reaches the CLI as before, whatever its value. + it 'does not concern a key already in wire spelling' do + sandbox = { enabled: true, excludedCommands: 'docker', 'failIfUnavailable' => 'yes', + network: { allowUnixSockets: true, httpProxyPort: 10**400, 'socksProxyPort' => -1 } } + + expect(sandbox_section(sandbox)).to eq(on_the_wire(sandbox)) + end + end + + # JSON.generate has no way to write a typed value: left inside a Hash it + # went out as its #inspect text ("#<ClaudeAgentSDK::SandboxNetworkConfig + # ...>"), a String where the CLI wants an object, and the CLI discarded the + # whole --settings value over it. + describe 'a typed network or filesystem inside a Hash' do + it 'is written like a Hash with the same fields' do + sandbox = { + enabled: true, + network: ClaudeAgentSDK::SandboxNetworkConfig.new(denied_domains: ['evil.example']), + 'filesystem' => ClaudeAgentSDK::SandboxFilesystemConfig.new(deny_read: ['/private/etc/ssh']) + } + + expect(sandbox_section(sandbox)).to eq( + 'enabled' => true, + 'network' => { 'deniedDomains' => ['evil.example'] }, + 'filesystem' => { 'denyRead' => ['/private/etc/ssh'] } + ) + end + end + + describe 'what the caller did not write' do + it 'is not added: an enabled sandbox is sent without failIfUnavailable' do + expect([{ enabled: true }, { 'enabled' => true }, ClaudeAgentSDK::SandboxSettings.new(enabled: true)] + .map { |sandbox| sandbox_section(sandbox) }).to eq([{ 'enabled' => true }] * 3) + end + + it 'stays an empty section for an empty Hash' do + expect(settings_argument({})).to eq('{"sandbox":{}}') + end + end + + describe 'the Hash the caller passed' do + it 'is left untouched, so a frozen constant can be used for every session' do + sandbox = { + enabled: true, excluded_commands: ['docker'].freeze, + network: { denied_domains: ['evil.example'].freeze }.freeze + }.freeze + expected = { 'enabled' => true, 'excludedCommands' => ['docker'], 'network' => { 'deniedDomains' => ['evil.example'] } } + + expect([sandbox_section(sandbox), sandbox_section(sandbox)]).to eq([expected, expected]) + expect(sandbox).to eq( + enabled: true, excluded_commands: ['docker'], network: { denied_domains: ['evil.example'] } + ) + end + + it 'is not modified when a typed SandboxSettings holds it' do + network = { denied_domains: ['evil.example'], deniedDomains: ['other.example'], allowed_domains: nil }.freeze + sandbox = ClaudeAgentSDK::SandboxSettings.new(enabled: true, network: network) + + expect(sandbox.to_h).to eq(enabled: true, network: { deniedDomains: ['other.example'] }) + expect(network).to eq(denied_domains: ['evil.example'], deniedDomains: ['other.example'], allowed_domains: nil) + end + end +end diff --git a/spec/unit/sandbox_unavailable_warning_spec.rb b/spec/unit/sandbox_unavailable_warning_spec.rb new file mode 100644 index 00000000..85d45c1d --- /dev/null +++ b/spec/unit/sandbox_unavailable_warning_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'stringio' +require 'tmpdir' + +# `sandbox: { enabled: true }` asks for a sandbox; when the CLI cannot start +# one (and failIfUnavailable is not set) it says so on stderr and carries on +# unsandboxed. An SDK host never sees that line unless it passed a `stderr:` +# callback, so the transport repeats it as a Ruby warning. +RSpec.describe ClaudeAgentSDK::SubprocessCLITransport, 'unavailable sandbox warning' do + after { described_class.active_processes.clear } + + around do |example| + Dir.mktmpdir('sandbox-warning-spec') do |dir| + @dir = dir + example.run + end + end + + # Byte for byte what CLI 2.1.286 / 2.1.287 write at startup + # (`process.stderr.write(`\n⚠ Sandbox disabled: ${reason}\n Commands + # will run WITHOUT sandboxing. ...\n\n`)`), with the reason the CLI builds + # on a Linux host that has neither bubblewrap nor socat. + let(:unavailable_reason) do + 'sandbox is enabled but dependencies are missing: ' \ + 'bubblewrap (bwrap) not installed, socat not installed ' \ + '· install missing tools (e.g. apt install bubblewrap socat) ' \ + 'or see https://code.claude.com/docs/en/sandboxing' + end + let(:cli_line) { "⚠ Sandbox disabled: #{unavailable_reason}" } + let(:cli_notice) do + "\n#{cli_line}\n " \ + "Commands will run WITHOUT sandboxing. Network and filesystem restrictions will NOT be enforced.\n\n" + end + + # Writes +stderr_text+ to stderr, then ends the run (with a result frame + # unless +result+ is false). + def install_fake_cli(stderr_text, exit_status: 0, result: true) + stderr_file = File.join(@dir, 'stderr.txt') + File.write(stderr_file, stderr_text) + path = File.join(@dir, 'claude') + File.write(path, <<~SH) + #!/bin/sh + if [ "$1" = "-v" ]; then + echo '2.1.286 (Claude Code)' + exit 0 + fi + cat '#{stderr_file}' >&2 + #{"printf '%s\\n' '#{JSON.generate(sample_result_message)}'" if result} + exit #{exit_status} + SH + File.chmod(0o755, path) + path + end + + # Everything written to $stderr while a session runs to its end. The + # warning is issued on the stderr drain thread, so the swap covers that + # thread's whole life: it ends at EOF, when the fake has exited. + def stderr_during_session(transport, stderr: StringIO.new) + original = $stderr + $stderr = stderr + begin + transport.connect + transport.instance_variable_get(:@stderr_task).join + transport.read_messages { |_frame| nil } + ensure + $stderr = original + transport.close + end + stderr.string if stderr.respond_to?(:string) + end + + def transport_with(**values) + described_class.new(ClaudeAgentSDK::ClaudeAgentOptions.new(**values)) + end + + # The real CLI prints the notice once; twice pins "one warning per + # transport" rather than "one warning per line". + let(:cli) { install_fake_cli(cli_notice * 2) } + + { + 'a SandboxSettings with enabled: true' => -> { ClaudeAgentSDK::SandboxSettings.new(enabled: true) }, + 'a Hash with enabled: true' => -> { { enabled: true } }, + 'a Hash with a String "enabled" key' => -> { { 'enabled' => true } }, + 'sandbox: true' => -> { true } + }.each do |description, sandbox| + it "warns once when the sandbox was requested by #{description}" do + output = stderr_during_session(transport_with(cli_path: cli, sandbox: sandbox.call)) + + expect(output.scan('[claude-agent-sdk]').size).to eq(1) + expect(output).to include(cli_line).and include('fail_if_unavailable: true') + end + end + + it 'warns once on the stderr-callback path too, and still hands the callback the line' do + seen = [] + transport = transport_with(cli_path: cli, sandbox: { enabled: true }, stderr: ->(line) { seen << line }) + + output = stderr_during_session(transport) + + expect(output.scan('[claude-agent-sdk]').size).to eq(1) + expect(seen.count(cli_line)).to eq(2) + end + + { + 'no sandbox option' => -> {}, + 'sandbox: false' => -> { false }, + 'a Hash with enabled: false' => -> { { enabled: false } }, + 'an empty Hash' => -> { {} }, + 'a SandboxSettings with enabled: false' => -> { ClaudeAgentSDK::SandboxSettings.new(enabled: false) }, + 'a SandboxSettings that leaves enabled unset' => -> { ClaudeAgentSDK::SandboxSettings.new } + }.each do |description, sandbox| + it "stays silent with #{description}" do + output = stderr_during_session(transport_with(cli_path: cli, sandbox: sandbox.call)) + + expect(output).to be_empty + end + end + + # With failIfUnavailable the CLI does not carry on unsandboxed: it prints + # this instead (2.1.286 / 2.1.287, same startup branch) and exits 1, which + # the session already reports as a ProcessError carrying the text. + it 'stays silent when the CLI refuses to start without the sandbox' do + refusal = "\nError: sandbox required but unavailable: #{unavailable_reason}\n " \ + "sandbox.failIfUnavailable is set — refusing to start without a working sandbox.\n\n" + refusing_cli = install_fake_cli(refusal, exit_status: 1, result: false) + transport = transport_with(cli_path: refusing_cli, sandbox: { enabled: true, failIfUnavailable: true }) + captured = StringIO.new + + expect { stderr_during_session(transport, stderr: captured) }.to raise_error(ClaudeAgentSDK::ProcessError) + expect(captured.string).to be_empty + end + + # A host's $stderr can be a pipe nobody reads any more. The warning is + # advisory: failing to print it must not end the drain, or the CLI stalls + # on a full stderr pipe. + it 'keeps draining when $stderr is broken' do + broken = Object.new + def broken.write(*) = raise(Errno::EPIPE) + # spawn flushes $stderr before it forks + def broken.flush = self + failing_cli = install_fake_cli("#{cli_notice}a later stderr line\n", exit_status: 1) + transport = transport_with(cli_path: failing_cli, sandbox: { enabled: true }) + + expect { stderr_during_session(transport, stderr: broken) } + .to raise_error(ClaudeAgentSDK::ProcessError) { |error| + expect(error.stderr.lines.last).to eq('a later stderr line') + } + end +end diff --git a/spec/unit/sdk_mcp_tool_meta_spec.rb b/spec/unit/sdk_mcp_tool_meta_spec.rb new file mode 100644 index 00000000..78f7be77 --- /dev/null +++ b/spec/unit/sdk_mcp_tool_meta_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# create_tool forwards annotations[:maxResultSizeChars] to the CLI as +# _meta['anthropic/maxResultSizeChars'] (MCP clients drop the annotation +# itself). It only did so when no meta: was passed: any explicit meta:, even +# one about something else, replaced the size hint. +RSpec.describe ClaudeAgentSDK, '.create_tool _meta from annotations and meta:' do + size_key = 'anthropic/maxResultSizeChars' + + def tool_with(**options) + described_class.create_tool('export_rows', 'Export rows', {}, **options) { |_args| 'rows' } + end + + def advertised_meta(tool) + described_class::SdkMcpServer.new(name: 'reports', tools: [tool]).list_tools.first[:_meta] + end + + it 'derives the size hint from the annotation when no meta: is given' do + tool = tool_with(annotations: { maxResultSizeChars: 500_000 }) + + expect(tool.meta).to eq(size_key => 500_000) + end + + it 'merges an unrelated meta: with the derived size hint instead of dropping it' do + tool = tool_with(annotations: { maxResultSizeChars: 500_000 }, meta: { 'acme/team' => 'billing' }) + + expect(tool.meta).to eq(size_key => 500_000, 'acme/team' => 'billing') + expect(advertised_meta(tool)).to eq(size_key => 500_000, 'acme/team' => 'billing') + end + + it 'advertises both keys through the mcp gem\'s tools/list too' do + tool = tool_with(annotations: { maxResultSizeChars: 500_000 }, meta: { 'acme/team' => 'billing' }) + server = described_class::SdkMcpServer.new(name: 'reports', tools: [tool]) + + listed = JSON.parse(server.handle_json(JSON.generate(jsonrpc: '2.0', id: 1, method: 'tools/list'))) + + expect(listed.dig('result', 'tools', 0, '_meta')).to eq(size_key => 500_000, 'acme/team' => 'billing') + end + + it 'reads the annotation under a String key as well' do + tool = tool_with(annotations: { 'maxResultSizeChars' => 500_000 }, meta: { 'acme/team' => 'billing' }) + + expect(tool.meta).to eq(size_key => 500_000, 'acme/team' => 'billing') + end + + it 'lets a size key set in meta: win over the annotation' do + tool = tool_with(annotations: { maxResultSizeChars: 500_000 }, meta: { size_key => 100, 'acme/team' => 'billing' }) + + expect(tool.meta).to eq(size_key => 100, 'acme/team' => 'billing') + end + + it 'lets a Symbol-keyed size key in meta: win, without a second spelling of the key' do + tool = tool_with(annotations: { maxResultSizeChars: 500_000 }, meta: { size_key.to_sym => 100 }) + + expect(tool.meta).to eq(size_key.to_sym => 100) + # Counted in the serialized frame: two spellings of one key are a + # duplicate JSON key, which json 3.x refuses to generate. + expect(JSON.generate(advertised_meta(tool)).scan(size_key).size).to eq(1) + end + + it 'does not modify the Hash passed as meta:' do + meta = { 'acme/team' => 'billing' }.freeze + + tool = tool_with(annotations: { maxResultSizeChars: 500_000 }, meta: meta) + + expect(meta).to eq('acme/team' => 'billing') + expect(tool.meta).not_to equal(meta) + end + + it 'passes meta: through untouched when the annotations carry no size' do + meta = { 'acme/team' => 'billing' } + + expect(tool_with(annotations: { readOnlyHint: true }, meta: meta).meta).to equal(meta) + expect(tool_with(meta: meta).meta).to equal(meta) + end + + it 'leaves _meta out when there is neither a size annotation nor meta:' do + expect(tool_with(annotations: { readOnlyHint: true }).meta).to be_nil + expect(tool_with.meta).to be_nil + expect(described_class::SdkMcpServer.new(name: 'reports', tools: [tool_with]).list_tools.first).not_to have_key(:_meta) + end +end diff --git a/spec/unit/sdk_mcp_tool_result_spec.rb b/spec/unit/sdk_mcp_tool_result_spec.rb new file mode 100644 index 00000000..ee3fde84 --- /dev/null +++ b/spec/unit/sdk_mcp_tool_result_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# A handler may return a String, or a Hash whose :content is an Array of +# content blocks. A Hash whose :content is itself a String (an easy slip next +# to the String form) or a single block Hash used to be forwarded as it was: +# the CLI rejected the frame against its schema and told the model that the +# SERVER "returned a malformed result", so the text never reached it. +RSpec.describe ClaudeAgentSDK::SdkMcpServer, 'a tool result whose :content is not an Array' do + def server_returning(result) + tool = ClaudeAgentSDK.create_tool('order_status', 'Look up an order', { id: :string }) { |_args| result } + described_class.new(name: 'shop', tools: [tool]) + end + + # tools/call the way a session routes it: the frame from the CLI's + # mcp_message control request, parsed with symbol keys, through the mcp gem. + def call_through_session(server) + wire = JSON.generate(jsonrpc: '2.0', id: 3, method: 'tools/call', + params: { name: 'order_status', arguments: { id: 'A-7' } }) + server.handle_message(JSON.parse(wire, symbolize_names: true))[:result] + end + + def expect_content_error(result, got) + expect(result[:isError]).to be true + expect(result[:content]).to eq( + [{ type: 'text', + text: "Tool 'order_status' must return :content as an Array of content blocks (got #{got})" }] + ) + end + + [ + ['a String', { content: 'Order A-7 has shipped' }, 'String'], + ['a single block Hash', { content: { type: 'text', text: 'Order A-7 has shipped' } }, 'Hash'], + ['a String under a String key', { 'content' => 'Order A-7 has shipped' }, 'String'], + ['an Integer', { content: 7 }, 'Integer'], + # Present, but falsy: the key is there, so this is a wrong value, not a + # missing key. + ['false', { content: false }, 'FalseClass'], + ['a present nil', { content: nil }, 'NilClass'], + ['a present nil under a String key', { 'content' => nil }, 'NilClass'] + ].each do |label, handler_result, got| + it "answers a session's tools/call with an in-band error when :content is #{label}" do + expect_content_error(call_through_session(server_returning(handler_result)), got) + end + + it "returns the same error from #call_tool when :content is #{label}" do + expect_content_error(server_returning(handler_result).call_tool('order_status', { id: 'A-7' }), got) + end + end + + it 'keeps the handler\'s own is_error flag out of it: the result is an error either way' do + result = call_through_session(server_returning({ content: 'fine', is_error: false })) + + expect_content_error(result, 'String') + end + + # The other diagnostic is about the KEY, and only about the key: a result + # that is not a Hash, or a Hash with no :content under either spelling. + [ + ['nil', nil], + ['an Array of blocks without the Hash around it', [{ type: 'text', text: 'Order A-7 has shipped' }]], + ['a Hash without the key', { text: 'Order A-7 has shipped' }], + ['a Hash whose only key is :contents', { contents: [{ type: 'text', text: 'Order A-7 has shipped' }] }] + ].each do |label, handler_result| + it "says the :content key is missing, on both paths, when the handler returns #{label}" do + server = server_returning(handler_result) + missing = [{ type: 'text', text: "Tool 'order_status' must return a hash with :content key" }] + + session = call_through_session(server) + expect(session[:isError]).to be true + expect(session[:content]).to eq(missing) + + expect(server.call_tool('order_status', { id: 'A-7' })).to eq(content: missing, isError: true) + end + end + + it 'reads the Array under either spelling when the other one is nil' do + server = server_returning({ content: nil, 'content' => [{ type: 'text', text: 'shipped' }] }) + + expect(call_through_session(server)).to include(isError: false, content: [{ type: 'text', text: 'shipped' }]) + expect(server.call_tool('order_status', { id: 'A-7' })).not_to have_key(:isError) + end + + # The legal shapes are untouched. + [ + ['a String', 'Order A-7 has shipped', [{ type: 'text', text: 'Order A-7 has shipped' }]], + ['an Array of blocks', { content: [{ type: 'text', text: 'shipped' }, { type: 'text', text: 'on time' }] }, + [{ type: 'text', text: 'shipped' }, { type: 'text', text: 'on time' }]], + ['an empty Array', { content: [] }, []] + ].each do |label, handler_result, content| + it "still passes #{label} through on both paths" do + server = server_returning(handler_result) + + session = call_through_session(server) + expect(session[:isError]).to be false + expect(session[:content]).to eq(content) + + direct = server.call_tool('order_status', { id: 'A-7' }) + expect(direct[:content]).to eq(content) + expect(direct).not_to have_key(:isError) + end + end +end diff --git a/spec/unit/sdk_mcp_tool_schema_spec.rb b/spec/unit/sdk_mcp_tool_schema_spec.rb new file mode 100644 index 00000000..ce0cb49e --- /dev/null +++ b/spec/unit/sdk_mcp_tool_schema_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' + +# Shorthand tool schemas ({ name: type }) for collection parameters. +# `{ tags: Array }` used to be advertised as a string: the model then sent +# "fragile,gift" where the handler expected an Array, and a model that did +# send an array had its call rejected by argument validation. +# +# A session answers tools/list from SdkMcpServer#list_tools and routes +# tools/call through #handle_message (the mcp gem, which validates the +# arguments), so the two are checked against each other here. +RSpec.describe ClaudeAgentSDK::SdkMcpServer, 'shorthand schemas for Array and Hash parameters' do + def server_with(schema, received = []) + tool = ClaudeAgentSDK.create_tool('tag_order', 'Tag an order', schema) do |args| + received << args + "tagged with #{args.inspect}" + end + described_class.new(name: 'shop', tools: [tool]) + end + + # The frame the CLI embeds in an mcp_message control request, as the + # transport hands it over: parsed with symbol keys. + def call_tool(server, arguments) + wire = JSON.generate(jsonrpc: '2.0', id: 7, method: 'tools/call', + params: { name: 'tag_order', arguments: arguments }) + server.handle_message(JSON.parse(wire, symbolize_names: true)) + end + + def advertised_type(server, param) + server.list_tools.first.dig(:inputSchema, :properties, param, :type) + end + + [ + [Array, 'array', %w[fragile gift], 'fragile,gift'], + [:array, 'array', [1, 2, 3], 7], + [Hash, 'object', { gift: true, note: 'handle with care' }, 'gift=true'], + [:object, 'object', { priority: 2 }, %w[priority 2]] + ].each do |type, json_type, valid, invalid| + it "advertises #{type.inspect} as #{json_type}, accepts one and rejects anything else" do + received = [] + server = server_with({ order_id: Integer, value: type }, received) + + expect(advertised_type(server, :value)).to eq(json_type) + expect(advertised_type(server, :order_id)).to eq('integer') + expect(server.list_tools.first.dig(:inputSchema, :required)).to eq(%w[order_id value]) + + accepted = call_tool(server, { order_id: 7, value: valid }) + expect(accepted.dig(:result, :isError)).to be(false), accepted.inspect + expect(received).to eq([{ order_id: 7, value: valid }]) + + rejected = call_tool(server, { order_id: 7, value: invalid }) + expect(rejected.dig(:result, :isError)).to be true + expect(rejected.dig(:result, :content, 0, :text)).to match(%r{Invalid arguments.*/value}m) + expect(received.size).to eq(1) + end + end + + it 'advertises the same types through the mcp gem\'s own tools/list' do + server = server_with({ tags: Array, opts: Hash, ids: :array, meta: :object }) + + response = JSON.parse(server.handle_json(JSON.generate(jsonrpc: '2.0', id: 1, method: 'tools/list')), + symbolize_names: true) + properties = response.dig(:result, :tools, 0, :inputSchema, :properties) + + expect(properties.transform_values { |property| property[:type] }) + .to eq(tags: 'array', opts: 'object', ids: 'array', meta: 'object') + expect(server.list_tools.first.dig(:inputSchema, :properties)).to eq(properties) + end + + it 'accepts an empty Array and an empty Hash' do + received = [] + server = server_with({ tags: Array, opts: Hash }, received) + + response = call_tool(server, { tags: [], opts: {} }) + + expect(response.dig(:result, :isError)).to be false + expect(received).to eq([{ tags: [], opts: {} }]) + end + + # Everything else in a shorthand schema keeps its present meaning. What an + # unrecognized value should do (warn, raise) is a separate decision; until + # then it is advertised as a string, exactly as before. + it 'leaves every other shorthand value as it was' do + schema = { name: String, count: :integer, ratio: Float, flag: :boolean, + typo: :interger, short: :bool, numeric: Numeric, + list_of: [String], fragment: { type: 'array', items: { type: 'string' } } } + + types = server_with(schema).list_tools.first.dig(:inputSchema, :properties).transform_values { |p| p[:type] } + + expect(types).to eq(name: 'string', count: 'integer', ratio: 'number', flag: 'boolean', + typo: 'string', short: 'string', numeric: 'string', + list_of: 'string', fragment: 'string') + end + + it 'still reads { type: :object } as a prebuilt schema, not as a parameter named type' do + # :object is now a shorthand value too; a propertyless object schema has + # always been detected first and must stay "accept any object". + server = server_with({ type: :object }) + + expect(server.list_tools.first[:inputSchema]).to eq({ type: 'object' }) + expect(call_tool(server, { anything: 1 }).dig(:result, :isError)).to be false + end + + it 'declares an object parameter literally named type through the class form' do + schema = server_with({ type: Hash, name: String }).list_tools.first[:inputSchema] + + expect(schema).to eq(type: 'object', required: %w[type name], + properties: { type: { type: 'object' }, name: { type: 'string' } }) + end +end diff --git a/spec/unit/session_c_locale_paths_spec.rb b/spec/unit/session_c_locale_paths_spec.rb new file mode 100644 index 00000000..6ed373a5 --- /dev/null +++ b/spec/unit/session_c_locale_paths_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'open3' +require 'rbconfig' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# Under LANG=C / LC_ALL=C (minimal Docker images, cron, systemd) Ruby hands +# out the paths it gets from the system — ENV values, Dir.pwd, File.realpath, +# File.readlink — tagged BINARY or US-ASCII. They are UTF-8 all the same. The +# locale of a process is fixed when it starts, so this runs the SDK in a +# child process. +RSpec.describe 'session paths under a non-UTF-8 locale' do + include_context 'with a Claude config dir' + + let(:session_id) { '7e6f5d4c-3b2a-4190-8f7e-6d5c4b3a2910' } + let(:accented) { "caf#{[0xE9].pack('U')}" } # "café", NFC + + # The start of each child script. A String is reported as hex (the pipe + # back is read as bytes) with its encoding, an error as its class and + # message. + let(:prelude) do + <<~'RUBY' + require 'claude_agent_sdk' + require 'json' + report = lambda do |value| + value.is_a?(String) ? [value.unpack1('H*'), value.encoding.name, value.valid_encoding?] : value + end + attempt = lambda do |&block| + report.call(block.call) + rescue StandardError => e + "#{e.class}: #{e.message}" + end + RUBY + end + + def hex(string) + [string.unpack1('H*'), 'UTF-8', true] + end + + def key_of(path) + hex(ClaudeAgentSDK::Sessions.sanitize_path(path)) + end + + def record_session(directory, config = config_dir) + transcript = CLITranscript.new(session_id: session_id, cwd: directory) + transcript.prompt(:prompt, 'hi') + transcript.write(File.join(config, 'projects', ClaudeAgentSDK::Sessions.sanitize_path(directory), + "#{session_id}.jsonl")) + end + + # What +script+ prints as JSON in a child started with LC_ALL=C in +chdir+. + def run_under_c_locale(script, chdir:, **env) + env = { 'LC_ALL' => 'C', 'LANG' => 'C', 'LC_CTYPE' => 'C' }.merge(env.transform_keys(&:to_s)) + output, errors, status = Open3.capture3(env, RbConfig.ruby, '-I', File.expand_path('../../lib', __dir__), + '-e', "#{prelude}\n#{script}", chdir: chdir) + raise "probe failed: #{errors}" unless status.success? + + JSON.parse(output) + end + + it 'reads a non-ASCII CLAUDE_CONFIG_DIR and lists the sessions of Dir.pwd' do + config = File.join(cwd, "#{accented}-config") + project = File.join(cwd, "#{accented}-project").tap { |dir| FileUtils.mkdir_p(dir) } + record_session(project, config) + script = <<~RUBY + puts JSON.generate( + 'external' => Encoding.default_external.name, + 'config_dir' => attempt.call { ClaudeAgentSDK::Sessions.config_dir }, + 'projects_dir' => attempt.call { ClaudeAgentSDK::SessionStores.projects_dir }, + 'key_for_pwd' => attempt.call { ClaudeAgentSDK.project_key_for_directory(Dir.pwd) }, + 'key_for_default' => attempt.call { ClaudeAgentSDK.project_key_for_directory }, + 'listed_for_pwd' => attempt.call { ClaudeAgentSDK.list_sessions(directory: Dir.pwd).map(&:session_id) } + ) + RUBY + + seen = run_under_c_locale(script, chdir: project, CLAUDE_CONFIG_DIR: config) + + expect(seen['external']).to eq('US-ASCII') + expect(seen['config_dir']).to eq(hex(config)) + expect(seen['projects_dir']).to eq(hex(File.join(config, 'projects'))) + expect(seen['key_for_pwd']).to eq(key_of(project)) + expect(seen['key_for_default']).to eq(seen['key_for_pwd']) + expect(seen['listed_for_pwd']).to eq([session_id]) + end + + # A path File.realpath cannot resolve is walked component by component. + # The walk joins what it is given with the working directory (BINARY here) + # and with the targets of the links on the way (US-ASCII here, whatever + # their bytes): a session recorded in café-checkout, reached through the + # link `current` after café-checkout was removed, and a relative path with + # a non-ASCII name of its own. + it 'resolves a missing path through a link with a non-ASCII target, and a non-ASCII relative one' do + checkout = File.join(cwd, "#{accented}-checkout") + link = File.join(cwd, 'current').tap { |path| File.symlink(checkout, path) } + project = File.join(cwd, "#{accented}-project").tap { |dir| FileUtils.mkdir_p(dir) } + record_session(checkout) + script = <<~'RUBY' + link = ENV.fetch('SESSION_LINK') + id = ENV.fetch('SESSION_ID') + puts JSON.generate( + 'key_through_link' => attempt.call { ClaudeAgentSDK.project_key_for_directory(link) }, + 'listed_through_link' => attempt.call { ClaudeAgentSDK.list_sessions(directory: link).map(&:session_id) }, + 'title_after_rename' => attempt.call do + ClaudeAgentSDK.rename_session(session_id: id, title: 'Old checkout', directory: link) + ClaudeAgentSDK.get_session_info(session_id: id, directory: link).custom_title + end, + 'key_for_relative' => attempt.call { ClaudeAgentSDK.project_key_for_directory("caf#{[0xE9].pack('U')}-gone") } + ) + RUBY + + seen = run_under_c_locale(script, chdir: project, CLAUDE_CONFIG_DIR: config_dir, SESSION_LINK: link, + SESSION_ID: session_id) + + expect(seen['key_through_link']).to eq(key_of(checkout)) + expect(seen['listed_through_link']).to eq([session_id]) + expect(seen['title_after_rename']).to eq(hex('Old checkout')) + expect(seen['key_for_relative']).to eq(key_of(File.join(project, "#{accented}-gone"))) + end +end diff --git a/spec/unit/session_deleted_directory_spec.rb b/spec/unit/session_deleted_directory_spec.rb new file mode 100644 index 00000000..855fa403 --- /dev/null +++ b/spec/unit/session_deleted_directory_spec.rb @@ -0,0 +1,282 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'pathname' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# The CLI keys a project by its path, so the transcripts outlive the +# directory: after `rm -rf <worktree>` its sessions are still there to list, +# read, and clean up. +RSpec.describe 'sessions of a project directory that no longer exists' do + include_context 'with a Claude config dir' + + let(:session_id) { '2b3c4d5e-6f70-4812-9a3b-4c5d6e7f8091' } + let(:accented) { "caf#{[0xE9].pack('U')}" } # "café", NFC + + before { allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees) { |path| [path] } } + + # A session the CLI recorded while +directory+ existed: keyed by the + # directory's real path. + def record_session(directory) + FileUtils.mkdir_p(directory) + real = File.realpath(directory) + transcript = CLITranscript.new(session_id: session_id, cwd: real) + transcript.queue_operations('clean up the old worktree') + transcript.prompt(:prompt, 'clean up the old worktree') + transcript.assistant(:answer, transcript.text('Done.'), parent: :prompt) + transcript.last_prompt('clean up the old worktree', leaf: :answer) + transcript.write(transcript_path(session_id, real)) + end + + context 'when the directory was removed' do + let(:removed) { File.join(cwd, 'removed-worktree') } + let!(:transcript_file) { record_session(removed).tap { FileUtils.rm_rf(removed) } } + + it 'is still found by the readers' do + expect(ClaudeAgentSDK.list_sessions(directory: removed).map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: removed).length).to eq(2) + end + + it 'can be renamed through the directory' do + ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Old worktree', directory: removed) + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: removed).custom_title) + .to eq('Old worktree') + end + + it 'can be tagged through the directory' do + ClaudeAgentSDK.tag_session(session_id: session_id, tag: 'stale', directory: removed) + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: removed).tag).to eq('stale') + end + + it 'can be forked through the directory' do + fork = ClaudeAgentSDK.fork_session(session_id: session_id, directory: removed) + + expect(ClaudeAgentSDK.get_session_messages(session_id: fork.session_id, directory: removed).length).to eq(2) + end + + it 'can be deleted through the directory' do + ClaudeAgentSDK.delete_session(session_id: session_id, directory: removed) + + expect(File.exist?(transcript_file)).to be(false) + end + + # directory: is declared a String. A Pathname has always been taken as + # well, by File.realpath and by what resolves the path when that fails. + it 'is found through the directory given as a Pathname', + rbs_incompatible: 'passes a Pathname for directory:, which the signature declares String?' do + expect(ClaudeAgentSDK.list_sessions(directory: Pathname.new(removed)).map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: Pathname.new(removed)).length) + .to eq(2) + Dir.chdir(cwd) do + expect(ClaudeAgentSDK.list_sessions(directory: Pathname.new('removed-worktree')).map(&:session_id)) + .to eq([session_id]) + end + ClaudeAgentSDK.tag_session(session_id: session_id, tag: 'stale', directory: Pathname.new(removed)) + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: Pathname.new(removed)).tag) + .to eq('stale') + end + end + + # The CLI recorded the real path; the caller names the directory through a + # symlink (macOS /tmp and /var are symlinks). With the directory gone there + # is nothing left to resolve at the end of the path, only above it. + context 'when the removed directory is named through a symlinked parent' do + let(:real_parent) { File.join(cwd, 'volumes', 'data').tap { |dir| FileUtils.mkdir_p(dir) } } + let(:link) { File.join(cwd, 'data').tap { |path| File.symlink(real_parent, path) } } + let(:removed) { File.join(link, 'removed-worktree') } + + before { record_session(removed).tap { FileUtils.rm_rf(removed) } } + + it 'is found by the readers' do + expect(ClaudeAgentSDK.list_sessions(directory: removed).map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: removed)&.summary) + .to eq('clean up the old worktree') + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: removed).length).to eq(2) + end + + it 'can be renamed through the directory' do + ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Old worktree', directory: removed) + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: removed).custom_title) + .to eq('Old worktree') + end + + it 'has the project key the directory had while it existed' do + expect(ClaudeAgentSDK.project_key_for_directory(removed)) + .to eq(ClaudeAgentSDK::Sessions.sanitize_path(File.join(real_parent, 'removed-worktree'))) + end + end + + # The session was recorded through a symlinked project directory, so the CLI + # keyed it by the link's target. The target is gone; the link is still + # there, pointing at nothing. + context 'when the directory is a symlink whose target was removed' do + let(:target) { File.join(cwd, 'checkouts', 'project') } + let(:link) { File.join(cwd, 'current') } + + before do + FileUtils.mkdir_p(target) + File.symlink(target, link) + record_session(link) + FileUtils.rm_rf(target) + end + + it 'is found by the readers through the link' do + expect(File.symlink?(link) && !File.exist?(link)).to be(true) + expect(ClaudeAgentSDK.list_sessions(directory: link).map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: link).length).to eq(2) + end + + it 'can be renamed through the link' do + ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Old checkout', directory: link) + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: link).custom_title) + .to eq('Old checkout') + end + + it 'has the project key of the former target, also through a relative link' do + relative = File.join(cwd, 'relative') + File.symlink(File.join('checkouts', 'project'), relative) + + expect(ClaudeAgentSDK.project_key_for_directory(link)).to eq(ClaudeAgentSDK::Sessions.sanitize_path(target)) + expect(ClaudeAgentSDK.project_key_for_directory(File.join(relative, 'packages', 'app'))) + .to eq(ClaudeAgentSDK::Sessions.sanitize_path(File.join(target, 'packages', 'app'))) + end + end + + # The session was recorded through `current/../sibling` while the target of + # `current` existed. A link is followed before the `..` after it is applied, + # so that is the sibling of the TARGET (checkouts/sibling), not of the link + # — and the link, still there, says so after its target is gone. + # + # The expected directory is what Python's os.path.realpath returns for this + # layout once checkouts/project is removed. Obtained by hand, not by the + # suite (Python 3.11.13 on macOS): + # python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' <path> + # printed <cwd>/checkouts/sibling for <cwd>/current/../sibling, + # <cwd>/relative/../sibling, <cwd>/via-target and + # <cwd>/gone/../checkouts/sibling, and for the relative current/../sibling + # (run in <cwd>) and ../../current/../sibling (run in <cwd>/checkouts/sibling). + context 'when the path goes up from a symlink whose target was removed' do + let(:target) { File.join(cwd, 'checkouts', 'project') } + let(:sibling) { File.join(cwd, 'checkouts', 'sibling') } + let(:link) { File.join(cwd, 'current') } + let(:through) { File.join(link, '..', 'sibling') } + let(:sibling_key) { ClaudeAgentSDK::Sessions.sanitize_path(sibling) } + + # The project key of the path while the target existed. Then the target + # goes; checkouts/sibling, where the session was recorded, stays. + let!(:recorded_key) do + FileUtils.mkdir_p(target) + File.symlink(target, link) + record_session(through) + ClaudeAgentSDK.project_key_for_directory(through).tap { FileUtils.rm_rf(target) } + end + + it 'keeps the project key it had while the target existed' do + expect(File.symlink?(link) && !File.exist?(link)).to be(true) + expect(recorded_key).to eq(sibling_key) + expect(ClaudeAgentSDK.project_key_for_directory(through)).to eq(sibling_key) + end + + it 'is found by the readers' do + expect(ClaudeAgentSDK.list_sessions(directory: through).map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: through).length).to eq(2) + end + + it 'can be renamed' do + ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Sibling checkout', directory: through) + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: through).custom_title) + .to eq('Sibling checkout') + end + + it 'goes up from the target of a relative link, and of a link another link names' do + File.symlink(File.join('checkouts', 'project'), File.join(cwd, 'relative')) + File.symlink(File.join('current', '..', 'sibling'), File.join(cwd, 'via-target')) + + expect(ClaudeAgentSDK.project_key_for_directory(File.join(cwd, 'relative', '..', 'sibling'))).to eq(sibling_key) + expect(ClaudeAgentSDK.project_key_for_directory(File.join(cwd, 'via-target'))).to eq(sibling_key) + end + + it 'goes up from a directory that is simply missing by its name' do + expect(ClaudeAgentSDK.project_key_for_directory(File.join(cwd, 'gone', '..', 'checkouts', 'sibling'))) + .to eq(sibling_key) + end + + it 'resolves the path alike when it is given relative to the working directory' do + Dir.chdir(cwd) do + relative = File.join('current', '..', 'sibling') + + expect(ClaudeAgentSDK.project_key_for_directory(relative)).to eq(sibling_key) + expect(ClaudeAgentSDK.list_sessions(directory: relative).map(&:session_id)).to eq([session_id]) + end + Dir.chdir(sibling) do + expect(ClaudeAgentSDK.project_key_for_directory(File.join('..', '..', 'current', '..', 'sibling'))) + .to eq(sibling_key) + end + end + end + + # File.realpath does not expand a leading ~, so a directory named with one + # is resolved as a missing path whether it exists or not. + context 'when the directory is named with a leading ~' do + let(:home) { File.join(cwd, "#{accented}-home") } + + around do |example| + previous_home = ENV.fetch('HOME', nil) # rubocop:disable Style/EnvHome -- raw value; nil when unset + ENV['HOME'] = home + example.run + ensure + previous_home.nil? ? ENV.delete('HOME') : (ENV['HOME'] = previous_home) + end + + it 'names the directory under the home directory, removed or not' do + record_session(File.join(home, 'project')) + + expect(ClaudeAgentSDK.list_sessions(directory: '~/project').map(&:session_id)).to eq([session_id]) + FileUtils.rm_rf(File.join(home, 'project')) + expect(ClaudeAgentSDK.list_sessions(directory: '~/project').map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.project_key_for_directory('~/gone/../project')) + .to eq(ClaudeAgentSDK::Sessions.sanitize_path(File.join(home, 'project'))) + expect(ClaudeAgentSDK.project_key_for_directory("~/#{accented}-gone")) + .to eq(ClaudeAgentSDK::Sessions.sanitize_path(File.join(home, "#{accented}-gone"))) + expect(ClaudeAgentSDK.project_key_for_directory('~')).to eq(ClaudeAgentSDK::Sessions.sanitize_path(home)) + end + end + + # Non-ASCII names in every part the walk puts together: the working + # directory, the path, and the target of a link on the way. + it 'resolves non-ASCII missing paths through a link with a non-ASCII target, absolute and relative' do + checkout = File.join(cwd, "#{accented}-checkout") + project = File.join(cwd, "#{accented}-project").tap { |dir| FileUtils.mkdir_p(dir) } + File.symlink(checkout, File.join(project, 'current')) + app_key = ClaudeAgentSDK::Sessions.sanitize_path(File.join(checkout, "#{accented}-app")) + + expect(ClaudeAgentSDK.project_key_for_directory(File.join(project, 'current', "#{accented}-app"))).to eq(app_key) + Dir.chdir(project) do + expect(ClaudeAgentSDK.project_key_for_directory(File.join('current', "#{accented}-app"))).to eq(app_key) + end + end + + it 'resolves a missing path under symlinks that point at each other without looping' do + one = File.join(cwd, 'one') + two = File.join(cwd, 'two') + File.symlink(two, one) + File.symlink(one, two) + + expect(ClaudeAgentSDK.list_sessions(directory: File.join(one, 'project'))).to eq([]) + end + + it 'reports a directory that never held the session as a missing session' do + typo = File.join(cwd, 'no-such-project') + + expect { ClaudeAgentSDK.rename_session(session_id: session_id, title: 'x', directory: typo) } + .to raise_error(Errno::ENOENT, /Session #{session_id} not found in project directory/) + expect { ClaudeAgentSDK.delete_session(session_id: session_id, directory: typo) } + .to raise_error(Errno::ENOENT, /Session #{session_id} not found in project directory/) + end +end diff --git a/spec/unit/session_fork_title_spec.rb b/spec/unit/session_fork_title_spec.rb new file mode 100644 index 00000000..39ef948d --- /dev/null +++ b/spec/unit/session_fork_title_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# A fork made without a title is named after its source: "<title> (fork)". +# The title is the one the listing shows for the source — the custom title, +# else the AI title, else the first prompt — whichever path forks it. +RSpec.describe 'the title fork_session derives for a fork' do + include_context 'with a Claude config dir' + + let(:session_id) { 'f1e2d3c4-b5a6-4978-8695-a4b3c2d1e0f9' } + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + let(:key) { { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } } + + # [title shown for the source, title of its disk fork, title of its store fork] + def titles(title: nil) + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + yield transcript + transcript.write(transcript_path(session_id)) + store.append(key, transcript.store_entries) + source = ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd) + [source && (source.custom_title || source.first_prompt), fork_title(title), fork_title(title, session_store: store)] + end + + # The title of a fork of the source, made on disk or in the store. + def fork_title(title, **where) + fork = ClaudeAgentSDK.fork_session(session_id: session_id, directory: cwd, title: title, **where) + ClaudeAgentSDK.get_session_info(session_id: fork.session_id, directory: cwd, **where).custom_title + end + + def exchange(transcript) + transcript.prompt(:prompt, 'my first prompt') + transcript.assistant(:answer, transcript.text('Answered.'), parent: :prompt) + end + + it 'is the custom title of the source' do + expect(titles { |t| exchange(t) && t.custom_title('Refactoring') }) + .to eq(['Refactoring', 'Refactoring (fork)', 'Refactoring (fork)']) + end + + it 'is the AI title when the custom title was cleared' do + shown = titles do |t| + exchange(t) + t.custom_title('Old title') + t.ai_title('AI title') + t.custom_title('') + end + + expect(shown).to eq(['AI title', 'AI title (fork)', 'AI title (fork)']) + end + + it 'is the first prompt when the custom title was cleared and there is no AI title' do + shown = titles do |t| + exchange(t) + t.custom_title('Old title') + t.custom_title('') + end + + expect(shown).to eq(['my first prompt', 'my first prompt (fork)', 'my first prompt (fork)']) + end + + it 'is not a customTitle key nested in a tool input' do + shown = titles do |t| + t.prompt(:prompt, 'my first prompt') + t.assistant(:use, t.tool_use('toolu_a', 'RenameThing', { 'customTitle' => 'TOOL ARGUMENT' }), parent: :prompt) + t.tool_result(:result, 'toolu_a', 'renamed', parent: :use) + t.assistant(:answer, t.text('Renamed.'), parent: :result, message: 'msg_02') + end + + expect(shown).to eq(['my first prompt', 'my first prompt (fork)', 'my first prompt (fork)']) + end + + it 'is the first prompt when it lies beyond the 64 KiB head' do + shown = titles do |t| + t.attachment(:hook, parent: nil, hook: 'SessionStart', stdout: 'A' * 92_000) + t.prompt(:prompt, 'my first prompt', parent: :hook) + t.assistant(:answer, t.text('Answered.'), parent: :prompt) + end + + expect(shown).to eq(['my first prompt', 'my first prompt (fork)', 'my first prompt (fork)']) + end + + # Nothing to list it under, still a conversation to fork. + it 'is the default when the source has no title and no prompt' do + shown = titles do |t| + t.meta(:skill_body, '<system-reminder>context</system-reminder>', parent: nil) + t.assistant(:answer, t.text('Noted.'), parent: :skill_body) + end + + expect(shown).to eq([nil, 'Forked session (fork)', 'Forked session (fork)']) + end + + it 'is the title the caller gives' do + expect(titles(title: 'Experiment') { |t| exchange(t) && t.custom_title('Refactoring') }) + .to eq(%w[Refactoring Experiment Experiment]) + end +end diff --git a/spec/unit/session_info_disk_vs_store_spec.rb b/spec/unit/session_info_disk_vs_store_spec.rb new file mode 100644 index 00000000..533380f1 --- /dev/null +++ b/spec/unit/session_info_disk_vs_store_spec.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# The disk reader looks at the first and the last 64 KiB of a transcript (and, +# for the first prompt, at most the first 1 MiB); the store fold sees every +# entry. Same transcript on both sides: written to disk, then imported. +RSpec.describe 'session info from disk and from a store, for one transcript' do + include_context 'with a Claude config dir' + + let(:session_id) { '8f9a0b1c-2d3e-4f50-8a61-7b8c9d0e1f20' } + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + + fields = %i[summary custom_title first_prompt git_branch cwd tag created_at] + padding = 'x' * 1_000 + + # +turns+ exchanges of about 2 KiB each, continuing from +parent+. + filler = lambda do |transcript, parent, turns, name| + turns.times do |turn| + transcript.prompt(:"#{name}_q#{turn}", "filler #{turn} #{padding}", parent: parent) + transcript.assistant(:"#{name}_a#{turn}", transcript.text("reply #{turn} #{padding}"), + parent: :"#{name}_q#{turn}", message: "msg_#{name}_#{turn}") + parent = :"#{name}_a#{turn}" + end + parent + end + + # [disk info, store info] for the transcript the block builds. The session + # was killed before the CLI wrote its exit-time lines (last-prompt, ...), + # unless the block adds them. + def read_both + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + yield transcript + transcript.write(transcript_path(session_id)) + ClaudeAgentSDK.import_session_to_store(session_id: session_id, session_store: store, directory: cwd) + [ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd), + ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd, session_store: store)] + end + + describe 'fields both paths report alike' do + { + 'a large SessionStart hook attachment before the first prompt' => [ + lambda do |t| + t.queue_operations('what does a.rb define?') + t.attachment(:hook, parent: nil, hook: 'SessionStart', stdout: 'A' * 92_000) + t.prompt(:prompt, 'what does a.rb define?', parent: :hook) + t.assistant(:answer, t.text('Foo.'), parent: :prompt) + end, + { first_prompt: 'what does a.rb define?', summary: 'what does a.rb define?' } + ], + 'a slash command in the head and the real prompt beyond it' => [ + lambda do |t| + t.queue_operations('/model') + t.prompt(:command, '<command-name>/model</command-name><command-args></command-args>') + t.attachment(:hook, parent: :command, hook: 'SessionStart', stdout: 'A' * 70_000) + t.prompt(:prompt, 'the actual question', parent: :hook) + t.assistant(:answer, t.text('The answer.'), parent: :prompt) + end, + { first_prompt: 'the actual question', summary: 'the actual question' } + ], + 'a branch switched in the middle of a 300 KB session' => [ + lambda do |t| + t.prompt(:prompt, 'start on main') + t.assistant(:answer, t.text('Started.'), parent: :prompt) + last = filler.call(t, :answer, 40, 'early') + t.git_branch = 'feature/x' + filler.call(t, last, 60, 'late') + end, + { git_branch: 'feature/x', first_prompt: 'start on main' } + ] + }.each do |name, (build, expected)| + it "agrees for #{name}" do + disk, stored = read_both(&build) + + expect(disk).not_to be_nil + expect(fields.to_h { |f| [f, disk.public_send(f)] }).to eq(fields.to_h { |f| [f, stored.public_send(f)] }) + expect(fields.to_h { |f| [f, disk.public_send(f)] }).to include(expected) + end + end + + # An SDK prompt that inlines a document. The prompt, and the entry's own + # timestamp after it on the same line, are found by reading that line to + # its end past the head window. + it 'agrees on the prompt and the created_at of a 300 KB first line' do + disk, stored = read_both do |t| + t.prompt(:prompt, "#{'Q' * 300_000} end") + t.assistant(:answer, t.text('Read.'), parent: :prompt) + end + + expect(disk.first_prompt).to eq("#{'Q' * 200}…") + expect(disk.created_at).not_to be_nil + expect(fields.to_h { |f| [f, disk.public_send(f)] }).to eq(fields.to_h { |f| [f, stored.public_send(f)] }) + end + end + + # An interactive session starts with metadata lines; the snapshot's + # timestamp is nested, the entry itself has none. + it 'takes created_at from the first entry with a timestamp of its own, not from a file-history-snapshot' do + disk, stored = read_both do |t| + t.mode + t.file_history_snapshot(timestamp: '2026-09-01T12:34:56.789Z') + t.prompt(:prompt, 'after the snapshot') + t.assistant(:answer, t.text('Done.'), parent: :prompt) + end + + expect(disk.created_at).to eq(stored.created_at) + expect(Time.at(disk.created_at / 1000).utc.strftime('%F %T')).to eq('2026-09-08 05:19:30') + end + + # The snapshot line is larger than the head window, which therefore cuts + # it: nothing on it can be checked there. Its nested timestamp (written + # before the large backup map) must not be taken for the session's; the + # line is read to its end, and the first entry with a timestamp of its + # own follows it. + it 'does not take created_at from the timestamp nested in a snapshot line the window cuts' do + disk, stored = read_both do |t| + t.mode + t.raw({ 'type' => 'file-history-snapshot', 'messageId' => t.id(:snapshot), + 'snapshot' => { 'messageId' => t.id(:snapshot), 'timestamp' => '2001-01-01T00:00:00.000Z', + 'trackedFileBackups' => { 'lib/big.rb' => { 'backupFileName' => 'x' * 70_000 } } }, + 'isSnapshotUpdate' => false }) + t.prompt(:prompt, 'after the snapshot') + t.assistant(:answer, t.text('Done.'), parent: :prompt) + end + + expect(disk.first_prompt).to eq('after the snapshot') + expect(Time.at(disk.created_at / 1000).utc.year).to eq(2026) # the prompt's, not the snapshot's 2001 + expect(disk.created_at).to eq(stored.created_at) + end + + it 'has no created_at when no line within the first 1 MiB carries a timestamp of its own' do + disk, stored = read_both do |t| + t.mode + t.raw({ 'type' => 'file-history-snapshot', 'messageId' => t.id(:snapshot), + 'snapshot' => { 'messageId' => t.id(:snapshot), 'timestamp' => '2001-01-01T00:00:00.000Z', + 'trackedFileBackups' => { 'lib/big.rb' => { 'backupFileName' => 'x' * 1_100_000 } } }, + 'isSnapshotUpdate' => false }) + t.prompt(:prompt, 'after the snapshot') + t.last_prompt('after the snapshot', leaf: :prompt) + end + + expect(disk.summary).to eq('after the snapshot') + expect(disk.created_at).to be_nil + expect(Time.at(stored.created_at / 1000).utc.year).to eq(2026) + end + + # What only a full read would find. The entries below have more than 64 KiB + # of transcript both before and after them, or sit past the first-prompt + # scan; the CLI re-appends its own metadata when it resumes a session, an + # entry the SDK appended while the session was running is not re-appended. + describe 'fields the disk reader takes from its windows only' do + { + 'a custom-title entry in the middle of the file' => [ + lambda do |t| + t.prompt(:prompt, 'first prompt') + t.assistant(:answer, t.text('Answered.'), parent: :prompt) + last = filler.call(t, :answer, 40, 'early') + t.custom_title('Renamed mid-session') + filler.call(t, last, 60, 'late') + end, + :custom_title, nil, 'Renamed mid-session' + ], + 'a tag entry in the middle of the file' => [ + lambda do |t| + t.prompt(:prompt, 'first prompt') + t.assistant(:answer, t.text('Answered.'), parent: :prompt) + last = filler.call(t, :answer, 40, 'early') + t.tag('experiment') + filler.call(t, last, 60, 'late') + end, + :tag, nil, 'experiment' + ], + 'a last-prompt entry at the head of the file only' => [ + lambda do |t| + t.last_prompt('what I asked last') + t.prompt(:prompt, 'first prompt') + t.assistant(:answer, t.text('Answered.'), parent: :prompt) + filler.call(t, :answer, 100, 'late') + end, + :summary, 'first prompt', 'what I asked last' + ] + }.each do |name, (build, field, on_disk, in_store)| + it "differs in #{field} for #{name}" do + disk, stored = read_both(&build) + + expect(disk.public_send(field)).to eq(on_disk) + expect(stored.public_send(field)).to eq(in_store) + end + end + + it 'does not find a first prompt whose line ends past the first 1 MiB' do + disk, stored = read_both do |t| + t.queue_operations('the late question') + t.attachment(:hook, parent: nil, hook: 'SessionStart', stdout: 'A' * 1_100_000) + t.prompt(:prompt, 'the late question', parent: :hook) + t.assistant(:answer, t.text('Answered.'), parent: :prompt) + end + + expect(disk).to be_nil # no title, no last-prompt, no prompt in reach: not listed + expect(stored.first_prompt).to eq('the late question') + end + end + + describe 'the first-prompt scan past the head' do + it 'finds a prompt on the last line of a file that does not end with a newline' do + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + transcript.attachment(:hook, parent: nil, hook: 'SessionStart', stdout: 'A' * 92_000) + transcript.prompt(:prompt, 'cut short', parent: :hook) + File.binwrite(transcript.write(transcript_path(session_id)), transcript.to_jsonl.chomp) + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd)&.first_prompt).to eq('cut short') + end + + it 'keeps the slash command as the fallback when no real prompt follows' do + disk, stored = read_both do |t| + t.prompt(:command, '<command-name>/model</command-name><command-args></command-args>') + t.attachment(:hook, parent: :command, hook: 'SessionStart', stdout: 'A' * 92_000) + t.assistant(:answer, t.text('Model set.'), parent: :hook) + end + + expect(disk.first_prompt).to eq('/model') + expect(stored.first_prompt).to eq('/model') + end + + it 'does not take a later tool result, meta entry or compact summary for the prompt' do + disk, stored = read_both do |t| + t.attachment(:hook, parent: nil, hook: 'SessionStart', stdout: 'A' * 92_000) + t.meta(:reminder, '<system-reminder>context</system-reminder>', parent: :hook) + t.assistant(:use_a, t.tool_use('toolu_a'), parent: :reminder) + t.tool_result(:result_a, 'toolu_a', 'file contents', parent: :use_a) + t.prompt(:compact, 'This session is being continued from a previous conversation', + parent: :result_a, isCompactSummary: true) + t.prompt(:prompt, 'the real prompt', parent: :compact) + end + + expect(disk.first_prompt).to eq('the real prompt') + expect(stored.first_prompt).to eq('the real prompt') + end + end +end diff --git a/spec/unit/session_lite_reader_windows_spec.rb b/spec/unit/session_lite_reader_windows_spec.rb new file mode 100644 index 00000000..427c8b5b --- /dev/null +++ b/spec/unit/session_lite_reader_windows_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# The disk listing scans the first and the last 64 KiB of a transcript for a +# handful of fields. The scan steps through the window by offset; Ruby keeps +# no character index for a UTF-8 String that is not ASCII-only, so on such a +# window every offset step walks the bytes from the start. The windows are +# therefore scanned as bytes, and only what is returned is UTF-8. +RSpec.describe 'the 64 KiB windows of the disk session reader' do + include_context 'with a Claude config dir' + + let(:session_id) { 'a9b8c7d6-e5f4-4a3b-8c2d-1e0f9a8b7c6d' } + let(:prompt) { '请总结这个仓库的结构' } + let(:title) { '仓库结构总结' } + let(:branch) { '功能/会话读取' } + let(:answer) { '好的。' } + let(:last_prompt) { '最后一个问题' } + + # A session of about 350 KB of CJK text: neither window is ASCII-only. + # +head_pad+ ASCII bytes in front of the first answer and +tail_pad+ after + # the last prompt shift where the two windows are cut. + def transcript_bytes(head_pad, tail_pad) + transcript = CLITranscript.new(session_id: session_id, cwd: cwd, git_branch: branch) + transcript.queue_operations(prompt) + transcript.prompt(:prompt, prompt) + parent = :prompt + 60.times do |turn| + text = transcript.text("#{'x' * head_pad if turn.zero?}#{answer * 500}") + transcript.assistant(:"answer_#{turn}", text, parent: parent, message: "msg_#{turn}") + transcript.prompt(:"next_#{turn}", "继续 #{turn} #{'请继续说明' * 100}", parent: :"answer_#{turn}") + parent = :"next_#{turn}" + end + transcript.custom_title(title) + transcript.tag('重要') + transcript.last_prompt("#{last_prompt}#{'x' * tail_pad}", leaf: parent) + transcript.to_jsonl + end + + def cut_inside_a_character?(bytes) + window = ClaudeAgentSDK::Sessions::LITE_READ_BUF_SIZE + [bytes.byteslice(0, window), bytes.byteslice(-window, window)].none? do |part| + part.force_encoding('UTF-8').valid_encoding? + end + end + + # The smallest of +pads+ for which the block names a UTF-8 continuation + # byte (the second or third byte of one of the fixture's CJK characters). + def smallest_pad(pads) + pads.find { |pad| yield(pad).between?(0x80, 0xBF) } || raise('no CJK text where the window is cut') + end + + # [head pad, tail pad] that cut both windows of the fixture inside a + # character, read off its unpadded bytes. + # + # cwd is on every line and is a temp dir, whose length differs between + # machines and from run to run. It decides where the windows begin and + # end, and that can be deep in the ASCII keys and ids of a line, where a + # byte or two of padding changes nothing. So the pads are computed: + # + # * the head window ends inside a character when the byte after it is a + # continuation byte; +pad+ bytes in front of the first answer bring the + # byte from +pad+ places earlier there (it must lie behind the padding); + # * the tail window starts inside a character when its first byte is one; + # +pad+ bytes after the last prompt move its start +pad+ places on. + # Padding in front of the window moves its start and the text alike, so + # the head pad does not come into it. + def window_pads + plain = transcript_bytes(0, 0).b + window = ClaudeAgentSDK::Sessions::LITE_READ_BUF_SIZE + tail_start = plain.bytesize - window + [ + smallest_pad(0...(window - plain.index(answer.b))) { |pad| plain.getbyte(window - pad) }, + smallest_pad(0...(plain.rindex(last_prompt.b) - tail_start)) { |pad| plain.getbyte(tail_start + pad) } + ] + end + + # The fixture with both of its windows cut inside a 3-byte character. + def write_transcript + bytes = transcript_bytes(*window_pads) + expect(cut_inside_a_character?(bytes)).to be(true), 'the fixture does not cut both windows inside a character' + + path = transcript_path(session_id) + FileUtils.mkdir_p(File.dirname(path)) + File.binwrite(path, bytes) + path + end + + def utf8_strings(info) + %i[summary custom_title first_prompt git_branch cwd tag].to_h { |field| [field, info.public_send(field)] } + end + + it 'scans the windows as bytes' do + path = write_transcript + + windows = ClaudeAgentSDK::Sessions.read_head_tail(path, File.size(path)) + + expect(windows.map(&:encoding)).to eq([Encoding::BINARY, Encoding::BINARY]) + end + + it 'returns every field as a valid UTF-8 String with its value' do + write_transcript + + info = ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd) + + expect(File.size(transcript_path(session_id))).to be > 2 * ClaudeAgentSDK::Sessions::LITE_READ_BUF_SIZE + expect(utf8_strings(info)).to eq(summary: title, custom_title: title, first_prompt: prompt, git_branch: branch, + cwd: cwd, tag: '重要') + expect(utf8_strings(info).values.map(&:encoding).uniq).to eq([Encoding::UTF_8]) + expect(utf8_strings(info).values).to all(be_valid_encoding) + end + + # The value of a field on a line the window cut off, and a value that is + # not a well-formed JSON string (a raw tab), come back as slices of the + # window rather than from a JSON parse. + describe 'values returned as slices of the window' do + let(:window) { "{\"type\":\"custom-title\",\"customTitle\":\"#{title}\",\"sessionId\":\"x\"}\n".b } + + it 'returns a value from a line cut by the window as UTF-8' do + cut = window[0, window.index('sessionId'.b)] + + value = ClaudeAgentSDK::Sessions.extract_top_level_string_field(cut, 'customTitle', last: true) + + expect([value, value.encoding, value.valid_encoding?]).to eq([title, Encoding::UTF_8, true]) + end + + it 'returns a value that does not parse as a JSON string as UTF-8' do + raw = window.sub(title.b, "#{title}\t原文".b) + + value = ClaudeAgentSDK::Sessions.extract_json_string_field(raw, 'customTitle', last: true) + + expect([value, value.encoding, value.valid_encoding?]).to eq(["#{title}\t原文", Encoding::UTF_8, true]) + end + end +end diff --git a/spec/unit/session_long_path_fallback_spec.rb b/spec/unit/session_long_path_fallback_spec.rb new file mode 100644 index 00000000..0600bda7 --- /dev/null +++ b/spec/unit/session_long_path_fallback_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# A project path longer than 200 sanitized characters is stored under its +# first 200 characters plus a hash of the whole path. Older CLIs hashed with +# Bun.hash, so when the exact name is missing the SDK looks for a directory +# with the same 200-character prefix — which every sibling path shares. +RSpec.describe 'project directory lookup for paths over 200 characters' do + include_context 'with a Claude config dir' + + let(:session_id) { '6d7e8f90-a1b2-4c3d-8e4f-5a6b7c8d9e0f' } + let(:tenant) { File.join(cwd, "tenant-#{'a' * 200}") } + let(:project_a) { File.join(tenant, 'one').tap { |dir| FileUtils.mkdir_p(dir) } } + let(:project_b) { File.join(tenant, 'two').tap { |dir| FileUtils.mkdir_p(dir) } } + + before { allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees) { |path| [path] } } + + def session_recorded_in(directory, id = session_id) + transcript = CLITranscript.new(session_id: id, cwd: directory) + transcript.queue_operations('the private question of project one') + transcript.prompt(:prompt, 'the private question of project one') + transcript.assistant(:answer, transcript.text('Answered.'), parent: :prompt) + transcript.last_prompt('the private question of project one', leaf: :answer) + transcript + end + + # The name an older CLI gave the project dir of +directory+: same 200 + # characters, another hash. + def old_cli_project_dir(directory, hash = 'bunhash1') + name = ClaudeAgentSDK::Sessions.sanitize_path(directory) + File.join(config_dir, 'projects', "#{name[0, 200]}-#{hash}") + end + + context 'when a sibling project shares the first 200 characters' do + let!(:transcript_a) { session_recorded_in(project_a).write(transcript_path(session_id, project_a)) } + + it 'has two different project keys with one prefix' do + key_a = ClaudeAgentSDK.project_key_for_directory(project_a) + key_b = ClaudeAgentSDK.project_key_for_directory(project_b) + + expect(key_a).not_to eq(key_b) + expect(key_a[0, 201]).to eq(key_b[0, 201]) + end + + it 'does not list the sibling project for a directory without sessions' do + expect(ClaudeAgentSDK.list_sessions(directory: project_b)).to eq([]) + expect(ClaudeAgentSDK.list_sessions(directory: project_a).map(&:session_id)).to eq([session_id]) + end + + it "does not read the sibling project's session through that directory" do + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: project_b)).to be_nil + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: project_b)).to eq([]) + end + + it "does not write to the sibling project's session through that directory" do + before_rename = File.binread(transcript_a) + + expect { ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Mine now', directory: project_b) } + .to raise_error(Errno::ENOENT) + expect { ClaudeAgentSDK.delete_session(session_id: session_id, directory: project_b) } + .to raise_error(Errno::ENOENT) + expect(File.binread(transcript_a)).to eq(before_rename) + end + end + + context 'when an older CLI named the project dir with another hash' do + it 'still finds it when a transcript in it records the directory as its cwd' do + session_recorded_in(project_a).write(File.join(old_cli_project_dir(project_a), "#{session_id}.jsonl")) + + expect(ClaudeAgentSDK.list_sessions(directory: project_a).map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: project_a).length).to eq(2) + end + + it 'does not use a directory whose transcripts record no cwd' do + transcript = CLITranscript.new(session_id: session_id, cwd: project_a) + transcript.ai_title('Titled, with no entry that carries a cwd') + transcript.last_prompt('the private question of project one') + transcript.write(File.join(old_cli_project_dir(project_a), "#{session_id}.jsonl")) + + expect(ClaudeAgentSDK.list_sessions(directory: project_a)).to eq([]) + end + + it 'does not choose between two directories that both record the directory' do + %w[bunhash1 bunhash2].each do |hash| + session_recorded_in(project_a).write(File.join(old_cli_project_dir(project_a, hash), "#{session_id}.jsonl")) + end + + expect(ClaudeAgentSDK.list_sessions(directory: project_a)).to eq([]) + end + end + + # Identity comes only from a parsed top-level cwd of a complete line, and + # it is checked per transcript: a directory found by the prefix fallback + # may hold sessions of several paths sharing the prefix. + context 'when the transcripts in an old-hash directory do not all belong to the path' do + let(:other_session_id) { '0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d' } + + # A's session opens with a tool call over 64 KiB whose input names B's + # directory as its cwd; A's own top-level cwd follows the input on the + # same line, past the window. + it 'does not take a cwd nested in a tool input on a line the window cuts' do + transcript = CLITranscript.new(session_id: session_id, cwd: project_a) + transcript.assistant(:call, transcript.tool_use('toolu_1', 'Bash', + { 'cwd' => project_b, 'command' => "cat #{'x' * 70_000}" }), + parent: nil) + transcript.prompt(:prompt, 'the private question of project one', parent: :call) + file = transcript.write(File.join(old_cli_project_dir(project_a), "#{session_id}.jsonl")) + before = File.binread(file) + + expect(ClaudeAgentSDK.list_sessions(directory: project_b)).to eq([]) + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: project_b)).to eq([]) + expect { ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Mine now', directory: project_b) } + .to raise_error(Errno::ENOENT) + expect(File.binread(file)).to eq(before) + end + + context 'with one session of each path in it' do + let!(:file_a) do + session_recorded_in(project_a).write(File.join(old_cli_project_dir(project_a), "#{session_id}.jsonl")) + end + + before do + session_recorded_in(project_b, other_session_id) + .write(File.join(old_cli_project_dir(project_a), "#{other_session_id}.jsonl")) + end + + it "lists each path's own session only" do + expect(ClaudeAgentSDK.list_sessions(directory: project_b).map(&:session_id)).to eq([other_session_id]) + expect(ClaudeAgentSDK.list_sessions(directory: project_a).map(&:session_id)).to eq([session_id]) + end + + it "reads and renames only the path's own session" do + before = File.binread(file_a) + + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: project_b)).to eq([]) + expect(ClaudeAgentSDK.get_session_messages(session_id: other_session_id, directory: project_b).length) + .to eq(2) + expect { ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Mine now', directory: project_b) } + .to raise_error(Errno::ENOENT) + expect(File.binread(file_a)).to eq(before) + end + + it 'keeps to the same rule when the path is reached as a worktree' do + main = File.join(cwd, 'main').tap { |dir| FileUtils.mkdir_p(dir) } + allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees).and_return([main, project_b]) + + expect(ClaudeAgentSDK.list_sessions(directory: main).map(&:session_id)).to eq([other_session_id]) + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: main)).to eq([]) + end + + # Both paths are worktrees of one repository. The old-hash directory is + # the project dir of each of them, so it is read for each: once, it + # gave the sessions of the first path and none of the second. + context 'when both paths are worktrees of one repository' do + before { allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees).and_return([project_a, project_b]) } + + it 'lists the session of each worktree once, from either of them' do + [project_a, project_b].each do |directory| + listed = ClaudeAgentSDK.list_sessions(directory: directory) + + expect(listed.map { |session| [session.session_id, session.cwd] }) + .to contain_exactly([session_id, project_a], [other_session_id, project_b]) + end + end + + it "still lists only a path's own session with include_worktrees: false" do + expect(ClaudeAgentSDK.list_sessions(directory: project_a, include_worktrees: false).map(&:session_id)) + .to eq([session_id]) + expect(ClaudeAgentSDK.list_sessions(directory: project_b, include_worktrees: false).map(&:session_id)) + .to eq([other_session_id]) + end + + it 'reads the shared directory once for each path' do + shared = old_cli_project_dir(project_a) + allow(ClaudeAgentSDK::Sessions).to receive(:read_sessions_from_dir).and_call_original + + ClaudeAgentSDK.list_sessions(directory: project_a) + + expect(ClaudeAgentSDK::Sessions).to have_received(:read_sessions_from_dir).with(shared, project_a).once + expect(ClaudeAgentSDK::Sessions).to have_received(:read_sessions_from_dir).with(shared, project_b).once + expect(ClaudeAgentSDK::Sessions).to have_received(:read_sessions_from_dir).twice + end + end + end + end +end diff --git a/spec/unit/session_meta_leaf_spec.rb b/spec/unit/session_meta_leaf_spec.rb new file mode 100644 index 00000000..b420efc7 --- /dev/null +++ b/spec/unit/session_meta_leaf_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# A session can end on an entry the CLI wrote itself: the body of a slash +# command or skill, a stop-hook message, a system reminder (isMeta: true), +# never answered because the user closed the session first. The conversation +# before it is still there to read. +RSpec.describe 'reading a session whose last entry is a meta injection' do + include_context 'with a Claude config dir' + + let(:session_id) { '9e4b7a61-0c2d-4f38-8b15-6a7c8d9e0f12' } + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + + def transcript + @transcript ||= CLITranscript.new(session_id: session_id, cwd: cwd) + end + + def exchange + transcript.prompt(:prompt, 'What does a.rb define?') + transcript.assistant(:answer, transcript.text('It defines Foo.'), parent: :prompt) + transcript.system(:stop_hook, parent: :answer) + end + + def from_disk + transcript.write(transcript_path(session_id)) + transcript.labels(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd)) + end + + def from_store + key = { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } + store.append(key, transcript.store_entries) + transcript.labels( + ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd, session_store: store) + ) + end + + # prompt ─ answer ─ stop_hook ─ reminder (isMeta, the last entry) + context 'when the meta entry is the last entry of the file' do + before do + exchange + transcript.meta(:reminder, '<system-reminder>The task list is empty.</system-reminder>', parent: :stop_hook) + end + + it('returns the conversation before it from disk') { expect(from_disk).to eq(%w[prompt answer]) } + it('returns the conversation before it from a store') { expect(from_store).to eq(%w[prompt answer]) } + end + + # prompt ─ answer ─ stop_hook ─ command ─ skill_body (isMeta) ─ attachment (the last entry) + context 'when an attachment hangs off the meta entry' do + before do + exchange + transcript.prompt(:command, '<command-name>/simplify</command-name>', parent: :stop_hook) + transcript.meta(:skill_body, [transcript.text('# Simplify: review the changed code')], parent: :command) + transcript.attachment(:listing, parent: :skill_body, type: 'skill_listing') + end + + it('returns the conversation before it from disk') { expect(from_disk).to eq(%w[prompt answer command]) } + it('returns the conversation before it from a store') { expect(from_store).to eq(%w[prompt answer command]) } + end + + # The same ending, plus a later branch with nothing to show: a subagent + # recorded in the main transcript (isSidechain: true), written after the + # meta entry. Its leaf is the latest in the file and its path to the root + # holds no visible message. + context 'when a sidechain branch was written after the meta entry' do + before do + exchange + transcript.meta(:reminder, '<system-reminder>The task list is empty.</system-reminder>', parent: :stop_hook) + transcript.prompt(:side_prompt, 'Explore the repository', parent: nil, isSidechain: true) + transcript.assistant(:side_answer, transcript.text('Explored.'), parent: :side_prompt, isSidechain: true) + end + + it('reads the conversation, not the sidechain, from disk') { expect(from_disk).to eq(%w[prompt answer]) } + it('reads the conversation, not the sidechain, from a store') { expect(from_store).to eq(%w[prompt answer]) } + end + + # A corrupt transcript: the parentUuid links of the later branch form a cycle. + it 'reads the conversation when the other branch loops back on itself' do + exchange + transcript.meta(:reminder, '<system-reminder>The task list is empty.</system-reminder>', parent: :stop_hook) + transcript.prompt(:loop_a, 'Explore the repository', parent: :loop_b, isSidechain: true) + transcript.assistant(:loop_b, transcript.text('Explored.'), parent: :loop_a, isSidechain: true) + transcript.attachment(:loop_tail, parent: :loop_b, isSidechain: true) + + expect(from_disk).to eq(%w[prompt answer]) + end + + it 'still returns nothing for a transcript written by a teammate throughout' do + teammate = { teamName: 'crew', agentName: 'reviewer' } + transcript.prompt(:prompt, 'Review the diff', **teammate) + transcript.assistant(:answer, transcript.text('Looks fine.'), parent: :prompt, **teammate) + + expect(from_disk).to eq([]) + end + + it 'still returns nothing for a transcript without any user or assistant entry' do + transcript.attachment(:environment, parent: nil, type: 'environment') + transcript.attachment(:model, parent: :environment, type: 'model') + + expect(from_disk).to eq([]) + end + + it 'prefers the answered branch when the conversation went on after a meta entry' do + exchange + transcript.meta(:reminder, '<system-reminder>The task list is empty.</system-reminder>', parent: :stop_hook) + transcript.prompt(:follow_up, 'And b.rb?', parent: :reminder) + transcript.assistant(:second_answer, transcript.text('It defines Bar.'), parent: :follow_up, message: 'msg_02') + + expect(from_disk).to eq(%w[prompt answer follow_up second_answer]) + end +end diff --git a/spec/unit/session_parallel_tool_results_spec.rb b/spec/unit/session_parallel_tool_results_spec.rb new file mode 100644 index 00000000..b99cdb97 --- /dev/null +++ b/spec/unit/session_parallel_tool_results_spec.rb @@ -0,0 +1,307 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# The CLI writes one assistant entry per tool_use and parents each tool_result +# on the entry holding ITS tool_use. With parallel tool calls only the result +# written last is an ancestor of the leaf; the others hang off the chain as +# siblings of the next tool_use entry. A reader that follows one parentUuid +# path returns tool_use blocks whose results are on disk but not in its answer. +RSpec.describe 'session readers and parallel tool calls' do + include_context 'with a Claude config dir' + + let(:session_id) { '0d9f6a3c-2b1e-4c7d-8a5f-3e2d1c0b9a88' } + let(:agent_id) { 'a1b2c3d4e5f60718' } + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + + # Two tool calls of one API message, in the order the CLI writes them: + # + # prompt + # └─ think assistant[thinking] msg_01 + # └─ use_a assistant[tool_use a] msg_01 + # ├─ use_b assistant[tool_use b] msg_01 + # │ ├─ pre attachment PreToolUse + # │ └─ result_b user[tool_result b] + # │ └─ post_b attachment PostToolUse + # │ └─ answer assistant[text] msg_02 <- leaf + # └─ result_a user[tool_result a] <- off the leaf's path + # └─ post_a attachment PostToolUse + # └─ pre_b attachment PreToolUse + def two_tool_batch(transcript) + transcript.prompt(:prompt, 'Read a.rb and b.rb') + transcript.assistant(:think, transcript.thinking, parent: :prompt) + transcript.assistant(:use_a, transcript.tool_use('toolu_a'), parent: :think) + transcript.assistant(:use_b, transcript.tool_use('toolu_b'), parent: :use_a) + transcript.attachment(:pre, parent: :use_b, hook: 'PreToolUse') + transcript.tool_result(:result_a, 'toolu_a', 'contents of a.rb', parent: :use_a) + transcript.attachment(:post_a, parent: :result_a) + transcript.attachment(:pre_b, parent: :post_a, hook: 'PreToolUse') + transcript.tool_result(:result_b, 'toolu_b', 'contents of b.rb', parent: :use_b) + transcript.attachment(:post_b, parent: :result_b) + transcript.assistant(:answer, transcript.text('Both files read.'), parent: :post_b, message: 'msg_02') + transcript + end + + def rewound_tool_call(transcript) + transcript.prompt(:prompt, 'Read a.rb') + transcript.assistant(:use, transcript.tool_use('toolu_one'), parent: :prompt) + transcript.tool_result(:result_old, 'toolu_one', 'old contents', parent: :use) + transcript.assistant(:abandoned, transcript.text('Abandoned.'), parent: :result_old, message: 'msg_02') + transcript.tool_result(:result_new, 'toolu_one', 'new contents', parent: :use) + transcript.assistant(:current, transcript.text('Current.'), parent: :result_new, message: 'msg_03') + transcript + end + + def main_transcript + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + transcript.queue_operations('Read a.rb and b.rb') + yield transcript + transcript.last_prompt('Read a.rb and b.rb', leaf: :answer) + transcript + end + + def tool_result_ids(messages) + messages.flat_map(&:content_blocks).grep(ClaudeAgentSDK::ToolResultBlock).map(&:tool_use_id) + end + + let(:batch_order) { %w[prompt think use_a use_b result_a result_b answer] } + + describe 'get_session_messages' do + it 'returns every result of a parallel batch from a transcript on disk' do + transcript = main_transcript { |t| two_tool_batch(t) } + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(batch_order) + expect(tool_result_ids(messages)).to eq(%w[toolu_a toolu_b]) + end + + it 'returns every result of a parallel batch from a session store' do + transcript = main_transcript { |t| two_tool_batch(t) } + key = { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } + store.append(key, transcript.store_entries) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd, session_store: store) + + expect(transcript.labels(messages)).to eq(batch_order) + end + + it 'returns each result once when a retried mirror batch repeated it in the store' do + transcript = main_transcript { |t| two_tool_batch(t) } + key = { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } + store.append(key, transcript.store_entries) + store.append(key, transcript.store_entries.last(8)) # the retry overlaps the first write + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd, session_store: store) + + expect(transcript.labels(messages)).to eq(batch_order) + end + + # One API message, three tool calls, streamed: the second call finished + # before the third tool_use was written (so that entry is parented under + # result_b), and the first call finished last. + # + # use_a ─ use_b ─ result_b ─ post_b ─ use_c ─ result_c ─ post_c ─ answer + # └─ result_a (written after use_c) + it 'keeps a late result with its batch: before the first result on the chain' do + transcript = main_transcript do |t| + t.prompt(:prompt, 'Read a.rb, b.rb and c.rb') + t.assistant(:use_a, t.tool_use('toolu_a'), parent: :prompt) + t.assistant(:use_b, t.tool_use('toolu_b'), parent: :use_a) + t.attachment(:pre, parent: :use_b, hook: 'PreToolUse') + t.tool_result(:result_b, 'toolu_b', 'contents of b.rb', parent: :use_b) + t.attachment(:post_b, parent: :result_b) + t.assistant(:use_c, t.tool_use('toolu_c'), parent: :post_b) + t.attachment(:pre_c, parent: :use_c, hook: 'PreToolUse') + t.tool_result(:result_a, 'toolu_a', 'contents of a.rb', parent: :use_a) + t.attachment(:post_a, parent: :result_a) + t.attachment(:pre_x, parent: :post_a, hook: 'PreToolUse') + t.tool_result(:result_c, 'toolu_c', 'contents of c.rb', parent: :use_c) + t.attachment(:post_c, parent: :result_c) + t.assistant(:answer, t.text('All three read.'), parent: :post_c, message: 'msg_02') + end + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use_a use_b result_a result_b use_c result_c answer]) + end + + it 'puts the results of a three-call batch back in the order they were written' do + transcript = main_transcript do |t| + t.prompt(:prompt, 'Read a.rb, b.rb and c.rb') + t.assistant(:use_a, t.tool_use('toolu_a'), parent: :prompt) + t.assistant(:use_b, t.tool_use('toolu_b'), parent: :use_a) + t.assistant(:use_c, t.tool_use('toolu_c'), parent: :use_b) + t.attachment(:pre, parent: :use_c, hook: 'PreToolUse') + t.tool_result(:result_b, 'toolu_b', 'contents of b.rb', parent: :use_b) # b finished first + t.attachment(:post_b, parent: :result_b) + t.tool_result(:result_a, 'toolu_a', 'contents of a.rb', parent: :use_a) + t.attachment(:post_a, parent: :result_a) + t.tool_result(:result_c, 'toolu_c', 'contents of c.rb', parent: :use_c) + t.attachment(:post_c, parent: :result_c) + t.assistant(:answer, t.text('All three read.'), parent: :post_c, message: 'msg_02') + end + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use_a use_b use_c result_b result_a result_c answer]) + end + + # A rewind leaves the abandoned prompt in the file as a second child of + # the entry the conversation was rewound to. It is a user sibling too, + # but it answers no tool call: the branch it starts was dropped. + it 'does not pull in the prompt of a branch abandoned by a rewind' do + transcript = main_transcript do |t| + t.prompt(:prompt, 'Read a.rb') + t.assistant(:use_a, t.tool_use('toolu_a'), parent: :prompt) + t.tool_result(:result_a, 'toolu_a', 'contents of a.rb', parent: :use_a) + t.assistant(:summary, t.text('a.rb defines Foo.'), parent: :result_a, message: 'msg_02') + t.prompt(:abandoned, 'Now delete it', parent: :summary) + t.assistant(:abandoned_reply, t.text('Deleted.'), parent: :abandoned, message: 'msg_03') + t.prompt(:kept, 'Now rename it', parent: :summary) + t.assistant(:answer, t.text('Renamed.'), parent: :kept, message: 'msg_04') + end + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use_a result_a summary kept answer]) + end + + # A rewind to the tool call: the call stays, its old result and the + # answer that followed it are the abandoned branch, and the call was + # answered again on the branch the conversation went on with. + # + # use ─ result_old ─ abandoned + # └─ result_new ─ current + it 'does not pull in the old result of a tool call the conversation answered again' do + transcript = main_transcript do |t| + rewound_tool_call(t) + t.assistant(:answer, t.text('Done.'), parent: :current, message: 'msg_04') + end + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use result_new current answer]) + end + + it 'does not pull in the old result of a tool call the conversation answered again, from a store' do + transcript = main_transcript do |t| + rewound_tool_call(t) + t.assistant(:answer, t.text('Done.'), parent: :current, message: 'msg_04') + end + store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id }, + transcript.store_entries) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd, session_store: store) + + expect(transcript.labels(messages)).to eq(%w[prompt use result_new current answer]) + end + + # Two results for one call that the conversation did not take up (the + # tool was run twice): one of them comes back, the first written. + it 'pulls in at most one result per tool call' do + transcript = main_transcript do |t| + t.prompt(:prompt, 'Read a.rb and b.rb') + t.assistant(:use_a, t.tool_use('toolu_a'), parent: :prompt) + t.assistant(:use_b, t.tool_use('toolu_b'), parent: :use_a) + t.tool_result(:result_a, 'toolu_a', 'contents of a.rb', parent: :use_a) + t.tool_result(:result_a_again, 'toolu_a', 'contents of a.rb', parent: :use_a) + t.tool_result(:result_b, 'toolu_b', 'contents of b.rb', parent: :use_b) + t.assistant(:answer, t.text('Both files read.'), parent: :result_b, message: 'msg_02') + end + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use_a use_b result_a result_b answer]) + end + + # Not a shape the CLI writes (a result is parented on its own tool_use + # entry); it pins the check itself. A user child that carries a + # tool_result for a call the conversation does not contain stays out. + it 'does not pull in a tool_result that answers no tool_use of the conversation' do + transcript = main_transcript do |t| + t.prompt(:prompt, 'Read a.rb') + t.assistant(:use_a, t.tool_use('toolu_a'), parent: :prompt) + t.tool_result(:stray, 'toolu_from_another_branch', 'unrelated', parent: :use_a) + t.tool_result(:result_a, 'toolu_a', 'contents of a.rb', parent: :use_a) + t.assistant(:answer, t.text('a.rb defines Foo.'), parent: :result_a, message: 'msg_02') + end + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use_a result_a answer]) + end + + it 'leaves a conversation without parallel calls as it was' do + transcript = main_transcript do |t| + t.prompt(:prompt, 'Read a.rb') + t.assistant(:use_a, t.tool_use('toolu_a'), parent: :prompt) + t.attachment(:pre, parent: :use_a, hook: 'PreToolUse') + t.tool_result(:result_a, 'toolu_a', 'contents of a.rb', parent: :use_a) + t.attachment(:post_a, parent: :result_a) + t.assistant(:answer, t.text('a.rb defines Foo.'), parent: :post_a, message: 'msg_02') + end + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use_a result_a answer]) + end + end + + # Every entry of a real subagent transcript carries isSidechain: true, and + # its parallel batches have the same shape as the main transcript's. + describe 'get_subagent_messages' do + def subagent_transcript + two_tool_batch(CLITranscript.new(session_id: session_id, cwd: cwd, agent_id: agent_id)) + end + + def parent_session + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + transcript.prompt(:prompt, 'Use a subagent to read a.rb and b.rb') + transcript + end + + it 'returns every result of a parallel batch from a subagent transcript on disk' do + parent_session.write(transcript_path(session_id)) + transcript = subagent_transcript + transcript.write(subagent_transcript_path(session_id, agent_id)) + + messages = ClaudeAgentSDK.get_subagent_messages(session_id: session_id, agent_id: agent_id, directory: cwd) + + expect(transcript.entries.grep(Hash)).to all(include('isSidechain' => true)) + expect(transcript.labels(messages)).to eq(batch_order) + end + + it 'does not pull in the old result of a tool call the subagent answered again' do + parent_session.write(transcript_path(session_id)) + transcript = rewound_tool_call(CLITranscript.new(session_id: session_id, cwd: cwd, agent_id: agent_id)) + transcript.write(subagent_transcript_path(session_id, agent_id)) + + messages = ClaudeAgentSDK.get_subagent_messages(session_id: session_id, agent_id: agent_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use result_new current]) + end + + it 'returns every result of a parallel batch from a subagent transcript in a session store' do + transcript = subagent_transcript + key = { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } + store.append(key, parent_session.store_entries) + store.append(key.merge('subpath' => "subagents/agent-#{agent_id}"), transcript.store_entries) + + messages = ClaudeAgentSDK.get_subagent_messages(session_id: session_id, agent_id: agent_id, directory: cwd, + session_store: store) + + expect(transcript.labels(messages)).to eq(batch_order) + end + end +end diff --git a/spec/unit/session_project_dir_name_spec.rb b/spec/unit/session_project_dir_name_spec.rb new file mode 100644 index 00000000..791b991d --- /dev/null +++ b/spec/unit/session_project_dir_name_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# The CLI names a project directory with JavaScript's +# cwd.replace(/[^a-zA-Z0-9]/g, "-") — no `u` flag, so the replacement runs per +# UTF-16 code unit and a character outside the BMP (an emoji, a CJK +# Extension B ideograph) becomes TWO hyphens. The expected names below are +# the ones CLI 2.1.286 created for these directories. +RSpec.describe 'project directory names for paths with non-BMP characters' do + rocket = [0x1F680].pack('U') # an emoji + ideograph = [0x20BB7].pack('U') # CJK Extension B + + describe 'Sessions.sanitize_path' do + { + 'an emoji' => ["/work/proj-#{rocket}-x", '-work-proj----x'], + 'a CJK Extension B ideograph' => ["/work/proj-#{ideograph}-x", '-work-proj----x'], + 'two of them in a row' => ["/work/#{rocket}#{ideograph}", '-work-----'], + 'BMP characters only' => ['/work/proj-日本語-x', '-work-proj-----x'] + }.each do |label, (path, name)| + it "names the directory as the CLI does for #{label}" do + expect(ClaudeAgentSDK::Sessions.sanitize_path(path)).to eq(name) + end + end + + it 'truncates a long name after the two hyphens and keeps the hash of the path' do + path = "/work/proj-#{rocket}-#{'a' * 190}" + + name = ClaudeAgentSDK::Sessions.sanitize_path(path) + + expect(name).to eq("-work-proj----#{'a' * 186}-#{ClaudeAgentSDK::Sessions.simple_hash(path)}") + expect(name.length).to eq(201 + ClaudeAgentSDK::Sessions.simple_hash(path).length) + end + end + + context 'with a transcript in the directory the CLI created' do + include_context 'with a Claude config dir' + + let(:session_id) { '4a5b6c7d-8e9f-4012-a345-b6c7d8e9f012' } + let(:directory) { File.join(cwd, "proj-#{rocket}-x").tap { |dir| FileUtils.mkdir_p(dir) } } + let(:cli_project_dir) do + File.join(config_dir, 'projects', "#{ClaudeAgentSDK::Sessions.sanitize_path(cwd)}-proj----x") + end + + before do + allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees) { |path| [path] } + transcript = CLITranscript.new(session_id: session_id, cwd: directory) + transcript.queue_operations('hi') + transcript.prompt(:prompt, 'hi') + transcript.assistant(:answer, transcript.text('Hello.'), parent: :prompt) + transcript.last_prompt('hi', leaf: :answer) + transcript.write(File.join(cli_project_dir, "#{session_id}.jsonl")) + end + + it 'derives the same name as the store key' do + expect(ClaudeAgentSDK.project_key_for_directory(directory)).to eq(File.basename(cli_project_dir)) + end + + it 'lists the session for the directory' do + expect(ClaudeAgentSDK.list_sessions(directory: directory).map(&:session_id)).to eq([session_id]) + end + + it 'reads the session for the directory' do + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: directory)&.summary).to eq('hi') + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: directory).length).to eq(2) + end + end +end diff --git a/spec/unit/session_read_boundaries_spec.rb b/spec/unit/session_read_boundaries_spec.rb new file mode 100644 index 00000000..3136f273 --- /dev/null +++ b/spec/unit/session_read_boundaries_spec.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# Boundary cases of the session readers and mutations, one group per case. +RSpec.describe 'session API boundaries' do + include_context 'with a Claude config dir' + + let(:session_id) { 'c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f' } + + before { allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees) { |path| [path] } } + + def conversation(directory = cwd) + transcript = CLITranscript.new(session_id: session_id, cwd: directory) + transcript.queue_operations('what does a.rb define?') + transcript.prompt(:prompt, 'what does a.rb define?') + transcript.assistant(:answer, transcript.text('Foo.'), parent: :prompt) + transcript.last_prompt('what does a.rb define?', leaf: :answer) + transcript + end + + # A config dir path is data, not a pattern: `/Volumes/Data [SSD]/claude`, + # `/srv/{tenant}/claude`. + describe 'a config dir whose path contains glob characters' do + ['cfg [prod]', 'tenant{a,b}'].each do |name| + it "lists the sessions under #{name.inspect}" do + ENV['CLAUDE_CONFIG_DIR'] = File.join(config_dir, name) + conversation.write(File.join(ENV.fetch('CLAUDE_CONFIG_DIR'), 'projects', + ClaudeAgentSDK::Sessions.sanitize_path(cwd), "#{session_id}.jsonl")) + + expect(ClaudeAgentSDK.list_sessions(directory: cwd).map(&:session_id)).to eq([session_id]) + expect(ClaudeAgentSDK.list_sessions.map(&:session_id)).to eq([session_id]) + end + end + end + + # created_at (and the store fallback for last_modified) are epoch + # milliseconds parsed from the ISO timestamps the CLI writes. + describe 'the millisecond of an ISO timestamp' do + let(:base) { Time.utc(2026, 9, 8, 5, 19, 30).to_i * 1000 } + + it 'is exact for every millisecond of a second' do + parsed = (0..999).map do |ms| + ClaudeAgentSDK::Sessions.parse_iso_timestamp_ms(format('2026-09-08T05:19:30.%03dZ', ms)) + end + + expect(parsed).to eq((0..999).map { |ms| base + ms }) + end + + it 'is exact with a UTC offset, and truncates what is finer than a millisecond' do + expect(ClaudeAgentSDK::Sessions.parse_iso_timestamp_ms('2026-09-08T13:19:30.933+08:00')) + .to eq(base + 933) + expect(ClaudeAgentSDK::Sessions.parse_iso_timestamp_ms('2026-09-08T05:19:30.933999Z')) + .to eq(base + 933) + end + + it 'gives a session the created_at of its first entry, on disk and from a store' do + transcript = CLITranscript.new(session_id: session_id, cwd: cwd, start: Time.utc(2026, 9, 8, 5, 19, 30.683r)) + transcript.prompt(:prompt, 'what does a.rb define?') + transcript.assistant(:answer, transcript.text('Foo.'), parent: :prompt) + transcript.write(transcript_path(session_id)) + store = ClaudeAgentSDK::InMemorySessionStore.new + ClaudeAgentSDK.import_session_to_store(session_id: session_id, session_store: store, directory: cwd) + exact = base + 933 + + expect(transcript.entries.first['timestamp']).to eq('2026-09-08T05:19:30.933Z') + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd).created_at).to eq(exact) + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd, session_store: store).created_at) + .to eq(exact) + end + end + + # list_subkeys is documented to return Strings. Resume already skips + # anything else an adapter hands back; the readers called String methods + # on it. + describe 'a store whose list_subkeys returns values that are not Strings' do + let(:agent_id) { 'a1b2c3d4e5f60718' } + let(:store) do + Class.new(ClaudeAgentSDK::InMemorySessionStore) do + def list_subkeys(key) = [:'subagents/agent-symbol', nil, 7] + super + end.new + end + + before do + key = { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } + store.append(key, conversation.store_entries) + subagent = CLITranscript.new(session_id: session_id, cwd: cwd, agent_id: agent_id) + subagent.prompt(:task, 'Read a.rb') + subagent.assistant(:report, subagent.text('It defines Foo.'), parent: :task) + store.append(key.merge('subpath' => "subagents/agent-#{agent_id}"), subagent.store_entries) + end + + it 'lists the subagents it can name' do + expect(ClaudeAgentSDK.list_subagents(session_id: session_id, directory: cwd, session_store: store)) + .to eq([agent_id]) + end + + it 'reads a subagent it can name' do + messages = ClaudeAgentSDK.get_subagent_messages(session_id: session_id, agent_id: agent_id, directory: cwd, + session_store: store) + + expect(messages.map(&:text)).to eq(['Read a.rb', 'It defines Foo.']) + end + end + + # last_modified on the store paths is the adapter's mtime for the session: + # the value the store listing reports and orders by. + describe 'last_modified of a session read from a store' do + let(:key) { { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } } + + def info_from(store) + ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd, session_store: store) + end + + # A session renamed before it has any timestamped entry in the store: + # metadata lines carry no timestamp. + def untimed_entries + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + transcript.ai_title('Reading a.rb') + transcript.last_prompt('what does a.rb define?') + transcript.store_entries + end + + context 'with a store that lists its sessions' do + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + + it 'is the same from get_session_info and from list_sessions' do + store.append(key, conversation.store_entries) + listed = ClaudeAgentSDK.list_sessions(directory: cwd, session_store: store).first + + expect(info_from(store).last_modified).to eq(listed.last_modified) + expect(Time.at(listed.last_modified / 1000).year).to eq(Time.now.year) # the adapter's clock, not the entries' + end + + it 'is the same for a session whose entries carry no timestamp' do + store.append(key, untimed_entries) + listed = ClaudeAgentSDK.list_sessions(directory: cwd, session_store: store).first + + expect(listed.last_modified).to be > 0 + expect(info_from(store).last_modified).to eq(listed.last_modified) + end + end + + context 'with a store that only summarizes its sessions' do + let(:store) do + Class.new(ClaudeAgentSDK::InMemorySessionStore) { undef_method :list_sessions }.new + end + + it 'takes the mtime of the summary' do + store.append(key, conversation.store_entries) + listed = ClaudeAgentSDK.list_sessions(directory: cwd, session_store: store).first + + expect(info_from(store).last_modified).to eq(listed.last_modified) + end + end + + # Only #append and #load are required of an adapter. + context 'with a store that cannot list' do + let(:store) do + Class.new do + def initialize = @sessions = {} + def append(key, entries) = (@sessions[key] ||= []).concat(entries) + def load(key) = @sessions[key] + end.new + end + + it 'falls back to the timestamp of the last entry' do + entries = conversation.store_entries + store.append(key, entries) + + expect(info_from(store).last_modified) + .to eq(ClaudeAgentSDK::Sessions.parse_iso_timestamp_ms(entries[-2]['timestamp'])) + end + + it 'is 0 when no entry carries a timestamp' do + store.append(key, untimed_entries) + + expect(info_from(store).last_modified).to eq(0) + end + end + end + + # A title or tag that is not a usable String is rejected like an empty + # one, before anything is written. + describe 'rename_session and tag_session with a value that is not a usable String', + rbs_incompatible: 'passes out-of-signature input to test its rejection' do + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + let(:key) { { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } } + let!(:transcript_file) { conversation.write(transcript_path(session_id)) } + + before { store.append(key, conversation.store_entries) } + + { 'nil' => nil, 'an Integer' => 123, 'invalid bytes' => "caf\xC3".dup.force_encoding('UTF-8') }.each do |label, title| + it "raises ArgumentError for a title that is #{label}, on disk and with a store" do + on_disk = File.binread(transcript_file) + + expect { ClaudeAgentSDK.rename_session(session_id: session_id, title: title, directory: cwd) } + .to raise_error(ArgumentError, 'title must be non-empty') + expect { ClaudeAgentSDK.rename_session(session_id: session_id, title: title, directory: cwd, session_store: store) } + .to raise_error(ArgumentError, 'title must be non-empty') + expect(File.binread(transcript_file)).to eq(on_disk) + expect(store.load(key).length).to eq(conversation.store_entries.length) + end + end + + { 'an Integer' => 123, 'invalid bytes' => "caf\xC3".dup.force_encoding('UTF-8') }.each do |label, tag| + it "raises ArgumentError for a tag that is #{label}, on disk and with a store" do + expect { ClaudeAgentSDK.tag_session(session_id: session_id, tag: tag, directory: cwd) } + .to raise_error(ArgumentError, 'tag must be non-empty (use nil to clear)') + expect { ClaudeAgentSDK.tag_session(session_id: session_id, tag: tag, directory: cwd, session_store: store) } + .to raise_error(ArgumentError, 'tag must be non-empty (use nil to clear)') + expect(store.load(key).length).to eq(conversation.store_entries.length) + end + end + + it 'still clears the tag for nil' do + ClaudeAgentSDK.tag_session(session_id: session_id, tag: 'keep', directory: cwd) + ClaudeAgentSDK.tag_session(session_id: session_id, tag: nil, directory: cwd) + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd).tag).to be_nil + end + end +end diff --git a/spec/unit/session_reader_assertions_spec.rb b/spec/unit/session_reader_assertions_spec.rb new file mode 100644 index 00000000..21cd5240 --- /dev/null +++ b/spec/unit/session_reader_assertions_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# Behavior of the disk readers that no example pinned: each of these stayed +# green with the line that implements it removed. +RSpec.describe 'disk session readers' do + include_context 'with a Claude config dir' + + let(:session_id) { 'd4c3b2a1-0f9e-4d8c-8b7a-6f5e4d3c2b1a' } + + before { allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees) { |path| [path] } } + + def transcript + @transcript ||= CLITranscript.new(session_id: session_id, cwd: cwd) + end + + def listed + transcript.write(transcript_path(session_id)) + ClaudeAgentSDK.list_sessions(directory: cwd) + end + + # A session opened and closed without a prompt: the CLI wrote its opening + # metadata lines and a hook attachment, nothing to show the session under. + it 'do not list a transcript with no title, summary or prompt' do + transcript.mode + transcript.permission_mode + transcript.attachment(:hook, parent: nil, hook: 'SessionStart') + + expect(listed).to eq([]) + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd)).to be_nil + end + + it 'list a transcript again once it has a prompt' do + transcript.mode + transcript.attachment(:hook, parent: nil, hook: 'SessionStart') + transcript.prompt(:prompt, 'what does a.rb define?', parent: :hook) + + expect(listed.map(&:summary)).to eq(['what does a.rb define?']) + end + + # prompt ─ skill_body (isMeta) ─ answer ─ note (a teammate's message) ─ reply + it 'leave meta entries and teammate entries out of the messages of a conversation' do + transcript.prompt(:prompt, '<command-name>/simplify</command-name>') + transcript.meta(:skill_body, [transcript.text('# Simplify: review the changed code')], parent: :prompt) + transcript.assistant(:answer, transcript.text('Simplified.'), parent: :skill_body) + transcript.prompt(:note, 'Reviewer: looks fine.', parent: :answer, teamName: 'crew', agentName: 'reviewer') + transcript.assistant(:reply, transcript.text('Thanks.'), parent: :note, message: 'msg_02') + transcript.write(transcript_path(session_id)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt answer reply]) + end + + describe 'summary' do + # An older CLI wrote {"type":"summary"} entries; a current one writes + # last-prompt at exit. A transcript resumed across versions has both. + def summary_entry(text) + transcript.raw({ 'type' => 'summary', 'summary' => text, 'leafUuid' => transcript.id(:answer) }) + end + + before do + transcript.prompt(:prompt, 'what does a.rb define?') + transcript.assistant(:answer, transcript.text('Foo.'), parent: :prompt) + end + + it 'is the last prompt when the tail holds a last-prompt and a summary entry' do + summary_entry('Exploring a.rb') + transcript.last_prompt('and b.rb?', leaf: :answer) + + expect(listed.map(&:summary)).to eq(['and b.rb?']) + end + + it 'is the summary entry when there is no last-prompt entry' do + summary_entry('Exploring a.rb') + + expect(listed.map(&:summary)).to eq(['Exploring a.rb']) + end + end +end diff --git a/spec/unit/session_resume_keychain_spec.rb b/spec/unit/session_resume_keychain_spec.rb new file mode 100644 index 00000000..dddf78fe --- /dev/null +++ b/spec/unit/session_resume_keychain_spec.rb @@ -0,0 +1,397 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require 'tmpdir' +require 'json' +require 'fileutils' +require 'open3' +require 'rbconfig' + +# The macOS Keychain bridge of store-backed resume. On macOS the CLI keeps its +# OAuth credentials in the Keychain rather than in .credentials.json, so +# materialization copies them (minus the refresh token) into the temp config +# dir the resumed subprocess runs against. +# +# No example here runs the real `security` binary or reads a real Keychain. +# The bridge is reached through materialize_resume_session — a stub of +# read_keychain_credentials would hide a wiring regression — and the layer +# below it, capture_with_timeout, is replaced by a recorder that answers like +# `security find-generic-password -w` does. capture_with_timeout itself is +# exercised against a real child process that is not `security`. +# +# `keychain: true` opts out of the suite-wide default stub of +# read_keychain_credentials (spec/support/keychain_bridge_stub.rb). +RSpec.describe ClaudeAgentSDK::SessionResume, keychain: true do + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + let(:cwd) { Dir.mktmpdir } + let(:home) { Dir.mktmpdir } + let(:sid) { SecureRandom.uuid } + let(:security_calls) { [] } + let(:materializations) { [] } + + # What the CLI stores in the Keychain item (values are fake). + let(:oauth) do + { 'accessToken' => 'fake-access-token', 'refreshToken' => 'fake-refresh-token', + 'expiresAt' => 1_790_000_000_000, 'scopes' => %w[user:inference user:profile], + 'subscriptionType' => 'max', 'rateLimitTier' => 'default_claude_max_20x' } + end + let(:keychain_payload) { JSON.generate('claudeAiOauth' => oauth) } + + # The bridge keys off the environment the CHILD will see, which falls back to + # the parent's for every key options.env does not set. Pin the ones it reads, + # so the examples behave the same on any machine: a shell or CI job exporting + # ANTHROPIC_API_KEY would otherwise skip the bridge altogether. + around do |example| + names = %w[CLAUDE_CONFIG_DIR ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN USER] + saved = names.to_h { |name| [name, ENV.fetch(name, nil)] } + names.each { |name| ENV.delete(name) } + ENV['USER'] = 'spec-user' + example.run + ensure + saved.each { |name, value| value.nil? ? ENV.delete(name) : (ENV[name] = value) } + end + + before do + stub_host_os('darwin24') # the bridge is a no-op elsewhere, and CI runs on Linux + store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => sid }, + [{ 'parentUuid' => nil, 'isSidechain' => false, 'userType' => 'external', 'cwd' => cwd, + 'sessionId' => sid, 'version' => '2.1.286', 'gitBranch' => 'main', 'type' => 'user', + 'message' => { 'role' => 'user', 'content' => 'hello' }, 'uuid' => SecureRandom.uuid, + 'timestamp' => '2026-09-01T12:00:00.000Z' }]) + end + + after do + materializations.each(&:cleanup) + [cwd, home].each { |dir| FileUtils.remove_entry(dir) if File.directory?(dir) } + end + + def stub_host_os(host_os) + allow(RbConfig::CONFIG).to receive(:[]).and_call_original + allow(RbConfig::CONFIG).to receive(:[]).with('host_os').and_return(host_os) + end + + # Stand-in for `security find-generic-password -w`, which prints the secret + # and a newline and exits 0 on a hit, and prints nothing and exits non-zero + # when the item does not exist. +exit_ok+ nil is capture_with_timeout's own + # answer for a command that timed out or could not be run: [nil, nil]. + def stub_security(stdout, exit_ok) + status = exit_ok.nil? ? nil : instance_double(Process::Status, success?: exit_ok) + allow(described_class).to receive(:capture_with_timeout) do |argv, timeout_s| + security_calls << [argv, timeout_s] + [stdout, status] + end + end + + # Store-backed resume as query() and Client#connect run it. HOME is a + # scratch directory, so nothing is seeded from the developer's own ~/.claude. + def materialize(env = {}) + options = ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: sid, cwd: cwd, + env: { 'HOME' => home }.merge(env)) + described_class.materialize_resume_session(options).tap { |mat| materializations << mat } + end + + def seeded_credentials(materialized) + File.join(materialized.config_dir, '.credentials.json') + end + + def write_credentials_file(config_dir, access_token) + FileUtils.mkdir_p(config_dir) + File.write(File.join(config_dir, '.credentials.json'), + JSON.generate('claudeAiOauth' => oauth.merge('accessToken' => access_token))) + end + + def security_argv(service) + ['security', 'find-generic-password', '-a', 'spec-user', '-w', '-s', service] + end + + describe 'with the default config dir' do + it "reads the CLI's default Keychain entry and seeds it without the refresh token, owner-only" do + stub_security("#{keychain_payload}\n", true) + + materialized = materialize + + expect(security_calls).to eq([[security_argv('Claude Code-credentials'), described_class::KEYCHAIN_TIMEOUT_SECONDS]]) + seeded = seeded_credentials(materialized) + expect(JSON.parse(File.read(seeded))).to eq('claudeAiOauth' => oauth.except('refreshToken')) + expect(File.read(seeded)).not_to include('fake-refresh-token') + expect(format('%o', File.stat(seeded).mode & 0o777)).to eq('600') + end + + it 'lets a Keychain hit override ~/.claude/.credentials.json' do + write_credentials_file(File.join(home, '.claude'), 'from-the-file') + stub_security("#{keychain_payload}\n", true) + + materialized = materialize + + expect(JSON.parse(File.read(seeded_credentials(materialized))).dig('claudeAiOauth', 'accessToken')) + .to eq('fake-access-token') + end + + it 'falls back to ~/.claude/.credentials.json when the Keychain has no entry' do + write_credentials_file(File.join(home, '.claude'), 'from-the-file') + stub_security('', false) + + materialized = materialize + + expect(security_calls.length).to eq(1) + expect(JSON.parse(File.read(seeded_credentials(materialized)))) + .to eq('claudeAiOauth' => oauth.except('refreshToken').merge('accessToken' => 'from-the-file')) + end + + { + 'the lookup exits non-zero (no such entry)' => ['', false], + 'the lookup exits non-zero after printing something' => [%({"claudeAiOauth":{"accessToken":"untrusted"}}\n), false], + 'the entry is empty' => ["\n", true], + 'the lookup times out or cannot be run' => [nil, nil] + }.each do |outcome, (stdout, exit_ok)| + it "seeds no credentials file when #{outcome}, and still materializes the transcript" do + stub_security(stdout, exit_ok) + + materialized = materialize + + expect(security_calls.length).to eq(1) + expect(File).not_to exist(seeded_credentials(materialized)) + expect(Dir.glob(File.join(materialized.config_dir, 'projects', '*', "#{sid}.jsonl")).length).to eq(1) + end + end + + %w[ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN].each do |name| + it "leaves the Keychain alone when options.env carries #{name}" do + stub_security("#{keychain_payload}\n", true) + + materialized = materialize(name => 'from-the-env') + + expect(security_calls).to be_empty + expect(File).not_to exist(seeded_credentials(materialized)) + end + + it "leaves the Keychain alone when the child inherits #{name} from the parent" do + stub_security("#{keychain_payload}\n", true) + ENV[name] = 'from-the-env' + + materialize + + expect(security_calls).to be_empty + end + end + + it 'leaves the Keychain alone off macOS' do + stub_host_os('linux-gnu') + stub_security("#{keychain_payload}\n", true) + + materialized = materialize + + expect(security_calls).to be_empty + expect(File).not_to exist(seeded_credentials(materialized)) + end + + it 'treats an empty CLAUDE_CONFIG_DIR as the default config dir, like the CLI' do + stub_security("#{keychain_payload}\n", true) + + materialize('CLAUDE_CONFIG_DIR' => '') + + expect(security_calls.map(&:first)).to eq([security_argv('Claude Code-credentials')]) + end + end + + # With a custom CLAUDE_CONFIG_DIR the CLI keeps its entry under + # "Claude Code-credentials-<first 8 hex of SHA-256(config dir)>", hashing the + # directory as the environment gives it, NFC-normalized — and writes no + # .credentials.json there. The expected suffixes below are literals computed + # outside Ruby (`printf '%s' <dir> | shasum -a 256 | cut -c1-8`), so they pin + # the derivation instead of restating it. + describe 'with a custom CLAUDE_CONFIG_DIR' do + # No such directory: nothing to copy from it, and no .credentials.json. + let(:custom_dir) { '/nonexistent/claude-profiles/work' } + let(:custom_service) { 'Claude Code-credentials-43da4a82' } + + it 'reads the entry the CLI keeps for that directory and seeds it without the refresh token' do + stub_security("#{keychain_payload}\n", true) + + materialized = materialize('CLAUDE_CONFIG_DIR' => custom_dir) + + expect(security_calls).to eq([[security_argv(custom_service), described_class::KEYCHAIN_TIMEOUT_SECONDS]]) + seeded = seeded_credentials(materialized) + expect(JSON.parse(File.read(seeded))).to eq('claudeAiOauth' => oauth.except('refreshToken')) + expect(format('%o', File.stat(seeded).mode & 0o777)).to eq('600') + end + + it 'reads it for a config dir the child inherits from the parent environment' do + stub_security("#{keychain_payload}\n", true) + ENV['CLAUDE_CONFIG_DIR'] = custom_dir + + materialized = materialize + + expect(security_calls.map(&:first)).to eq([security_argv(custom_service)]) + expect(File).to exist(seeded_credentials(materialized)) + end + + # "cafe" + U+0301 COMBINING ACUTE ACCENT: the decomposed spelling of the + # composed one-code-point form (U+00E9) that the CLI hashes. Built from + # the code point so no editor can quietly re-compose the literal. 0e56012f + # is the digest of the composed spelling; the bytes as given hash to + # d3d97122. + context 'when the directory is given in decomposed (NFD) form' do + let(:custom_dir) { "/nonexistent/claude-profiles/cafe#{[0x301].pack('U')}" } + let(:custom_service) { 'Claude Code-credentials-0e56012f' } + + it 'hashes its NFC form' do + stub_security("#{keychain_payload}\n", true) + + materialize('CLAUDE_CONFIG_DIR' => custom_dir) + + expect(custom_dir.unicode_normalize(:nfc)).not_to eq(custom_dir) + expect(security_calls.map(&:first)).to eq([security_argv(custom_service)]) + end + + it 'hashes the same form when Ruby tagged it as binary (a non-ASCII ENV value under a C locale)' do + stub_security("#{keychain_payload}\n", true) + + materialize('CLAUDE_CONFIG_DIR' => custom_dir.b) + + expect(security_calls.map(&:first)).to eq([security_argv(custom_service)]) + end + end + + it 'seeds nothing when the Keychain has no entry for that directory' do + stub_security('', false) + + materialized = materialize('CLAUDE_CONFIG_DIR' => custom_dir) + + expect(security_calls.length).to eq(1) + expect(File).not_to exist(seeded_credentials(materialized)) + end + + # Which source the CLI prefers when a custom config dir has both is not + # established, so the file keeps winning there, as it always has. + context 'when that directory has a .credentials.json' do + let(:custom_dir) { Dir.mktmpdir } + + after { FileUtils.remove_entry(custom_dir) if File.directory?(custom_dir) } + + it 'seeds the file and leaves the Keychain alone' do + write_credentials_file(custom_dir, 'from-the-file') + stub_security("#{keychain_payload}\n", true) + + materialized = materialize('CLAUDE_CONFIG_DIR' => custom_dir) + + expect(security_calls).to be_empty + expect(JSON.parse(File.read(seeded_credentials(materialized)))) + .to eq('claudeAiOauth' => oauth.except('refreshToken').merge('accessToken' => 'from-the-file')) + end + + it 'leaves the Keychain alone even when what is there cannot be read as a file' do + Dir.mkdir(File.join(custom_dir, '.credentials.json')) + stub_security("#{keychain_payload}\n", true) + + materialized = nil + expect { materialized = materialize('CLAUDE_CONFIG_DIR' => custom_dir) } + .to output(/skipping .*\.credentials\.json \(not a regular file/).to_stderr + + expect(security_calls).to be_empty + expect(File).not_to exist(seeded_credentials(materialized)) + end + end + + %w[ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN].each do |name| + it "leaves the Keychain alone when options.env carries #{name}" do + stub_security("#{keychain_payload}\n", true) + + materialized = materialize('CLAUDE_CONFIG_DIR' => custom_dir, name => 'from-the-env') + + expect(security_calls).to be_empty + expect(File).not_to exist(seeded_credentials(materialized)) + end + end + + it 'leaves the Keychain alone off macOS' do + stub_host_os('linux-gnu') + stub_security("#{keychain_payload}\n", true) + + materialized = materialize('CLAUDE_CONFIG_DIR' => custom_dir) + + expect(security_calls).to be_empty + expect(File).not_to exist(seeded_credentials(materialized)) + end + end + + describe '.capture_with_timeout' do + # A real child process (never `security`). A child that is expected to exit + # gets a deadline it cannot plausibly miss — the call returns the moment it + # exits — so machine load never decides the outcome; only the child that + # never exits meets a short one. + let(:ample) { 120 } + + def ruby_child(script) + [RbConfig.ruby, '--disable-gems', '-e', script] + end + + def capture(argv, timeout_s) + described_class.send(:capture_with_timeout, argv, timeout_s) + end + + it "returns the child's stdout and a successful status" do + stdout, status = capture(ruby_child('print "from the child"'), ample) + + expect(stdout).to eq('from the child') + expect(status).to be_success + end + + it 'returns everything a child wrote, past the size of a pipe buffer' do + stdout, status = capture(ruby_child('print "x" * 300_000'), ample) + + expect(stdout.bytesize).to eq(300_000) + expect(status).to be_success + end + + it 'returns the output and the failed status of a child that exits non-zero' do + stdout, status = capture(ruby_child('print "partial"; exit 3'), ample) + + expect(stdout).to eq('partial') + expect(status.exitstatus).to eq(3) + expect(status).not_to be_success + end + + it 'kills and reaps a child that outlives the deadline, returning [nil, nil]' do + waiter = nil + allow(Open3).to receive(:popen3).and_wrap_original do |original, *argv| + streams_and_waiter = original.call(*argv) + waiter = streams_and_waiter.last + streams_and_waiter + end + + expect(capture(ruby_child('sleep'), 0.05)).to eq([nil, nil]) + expect(waiter).not_to be_alive # already reaped: no zombie, no stray `security` + expect(waiter.value.termsig).to eq(Signal.list['KILL']) + end + + it 'returns [nil, nil] for a command that cannot be spawned' do + expect(capture([File.join(home, 'no-such-command')], ample)).to eq([nil, nil]) + end + end + + # spec/support/keychain_bridge_stub.rb switches the bridge off for every + # example in the suite that does not carry `keychain: true`. This group gives + # the tag up again, to pin that default exactly where the bridge would + # otherwise run: macOS, no env auth, and a config dir it covers. + describe 'the suite-wide default, without keychain: true', keychain: false do + it 'keeps materialization away from the lookup' do + stub_security("#{keychain_payload}\n", true) + + materialized = materialize + + expect(security_calls).to be_empty + expect(File).not_to exist(seeded_credentials(materialized)) + end + + it 'does so for a custom config dir without a .credentials.json as well' do + stub_security("#{keychain_payload}\n", true) + + materialized = materialize('CLAUDE_CONFIG_DIR' => '/nonexistent/claude-profiles/work') + + expect(security_calls).to be_empty + expect(File).not_to exist(seeded_credentials(materialized)) + end + end +end diff --git a/spec/unit/session_resume_nil_options_spec.rb b/spec/unit/session_resume_nil_options_spec.rb new file mode 100644 index 00000000..85eef26a --- /dev/null +++ b/spec/unit/session_resume_nil_options_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require 'tmpdir' +require 'fileutils' + +# ClaudeAgentOptions fills `env` and `load_timeout_ms` with their defaults in +# the constructor only. Both attributes are nilable, and the natural ways to +# clear one afterwards — `options.dup_with(env: nil)`, `options.env = nil` — +# leave nil in place. Store-backed resume has to read them as their defaults +# (60 s; no overrides) rather than raise NoMethodError on nil. +RSpec.describe ClaudeAgentSDK::SessionResume do + let(:store) { ClaudeAgentSDK::InMemorySessionStore.new } + let(:cwd) { Dir.mktmpdir } + let(:caller_config_dir) { Dir.mktmpdir } + let(:sid) { SecureRandom.uuid } + let(:materializations) { [] } + let(:options) { ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: sid, cwd: cwd) } + + # Without options.env the child's config dir is the parent's: point that at + # a scratch directory, so nothing is seeded from the developer's own. + around do |example| + previous = ENV.fetch('CLAUDE_CONFIG_DIR', nil) + ENV['CLAUDE_CONFIG_DIR'] = caller_config_dir + example.run + ensure + previous.nil? ? ENV.delete('CLAUDE_CONFIG_DIR') : (ENV['CLAUDE_CONFIG_DIR'] = previous) + end + + before do + store.append({ 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => sid }, + [{ 'type' => 'user', 'uuid' => SecureRandom.uuid, 'sessionId' => sid, + 'message' => { 'role' => 'user', 'content' => 'hello' } }]) + end + + after do + materializations.each(&:cleanup) + [cwd, caller_config_dir].each { |dir| FileUtils.remove_entry(dir) if File.directory?(dir) } + end + + def materialize(options) + described_class.materialize_resume_session(options).tap { |materialized| materializations << materialized if materialized } + end + + it 'bounds the store calls by the 60 s default when load_timeout_ms is nil' do + allow(ClaudeAgentSDK::FiberBoundary).to receive(:invoke).and_call_original + + materialized = materialize(options.dup_with(load_timeout_ms: nil)) + + expect(materialized.resume_session_id).to eq(sid) + expect(ClaudeAgentSDK::FiberBoundary).to have_received(:invoke).with(hash_including(timeout: 60.0)).at_least(:once) + end + + it 'still honors an explicit load_timeout_ms of 0' do + allow(ClaudeAgentSDK::FiberBoundary).to receive(:invoke).and_return([]) + + materialize(options.dup_with(load_timeout_ms: 0)) + + expect(ClaudeAgentSDK::FiberBoundary).to have_received(:invoke).with(hash_including(timeout: 0.0)).at_least(:once) + end + + it 'materializes from the parent environment and repoints the options when env is nil' do + File.write(File.join(caller_config_dir, 'settings.json'), '{"apiKeyHelper":"/bin/print-key"}') + without_env = options.dup_with(env: nil) + + materialized = materialize(without_env) + applied = described_class.apply_materialized_options(without_env, materialized) + + expect(File.read(File.join(materialized.config_dir, 'settings.json'))).to eq('{"apiKeyHelper":"/bin/print-key"}') + expect(applied.env).to eq('CLAUDE_CONFIG_DIR' => materialized.config_dir) + expect(applied.resume).to eq(sid) + expect(without_env.env).to be_nil # the caller's options are left as they were + end + + it 'gets Client past materialization with both set back to nil' do + cleared = options.dup_with(env: nil, load_timeout_ms: nil) + client = ClaudeAgentSDK::Client.new(options: cleared) + + applied = client.send(:materialize_resume, cleared) + materializations << client.instance_variable_get(:@materialized) + + expect(applied.env).to eq('CLAUDE_CONFIG_DIR' => materializations.first.config_dir) + expect(applied.resume).to eq(sid) + end +end diff --git a/spec/unit/session_resume_spec.rb b/spec/unit/session_resume_spec.rb index f30c1228..a4ed5783 100644 --- a/spec/unit/session_resume_spec.rb +++ b/spec/unit/session_resume_spec.rb @@ -1049,10 +1049,10 @@ def mode_of(path) # not delete it. It keeps the transcripts but scrubs the credential copies. describe 'MaterializedResume#preserve_transcripts' do it 'removes credential copies, keeps transcripts, warns, and never rmtrees' do - Dir.mktmpdir do |dir| - transcript = File.join(dir, 'projects', 'pk', 'sid.jsonl') - FileUtils.mkdir_p(File.dirname(transcript)) - File.write(transcript, "{}\n") + Dir.mktmpdir do |scratch| + dir = File.join(scratch, 'claude-resume-spec') + FileUtils.mkdir_p(File.join(dir, 'projects', 'pk')) + File.write(File.join(dir, 'projects', 'pk', 'sid.jsonl'), "{}\n") # settings.json / cowork_settings.json are seeded from the caller's # config dir too, and their env blocks routinely carry API keys — the # scrub must cover them, not just the credential files. @@ -1063,10 +1063,14 @@ def mode_of(path) materialized = ClaudeAgentSDK::MaterializedResume.new(config_dir: dir, resume_session_id: 'sid') expect { materialized.preserve_transcripts }.to output(/[Pp]reserving/).to_stderr + # The directory is kept in a private one created next to it (so still + # under scratch): that is where the transcript is now. + preserved = materialized.config_dir + expect(File.dirname(preserved, 2)).to eq(scratch) ['.credentials.json', '.claude.json', 'settings.json', 'cowork_settings.json'].each do |name| - expect(File).not_to exist(File.join(dir, name)) + expect(File).not_to exist(File.join(preserved, name)) end - expect(File).to exist(transcript) + expect(File).to exist(File.join(preserved, 'projects', 'pk', 'sid.jsonl')) end end end diff --git a/spec/unit/session_store_optional_methods_spec.rb b/spec/unit/session_store_optional_methods_spec.rb new file mode 100644 index 00000000..f99f38a5 --- /dev/null +++ b/spec/unit/session_store_optional_methods_spec.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'delegate' +require 'claude_agent_sdk/testing/session_store_conformance' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# Only #append and #load are required of a SessionStore. An optional method +# the adapter does not implement can still LOOK implemented: behind a +# delegating wrapper every stub inherited from SessionStore answers +# respond_to?, and an adapter may opt out of one at run time by raising +# NotImplementedError itself. NotImplementedError is a ScriptError, so it +# passes every `rescue StandardError`. +RSpec.describe 'a session store whose optional methods raise NotImplementedError' do + include_context 'with a Claude config dir' + + let(:session_id) { 'e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b' } + let(:agent_id) { 'c3d4e5f6a7b8c9d0' } + let(:key) { { 'project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id } } + + # An adapter with the two required methods, the optional ones inherited. + let(:minimal_store_class) do + Class.new(ClaudeAgentSDK::SessionStore) do + def initialize + super + @sessions = {} + end + + def append(key, entries) + (@sessions[key] ||= []).concat(entries) unless entries.empty? + end + + def load(key) = @sessions[key] + end + end + + def conversation + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + transcript.queue_operations('what does a.rb define?') + transcript.prompt(:prompt, 'what does a.rb define?') + transcript.assistant(:answer, transcript.text('Foo.'), parent: :prompt) + transcript.last_prompt('what does a.rb define?', leaf: :answer) + transcript.store_entries + end + + def subagent_conversation + transcript = CLITranscript.new(session_id: session_id, cwd: cwd, agent_id: agent_id) + transcript.prompt(:task, 'Read a.rb') + transcript.assistant(:report, transcript.text('It defines Foo.'), parent: :task) + transcript.store_entries + end + + def seed(store) + store.append(key, conversation) + store.append(key.merge('subpath' => "subagents/agent-#{agent_id}"), subagent_conversation) + store + end + + # A metrics or tenancy decorator around a minimal adapter. + context 'with a delegating wrapper around an adapter that implements only append and load' do + let(:store) { seed(SimpleDelegator.new(minimal_store_class.new)) } + + it 'looks as if it implemented every optional method' do + project_key = key['project_key'] + { list_sessions: project_key, list_session_summaries: project_key, delete: key, + list_subkeys: key }.each do |method, argument| + expect(ClaudeAgentSDK::SessionStore.implements?(store, method)).to be(true) + expect { store.public_send(method, argument) }.to raise_error(NotImplementedError) + end + end + + it 'delete_session is the no-op it is for a store without #delete' do + expect(ClaudeAgentSDK.delete_session(session_id: session_id, directory: cwd, session_store: store)).to be_nil + expect(store.load(key).length).to eq(conversation.length) + end + + it 'list_sessions raises the ArgumentError of a store that cannot list' do + expect { ClaudeAgentSDK.list_sessions(directory: cwd, session_store: store) } + .to raise_error(ArgumentError, /implements neither list_session_summaries nor list_sessions/) + end + + it 'list_subagents raises the ArgumentError of a store without list_subkeys' do + expect { ClaudeAgentSDK.list_subagents(session_id: session_id, directory: cwd, session_store: store) } + .to raise_error(ArgumentError, /does not implement list_subkeys/) + end + + it 'get_subagent_messages reads the direct subagent path' do + messages = ClaudeAgentSDK.get_subagent_messages(session_id: session_id, agent_id: agent_id, directory: cwd, + session_store: store) + + expect(messages.map(&:text)).to eq(['Read a.rb', 'It defines Foo.']) + end + + it 'get_session_info falls back to the timestamp of the last entry for last_modified' do + info = ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd, session_store: store) + + expect(info.summary).to eq('what does a.rb define?') + expect(info.last_modified).to eq(ClaudeAgentSDK::Sessions.parse_iso_timestamp_ms(conversation[-2]['timestamp'])) + end + + it 'resume materializes the main transcript' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: session_id, cwd: cwd, + env: { 'CLAUDE_CONFIG_DIR' => config_dir }) + + materialized = ClaudeAgentSDK::SessionResume.materialize_resume_session(options) + begin + transcript = File.join(materialized.config_dir, 'projects', key['project_key'], "#{session_id}.jsonl") + expect(File.readlines(transcript).length).to eq(conversation.length) + ensure + materialized&.cleanup + end + end + end + + # An adapter that has the method and declines it at run time. + context 'with an adapter that raises NotImplementedError from one optional method' do + def opting_out_of(method) + seed(Class.new(ClaudeAgentSDK::InMemorySessionStore) do + define_method(method) { |*| raise NotImplementedError, "#{method} is disabled for this tenant" } + end.new) + end + + it 'list_sessions falls back from list_session_summaries to list_sessions' do + store = opting_out_of(:list_session_summaries) + + expect(ClaudeAgentSDK.list_sessions(directory: cwd, session_store: store).map(&:session_id)).to eq([session_id]) + end + + it 'list_sessions uses the summaries without the listing' do + store = opting_out_of(:list_sessions) + + expect(ClaudeAgentSDK.list_sessions(directory: cwd, session_store: store).map(&:session_id)).to eq([session_id]) + end + + it 'get_session_info takes last_modified from the summaries when the listing is declined' do + store = opting_out_of(:list_sessions) + listed = ClaudeAgentSDK.list_sessions(directory: cwd, session_store: store).first + + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd, session_store: store).last_modified) + .to eq(listed.last_modified) + end + + it 'delete_session is a no-op' do + store = opting_out_of(:delete) + + expect(ClaudeAgentSDK.delete_session(session_id: session_id, directory: cwd, session_store: store)).to be_nil + expect(store.load(key)).not_to be_nil + end + + it 'resume continues the newest session without the summaries' do + store = opting_out_of(:list_session_summaries) + options = ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, continue_conversation: true, cwd: cwd, + env: { 'CLAUDE_CONFIG_DIR' => config_dir }) + + materialized = ClaudeAgentSDK::SessionResume.materialize_resume_session(options) + begin + expect(materialized.resume_session_id).to eq(session_id) + ensure + materialized&.cleanup + end + end + end + + # list_subkeys works; the REQUIRED load fails for the subagent it lists. + # That is not "list_subkeys is not implemented": the resume must fail, and + # leave nothing behind, rather than hand over a session without its + # subagent. + context 'when the required load fails for a subagent that list_subkeys names' do + let(:store) do + seed(Class.new(ClaudeAgentSDK::InMemorySessionStore) do + def load(key) + raise NotImplementedError, 'load is not available for subpaths' if key['subpath'] + + super + end + end.new) + end + # The resume materializes into Dir.tmpdir: a private one shows what is left. + let(:tmpdir) { File.join(cwd, 'tmp').tap { |dir| FileUtils.mkdir_p(dir) } } + let(:options) do + ClaudeAgentSDK::ClaudeAgentOptions.new(session_store: store, resume: session_id, cwd: cwd, + cli_path: File.join(cwd, 'no-such-claude'), + env: { 'CLAUDE_CONFIG_DIR' => config_dir }) + end + + around do |example| + saved = ENV.fetch('TMPDIR', nil) + ENV['TMPDIR'] = tmpdir + example.run + ensure + saved.nil? ? ENV.delete('TMPDIR') : ENV['TMPDIR'] = saved + end + + it 'lets the NotImplementedError surface from the resume materialization and removes its directory' do + expect { ClaudeAgentSDK::SessionResume.materialize_resume_session(options) } + .to raise_error(NotImplementedError, 'load is not available for subpaths') + expect(Dir.children(tmpdir)).to eq([]) + end + + # The public path: query() materializes the resume before it starts a CLI + # (there is none at cli_path, and it is never looked for). + it 'lets it surface from query() with resume: and session_store:' do + expect { ClaudeAgentSDK.query(prompt: 'continue', options: options) { |_message| nil } } + .to raise_error(NotImplementedError, 'load is not available for subpaths') + expect(Dir.children(tmpdir)).to eq([]) + end + end + + it 'still lets a NotImplementedError from a required method through' do + store = Class.new(ClaudeAgentSDK::InMemorySessionStore) do + def load(_key) = raise(NotImplementedError, 'load is not available') + end.new + + expect { ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd, session_store: store) } + .to raise_error(NotImplementedError) + end + + describe 'the conformance suite' do + it 'skips the contracts of an optional method the adapter declines at run time' do + adapter = Class.new(ClaudeAgentSDK::InMemorySessionStore) do + def list_session_summaries(_project_key) = raise(NotImplementedError, 'summaries are disabled for this tenant') + end + + expect(ClaudeAgentSDK::Testing.run_session_store_conformance(-> { adapter.new })).to be_nil + end + + it 'runs the required contracts only for a delegating wrapper around a minimal adapter' do + make_store = -> { SimpleDelegator.new(minimal_store_class.new) } + + expect(ClaudeAgentSDK::Testing.run_session_store_conformance(make_store)).to be_nil + end + + it 'still fails an adapter whose optional method breaks a contract' do + adapter = Class.new(ClaudeAgentSDK::InMemorySessionStore) do + def list_subkeys(_key) = ['subagents/agent-of-another-session'] + end + + expect { ClaudeAgentSDK::Testing.run_session_store_conformance(-> { adapter.new }) } + .to raise_error(ClaudeAgentSDK::Testing::ConformanceError) + end + end +end diff --git a/spec/unit/session_subagent_listing_spec.rb b/spec/unit/session_subagent_listing_spec.rb new file mode 100644 index 00000000..34fc0aea --- /dev/null +++ b/spec/unit/session_subagent_listing_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# Subagent transcripts live at <session>/subagents/agent-<id>.jsonl and may +# nest under workflows/<runId>/. An id is one subagent wherever its +# transcripts sit: the message and metadata readers take the first match. +RSpec.describe 'list_subagents on disk' do + include_context 'with a Claude config dir' + + let(:session_id) { 'b2a1c0d9-e8f7-4a6b-9c5d-4e3f2a1b0c9d' } + + before { allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees) { |path| [path] } } + + def write_subagent(agent_id, *nesting) + transcript = CLITranscript.new(session_id: session_id, cwd: cwd, agent_id: agent_id) + transcript.prompt(:task, 'Read a.rb') + transcript.assistant(:report, transcript.text('It defines Foo.'), parent: :task) + transcript.write(File.join(project_dir_for(cwd), session_id, 'subagents', *nesting, "agent-#{agent_id}.jsonl")) + end + + before do + parent = CLITranscript.new(session_id: session_id, cwd: cwd) + parent.prompt(:prompt, 'Use subagents to read a.rb') + parent.write(transcript_path(session_id)) + end + + it 'returns an id once when its transcript exists at two depths' do + write_subagent('a1b2c3') + write_subagent('a1b2c3', 'workflows', 'run-1') + write_subagent('d4e5f6', 'workflows', 'run-1') + + expect(ClaudeAgentSDK.list_subagents(session_id: session_id, directory: cwd)).to eq(%w[a1b2c3 d4e5f6]) + end +end diff --git a/spec/unit/session_transcript_encoding_spec.rb b/spec/unit/session_transcript_encoding_spec.rb new file mode 100644 index 00000000..057076c9 --- /dev/null +++ b/spec/unit/session_transcript_encoding_spec.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# Transcripts are UTF-8 whatever the process locale says, and a CLI killed in +# the middle of a write leaves a final line that stops inside a character. +RSpec.describe 'reading transcripts that are not clean UTF-8 text' do + include_context 'with a Claude config dir' + + let(:session_id) { '3f8a1c2d-5e6b-4a79-9c80-d1e2f3a4b5c6' } + let(:agent_id) { 'b7c8d9e0f1a2b3c4' } + let(:question) { '你好,请读一下 a.rb — 然后总结' } + let(:answer) { '好的。a.rb 定义了 Foo。' } + let(:replacement) { [0xFFFD].pack('U') } + + def conversation(transcript) + transcript.prompt(:prompt, question) + transcript.assistant(:use_a, transcript.tool_use('toolu_a'), parent: :prompt) + transcript.tool_result(:result_a, 'toolu_a', 'class Foo; end', parent: :use_a) + transcript.assistant(:answer, transcript.text(answer), parent: :result_a, message: 'msg_02') + transcript + end + + def main_transcript + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + transcript.queue_operations(question) + conversation(transcript) + transcript.last_prompt(question, leaf: :answer) + transcript + end + + # What LANG=C / LC_ALL=C does to a Ruby process: files and pipes opened + # without an explicit encoding yield US-ASCII-tagged Strings. + def with_default_external(encoding) + saved = Encoding.default_external + switch_default_external(encoding) + yield + ensure + switch_default_external(saved) + end + + def switch_default_external(encoding) + verbose = $VERBOSE + $VERBOSE = nil # the assignment warns under -w + Encoding.default_external = encoding + ensure + $VERBOSE = verbose + end + + # +line+ cut one byte into the 3-byte character +char+: no closing quote, + # no newline, and a lead byte with nothing after it. + def cut_inside(line, char) + bytes = line.b + bytes[0, bytes.index(char.b) + 1] + end + + describe 'under a non-UTF-8 locale' do + it 'get_session_messages reads a transcript with non-ASCII text' do + main_transcript.write(transcript_path(session_id)) + + messages = with_default_external(Encoding::US_ASCII) do + ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + end + + expect(messages.map(&:text)).to eq([question, '', '', answer]) + expect(messages.last.text.encoding).to eq(Encoding::UTF_8) + end + + it 'get_subagent_messages reads a subagent transcript with non-ASCII text' do + main_transcript.write(transcript_path(session_id)) + conversation(CLITranscript.new(session_id: session_id, cwd: cwd, agent_id: agent_id)) + .write(subagent_transcript_path(session_id, agent_id)) + + messages = with_default_external(Encoding::US_ASCII) do + ClaudeAgentSDK.get_subagent_messages(session_id: session_id, agent_id: agent_id, directory: cwd) + end + + expect(messages.map(&:text)).to eq([question, '', '', answer]) + end + end + + describe 'a final line cut inside a multibyte character' do + before do + transcript = main_transcript + interrupted = CLITranscript.new(session_id: session_id, cwd: cwd) + .assistant(:interrupted, transcript.text('最后一条回复被截断'), parent: :answer) + File.binwrite(transcript.write(transcript_path(session_id)), + cut_inside(JSON.generate(interrupted), '最'), mode: 'ab') + end + + it 'returns the complete messages before it' do + expect(File.binread(transcript_path(session_id))[-1].unpack1('C')).to eq(0xE6) # a lead byte, then EOF + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(messages.map(&:text)).to eq([question, '', '', answer]) + end + + it 'stays readable after a later rename appended to the file' do + ClaudeAgentSDK.rename_session(session_id: session_id, title: 'Renamed', directory: cwd) + + expect(ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd).length).to eq(4) + expect(ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd).custom_title).to eq('Renamed') + end + end + + it 'returns a message that holds a raw invalid byte, with the byte replaced by U+FFFD' do + transcript = main_transcript + path = transcript.write(transcript_path(session_id)) + File.binwrite(path, File.binread(path).sub('class Foo; end'.b, "class \xFF; end".b)) + + messages = ClaudeAgentSDK.get_session_messages(session_id: session_id, directory: cwd) + + expect(transcript.labels(messages)).to eq(%w[prompt use_a result_a answer]) + result = messages[2].content_blocks.first + expect(result.content).to eq("class #{replacement}; end") + expect(result.content).to be_valid_encoding + end + + # The same stray byte inside the first prompt, which the listing and the + # fork title are taken from. The session was killed before the CLI wrote + # its exit-time lines, so the prompt is all there is to list it under. + describe 'a raw invalid byte inside the first prompt' do + before do + transcript = CLITranscript.new(session_id: session_id, cwd: cwd) + conversation(transcript) + path = transcript.write(transcript_path(session_id)) + File.binwrite(path, File.binread(path).sub('你好'.b, "\xFF好".b)) + end + + it 'lists the session, with the byte replaced by U+FFFD' do + info = ClaudeAgentSDK.get_session_info(session_id: session_id, directory: cwd) + + expect(info&.first_prompt).to eq(question.sub('你', replacement)) + end + + it 'forks the session under that prompt' do + fork = ClaudeAgentSDK.fork_session(session_id: session_id, directory: cwd) + + expect(ClaudeAgentSDK.get_session_info(session_id: fork.session_id, directory: cwd).custom_title) + .to eq("#{question.sub('你', replacement)} (fork)") + end + end + + describe 'import_session_to_store' do + # A networked adapter serializes what it is given, and JSON.generate + # rejects a String that is not valid UTF-8. + let(:store) do + Class.new(ClaudeAgentSDK::SessionStore) do + def initialize + super + @rows = Hash.new { |rows, key| rows[key] = [] } + end + + def append(key, entries) + @rows[key].concat(entries.map { |entry| JSON.generate(entry) }) + end + + def load(key) + @rows.key?(key) ? @rows[key].map { |row| JSON.parse(row) } : nil + end + end.new + end + + it 'imports every entry of a transcript that holds a raw invalid byte' do + transcript = main_transcript + path = transcript.write(transcript_path(session_id)) + File.binwrite(path, File.binread(path).sub('class Foo; end'.b, "class \xFF; end".b)) + + ClaudeAgentSDK.import_session_to_store(session_id: session_id, session_store: store, directory: cwd, + batch_size: 2) + + stored = store.load('project_key' => ClaudeAgentSDK.project_key_for_directory(cwd), 'session_id' => session_id) + expect(stored.map { |entry| entry['type'] }).to eq(transcript.entries.map { |entry| entry['type'] }) + expect(stored[4].dig('message', 'content', 0, 'content')).to eq("class #{replacement}; end") + end + end +end diff --git a/spec/unit/session_worktree_listing_spec.rb b/spec/unit/session_worktree_listing_spec.rb new file mode 100644 index 00000000..3541fe5e --- /dev/null +++ b/spec/unit/session_worktree_listing_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' +require 'securerandom' +require_relative '../fixtures/cli_transcript' +require_relative '../fixtures/claude_config_dir' + +# list_sessions(directory:) covers the directory and every worktree of its +# repository. `git worktree list` reports worktree ROOTS, so a subdirectory +# (a monorepo package) is none of the paths it prints. +RSpec.describe 'list_sessions over git worktrees' do + include_context 'with a Claude config dir' + + # One listed session recorded for +directory+: the CLI keeps it in the + # project dir named after the canonical, NFC-normalized directory. + def record_session(directory, prompt, session_id: SecureRandom.uuid) + transcript = CLITranscript.new(session_id: session_id, cwd: directory) + transcript.queue_operations(prompt) + transcript.prompt(:prompt, prompt) + transcript.assistant(:answer, transcript.text('Done.'), parent: :prompt) + transcript.last_prompt(prompt, leaf: :answer) + transcript.write(transcript_path(session_id, directory)) + end + + def summaries(directory, **) + ClaudeAgentSDK.list_sessions(directory: directory, **).map(&:summary) + end + + def git(repo, *) + output, status = Open3.capture2e('git', '-C', repo, '-c', 'user.name=spec', '-c', 'user.email=spec@example.invalid', + '-c', 'commit.gpgsign=false', *) + raise "git #{[*].join(' ')} failed: #{output}" unless status.success? + end + + # A `git` that prints +listing+ for any arguments, first on PATH: the + # reader runs it as a real subprocess and reads a real pipe. + def with_git_printing(listing) + Dir.mktmpdir('fake-git') do |bin| + script = File.join(bin, 'git') + File.binwrite(script, "#!/bin/sh\nprintf '%s\\n' #{listing.lines(chomp: true).map { |l| "'#{l}'" }.join(' ')}\n") + File.chmod(0o755, script) + saved = ENV.fetch('PATH', nil) + ENV['PATH'] = "#{bin}#{File::PATH_SEPARATOR}#{saved}" + begin + yield + ensure + ENV['PATH'] = saved + end + end + end + + let(:root) { File.join(cwd, 'repo').tap { |dir| FileUtils.mkdir_p(dir) } } + let(:second) { File.join(cwd, 'repo-wt2').tap { |dir| FileUtils.mkdir_p(dir) } } + let(:package) { File.join(root, 'packages', 'app').tap { |dir| FileUtils.mkdir_p(dir) } } + + context 'when the repository has two worktrees' do + before do + allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees).and_return([root, second]) + record_session(root, 'asked in the repository root') + record_session(second, 'asked in the second worktree') + record_session(package, 'asked in packages/app') + end + + it "lists a subdirectory's own sessions along with those of every worktree" do + expect(summaries(package)).to contain_exactly( + 'asked in packages/app', 'asked in the repository root', 'asked in the second worktree' + ) + end + + it 'lists a worktree root as before' do + expect(summaries(root)).to contain_exactly('asked in the repository root', 'asked in the second worktree') + end + + it "lists only the directory's own sessions with include_worktrees: false" do + expect(summaries(package, include_worktrees: false)).to eq(['asked in packages/app']) + end + end + + # The same session in two project dirs with equal mtime and size: the copy + # recorded for the directory that was asked about is kept. + it "keeps the directory's own copy of a session on an equal-rank duplicate" do + main = File.join(cwd, 'repo-a').tap { |dir| FileUtils.mkdir_p(dir) } + linked = File.join(cwd, 'repo-b').tap { |dir| FileUtils.mkdir_p(dir) } + allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees).and_return([main, linked]) + session_id = '7c1e9d52-3a4b-4f60-8e21-0b9a8c7d6e5f' + written = Time.utc(2026, 9, 8, 6, 0, 0) + copies = { main => 'copy kept for repo-a', linked => 'copy kept for repo-b' }.map do |directory, prompt| + record_session(directory, prompt, session_id: session_id).tap { |path| File.utime(written, written, path) } + end + + expect(copies.map { |path| File.size(path) }.uniq.length).to eq(1) + expect(summaries(linked)).to eq(['copy kept for repo-b']) + expect(summaries(main)).to eq(['copy kept for repo-a']) + end + + # `repo.one` and `repo-one` have one sanitized name, hence one project dir + # (for the CLI too). It holds the sessions of both and is read once. + it 'reads a project dir that two worktree paths share by name once' do + dotted = File.join(cwd, 'repo.one').tap { |dir| FileUtils.mkdir_p(dir) } + dashed = File.join(cwd, 'repo-one').tap { |dir| FileUtils.mkdir_p(dir) } + allow(ClaudeAgentSDK::Sessions).to receive(:detect_worktrees).and_return([dotted, dashed]) + record_session(dotted, 'asked in repo.one') + record_session(dashed, 'asked in repo-one') + allow(ClaudeAgentSDK::Sessions).to receive(:read_sessions_from_dir).and_call_original + + expect(summaries(dotted)).to contain_exactly('asked in repo.one', 'asked in repo-one') + expect(ClaudeAgentSDK::Sessions).to have_received(:read_sessions_from_dir).once + end + + # No stub: a real `git worktree list --porcelain` over a real repository. + context 'with a real repository' do + # git takes GIT_DIR and friends from the environment (they are set while + # a git hook runs the suite) and would then describe another repository. + around do |example| + saved = ENV.select { |name, _| name.start_with?('GIT_') } + saved.each_key { |name| ENV.delete(name) } + example.run + ensure + saved&.each { |name, value| ENV[name] = value } + end + + before do + git(root, 'init', '-q') + git(root, 'commit', '-q', '--no-verify', '--allow-empty', '-m', 'init') + git(root, 'worktree', 'add', '-q', '--detach', '--no-checkout', second) + record_session(root, 'asked in the repository root') + record_session(second, 'asked in the second worktree') + record_session(package, 'asked in packages/app') + end + + it 'lists a subdirectory with the sessions of both worktrees' do + expect(summaries(package)).to contain_exactly( + 'asked in packages/app', 'asked in the repository root', 'asked in the second worktree' + ) + end + + it 'lists the linked worktree with the sessions of the main one' do + expect(summaries(second)).to contain_exactly('asked in the repository root', 'asked in the second worktree') + end + end + + # git prints a path as the filesystem stores it; one that decomposes names + # (HFS+) reports an accented e as "e" + U+0301. The CLI names the project + # dir after the NFC form, and the two forms sanitize to different names. + it 'finds the sessions of a worktree whose path git reports decomposed' do + composed = File.join(cwd, "caf#{[0xE9].pack('U')}-wt") # U+00E9 + decomposed = File.join(cwd, "cafe#{[0x301].pack('U')}-wt") # e, then combining U+0301 + record_session(root, 'asked in the repository root') + record_session(composed, 'asked in the accented worktree') + + listing = "worktree #{root}\nHEAD 1111111\nbranch refs/heads/main\n\nworktree #{decomposed}\nHEAD 2222222\ndetached\n" + with_git_printing(listing) do + expect(summaries(root)).to contain_exactly('asked in the repository root', 'asked in the accented worktree') + end + end + + # LANG=C / LC_ALL=C (minimal Docker images, cron): a pipe read without an + # explicit encoding yields US-ASCII Strings, and git prints the UTF-8 bytes + # of the path. + it 'finds the sessions of a worktree with a non-ASCII path under a non-UTF-8 locale' do + worktree = File.join(cwd, 'プロジェクト-wt') + record_session(root, 'asked in the repository root') + record_session(worktree, 'asked in the Japanese worktree') + listing = "worktree #{root}\nHEAD 1111111\nbranch refs/heads/main\n\nworktree #{worktree}\nHEAD 2222222\ndetached\n" + saved = Encoding.default_external + + listed = with_git_printing(listing) do + silence_warnings { Encoding.default_external = Encoding::US_ASCII } + summaries(root) + ensure + silence_warnings { Encoding.default_external = saved } + end + + expect(listed).to contain_exactly('asked in the repository root', 'asked in the Japanese worktree') + end + + def silence_warnings + verbose = $VERBOSE + $VERBOSE = nil # assigning Encoding.default_external warns under -w + yield + ensure + $VERBOSE = verbose + end +end diff --git a/spec/unit/stderr_callback_isolation_spec.rb b/spec/unit/stderr_callback_isolation_spec.rb new file mode 100644 index 00000000..027d4564 --- /dev/null +++ b/spec/unit/stderr_callback_isolation_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'tmpdir' + +# The `stderr:` callback runs on the transport's stderr drain thread. An +# exception that is not a StandardError (NotImplementedError and LoadError +# are ScriptErrors; SystemStackError is its own branch) used to end that +# thread. Nothing read the pipe any more, and a CLI that then wrote more than +# one pipe buffer of stderr (64 KiB) blocked in write(2) for good. +RSpec.describe ClaudeAgentSDK::SubprocessCLITransport, 'stderr callback isolation' do + after { described_class.active_processes.clear } + + around do |example| + Dir.mktmpdir('stderr-callback-spec') do |dir| + @dir = dir + example.run + end + end + + # 800 lines of ~120 bytes: about 94 KiB, well past one pipe buffer. + let(:stderr_line_count) { 800 } + let(:last_stderr_line) { format('stderr line %<n>04d %<pad>s', n: stderr_line_count - 1, pad: 'x' * 100) } + + # Fills stderr, then ends the way a failed run does: a result frame on + # stdout and a non-zero exit — which is when the SDK reports what it kept + # of stderr (ProcessError#stderr). + let(:fake_cli) do + path = File.join(@dir, 'claude') + File.write(path, <<~SH) + #!/bin/sh + if [ "$1" = "-v" ]; then + echo '2.1.286 (Claude Code)' + exit 0 + fi + pad=#{'x' * 100} + i=0 + while [ "$i" -lt #{stderr_line_count} ]; do + printf 'stderr line %04d %s\\n' "$i" "$pad" >&2 + i=$((i + 1)) + done + printf '%s\\n' '#{JSON.generate(sample_result_message)}' + exit 1 + SH + File.chmod(0o755, path) + path + end + + { + NotImplementedError => 'Logger#write is abstract', + LoadError => 'cannot load such file -- some_logger', + SystemStackError => 'stack level too deep' + }.each do |error_class, message| + it "keeps draining after the callback raises #{error_class} on the first line" do + seen = [] + callback = lambda do |line| + seen << line + raise error_class, message if seen.size == 1 + end + transport = described_class.new(ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: fake_cli, stderr: callback)) + frames = [] + + begin + transport.connect + # A drain thread the callback killed leaves the fake blocked on a + # full pipe, short of its result frame, so reading stdout would + # hang. Joining the thread first reports that as the callback's own + # exception instead. + transport.instance_variable_get(:@stderr_task).join + + expect { transport.read_messages { |frame| frames << frame } } + .to raise_error(ClaudeAgentSDK::ProcessError) { |error| + expect(error.exit_code).to eq(1) + expect(error.stderr.lines.last).to eq(last_stderr_line) + } + ensure + transport.close + end + + expect(frames.map { |frame| frame[:type] }).to eq(['result']) + expect(seen.size).to eq(stderr_line_count) + expect(seen.last).to eq(last_stderr_line) + end + end +end diff --git a/spec/unit/strict_attributes_inheritance_spec.rb b/spec/unit/strict_attributes_inheritance_spec.rb new file mode 100644 index 00000000..d3475e41 --- /dev/null +++ b/spec/unit/strict_attributes_inheritance_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Strict attributes are documented as inherited (Type.strict_attributes): a +# subclass of a type the user builds — HookMatcher here — rejects a key it +# does not know, as the type itself does, and still accepts one that names a +# reader the subclass defines. strict_attributes_spec.rb covers the SDK's +# own classes; nothing covered a subclass. +RSpec.describe ClaudeAgentSDK::Type do + describe 'strict attributes on a subclass of a strict type' do + it 'raises for an unknown key, as the parent type does' do + subclass = Class.new(ClaudeAgentSDK::HookMatcher) + + expect { subclass.new(matchr: 'Bash') }.to raise_error(ArgumentError, /unknown attribute :matchr/) + expect(subclass.new(matcher: 'Bash').matcher).to eq('Bash') + end + + it 'accepts a key that names a reader the subclass defines' do + subclass = Class.new(ClaudeAgentSDK::HookMatcher) do + def note + 'defined by the subclass' + end + end + + expect { subclass.new(matcher: 'Bash', note: 'x') }.not_to raise_error + expect { subclass.new(notes: 'x') }.to raise_error(ArgumentError, /unknown attribute :notes/) + end + end +end diff --git a/spec/unit/subprocess_cli_transport_fake_cli_spec.rb b/spec/unit/subprocess_cli_transport_fake_cli_spec.rb new file mode 100644 index 00000000..67827bcb --- /dev/null +++ b/spec/unit/subprocess_cli_transport_fake_cli_spec.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +# SubprocessCLITransport against a real child process (FakeClaude, +# spec/support/fake_claude.rb): what #connect hands the child, what it raises +# when the spawn fails, and how #close and the read loop treat a child that +# finishes, lingers or fails. The other transport specs enter #connect with +# Open3.popen3 stubbed, or plant a process in @process; here nothing is +# stubbed, and what the child saw is read from the child's own log. +RSpec.describe ClaudeAgentSDK::SubprocessCLITransport do + around do |example| + Dir.mktmpdir('fake-claude') do |dir| + @dir = File.realpath(dir) + @cli = FakeClaude.install(@dir) + @log = File.join(@dir, 'fake-claude.jsonl') + example.run + end + end + + def transport_for(scenario: 'conversation', cli_path: @cli, cwd: @dir, env: {}, **options) + described_class.new( + ClaudeAgentSDK::ClaudeAgentOptions.new( + cli_path: cli_path, cwd: cwd, env: FakeClaude.env(scenario: scenario, log: @log).merge(env), **options + ) + ) + end + + # Runs the block on a thread of its own and fails the example if it is + # still running after +seconds+. The regressions these examples guard + # against leave the SDK waiting for a child forever, and that has to be a + # failure here, not a suite that never ends. Generous on purpose: nothing + # healthy comes near it. + def bounded(seconds = 60) + worker = Thread.new do + Thread.current.report_on_exception = false # the error is re-raised below, by #value + yield + end + return worker.value if worker.join(seconds) + + # The child is what the SDK is stuck on: take it away, then report. + FakeClaude.pids(@log).each { |pid| kill_fake_claude(pid) } + worker.kill unless worker.join(10) + raise "still running after #{seconds}s (the fake CLI was killed to end the example)" + end + + # KILLs +pid+ only while it still is the fake CLI: a pid outlives its + # process as a number that the system hands out again. + def kill_fake_claude(pid) + command, = Open3.capture2('ps', '-o', 'command=', '-p', pid.to_s) + Process.kill('KILL', pid) if command.include?('fake_claude.rb') + rescue Errno::ESRCH + nil + end + + # The first exchange of every session, and proof that the child is up and + # reading: the `initialize` request the SDK sends, and the child's answer. + def handshake(transport) + request = { type: 'control_request', request_id: 'req_1_0a1b2c3d', + request: { subtype: 'initialize', hooks: nil, agents: nil } } + transport.write("#{JSON.generate(request)}\n") + answer = transport.read_messages { |frame| break frame } + expect(answer).to include(type: 'control_response') + expect(answer.dig(:response, :request_id)).to eq('req_1_0a1b2c3d') + end + + def events + FakeClaude.events(@log) + end + + describe '#connect' do + it 'starts the child in cwd with the inherited environment, except CLAUDECODE' do + previous = %w[CLAUDECODE FAKE_CLAUDE_INHERITED].to_h { |name| [name, ENV.fetch(name, nil)] } + ENV['CLAUDECODE'] = '1' # what a shell inside Claude Code has; the CLI refuses to nest under it + ENV['FAKE_CLAUDE_INHERITED'] = 'yes' + transport = transport_for(env: { 'FAKE_CLAUDE_FROM_OPTIONS' => 'yes' }) + + bounded do + transport.connect + handshake(transport) + transport.close + end + + start = events.first + expect(start).to include('event' => 'start', 'cwd' => @dir) + # Inherited and option-supplied variables both arrive, so the missing + # one below was removed, not lost with everything else. + expect(start.fetch('env_names')).to include('FAKE_CLAUDE_INHERITED', 'FAKE_CLAUDE_FROM_OPTIONS') + expect(start.fetch('env_names')).not_to include('CLAUDECODE') + expect(start.dig('env', 'CLAUDE_AGENT_SDK_VERSION')).to eq(ClaudeAgentSDK::VERSION) + expect(start.dig('env', 'PWD')).to eq(@dir) + ensure + transport&.close + previous&.each { |name, value| value ? ENV[name] = value : ENV.delete(name) } + end + + it 'raises CLINotFoundError when the executable does not exist' do + missing = File.join(@dir, 'no-such-claude') + transport = transport_for(cli_path: missing) + + expect { bounded { transport.connect } } + .to raise_error(ClaudeAgentSDK::CLINotFoundError, /#{Regexp.escape(missing)}/) + expect(transport).not_to be_ready + ensure + transport&.close + end + + it 'raises a CLIConnectionError naming the directory when cwd does not exist' do + gone = File.join(@dir, 'gone') + transport = transport_for(cwd: gone) + + expect { bounded { transport.connect } }.to raise_error(ClaudeAgentSDK::CLIConnectionError) do |error| + # The executable is there: this must not be reported as a missing CLI. + expect(error).not_to be_a(ClaudeAgentSDK::CLINotFoundError) + expect(error.message).to include(gone) + end + expect(events).to be_empty # the child was never started + ensure + transport&.close + end + end + + describe '#close' do + # The grace period after stdin EOF exists for this child: the CLI flushes + # its session file before it exits, and a SIGTERM in that window loses + # the last message. A child that ignores TERM cannot tell a grace period + # from none; one that needs a moment can. + it 'lets a child that needs a moment after stdin EOF finish on its own, unsignalled' do + transport = transport_for(scenario: 'slow_exit') + + bounded do + transport.connect + handshake(transport) + transport.close + end + + expect(events.map { |event| event['event'] }).to eq(%w[start stdin eof flushed exit]) + expect(events.last).to include('status' => 0) + ensure + transport&.close + end + + it 'kills a child that ignores stdin EOF and SIGTERM' do + transport = transport_for(scenario: 'wedged') + + # Real time: the ladder waits 5s for the exit and 2s after TERM before + # KILL. If the escalation regresses, #close waits for a child that + # never dies and the bound ends the example. + bounded do + transport.connect + handshake(transport) + transport.close + end + + # It saw EOF, was asked with TERM, and never logged an exit of its own... + expect(events.map { |event| event['event'] }).to eq(%w[start stdin eof signal]) + expect(events.last).to include('name' => 'TERM') + # ...and it is gone, reaped, all the same. + expect { Process.kill(0, events.first.fetch('pid')) }.to raise_error(Errno::ESRCH) + ensure + transport&.close + end + end + + describe '#read_messages and #write around a child that has exited' do + it 'keeps the last 20 stderr lines and puts the last 10 in the ProcessError of a failed child' do + lines = Thread::Queue.new + transport = transport_for(scenario: 'stderr_then_fail', env: { 'FAKE_CLAUDE_STDERR_LINES' => '25' }, + stderr: ->(line) { lines << line }) + written = Array.new(25) { |i| "fake claude: stderr line #{i + 1}" } + + error = bounded do + transport.connect + # All 25 have gone through the stderr thread before the child is + # allowed to exit, so what the error carries below is not a race + # with that thread. + expect(Array.new(25) { lines.pop(timeout: 30) }).to eq(written) + transport.end_input + begin + transport.read_messages { |_frame| nil } + nil + rescue ClaudeAgentSDK::ProcessError => e + e + end + end + + expect(error).to be_a(ClaudeAgentSDK::ProcessError) + expect(error.exit_code).to eq(1) + expect(error.stderr).to eq(written.last(10).join("\n")) + expect(transport.instance_variable_get(:@recent_stderr)).to eq(written.last(20)) + ensure + transport&.close + end + + it 'refuses to write to a child that has exited' do + transport = transport_for(scenario: 'exit_at_once') + + bounded do + transport.connect + transport.read_messages { |_frame| nil } # returns at stdout EOF, once the child is reaped + expect { transport.write("{}\n") } + .to raise_error(ClaudeAgentSDK::CLIConnectionError, 'Cannot write to terminated process') + end + ensure + transport&.close + end + end + + # The whole stack on the fake: ClaudeAgentSDK.query spawns it, probes its + # version, initializes, serves the SDK MCP handshake, a hook and a tool + # call, and closes stdin once the child reports idle. + describe 'a query() run end to end' do + it 'serves hooks and an SDK MCP server over the control channel, then lets the child exit on its own' do + hook_inputs = [] + hook = lambda do |input, _tool_use_id, _context| + hook_inputs << input + {} + end + tool_calls = [] + add = ClaudeAgentSDK.create_tool('add', 'Add two numbers', { a: :number, b: :number }) do |args| + tool_calls << args + (args[:a] + args[:b]).to_s + end + options = ClaudeAgentSDK::ClaudeAgentOptions.new( + cli_path: @cli, cwd: @dir, + env: FakeClaude.env(log: @log, tool_call: JSON.generate(server: 'calc', name: 'add', arguments: { a: 2, b: 3 })), + hooks: { 'PreToolUse' => [ClaudeAgentSDK::HookMatcher.new(matcher: 'Bash', hooks: [hook])] }, + mcp_servers: { 'calc' => ClaudeAgentSDK.create_sdk_mcp_server(name: 'calc', tools: [add]) } + ) + + messages = [] + bounded { ClaudeAgentSDK.query(prompt: 'hi', options: options) { |message| messages << message } } + + # What the caller sees. + expect(messages.map(&:class)).to eq([ClaudeAgentSDK::InitMessage, ClaudeAgentSDK::AssistantMessage, + ClaudeAgentSDK::ResultMessage]) + expect(messages.last.result).to eq('OK') + expect(hook_inputs.map(&:class)).to eq([ClaudeAgentSDK::PreToolUseHookInput]) + expect(hook_inputs.first.tool_input).to eq(command: 'ls', description: 'List files') + expect(tool_calls).to eq([{ a: 2, b: 3 }]) + + # What the child received on its stdin. + received = events.select { |event| event['event'] == 'stdin' }.map { |event| event.fetch('frame') } + expect(received.first.dig('request', 'subtype')).to eq('initialize') + expect(received.first.dig('request', 'hooks', 'PreToolUse')).to eq( + [{ 'matcher' => 'Bash', 'hookCallbackIds' => ['hook_0'] }] + ) + expect(received.find { |frame| frame['type'] == 'user' }.dig('message', 'content')).to eq('hi') + mcp_results = received.filter_map { |frame| frame.dig('response', 'response', 'mcp_response', 'result') } + expect(mcp_results[0]).to include('protocolVersion' => '2024-11-05', 'capabilities' => { 'tools' => {} }) + expect(mcp_results[2].fetch('tools').map { |tool| tool['name'] }).to eq(['add']) + expect(mcp_results[3].fetch('content')).to eq([{ 'type' => 'text', 'text' => '5' }]) + + # stdin was closed once the child reported idle, and it exited by + # itself: an EOF, then a clean exit, no signal. + expect(events.last(2).map { |event| event['event'] }).to eq(%w[eof exit]) + expect(events.last).to include('status' => 0) + end + end +end diff --git a/spec/unit/subprocess_cli_transport_spec.rb b/spec/unit/subprocess_cli_transport_spec.rb index dbcb967f..e4cfbdf0 100644 --- a/spec/unit/subprocess_cli_transport_spec.rb +++ b/spec/unit/subprocess_cli_transport_spec.rb @@ -11,6 +11,23 @@ # (and other specs) from observing leaked test doubles. after { described_class.active_processes.clear } + # SIGKILL cannot be refused, but how soon the kernel has reaped the child + # afterwards is the scheduler's business. Makes Process.kill return from a + # KILL of +waiter+'s process only once that process has been reaped, so an + # example can keep a short grace for the wait that has to lapse (its child + # ignores TERM) without racing the wait that follows the KILL. The signals + # are still sent for real. Returns the [signal, pid] pairs, in order. + def reap_before_kill_returns(waiter) + sent = [] + allow(Process).to receive(:kill).and_wrap_original do |original, signal, pid| + sent << [signal, pid] + result = original.call(signal, pid) + waiter.join(30) if signal == 'KILL' && pid == waiter.pid + result + end + sent + end + describe '#build_command' do it 'passes a string system_prompt via --system-prompt' do options = ClaudeAgentSDK::ClaudeAgentOptions.new( @@ -459,10 +476,17 @@ let(:tmp_dir) { @tmp_dir } + # PATH is one directory of the example's own, empty unless the example + # calls #claude_on_path: discovery searches PATH itself, and the host's + # real `claude` must never be what it finds. around do |example| Dir.mktmpdir('find-cli-spec') do |dir| @tmp_dir = dir + previous_path = ENV.fetch('PATH', nil) + ENV['PATH'] = File.join(dir, 'on-path').tap { |path_dir| FileUtils.mkdir_p(path_dir) } example.run + ensure + previous_path.nil? ? ENV.delete('PATH') : (ENV['PATH'] = previous_path) end end @@ -472,8 +496,6 @@ allow(ENV).to receive(:fetch).and_call_original allow(ENV).to receive(:fetch).with('CLAUDE_CLI_PATH', nil).and_return(nil) allow(ClaudeAgentSDK::CLIInstaller).to receive(:installed_path).and_return(nil) - allow(Open3).to receive(:capture2).and_call_original - allow(Open3).to receive(:capture2).with('which', 'claude').and_return(['', nil]) end def executable(name) @@ -483,11 +505,16 @@ def executable(name) path end + # A `claude` in the one directory PATH names. + def claude_on_path + executable(File.join('on-path', 'claude')) + end + it 'prefers CLAUDE_CLI_PATH over everything else' do env_cli = executable('env-claude') allow(ENV).to receive(:fetch).with('CLAUDE_CLI_PATH', nil).and_return(env_cli) allow(ClaudeAgentSDK::CLIInstaller).to receive(:installed_path).and_return(executable('vendored-claude')) - allow(Open3).to receive(:capture2).with('which', 'claude').and_return([executable('which-claude'), nil]) + claude_on_path expect(transport.find_cli).to eq(env_cli) end @@ -525,27 +552,25 @@ def executable(name) expect(transport.find_cli).to eq(vendored) end - it 'prefers the vendored binary over a `which`-discovered one' do + it 'prefers the vendored binary over one on PATH' do vendored = executable('vendored-claude') allow(ClaudeAgentSDK::CLIInstaller).to receive(:installed_path).and_return(vendored) - allow(Open3).to receive(:capture2).with('which', 'claude').and_return([executable('which-claude'), nil]) + claude_on_path expect(transport.find_cli).to eq(vendored) end - it 'falls back to `which` when no override and no vendored binary exist' do - which_cli = executable('which-claude') - allow(Open3).to receive(:capture2).with('which', 'claude').and_return(["#{which_cli}\n", nil]) + it 'falls back to PATH when no override and no vendored binary exist' do + path_cli = claude_on_path - expect(transport.find_cli).to eq(which_cli) + expect(transport.find_cli).to eq(path_cli) end it 'tolerates CLIInstaller.installed_path raising' do - which_cli = executable('which-claude') + path_cli = claude_on_path allow(ClaudeAgentSDK::CLIInstaller).to receive(:installed_path).and_raise(Errno::ENOENT) - allow(Open3).to receive(:capture2).with('which', 'claude').and_return([which_cli, nil]) - expect(transport.find_cli).to eq(which_cli) + expect(transport.find_cli).to eq(path_cli) end it 'mentions the installer and CLAUDE_CLI_PATH when nothing is found' do @@ -1458,6 +1483,15 @@ def park_thread_writer(transport, payload, outcome) writer end + # What the parked thread writer ended with. Bounded: a shutdown that + # never interrupts the writer leaves the queue empty forever, and that + # must fail the example, not hang the suite. + def writer_outcome(outcome) + result = outcome.pop(timeout: 10) + expect(result).not_to be_nil, 'the writer parked in IO#write was never interrupted' + result + end + def expect_woken_with_connection_error(writer, caught) expect(writer).to be_finished expect(caught.size).to eq(1) @@ -1514,7 +1548,7 @@ def expect_woken_with_connection_error(writer, caught) expect(second).not_to be_finished task.with_timeout(5, hang) { transport.end_input } - second.wait + task.with_timeout(10) { second.wait } # bounded: a writer left parked must fail here, not hang expect(first).to be_finished expect(caught.size).to eq(2) @@ -1542,7 +1576,7 @@ def expect_woken_with_connection_error(writer, caught) task.with_timeout(5, hang) { transport.close } end.wait - expect(outcome.pop).to be_a(ClaudeAgentSDK::CLIConnectionError) + expect(writer_outcome(outcome)).to be_a(ClaudeAgentSDK::CLIConnectionError) expect(w).to be_closed ensure writer&.join(5) @@ -1558,7 +1592,7 @@ def expect_woken_with_connection_error(writer, caught) task.with_timeout(5, hang) { transport.end_input } end.wait - expect(outcome.pop).to be_a(ClaudeAgentSDK::CLIConnectionError) + expect(writer_outcome(outcome)).to be_a(ClaudeAgentSDK::CLIConnectionError) expect(w).to be_closed expect(transport.instance_variable_get(:@stdin)).to be_nil ensure @@ -1621,7 +1655,7 @@ def expect_woken_with_connection_error(writer, caught) end.wait # The detached helper's close interrupts the writer. - expect(outcome.pop).to be_a(ClaudeAgentSDK::CLIConnectionError) + expect(writer_outcome(outcome)).to be_a(ClaudeAgentSDK::CLIConnectionError) ensure writer&.join(5) [r, w].each { |io| io&.close unless io&.closed? } @@ -1633,6 +1667,10 @@ def expect_woken_with_connection_error(writer, caught) let(:options) { ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: '/usr/bin/claude') } let(:transport) { described_class.new('hi', options) } + # Short graces, for the waits that have to lapse: a child that never + # exits on its own, or ignores TERM. An example whose child is expected + # to act INSIDE a grace (die on TERM, exit by itself) gives that grace a + # generous value instead, so a slow machine cannot change the outcome. before do stub_const("#{described_class}::EOF_EXIT_GRACE_SECONDS", 0.2) stub_const("#{described_class}::EOF_TERM_GRACE_SECONDS", 0.2) @@ -1664,7 +1702,7 @@ def read_to_end(transport) rescue StandardError => e e end - expect(@runner.join(10)).not_to be_nil, 'read_messages never returned after stdout EOF' + expect(@runner.join(60)).not_to be_nil, 'read_messages never returned after stdout EOF' @runner.value end @@ -1683,8 +1721,10 @@ def reap(stdout, stderr, waiter) it 'escalates TERM then KILL for a child that ignores TERM, surfacing a ProcessError like the signal path' do stdout, stderr, waiter = spawn_child('sleep', prelude: 'trap("TERM") {}; ') + sent = reap_before_kill_returns(waiter) error = read_to_end(transport) + expect(sent.first(2)).to eq([['TERM', waiter.pid], ['KILL', waiter.pid]]) expect(error).to be_a(ClaudeAgentSDK::ProcessError) expect(error.exit_code).to eq(-9) expect(error.message).to include('did not exit within 0.2s of closing stdout') @@ -1695,6 +1735,9 @@ def reap(stdout, stderr, waiter) end it 'TERMs a child that keeps running after stdout EOF and reports the signal' do + # A healthy child dies on TERM in milliseconds; the generous grace only + # keeps a slow machine from escalating to KILL. + stub_const("#{described_class}::EOF_TERM_GRACE_SECONDS", 30) stdout, stderr, waiter = spawn_child('sleep') error = read_to_end(transport) @@ -1706,6 +1749,7 @@ def reap(stdout, stderr, waiter) end it 'waits without blocking the reactor when the read loop runs in a task' do + stub_const("#{described_class}::EOF_TERM_GRACE_SECONDS", 30) # as above: TERM is expected to work stdout, stderr, waiter = spawn_child('sleep') ticks = 0 @@ -1757,7 +1801,7 @@ def reap(stdout, stderr, waiter) it 'does not signal a child that exits on its own within the grace period' do # A generous grace here: interpreter exit + reap must fit inside it even # on a slow CI runner, or the example would signal a healthy child. - stub_const("#{described_class}::EOF_EXIT_GRACE_SECONDS", 5) + stub_const("#{described_class}::EOF_EXIT_GRACE_SECONDS", 30) expect(Process).not_to receive(:kill) stdout, stderr, waiter = spawn_child('exit 0') @@ -1950,9 +1994,17 @@ def bare_transport expect(stdout.gets).to eq("ready\n") described_class.register_active_process(waiter) - worker = bare_transport.force_terminate_in_background(waiter, grace_seconds: 0.05) - expect(worker.join(2)).not_to be_nil - expect(waiter.join(1)).not_to be_nil + # The worker waits grace_seconds twice: for TERM to work, then for the + # KILLed child to be reaped. A child that dies on TERM gets a generous + # grace (it is gone in milliseconds, so this stays fast). One that + # ignores TERM keeps the short grace, which has to lapse anyway, and + # the KILL only returns once the child is reaped. + sent = reap_before_kill_returns(waiter) if ignore_term + worker = bare_transport.force_terminate_in_background(waiter, grace_seconds: ignore_term ? 0.05 : 30) + # Worker finished and child reaped, before the registry is read. + expect(worker.join(60)).not_to be_nil + expect(sent).to eq([['TERM', waiter.pid], ['KILL', waiter.pid]]) if ignore_term + expect(waiter.join(60)).not_to be_nil expect(waiter.value.termsig).to eq(Signal.list.fetch(ignore_term ? 'KILL' : 'TERM')) expect(described_class.active_processes).not_to include(waiter) ensure diff --git a/spec/unit/tools_string_and_pathname_options_spec.rb b/spec/unit/tools_string_and_pathname_options_spec.rb new file mode 100644 index 00000000..05a24021 --- /dev/null +++ b/spec/unit/tools_string_and_pathname_options_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'pathname' +require 'tmpdir' + +# Two forms a Ruby caller writes naturally and the command builder had no +# branch for, so they were dropped without a word: +# +# tools: 'Read,Grep' the CLI's own --tools syntax. No flag was sent, so +# the session got EVERY built-in tool, not two. +# a Pathname where a path is expected. settings: Pathname sent no +# --settings at all; SystemPromptFile#path and a plugin +# path put the Pathname itself on the command line, +# which the spawn rejects ("no implicit conversion of +# Pathname into String"). +RSpec.describe 'tools as a String and paths as Pathnames' do + # The command line built for these options. + def argv(**options) + ClaudeAgentSDK::CommandBuilder.new('/usr/bin/claude', ClaudeAgentSDK::ClaudeAgentOptions.new(**options)).build + end + + # The value that follows +flag+ on that command line (nil without the flag). + def flag_value(cmd, flag) + index = cmd.index(flag) + index && cmd[index + 1] + end + + describe 'tools: as a String' do + it 'builds the command line the Array of the same names builds' do + expect(argv(tools: 'Read,Grep')).to eq(argv(tools: %w[Read Grep])) + end + + it 'is passed to --tools as written: it is the CLI\'s own syntax' do + values = ['Read,Grep', 'Read', 'default', ''].map { |tools| flag_value(argv(tools: tools), '--tools') } + + expect(values).to eq(['Read,Grep', 'Read', 'default', '']) + end + + it 'still sends no --tools flag for a value of another class', + rbs_incompatible: 'passes values outside the signature of tools to show they are still ignored' do + expect([42, :default, true].map { |tools| argv(tools: tools) }).to eq([argv] * 3) + end + end + + describe 'settings: as a Pathname' do + it 'builds the command line the String path builds' do + path = '/srv/app/config/claude.json' + + expect(argv(settings: Pathname.new(path))).to eq(argv(settings: path)) + expect(flag_value(argv(settings: Pathname.new(path)), '--settings')).to eq(path) + end + + it 'is read and merged with the sandbox option like the String path, relative to the CLI\'s cwd' do + Dir.mktmpdir('pathname-settings') do |dir| + File.write(File.join(dir, 'claude.json'), JSON.generate(permissions: { deny: ['WebFetch'] })) + absolute = File.join(dir, 'claude.json') + forms = [ + { settings: Pathname.new(absolute) }, + { settings: Pathname.new('claude.json'), cwd: dir }, + { settings: Pathname.new('claude.json'), cwd: Pathname.new(dir) } + ] + + merged = forms.map { |form| JSON.parse(flag_value(argv(sandbox: { enabled: true }, **form), '--settings')) } + + expect(merged).to eq([{ 'permissions' => { 'deny' => ['WebFetch'] }, 'sandbox' => { 'enabled' => true } }] * 3) + expect(argv(sandbox: { enabled: true }, settings: Pathname.new(absolute))) + .to eq(argv(sandbox: { enabled: true }, settings: absolute)) + end + end + + # A String is tried as inline JSON first and taken for a path when it does + # not parse. A Pathname says which of the two it is. + it 'is a path even when its name would parse as JSON' do + expect(flag_value(argv(settings: Pathname.new('42')), '--settings')).to eq('42') + end + end + + describe 'the path of a system prompt file, as a Pathname' do + path = '/srv/app/prompts/reviewer.md' + { + 'a SystemPromptFile' => ClaudeAgentSDK::SystemPromptFile.new(path: Pathname.new(path)), + 'a Hash' => { type: 'file', path: Pathname.new(path) }, + 'a Hash with a Symbol type and String keys' => { 'type' => :file, 'path' => Pathname.new(path) } + }.each do |form, system_prompt| + it "reaches the command line as a String for #{form}" do + cmd = argv(system_prompt: system_prompt) + + expect(cmd).to all(be_a(String)) + expect(cmd).to eq(argv(system_prompt: ClaudeAgentSDK::SystemPromptFile.new(path: path))) + expect(flag_value(cmd, '--system-prompt-file')).to eq(path) + end + end + + it 'stays the Pathname the caller gave on the typed value' do + pathname = Pathname.new(path) + prompt = ClaudeAgentSDK::SystemPromptFile.new(path: pathname) + + expect(prompt.path).to equal(pathname) + expect(prompt.to_h).to eq(type: 'file', path: pathname) + end + end + + describe 'a plugin path as a Pathname' do + path = '/srv/app/plugins/review' + { + 'an SdkPluginConfig' => ClaudeAgentSDK::SdkPluginConfig.new(path: Pathname.new(path)), + 'a Hash' => { type: 'local', path: Pathname.new(path) }, + 'a Hash with String keys' => { 'type' => 'local', 'path' => Pathname.new(path) } + }.each do |form, plugin| + it "reaches the command line as a String for #{form}" do + cmd = argv(plugins: [plugin]) + + expect(cmd).to all(be_a(String)) + expect(cmd).to eq(argv(plugins: [ClaudeAgentSDK::SdkPluginConfig.new(path: path)])) + expect(flag_value(cmd, '--plugin-dir')).to eq(path) + end + end + + it 'stays the Pathname the caller gave on the typed value' do + pathname = Pathname.new(path) + plugin = ClaudeAgentSDK::SdkPluginConfig.new(path: pathname) + + expect(plugin.path).to equal(pathname) + expect(plugin.to_h).to eq(type: 'local', path: pathname) + end + end +end diff --git a/spec/unit/transcript_mirror_batcher_backoff_spec.rb b/spec/unit/transcript_mirror_batcher_backoff_spec.rb new file mode 100644 index 00000000..5f5a4588 --- /dev/null +++ b/spec/unit/transcript_mirror_batcher_backoff_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' + +RSpec.describe ClaudeAgentSDK::TranscriptMirrorBatcher do + # Between attempts the batcher slept with Async::Task#sleep when a reactor + # was running. async deprecated that in favor of Kernel#sleep, which is + # scheduler-aware, and warns about it when warnings are on (`ruby -w`): + # one line on stderr per retry. + describe 'retry backoff' do + let(:projects) { '/cfg/projects' } + let(:file_path) { "#{projects}/-Users-dev-app/11111111-1111-4111-8111-111111111111.jsonl" } + + # What `ruby -w` turns on. + around do |example| + verbose = $VERBOSE + deprecated = Warning[:deprecated] + $VERBOSE = true + Warning[:deprecated] = true + example.run + ensure + $VERBOSE = verbose + Warning[:deprecated] = deprecated + end + + it 'retries inside a reactor without a deprecation warning' do + stub_const("#{described_class}::MIRROR_APPEND_BACKOFF_S", [0, 0].freeze) # nothing to wait for here + attempts = 0 + store = Class.new(ClaudeAgentSDK::SessionStore) do + define_method(:append) do |_key, _entries| + attempts += 1 + raise IOError, 'connection reset' if attempts == 1 + end + + def load(_key) = nil + end.new + batcher = described_class.new(store: store, projects_dir: projects, on_error: ->(_key, _message) {}) + + expect do + Async do + batcher.enqueue(file_path, [{ type: 'user', uuid: 'a' }]) + batcher.flush + end.wait + end.not_to output(/Async::Task#sleep/).to_stderr + + expect(attempts).to eq(2) # the backoff ran: one failure, one retry + end + + # With [0, 0] intervals the example above cannot tell a backoff from no + # backoff at all. Here the configured intervals have to reach + # Kernel#sleep — intercepted on the batcher, so nothing actually waits. + it 'sleeps the configured interval before each retry' do + stub_const("#{described_class}::MIRROR_APPEND_BACKOFF_S", [0.25, 0.5].freeze) + attempts = 0 + store = Class.new(ClaudeAgentSDK::SessionStore) do + define_method(:append) do |_key, _entries| + attempts += 1 + raise IOError, 'connection reset' if attempts < 3 + end + + def load(_key) = nil + end.new + batcher = described_class.new(store: store, projects_dir: projects, on_error: ->(_key, _message) {}) + sleeps = [] + allow(batcher).to receive(:sleep) { |seconds| sleeps << seconds } + + Async do + batcher.enqueue(file_path, [{ type: 'user', uuid: 'a' }]) + batcher.flush + end.wait + + expect(attempts).to eq(3) + expect(sleeps).to eq([0.25, 0.5]) + end + end +end diff --git a/spec/unit/transcript_mirror_batcher_cancellation_spec.rb b/spec/unit/transcript_mirror_batcher_cancellation_spec.rb new file mode 100644 index 00000000..a2ea58dc --- /dev/null +++ b/spec/unit/transcript_mirror_batcher_cancellation_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'async' +require 'async/condition' + +# A drain takes the pending buffer only once it holds the lock. It used to +# detach first and then queue for the lock, so a #flush cancelled while it +# waited behind an in-flight append (a task stopped, a timeout around the +# caller) took its batch down with it: counted by #batches_dropped?, but never +# appended and never reported through on_error (upstream Python PR #1289). +# +# Every interleaving below is forced with a gate the example controls — a +# Thread::Queue for the default thread-hop store, an Async::Condition for a +# fiber-native one. Nothing waits on a clock. +RSpec.describe ClaudeAgentSDK::TranscriptMirrorBatcher do + let(:projects) { '/cfg/projects' } + let(:file_path) { "#{projects}/-Users-dev-app/11111111-1111-4111-8111-111111111111.jsonl" } + let(:errors) { [] } + let(:on_error) { ->(key, message) { errors << [key, message] } } + + # Each append announces itself on `started`, then parks its (batcher-spawned) + # worker thread on `gate` until the example closes it. + let(:gated_store) do + Class.new(ClaudeAgentSDK::SessionStore) do + attr_reader :started, :gate + + def initialize + super + @entries = [] + @mutex = Mutex.new + @started = Thread::Queue.new + @gate = Thread::Queue.new + end + + def append(_key, entries) + @started.push(true) + @gate.pop # nil once the gate is closed + @mutex.synchronize { @entries.concat(entries) } + end + + def load(_key) = @mutex.synchronize { @entries.dup } + def uuids = load(nil).map { |entry| entry['uuid'] } + end.new + end + + # Fiber-native adapter: #append runs in place on the reactor and parks on a + # condition until the example releases it. + let(:inline_store) do + Class.new(ClaudeAgentSDK::SessionStore) do + attr_reader :uuids + + def initialize + super + @uuids = [] + @entered = false + @released = false + @gate = Async::Condition.new + end + + def callback_scheduling = :inline + def entered? = @entered + def load(_key) = nil + + def append(_key, entries) + @entered = true + @gate.wait until @released + @uuids.concat(entries.map { |entry| entry['uuid'] }) + end + + def release! + @released = true + @gate.signal + end + end.new + end + + def batcher(store) + described_class.new(store: store, projects_dir: projects, on_error: on_error) + end + + # A frame as the transport delivers it: symbol keys. + def frame(uuid) + [{ type: 'user', uuid: uuid, message: { role: 'user', content: uuid } }] + end + + it 'keeps the batch of a flush cancelled while it waits behind an in-flight append' do + b = batcher(gated_store) + Async do |task| + task.with_timeout(30) do + b.enqueue(file_path, frame('one')) + first = task.async { b.flush } # holds the lock; its append is parked on the gate + gated_store.started.pop + b.enqueue(file_path, frame('two')) + waiter = task.async { b.flush } # queued on the lock behind `first` + waiter.stop # ... and cancelled there + b.enqueue(file_path, frame('three')) + + gated_store.gate.close + first.wait + b.close + end + end.wait + + expect(gated_store.uuids).to eq(%w[one two three]) + expect(b.batches_dropped?).to be(false) + expect(errors).to be_empty + end + + it 'keeps it for a fiber-native (inline) store too' do + b = batcher(inline_store) + Async do |task| + task.with_timeout(30) do + b.enqueue(file_path, frame('one')) + first = task.async { b.flush } # in place on the reactor, parked on the condition + expect(inline_store).to be_entered + b.enqueue(file_path, frame('two')) + waiter = task.async { b.flush } + waiter.stop + b.enqueue(file_path, frame('three')) + + inline_store.release! + first.wait + b.close + end + end.wait + + expect(inline_store.uuids).to eq(%w[one two three]) + expect(b.batches_dropped?).to be(false) + expect(errors).to be_empty + end + + # What a drain HAS detached is still lost with it, and counted: the + # materialized resume dir then holds the only copy and must be kept. + it 'still counts a batch as dropped when its drain is cancelled inside the append' do + b = batcher(gated_store) + Async do |task| + task.with_timeout(30) do + b.enqueue(file_path, frame('one')) + flusher = task.async { b.flush } + gated_store.started.pop # the append is in flight: `one` is detached + flusher.stop + gated_store.gate.close + end + end.wait + + expect(b.batches_dropped?).to be(true) + end + + # #close does not chase frames that arrive after it detached. With nothing + # left to drain them they stay buffered, and that counts as a drop. + it 'counts a frame that arrives during the final append as dropped when nothing drains it' do + b = batcher(gated_store) + Async do |task| + task.with_timeout(30) do + b.enqueue(file_path, frame('a')) + closer = task.async { b.close } # detaches `a` under the lock; its append is parked on the gate + gated_store.started.pop + b.enqueue(file_path, frame('late')) # below the thresholds: no drain is scheduled for it + gated_store.gate.close + closer.wait + end + end.wait + + expect(gated_store.uuids).to eq(%w[a]) + expect(b.batches_dropped?).to be(true) + end +end diff --git a/spec/unit/transcript_mirror_batcher_spec.rb b/spec/unit/transcript_mirror_batcher_spec.rb index 350bce5a..fb1e564e 100644 --- a/spec/unit/transcript_mirror_batcher_spec.rb +++ b/spec/unit/transcript_mirror_batcher_spec.rb @@ -165,8 +165,8 @@ def load(_key) = nil # Latency-injecting store: append sleeps on its (batcher-spawned) worker # thread, so a drain is in flight — holding the semaphore — while later # frames enqueue and schedule their own drains. This exercises the - # detach-before-lock + Async::Semaphore(1) ordering guarantee under genuine - # concurrency (the sole reason that machinery exists). + # detach-under-the-lock + Async::Semaphore(1) ordering guarantee under + # genuine concurrency (the sole reason that machinery exists). slow_store = Class.new(ClaudeAgentSDK::SessionStore) do def initialize super @@ -302,10 +302,11 @@ def load(_key) = @mutex.synchronize { @entries.dup } end # The looping drainer re-detaches after its append completes. A #flush - # barrier that detached OLDER frames is already parked on the lock by - # then, and Semaphore#release hands the lock straight to it (FIFO), so the - # drainer's NEWER frames must land after the barrier's — not ahead of it. - it 'appends a parked flush barrier batch before frames the drainer picks up later' do + # barrier is already parked on the lock by then; Semaphore#release hands + # the lock straight to it (FIFO) and it takes everything pending, older + # frames first — so nothing the drainer picks up later can land ahead of + # a frame that was enqueued before it. + it 'keeps enqueue order across a flush barrier parked behind the looping drainer' do Async do |task| task.with_timeout(10) do b = described_class.new(store: gated_store, projects_dir: projects, on_error: on_error, @@ -313,12 +314,12 @@ def load(_key) = @mutex.synchronize { @entries.dup } b.enqueue(file_path, [{ 'type' => 'user', 'uuid' => 'a' }]) gated_store.started.pop # drainer's append of `a` parked on the gate b.enqueue(file_path, [{ 'type' => 'user', 'uuid' => 'b' }]) - flusher = task.async { b.flush } # detaches `b`, parks on the lock - b.enqueue(file_path, [{ 'type' => 'user', 'uuid' => 'c' }]) # buffered for the drainer + flusher = task.async { b.flush } # parks on the lock + b.enqueue(file_path, [{ 'type' => 'user', 'uuid' => 'c' }]) # buffered behind `b` gated_store.gate.close flusher.wait - b.flush # barrier covering the drainer's `c` iteration + b.flush # barrier: nothing the drainer took is still in flight end end @@ -328,12 +329,14 @@ def load(_key) = @mutex.synchronize { @entries.dup } end # Teardown corner: Query#close runs batcher.close, then stops the read - # task (the drainer's parent). A frame the read loop enqueued during the - # close window sits in @pending (the live drainer would have taken it - # next), so stopping the read task first loses it — and that loss must - # surface through batches_dropped?, as the old per-frame parked drain's - # cancellation did, so resume-from-store teardown preserves the temp dir. - it 'counts a frame buffered during close as dropped when the read task is stopped first' do + # task (the drainer's parent). A frame the read loop enqueues while close + # is still queued behind the drainer's in-flight append used to sit in + # @pending until the read task was stopped, and was lost (reported through + # batches_dropped?). close detaches inside the lock now, so it takes that + # frame along and delivers it before the read task is stopped. (A frame + # that arrives after close detached is still a counted drop — see + # transcript_mirror_batcher_cancellation_spec.rb.) + it 'delivers a frame buffered while close waits behind an in-flight append, before the read task is stopped' do b = nil pending_at_stop = nil Async do |task| @@ -363,9 +366,9 @@ def load(_key) = @mutex.synchronize { @entries.dup } end end - expect(pending_at_stop).to eq(1) # the corner was reached: `late` never detached - expect(gated_store.entries.map { |e| e['uuid'] }).to eq(%w[a]) - expect(b.batches_dropped?).to be(true) + expect(pending_at_stop).to eq(0) # `late` went out with close's own batch + expect(gated_store.entries.map { |e| e['uuid'] }).to eq(%w[a late]) + expect(b.batches_dropped?).to be(false) end it 'does not report a drop when frames buffered during close are drained before teardown ends' do diff --git a/spec/unit/transport_nil_options_spec.rb b/spec/unit/transport_nil_options_spec.rb new file mode 100644 index 00000000..96bc85d5 --- /dev/null +++ b/spec/unit/transport_nil_options_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'tmpdir' + +# `env` and `extra_args` are signed nilable: the constructor fills in `{}`, +# but `options.dup_with(env: nil)` and `options.env = nil` — the natural way +# to clear one — keep the nil. The transport read both as Hashes. +RSpec.describe ClaudeAgentSDK::SubprocessCLITransport, 'options set back to nil' do + after { described_class.active_processes.clear } + + around do |example| + Dir.mktmpdir('nil-options-spec') do |dir| + @dir = dir + example.run + end + end + + let(:seen_sdk_version) { File.join(@dir, 'sdk-version') } + + # Answers the version probe; as a session it records the SDK version the + # transport put in its environment, then ends the run. + let(:fake_cli) do + path = File.join(@dir, 'claude') + File.write(path, <<~SH) + #!/bin/sh + if [ "$1" = "-v" ]; then + echo '2.1.286 (Claude Code)' + exit 0 + fi + printf '%s' "$CLAUDE_AGENT_SDK_VERSION" > '#{seen_sdk_version}' + printf '%s\\n' '#{JSON.generate(sample_result_message)}' + SH + File.chmod(0o755, path) + path + end + + let(:options) { ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: fake_cli) } + + def frame_types_from(transport) + types = [] + transport.connect + transport.read_messages { |frame| types << frame[:type] } + types + ensure + transport.close + end + + it 'connects when env was cleared with dup_with(env: nil)' do + transport = described_class.new(options.dup_with(env: nil)) + + expect(frame_types_from(transport)).to eq(['result']) + # The SDK's own additions to the environment still reach the CLI. + expect(File.read(seen_sdk_version)).to eq(ClaudeAgentSDK::VERSION) + end + + it 'connects when env was cleared with the setter' do + options.env = nil + + expect(frame_types_from(described_class.new(options))).to eq(['result']) + end + + # CommandBuilder reads extra_args too and is nil-unsafe in the same way + # (command_builder.rb, fixed separately); stubbing the argv keeps this + # example on the transport's own read of the option. + it 'connects when extra_args was cleared with dup_with(extra_args: nil)' do + transport = described_class.new(options.dup_with(extra_args: nil)) + allow(transport).to receive(:build_command).and_return([fake_cli]) + + expect(frame_types_from(transport)).to eq(['result']) + end +end diff --git a/spec/unit/transport_poll_sleep_spec.rb b/spec/unit/transport_poll_sleep_spec.rb new file mode 100644 index 00000000..61b204b9 --- /dev/null +++ b/spec/unit/transport_poll_sleep_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +# The transport polls in two places: while it waits for the CLI to exit, and +# while the version probe runs. Both used Async::Task#sleep when a task was +# current — deprecated in favor of the scheduler-aware Kernel#sleep, and a +# warning per call (every 50 ms) under `ruby -w`. +RSpec.describe ClaudeAgentSDK::SubprocessCLITransport, 'polling sleeps' do + after { described_class.active_processes.clear } + + around do |example| + Dir.mktmpdir('poll-sleep-spec') do |dir| + @dir = dir + example.run + end + end + + # What `ruby -w` turns on: verbose mode and the :deprecated category + # (async tags its warning with it, and $VERBOSE alone does not enable it). + def with_deprecation_warnings + verbose = $VERBOSE + deprecated = Warning[:deprecated] + $VERBOSE = true + Warning[:deprecated] = true + yield + ensure + $VERBOSE = verbose + Warning[:deprecated] = deprecated + end + + def transport_for(cli_path) + described_class.new(ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: cli_path)) + end + + describe 'waiting for the CLI to exit' do + let(:transport) { transport_for('/usr/bin/claude') } + let(:process) { instance_double(Process::Waiter, value: :exited) } + + it 'does not use the deprecated Async::Task#sleep inside a task' do + allow(process).to receive(:alive?).and_return(true, false) + + expect do + with_deprecation_warnings { Sync { transport.send(:wait_process_with_timeout, 5, process) } } + end.not_to output(/Async::Task#sleep/).to_stderr + end + + # Two polls, whatever happens: a sleep that blocked the thread instead + # of parking the fiber would finish the wait before the other task ever + # ran, and the order below would be reversed. + it 'parks only the waiting fiber: another task on the reactor runs meanwhile' do + allow(process).to receive(:alive?).and_return(true, true, false) + events = [] + + result = Sync do |task| + waiter = task.async do + transport.send(:wait_process_with_timeout, 5, process).tap { events << :wait_returned } + end + # Control comes back here when the waiter parks in its first poll. + events << :other_task_ran + waiter.wait + end + + expect(result).to eq(:exited) + expect(events).to eq(%i[other_task_ran wait_returned]) + end + end + + describe 'the version probe' do + around do |example| + previous = ENV.fetch('CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK', nil) + ENV.delete('CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK') + example.run + ensure + ENV['CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK'] = previous if previous + end + + let(:gate_path) { File.join(@dir, 'gate') } + + # `claude -v` that answers only once a line arrives on the gate FIFO, so + # the probe is still running when the poll loop first goes round. + let(:gated_cli) do + File.mkfifo(gate_path) + path = File.join(@dir, 'claude') + File.write(path, <<~SH) + #!/bin/sh + read go < '#{gate_path}' + echo '2.1.286 (Claude Code)' + SH + File.chmod(0o755, path) + path + end + + it 'does not use the deprecated Async::Task#sleep inside a task' do + # The loop is `until drainer.join(0)`, and under a fiber scheduler + # Ruby 3.2's Thread#join ignores its timeout: it returns only when the + # thread has finished, so the loop body — the sleep under test, and + # with it the gate below — is never reached there. + skip 'on Ruby 3.2 this poll never sleeps inside a task' if RUBY_VERSION < '3.3' + + transport = transport_for(gated_cli) + + # Opened read-write so neither end blocks in open(2). + File.open(gate_path, 'r+') do |gate| + # The poll's own sleep opens the gate: the loop has then run at + # least once, and the probe finishes right after. A poll that sleeps + # some other way never opens it and runs into the probe's deadline. + allow(transport).to receive(:sleep).and_wrap_original do |original, *args| + gate.syswrite("go\n") + original.call(*args) + end + + expect do + with_deprecation_warnings { Sync { transport.check_claude_version } } + end.not_to output(/Async::Task#sleep/).to_stderr + expect(transport).to have_received(:sleep).at_least(:once) + end + end + end +end diff --git a/spec/unit/type_inspect_filtering_spec.rb b/spec/unit/type_inspect_filtering_spec.rb new file mode 100644 index 00000000..eee38d17 --- /dev/null +++ b/spec/unit/type_inspect_filtering_spec.rb @@ -0,0 +1,437 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'pp' +require 'uri' + +# Options objects and MCP server configs end up in logs, so Type#inspect +# filters every attribute that can carry a credential: env and headers, and +# also settings, extra_args, a stdio server's args, the URL of an HTTP / SSE +# server, the raw Hash server configs inside mcp_servers, and the server +# configs the CLI echoes back in mcp_status. Filtering is for display only: +# nothing sent to the CLI changes. +RSpec.describe 'Type#inspect credential filtering' do + let(:secret) { 'sk-ant-api03-SECRET0123' } + + # The mcp_status frame CLI 2.1.287 sends for an http, an sse and a stdio + # server that failed to connect (a handshake without a model turn). Each + # config comes back with its headers, full url and args; the stdio env is + # left out. The CLI redacts the url in its own error text only. + let(:status_frame) do + { + mcpServers: [ + { + name: 'api', status: 'failed', + error: 'SdkHttpError dialing http://127.0.0.1:9[redacted]?key=REDACTED (CLIENT_HTTP_NOT_IMPLEMENTED)', + config: { + type: 'http', url: "http://127.0.0.1:9/mcp?key=#{secret}", headers: { Authorization: "Bearer #{secret}" } + }, + scope: 'dynamic', source: 'dynamic' + }, + { + name: 'events', status: 'failed', error: 'Error dialing http://127.0.0.1:9[redacted]?key=REDACTED', + config: { type: 'sse', url: "http://127.0.0.1:9/sse?key=#{secret}", headers: { 'X-Api-Key': secret } }, + scope: 'dynamic', source: 'dynamic' + }, + { + name: 'fs', status: 'failed', error: 'Connection closed', + config: { type: 'stdio', command: '/usr/bin/true', args: ['--token', secret] }, + scope: 'dynamic', source: 'dynamic' + } + ] + } + end + + # url template (%s is the secret) => what #inspect shows for it + url_rows = { + 'a token in the query string' => ['https://mcp.example.com/v1/mcp?api_key=%s', 'https://mcp.example.com/[FILTERED]'], + 'credentials in the userinfo' => ['https://deploy:%s@mcp.example.com/v1/mcp', 'https://mcp.example.com/[FILTERED]'], + 'an "@" inside the password' => ['https://deploy:p@ss%s@mcp.example.com/', 'https://mcp.example.com/[FILTERED]'], + 'a token in the path' => ['https://mcp.example.com/s/%s/mcp', 'https://mcp.example.com/[FILTERED]'], + 'a token in the fragment' => ['https://mcp.example.com/mcp#%s', 'https://mcp.example.com/[FILTERED]'], + 'a port' => ['http://localhost:8080/sse?token=%s', 'http://localhost:8080/[FILTERED]'], + 'an IPv6 host' => ['https://[::1]:8443/mcp?token=%s', 'https://[::1]:8443/[FILTERED]'], + 'a WebSocket scheme' => ['wss://mcp.example.com/ws?token=%s', 'wss://mcp.example.com/[FILTERED]'], + 'nothing after the host' => ['https://mcp.example.com', 'https://mcp.example.com/[FILTERED]'], + 'no scheme' => ['mcp.example.com/mcp?token=%s', '[FILTERED]'], + 'a scheme other than http(s) or ws(s)' => ['%s://mcp.example.com/mcp', '[FILTERED]'], + 'no host' => ['https://deploy:%s@/mcp', '[FILTERED]'], + 'a port that is not a number' => ['https://mcp.example.com:%s/mcp', '[FILTERED]'], + 'a backslash after the host' => ['https://mcp.example.com\\%s/mcp', '[FILTERED]'], + 'a backslash before an "@"' => ['https://mcp.example.com\\@%s/mcp', '[FILTERED]'], + 'a line break after the host' => ["https://mcp.example.com\n%s/mcp", '[FILTERED]'], + 'a leading space' => [' https://mcp.example.com/mcp?token=%s', '[FILTERED]'] + }.freeze + + describe ClaudeAgentSDK::ClaudeAgentOptions do + it 'still filters env values and keeps the variable names' do + rendered = described_class.new(env: { 'ANTHROPIC_API_KEY' => secret }).inspect + + expect(rendered).to include('env={"ANTHROPIC_API_KEY" => "[FILTERED]"}') + expect(rendered).not_to include('SECRET') + end + + it 'filters settings given as a JSON String' do + [{ env: { ANTHROPIC_API_KEY: secret } }, { apiKeyHelper: "echo #{secret}" }].each do |settings| + rendered = described_class.new(settings: JSON.generate(settings)).inspect + + expect(rendered).to include('settings="[FILTERED]"') + expect(rendered).not_to include('SECRET') + end + end + + it 'filters the values of settings given as a Hash and keeps its keys' do + settings = { env: { ANTHROPIC_API_KEY: secret }, apiKeyHelper: "echo #{secret}" } + rendered = described_class.new(settings: settings).inspect + + expect(rendered).to include('settings={env: "[FILTERED]", apiKeyHelper: "[FILTERED]"}') + expect(rendered).not_to include('SECRET') + end + + it 'filters a settings file path as well: any String is replaced' do + rendered = described_class.new(settings: '/etc/claude/settings.json').inspect + + expect(rendered).to include('settings="[FILTERED]"') + expect(rendered).not_to include('/etc/claude') + end + + it 'filters extra_args values and keeps the flag names' do + rendered = described_class.new(extra_args: { 'api-key' => secret, 'debug-to-stderr' => nil }).inspect + + expect(rendered).to include('extra_args={"api-key" => "[FILTERED]", "debug-to-stderr" => "[FILTERED]"}') + expect(rendered).not_to include('SECRET') + end + + it 'leaves the empty defaults as they are' do + rendered = described_class.new.inspect + + expect(rendered).to include(' env={}').and include(' extra_args={}').and include(' mcp_servers={}') + expect(rendered).not_to include('settings=') + end + + it 'filters mcp_servers given as a String: the JSON of a config, or the path to one' do + json = JSON.generate(mcpServers: { gh: { command: 'npx', env: { TOKEN: secret } } }) + + [json, '/etc/claude/mcp.json'].each do |mcp_servers| + rendered = described_class.new(mcp_servers: mcp_servers).inspect + + expect(rendered).to include(' mcp_servers="[FILTERED]"') + expect(rendered).not_to include('SECRET') + expect(rendered).not_to include('/etc/claude') + end + end + + describe 'raw Hash server configs in mcp_servers' do + it 'shows type and command of a stdio config and filters args and env' do + options = described_class.new( + mcp_servers: { + github: { + type: 'stdio', command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github', '--token', secret], + env: { 'GITHUB_PERSONAL_ACCESS_TOKEN' => secret } + } + } + ) + + expect(options.inspect) + .to include('mcp_servers={github: {type: "stdio", command: "npx", args: "[FILTERED]", env: "[FILTERED]"}}') + expect(options.inspect).not_to include('SECRET') + end + + it 'shows type and host of an http config and filters headers' do + options = described_class.new( + mcp_servers: { + api: { + type: 'http', url: "https://mcp.example.com/v1/mcp?api_key=#{secret}", + headers: { 'Authorization' => "Bearer #{secret}" } + } + } + ) + + expect(options.inspect).to include( + 'mcp_servers={api: {type: "http", url: "https://mcp.example.com/[FILTERED]", headers: "[FILTERED]"}}' + ) + expect(options.inspect).not_to include('SECRET') + end + + # A String is printed at any nesting depth, so this one was never + # covered by the nesting limit that happened to hide env and headers. + it 'filters a key it does not know, whatever the value' do + options = described_class.new( + mcp_servers: { + api: { + type: 'sse', url: 'https://mcp.example.com/sse', + headersHelper: "/usr/local/bin/mcp-headers --token #{secret}", oauth: { clientSecret: secret } + } + } + ) + + expect(options.inspect).to include( + 'mcp_servers={api: {type: "sse", url: "https://mcp.example.com/[FILTERED]", ' \ + 'headersHelper: "[FILTERED]", oauth: "[FILTERED]"}}' + ) + expect(options.inspect).not_to include('SECRET') + end + + it 'reads a String-keyed config the same way' do + options = described_class.new( + mcp_servers: { + 'api' => { + 'type' => 'http', 'url' => "https://mcp.example.com/mcp?key=#{secret}", + 'headers' => { 'Authorization' => "Bearer #{secret}" } + } + } + ) + + expect(options.inspect).to include( + 'mcp_servers={"api" => {"type" => "http", "url" => "https://mcp.example.com/[FILTERED]", ' \ + '"headers" => "[FILTERED]"}}' + ) + expect(options.inspect).not_to include('SECRET') + end + + it 'keeps showing an SDK server config: type, name and instance' do + server = ClaudeAgentSDK.create_sdk_mcp_server(name: 'calc', tools: []) + rendered = described_class.new(mcp_servers: { calc: server }).inspect + + expect(rendered) + .to include('mcp_servers={calc: {type: "sdk", name: "calc", instance: #<ClaudeAgentSDK::SdkMcpServer>}}') + end + + it 'leaves a typed config to its own filtering' do + config = ClaudeAgentSDK::McpStdioServerConfig.new( + command: 'mcp-files', args: ["--token=#{secret}"], env: { 'TOKEN' => secret } + ) + rendered = described_class.new(mcp_servers: { files: config }).inspect + + expect(rendered).to include('mcp_servers={files: #<ClaudeAgentSDK::McpStdioServerConfig command="mcp-files" ' \ + 'args="[FILTERED]" env={…(1)} type="stdio">}') + expect(rendered).not_to include('SECRET') + end + + url_rows.each do |label, (template, shown)| + it "shows scheme and host of a url with #{label}" do + url = format(template, secret) + rendered = described_class.new(mcp_servers: { api: { type: 'http', url: url } }).inspect + + expect(rendered).to include(%(mcp_servers={api: {type: "http", url: "#{shown}"}})) + expect(rendered).not_to include('SECRET') + end + end + end + + it 'filters the same way through #to_s, interpolation and pp' do + options = described_class.new( + settings: { apiKeyHelper: "echo #{secret}" }, extra_args: { 'api-key' => secret }, + mcp_servers: { api: { type: 'http', url: "https://mcp.example.com/mcp?key=#{secret}" } } + ) + + [options.to_s, "#{options}", PP.pp(options, +'')].each do |rendered| # rubocop:disable Style/RedundantInterpolation + expect(rendered).to include('settings={apiKeyHelper: "[FILTERED]"}') + expect(rendered).not_to include('SECRET') + end + end + end + + describe ClaudeAgentSDK::McpStdioServerConfig do + it 'filters args, which carry flags such as --api-key' do + config = described_class.new(command: 'npx', args: ['-y', 'mcp-remote', "--api-key=#{secret}"]) + + expect(config.inspect).to eq('#<ClaudeAgentSDK::McpStdioServerConfig command="npx" args="[FILTERED]" type="stdio">') + end + + it 'still filters env values and keeps the variable names' do + config = described_class.new(command: 'npx', env: { 'GITHUB_PERSONAL_ACCESS_TOKEN' => secret }) + + expect(config.inspect).to eq('#<ClaudeAgentSDK::McpStdioServerConfig command="npx" ' \ + 'env={"GITHUB_PERSONAL_ACCESS_TOKEN" => "[FILTERED]"} type="stdio">') + end + end + + [ClaudeAgentSDK::McpHttpServerConfig, ClaudeAgentSDK::McpSSEServerConfig].each do |klass| + describe klass do + let(:type) { klass.new.type } + + it 'still filters header values and keeps the header names' do + config = klass.new(url: 'https://mcp.example.com/mcp', headers: { 'Authorization' => "Bearer #{secret}" }) + + expect(config.inspect).to eq(%(#<#{klass.name} url="https://mcp.example.com/[FILTERED]" ) + + %(headers={"Authorization" => "[FILTERED]"} type="#{type}">)) + end + + url_rows.each do |label, (template, shown)| + it "shows scheme and host of a url with #{label}" do + config = klass.new(url: format(template, secret)) + + expect(config.inspect).to eq(%(#<#{klass.name} url="#{shown}" type="#{type}">)) + end + end + + it 'filters a url that is not a String', rbs_incompatible: 'plants a URI where the signature says String' do + config = klass.new(url: URI("https://mcp.example.com/mcp?key=#{secret}")) + + expect(config.inspect).to eq(%(#<#{klass.name} url="[FILTERED]" type="#{type}">)) + end + end + end + + # McpStatusResponse.parse(client.mcp_status) is the typed view of that + # frame: printing one of its servers must not print what its config holds. + describe ClaudeAgentSDK::McpServerStatus do + let(:servers) { ClaudeAgentSDK::McpStatusResponse.parse(status_frame).mcp_servers } + + it 'shows type and host of an echoed http config and filters headers' do + expect(servers[0].inspect).to eq( + '#<ClaudeAgentSDK::McpServerStatus name="api" status="failed" ' \ + 'error="SdkHttpError dialing http://127.0.0.1:9[redacted]?key=REDACTED (CLIENT_HTTP_NOT_"…(+12 chars) ' \ + 'config={type: "http", url: "http://127.0.0.1:9/[FILTERED]", headers: "[FILTERED]"} scope="dynamic">' + ) + end + + it 'shows type and host of an echoed sse config and filters headers' do + expect(servers[1].inspect).to eq( + '#<ClaudeAgentSDK::McpServerStatus name="events" status="failed" ' \ + 'error="Error dialing http://127.0.0.1:9[redacted]?key=REDACTED" ' \ + 'config={type: "sse", url: "http://127.0.0.1:9/[FILTERED]", headers: "[FILTERED]"} scope="dynamic">' + ) + end + + it 'shows type and command of an echoed stdio config and filters args' do + expect(servers[2].inspect).to eq( + '#<ClaudeAgentSDK::McpServerStatus name="fs" status="failed" error="Connection closed" ' \ + 'config={type: "stdio", command: "/usr/bin/true", args: "[FILTERED]"} scope="dynamic">' + ) + end + + it 'filters the same way through #to_s, interpolation and pp' do + servers.each do |server| + [server.to_s, "#{server}", PP.pp(server, +'')].each do |rendered| # rubocop:disable Style/RedundantInterpolation + expect(rendered).to include('[FILTERED]') + expect(rendered).not_to include('SECRET') + end + end + end + + it 'still collapses the configs inside a McpStatusResponse' do + rendered = ClaudeAgentSDK::McpStatusResponse.parse(status_frame).inspect + + expect(rendered.scan('config={…(3)}').size).to eq(3) + expect(rendered).not_to include('SECRET') + end + end + + # What the CLI receives is built from the attributes and #to_h, never from + # #inspect: the filters above change no byte of it. + describe 'the wire form' do + let(:stdio) do + ClaudeAgentSDK::McpStdioServerConfig.new(command: 'mcp-files', args: ["--token=#{secret}"], + env: { 'TOKEN' => secret }) + end + let(:sse) do + ClaudeAgentSDK::McpSSEServerConfig.new(url: "https://deploy:#{secret}@sse.example.com/events", + headers: { 'X-Api-Key' => secret }) + end + let(:http) do + ClaudeAgentSDK::McpHttpServerConfig.new(url: "https://mcp.example.com/s/#{secret}/mcp", + headers: { 'Authorization' => "Bearer #{secret}" }) + end + let(:options) do + ClaudeAgentSDK::ClaudeAgentOptions.new( + env: { 'ANTHROPIC_API_KEY' => secret }, + settings: { env: { ANTHROPIC_API_KEY: secret }, apiKeyHelper: "echo #{secret}" }, + extra_args: { 'api-key' => secret, 'debug-to-stderr' => nil }, + mcp_servers: { + github: { + type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github', '--token', secret], + env: { 'GITHUB_PERSONAL_ACCESS_TOKEN' => secret } + }, + api: { + type: 'http', url: "https://mcp.example.com/v1/mcp?api_key=#{secret}", + headers: { 'Authorization' => "Bearer #{secret}" } + }, + files: stdio, events: sse, docs: http + } + ) + end + + def command_line(options) + ClaudeAgentSDK::CommandBuilder.new('claude', options).build + end + + it 'builds the same command line before and after #inspect, credentials included' do + before = command_line(options) + options.inspect + options.to_s + argv = command_line(options) + + expect(argv).to eq(before) + expect(argv[argv.index('--settings') + 1]) + .to eq(%({"env":{"ANTHROPIC_API_KEY":"#{secret}"},"apiKeyHelper":"echo #{secret}"})) + expect(argv[argv.index('--mcp-config') + 1]).to eq( + [ + '{"mcpServers":{', + '"github":{"type":"stdio","command":"npx",', + %("args":["-y","@modelcontextprotocol/server-github","--token","#{secret}"],), + %("env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"#{secret}"}},), + %("api":{"type":"http","url":"https://mcp.example.com/v1/mcp?api_key=#{secret}",), + %("headers":{"Authorization":"Bearer #{secret}"}},), + %("files":{"type":"stdio","command":"mcp-files","args":["--token=#{secret}"],"env":{"TOKEN":"#{secret}"}},), + %("events":{"type":"sse","url":"https://deploy:#{secret}@sse.example.com/events",), + %("headers":{"X-Api-Key":"#{secret}"}},), + %("docs":{"type":"http","url":"https://mcp.example.com/s/#{secret}/mcp",), + %("headers":{"Authorization":"Bearer #{secret}"}}}}) + ].join + ) + expect(argv.each_cons(2)).to include(['--api-key', secret]) + expect(argv).to include('--debug-to-stderr') + end + + it 'leaves every #to_h and every attribute as it was' do + [options, stdio, sse, http].each(&:inspect) + + expect(stdio.to_h).to eq(type: 'stdio', command: 'mcp-files', args: ["--token=#{secret}"], + env: { 'TOKEN' => secret }) + expect(sse.to_h).to eq(type: 'sse', url: "https://deploy:#{secret}@sse.example.com/events", + headers: { 'X-Api-Key' => secret }) + expect(http.to_h).to eq(type: 'http', url: "https://mcp.example.com/s/#{secret}/mcp", + headers: { 'Authorization' => "Bearer #{secret}" }) + expect(options.env).to eq('ANTHROPIC_API_KEY' => secret) + expect(options.settings).to eq(env: { ANTHROPIC_API_KEY: secret }, apiKeyHelper: "echo #{secret}") + expect(options.extra_args).to eq('api-key' => secret, 'debug-to-stderr' => nil) + expect(options.mcp_servers[:api]).to eq(type: 'http', url: "https://mcp.example.com/v1/mcp?api_key=#{secret}", + headers: { 'Authorization' => "Bearer #{secret}" }) + expect(options.mcp_servers[:github][:env]).to eq('GITHUB_PERSONAL_ACCESS_TOKEN' => secret) + end + + it 'passes mcp_servers given as a String to --mcp-config as it is' do + json = JSON.generate(mcpServers: { gh: { command: 'npx', env: { TOKEN: secret } } }) + string_options = ClaudeAgentSDK::ClaudeAgentOptions.new(mcp_servers: json) + before = command_line(string_options) + string_options.inspect + string_options.to_s + + expect(command_line(string_options)).to eq(before) + expect(before.each_cons(2)) + .to include(['--mcp-config', %({"mcpServers":{"gh":{"command":"npx","env":{"TOKEN":"#{secret}"}}}})]) + expect(string_options.mcp_servers).to equal(json) + end + + # A parsed mcp_status frame is never sent anywhere; what must not change + # is what the application reads from it. + it 'leaves the configs of a parsed mcp_status frame as the CLI sent them' do + response = ClaudeAgentSDK::McpStatusResponse.parse(status_frame) + response.inspect + response.mcp_servers.each(&:inspect) + + expect(response.mcp_servers.map(&:config)).to eq( + [ + { type: 'http', url: "http://127.0.0.1:9/mcp?key=#{secret}", headers: { Authorization: "Bearer #{secret}" } }, + { type: 'sse', url: "http://127.0.0.1:9/sse?key=#{secret}", headers: { 'X-Api-Key': secret } }, + { type: 'stdio', command: '/usr/bin/true', args: ['--token', secret] } + ] + ) + expect(response.mcp_servers.first.config).to equal(status_frame[:mcpServers].first[:config]) + end + end +end diff --git a/spec/unit/type_inspect_robustness_spec.rb b/spec/unit/type_inspect_robustness_spec.rb new file mode 100644 index 00000000..fbe51be4 --- /dev/null +++ b/spec/unit/type_inspect_robustness_spec.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Type#inspect runs inside loggers, `puts` and exception messages, so it must +# not raise, whatever an attribute holds. A value that fails while it is +# rendered shows as a placeholder and the rest of the object still prints. +RSpec.describe 'Type#inspect on a value it cannot render', + rbs_incompatible: 'plants values outside the attribute types' do + def hostile(name, base, &body) + stub_const(name, Class.new(base, &body)) + end + + describe 'an attribute that is a BasicObject' do + it 'renders a placeholder next to the other attributes' do + block = ClaudeAgentSDK::ToolUseBlock.new(id: 'toolu_1', input: BasicObject.new) + + expect(block.inspect).to eq('#<ClaudeAgentSDK::ToolUseBlock id="toolu_1" input=#<?>>') + expect(block.to_s).to eq(block.inspect) + end + + # A BasicObject has no #hash, so only an identity Hash can be keyed by one. + it 'renders a BasicObject Hash key and keeps the rest of the Hash' do + input = {}.compare_by_identity + input[BasicObject.new] = 1 + input[:path] = '/tmp' + block = ClaudeAgentSDK::ToolUseBlock.new(input: input) + + expect(block.inspect).to eq('#<ClaudeAgentSDK::ToolUseBlock input={#<?> => 1, path: "/tmp"}>') + end + + it 'prints a filtered attribute as filtered' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new + options.env = BasicObject.new + options.settings = BasicObject.new + config = ClaudeAgentSDK::McpHttpServerConfig.new + config.url = BasicObject.new + + expect(options.inspect).to include(' env="[FILTERED]"').and include(' settings="[FILTERED]"') + expect(config.inspect).to eq('#<ClaudeAgentSDK::McpHttpServerConfig type="http" url="[FILTERED]">') + end + + it 'prints mcp_servers as filtered when a server config cannot be read' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new + oddly_keyed = {}.compare_by_identity + oddly_keyed[BasicObject.new] = 'x' + rendered = [BasicObject.new, { api: BasicObject.new }, { api: oddly_keyed }].map do |servers| + options.mcp_servers = servers + options.inspect[/ mcp_servers=\S+/] + end + + expect(rendered).to all(eq(' mcp_servers="[FILTERED]"')) + end + + it 'prints the config of an McpServerStatus as filtered when it cannot be read' do + oddly_keyed = {}.compare_by_identity + oddly_keyed[BasicObject.new] = 'x' + status = ClaudeAgentSDK::McpServerStatus.new(name: 'api', config: oddly_keyed) + opaque = ClaudeAgentSDK::McpServerStatus.new(name: 'api') + opaque.instance_variable_set(:@config, BasicObject.new) + + expect(status.inspect).to eq('#<ClaudeAgentSDK::McpServerStatus name="api" config="[FILTERED]">') + expect(opaque.inspect).to eq('#<ClaudeAgentSDK::McpServerStatus name="api" config=#<?>>') + end + end + + describe 'a String whose own methods raise' do + it 'renders a placeholder when #length raises' do + hostile('LengthlessString', String) { def length = raise('len') } + block = ClaudeAgentSDK::TextBlock.new(text: LengthlessString.new('x')) + + expect(block.inspect).to eq('#<ClaudeAgentSDK::TextBlock text=#<LengthlessString>>') + end + + it 'renders a placeholder when #inspect or #[] raises' do + hostile('UninspectableString', String) { def inspect = raise('inspect') } + hostile('UnsliceableString', String) { def [](*) = raise('slice') } + message = ClaudeAgentSDK::ResultMessage.new( + subtype: UninspectableString.new('success'), result: UnsliceableString.new('a' * 200), num_turns: 3 + ) + + expect(message.inspect).to eq('#<ClaudeAgentSDK::ResultMessage subtype=#<UninspectableString> ' \ + 'result=#<UnsliceableString> num_turns=3>') + end + + it 'renders a placeholder when #length answers something that is not a number' do + hostile('OddString', String) { def length = nil } + + expect(ClaudeAgentSDK::TextBlock.new(text: OddString.new('x')).inspect) + .to eq('#<ClaudeAgentSDK::TextBlock text=#<OddString>>') + end + + it 'replaces only that value inside a container' do + hostile('LengthlessString', String) { def length = raise('len') } + block = ClaudeAgentSDK::ToolUseBlock.new(input: { command: LengthlessString.new('ls'), cwd: '/tmp' }) + + expect(block.inspect).to eq('#<ClaudeAgentSDK::ToolUseBlock input={command: #<LengthlessString>, cwd: "/tmp"}>') + end + end + + describe 'a container whose own methods raise' do + it 'renders a placeholder for the container' do + hostile('SizelessHash', Hash) { def size = raise('size') } + hostile('BottomlessArray', Array) { def first(*) = raise('first') } + hostile('EmptinessUnknownArray', Array) { def empty? = raise('empty?') } + message = ClaudeAgentSDK::AssistantMessage.new( + content: BottomlessArray.new([1, 2]), usage: SizelessHash[(1..6).to_h { |i| [:"k#{i}", i] }], + model: 'claude-opus-5', error: EmptinessUnknownArray.new + ) + + expect(message.inspect).to eq('#<ClaudeAgentSDK::AssistantMessage content=#<BottomlessArray> ' \ + 'usage=#<SizelessHash> model="claude-opus-5" error=#<EmptinessUnknownArray>>') + end + end + + describe 'a nested Type that cannot list its attributes' do + it 'renders a placeholder for it, also when it is the object being inspected' do + hostile('BrokenBlock', ClaudeAgentSDK::TextBlock) do + private + + def inspect_attributes = raise('attributes') + end + broken = BrokenBlock.new(text: 'x') + message = ClaudeAgentSDK::AssistantMessage.new(content: [ClaudeAgentSDK::TextBlock.new(text: 'ok'), broken]) + + expect(message.inspect) + .to eq('#<ClaudeAgentSDK::AssistantMessage content=[#<ClaudeAgentSDK::TextBlock text="ok">, #<BrokenBlock>]>') + expect(broken.inspect).to eq('#<BrokenBlock>') + end + end + + it 'keeps the placeholder bounded' do + long_named = hostile("Lengthless#{'Very' * 40}LongName", String) { def length = raise('len') } + block = ClaudeAgentSDK::TextBlock.new(text: long_named.new('x')) + + expect(block.inspect).to eq("#<ClaudeAgentSDK::TextBlock text=#<Lengthless#{'Very' * 17}…(+101 chars)>") + end + + it 'lets process-control exceptions through' do + hostile('InterruptingString', String) { def length = raise(Interrupt) } + hostile('ExitingString', String) { def length = exit(3) } + + expect { ClaudeAgentSDK::TextBlock.new(text: InterruptingString.new('x')).inspect }.to raise_error(Interrupt) + expect { ClaudeAgentSDK::TextBlock.new(text: ExitingString.new('x')).inspect }.to raise_error(SystemExit) + end +end diff --git a/spec/unit/type_setter_shaped_names_spec.rb b/spec/unit/type_setter_shaped_names_spec.rb new file mode 100644 index 00000000..ffa6f1f9 --- /dev/null +++ b/spec/unit/type_setter_shaped_names_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# #[] reads attributes, and a name that is not one reads as nil +# (docs/types.md, "Attributes Only"). A setter-shaped name (`session_id=`) +# names a writer: #[] must not call it. On the write side, +# ClaudeAgentOptions accepts an option name only when it is an attribute or a +# setter user code defined, never a method such as #[]= or #==. +RSpec.describe 'setter-shaped and operator-shaped attribute names' do + describe 'Type#[] with a setter-shaped name' do + # A subclass of its own, so the per-class reader cache starts empty. + let(:message_class) { Class.new(ClaudeAgentSDK::ResultMessage) } + let(:message) { message_class.new(subtype: 'success', session_id: 's1') } + + it 'returns nil in every spelling and leaves the attribute alone' do + [:session_id=, 'session_id=', :sessionId=, 'sessionId='].each do |name| + expect(message[name]).to be_nil, "#[#{name.inspect}]" + end + + expect(message.session_id).to eq('s1') + end + + it 'never stores the name in the reader cache' do + message[:session_id=] + message['sessionId='] + + expect(message_class.cached_attribute_reader(:session_id=)).to be_nil + expect(message_class.cached_attribute_reader('sessionId=')).to be_nil + end + + # The camelCase writer (message.sessionId = ...) is resolved by + # method_missing, which keeps what it resolved in the same cache. + it 'returns nil after the camelCase writer has been called' do + message.sessionId = 's2' + + expect(message[:sessionId=]).to be_nil + expect(message.session_id).to eq('s2') + end + + it 'returns nil for a hand-written setter and for a setter user code defined' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new(fork_session: true, env: { 'A' => 'b' }) + custom = Class.new(ClaudeAgentSDK::ResultMessage) do + def note=(value) # rubocop:disable Style/TrivialAccessors + @note = value + end + end.new + + expect(options[:env=]).to be_nil + expect(options['forkSession=']).to be_nil + expect(ClaudeAgentSDK::HookMatcher.new(matcher: 'Bash')['hooks=']).to be_nil + expect(custom[:note=]).to be_nil + expect(options.fork_session).to be(true) + expect(options.env).to eq('A' => 'b') + end + + it 'returns nil for a name that only ends in "=" once converted to a String', + rbs_incompatible: 'passes a name that is neither a Symbol nor a String' do + name = Class.new { def to_s = 'session_id=' }.new + + expect(message[name]).to be_nil + expect(message.session_id).to eq('s1') + end + + it 'still returns nil for the operators that end in "="' do + %i[== != === []=].each { |name| expect(message[name]).to be_nil, "#[#{name.inspect}]" } + end + + it 'still reads attributes and predicates' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new(fork_session: true) + + expect([message[:session_id], message['sessionId']]).to eq(%w[s1 s1]) + expect([options[:fork_session], options[:fork_session?], options['forkSession?']]).to eq([true, true, true]) + end + end + + describe 'ClaudeAgentOptions with an option name that resolves to a method of the SDK or of Ruby' do + after { ClaudeAgentSDK.reset_configuration } + + it 'reports "[]" as an unknown option in the constructor' do + expect { ClaudeAgentSDK::ClaudeAgentOptions.new('[]' => 1) } + .to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: "[]"') + end + + it 'reports it through #[]= and #dup_with too' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new + + expect { options['[]'] = 1 }.to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: "[]"') + expect { options.dup_with('[]': 1) }.to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: :[]') + end + + it 'reports the name as written when defaults are configured' do + ClaudeAgentSDK.configure { |config| config.default_options = { model: 'sonnet' } } + + expect { ClaudeAgentSDK::ClaudeAgentOptions.new('[]' => 1) } + .to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: "[]"') + end + + # '=' + '=' is #==, '!' + '=' is #!=, '==' + '=' is #===: each used to be + # called with the value, and the key was dropped without a word. + it 'reports the names that resolve to a comparison operator' do + ['=', '!', '=='].each do |name| + expect { ClaudeAgentSDK::ClaudeAgentOptions.new(name => 1) } + .to raise_error(ArgumentError, "unknown ClaudeAgentOptions option: #{name.inspect}") + end + end + + it 'still reports a misspelled option and a method that is not an option' do + expect { ClaudeAgentSDK::ClaudeAgentOptions.new(modle: 'sonnet') } + .to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: :modle') + expect { ClaudeAgentSDK::ClaudeAgentOptions.new(dup_with: 1) } + .to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: :dup_with') + end + + it 'still accepts every declared option in any spelling' do + options = ClaudeAgentSDK::ClaudeAgentOptions.new('permissionMode' => 'plan', forkSession: true, 'max-turns' => 3) + options[:callbackScheduling] = 'inline' + + expect([options.permission_mode, options.fork_session, options.max_turns, options.callback_scheduling]) + .to eq(['plan', true, 3, :inline]) + end + end + + # docs/types.md, "Attributes Only": methods user code adds to a subclass + # (an attr_accessor, a hand-written setter, a mixin's accessors, a singleton + # method) count as attributes. + describe 'a ClaudeAgentOptions subclass with its own setters' do + after { ClaudeAgentSDK.reset_configuration } + + let(:mixin) { Module.new { attr_accessor :team } } + let(:labelled_options) do + mod = mixin + Class.new(ClaudeAgentSDK::ClaudeAgentOptions) do + include mod + + attr_accessor :tenant + + def label=(value) + @label = value.to_s + end + end + end + + it 'accepts them in the constructor, in any spelling' do + options = labelled_options.new(label: :nightly, 'tenant' => 'acme', team: 'infra', model: 'sonnet') + + expect(options.instance_variable_get(:@label)).to eq('nightly') + expect([options.tenant, options.team, options.model]).to eq(%w[acme infra sonnet]) + end + + it 'accepts them through #[]= and #dup_with' do + options = labelled_options.new + options[:label] = 'weekly' + copy = options.dup_with(tenant: 'acme', label: 'daily') + + expect(options.instance_variable_get(:@label)).to eq('weekly') + expect([copy.tenant, copy.instance_variable_get(:@label)]).to eq(%w[acme daily]) + end + + it 'accepts them when defaults are configured' do + ClaudeAgentSDK.configure { |config| config.default_options = { model: 'sonnet' } } + options = labelled_options.new('label' => 'nightly', tenant: 'acme') + + expect([options.instance_variable_get(:@label), options.tenant, options.model]).to eq(%w[nightly acme sonnet]) + end + + it 'accepts a setter defined on the object itself' do + options = labelled_options.new + options.define_singleton_method(:trace_id=) { |value| @trace_id = value } + options[:trace_id] = 'abc' + + expect(options.instance_variable_get(:@trace_id)).to eq('abc') + end + + it 'still reports an unknown option and "[]"' do + expect { labelled_options.new(lable: 'x') } + .to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: :lable') + expect { labelled_options.new('[]' => 1) } + .to raise_error(ArgumentError, 'unknown ClaudeAgentOptions option: "[]"') + end + end +end diff --git a/spec/unit/types_spec.rb b/spec/unit/types_spec.rb index 5150ef45..2a73b86c 100644 --- a/spec/unit/types_spec.rb +++ b/spec/unit/types_spec.rb @@ -3027,7 +3027,7 @@ def []=(key, value) end it 'keeps filtered attributes per class (subclasses inherit, siblings do not)' do - expect(ClaudeAgentSDK::ClaudeAgentOptions.inspect_filtered_attributes).to eq(['env']) + expect(ClaudeAgentSDK::ClaudeAgentOptions.inspect_filtered_attributes).to eq(%w[env settings extra_args]) expect(ClaudeAgentSDK::McpHttpServerConfig.inspect_filtered_attributes).to eq(['headers']) expect(ClaudeAgentSDK::ResultMessage.inspect_filtered_attributes).to eq([]) end