claude-agent-sdk 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 35da7de1ea5624dce1116d0c031a6141549e1d554434f3636cf47f5bcc81ca70
4
- data.tar.gz: f9093848753fc2b583aed232d30013c8f328e36c6a958dc44f7d30c6ff319ae8
3
+ metadata.gz: b739ecaa08f1ddc9bc97ffdf639deca97a69da0251ec7b4ec975c7994c76c4b7
4
+ data.tar.gz: c85fe94b25b9b18c7de7d842cdeef7d0059c40cf829f33a963a4c6a215b4f999
5
5
  SHA512:
6
- metadata.gz: f73779c80dfced4d4600384853900a68d658db1dcb9a73813949c49bced1593641a8b68085d83d6127178a533cb5a6daf2d045ffdaac4f526fc8d21f24db3636
7
- data.tar.gz: 8e1022e4c20937b7464f46636636d2d9c6c160dfce9937617d1b56c5fae1c0abce401e6d9795fe4b566d2c59ba6e628c6c8dbf6e090278743eeac54a9a8a8489
6
+ metadata.gz: 7c1e37466bb844a9bcb4be7206881051e097e45181b64554f1e1299602a00f0ad88f1ffcefd73796e118715595bcef83cfd8e63138e2f537e2b6312428767e2c
7
+ data.tar.gz: daa2ba1268fdf355b426fec710b28254eaa4db61ff45017fe444f05b1499986f13aa2b1273997a52f64c061ce84a6cd7efda897d977c5d753323511c05184399
data/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.22.0] - 2026-07-03
11
+
12
+ Final batch (Batch D) from the 2026-07-03 full-codebase audit, closing it out: tests/docs/examples plus the three recorded split-verdict findings (P1-P3). Minor because the conformance suite gained a contract (an adapter that passed 0.21.0 can now fail contract 16) and new API surface (`check_uuid_dedupe:`).
13
+
14
+ ### Fixed
15
+ - OTel observer: the next turn's prompt is no longer dropped from the new trace after an interrupted turn or `/clear` (a superseding init with no ResultMessage) — prompts arriving while the current trace already has its input are buffered for the next trace instead of being latched out; a prompt queued mid-turn now labels its own trace too.
16
+ - Disk session listings no longer misclassify a session as a sidechain from a nested `"isSidechain":true` inside a structured field: the first line is parsed and the top-level key checked (same as the store fold), with the substring heuristic kept only for window-truncated first lines.
17
+ - SDK MCP servers: propertyless object schemas (`{ type: 'object', additionalProperties: ... }`, `oneOf`, accept-anything forms) now pass through intact instead of being mangled into nonsense parameter lists ("additionalProperties" advertised as a required string param).
18
+ - `import_session_to_store` skips an unparseable trailing line with a warning (an ordinary interrupted-CLI artifact every read path already tolerates) instead of aborting mid-import with a raw `JSON::ParserError` and leaving a partial store import behind.
19
+ - Redis reference adapter: the delete cascade now WATCHes the subkey set, so a concurrent eager-mode append can no longer orphan a freshly-created subagent list forever; both the Redis and Postgres reference adapters stamp mtimes through a monotonic guard (like S3) so a backward clock step can't misdirect `--continue`.
20
+ - Redis example spec no longer FLUSHDBs whatever `SESSION_STORE_REDIS_URL` points at — it uses a random key prefix per run with prefix-scoped cleanup, like the Postgres spec.
21
+
22
+ ### Added
23
+ - Conformance suite contract 16: `list_sessions` must return exactly one row per session under multiple appends (the naive one-row-per-append implementation previously passed every contract, then showed N duplicate sessions in pickers). Contract 4 now also asserts `append([])` cannot create a phantom key. New opt-in `check_uuid_dedupe:` flag asserts the advisory retried-batch uuid-dedupe recommendation.
24
+ - `--continue` with a store that implements `list_session_summaries` skips sidechain candidates via the summary sidecar instead of downloading every candidate's full transcript.
25
+
26
+ ### Changed
27
+ - `RUN_INTEGRATION=1` now actually runs the real-CLI integration suite (the old tautological placeholder suite is gone; `RUN_REAL_INTEGRATION` remains as an alias). The real suite still self-skips without a `claude` CLI or `ANTHROPIC_API_KEY`.
28
+ - Packaged docs/README now link to examples and repo files via absolute GitHub URLs — the relative links were dead in installed gems and on rubydoc.info (examples/ and assets/ are not packaged).
29
+
30
+ ## [0.21.0] - 2026-07-03
31
+
32
+ Design-decision fix batch (Batch C) from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #43), plus three teardown-race hardenings from its adversarial review. Minor (not patch) because three fixes change observable behavior for previously-broken flows: a missing settings file with sandbox no longer raises, a raising initial prompt stream no longer notifies observers, and teardown can now preserve (instead of delete) the materialized resume dir.
33
+
34
+ ### Fixed
35
+ - `Query#close` (and therefore `Client#disconnect`) is now safe to call from any thread. Calling it from a FiberBoundary worker — e.g. a tool handler, hook, or permission callback ending the session — crashed with `NoMethodError` (nil `Fiber.scheduler`) and left the read/child tasks running, hanging the enclosing reactor. A transient reactor-side watcher now serves closes marshaled from foreign threads with identical semantics; once the reactor is gone, close falls back to a direct (fiber-dead-safe) teardown. `close` is also idempotent now — concurrent or repeated closes no longer re-run teardown against half-torn-down state.
36
+ - Eager-mode mirror failures during the last flush of a stream are reported before the `'end'` sentinel: the `MirrorErrorMessage` for an in-flight dropped batch could previously be enqueued after `'end'` and never delivered, violating the documented "failures surface as MirrorErrorMessage" guarantee.
37
+ - Disk session listings no longer report tool-argument text as the session summary/title: the head/tail byte-scans for `summary`/`customTitle`/`aiTitle`/`lastPrompt` now verify each match against the top level of its containing JSONL line (the same keys the SessionStore fold reads), so keys nested inside tool_use inputs (subagent/teammate tool arguments) are ignored. Lines truncated at the 64KB window edge keep the raw-scan value.
38
+ - Resume-from-store teardown no longer deletes the only copy of turns the mirror failed to persist: when the batcher dropped batches (tracked via `TranscriptMirrorBatcher#batches_dropped?` / `Query#mirror_batches_dropped?`, including drains cancelled mid-flight), the materialized temp `CLAUDE_CONFIG_DIR` is preserved — credential copies scrubbed, transcripts kept, warning with the path — instead of removed.
39
+ - A missing settings file with `sandbox` set now warns and continues with sandbox-only settings (Python parity) instead of raising `CLIConnectionError`.
40
+ - `Client#connect` no longer fires observer `on_error` for a raising initial Enumerator prompt: input-stream errors are swallowed-with-warn (documented `Observer#on_error` contract, `query()` and Python parity). Notifying them also marked still-live OTel traces as failed.
41
+ - Postgres reference adapter (`examples/session_stores/postgres_session_store.rb`): all DB round-trips now serialize through an internal mutex — mirror appends run on fresh worker threads and can overlap after a send-timeout abandon, which pg's single-connection thread rules forbid. The "a single PG::Connection suffices" concurrency guidance was wrong and has been rewritten.
42
+
10
43
  ## [0.20.0] - 2026-07-03
11
44
 
12
45
  Behavioral fix batch (Batch B) from the 2026-07-03 full-codebase audit (`AUDIT-2026-07-03.md`, PR #42). Every fix aligns code with a documented contract or Python SDK behavior and changes behavior only for inputs that previously produced wrong results, hangs, or crashes. Minor (not patch) because two fixes raise where the SDK previously reported success: a signal-killed CLI now raises `ProcessError`, and assistant messages without `message.model` now raise `MessageParseError`.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Claude Agent SDK for Ruby
2
2
 
3
- ![Claude Agent SDK for Ruby banner](assets/claude-agent-sdk-ruby-banner.png)
3
+ ![Claude Agent SDK for Ruby banner](https://raw.githubusercontent.com/ya-luotao/claude-agent-sdk-ruby/main/assets/claude-agent-sdk-ruby-banner.png)
4
4
 
5
5
  [![Gem Version](https://badge.fury.io/rb/claude-agent-sdk.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/claude-agent-sdk)
6
6
 
@@ -68,7 +68,7 @@ Add this line to your application's Gemfile:
68
68
  gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
69
69
 
70
70
  # Or use a stable version from RubyGems
71
- gem 'claude-agent-sdk', '~> 0.20.0'
71
+ gem 'claude-agent-sdk', '~> 0.22.0'
72
72
  ```
73
73
 
74
74
  Then `bundle install`, or install directly: `gem install claude-agent-sdk`.
@@ -144,7 +144,7 @@ ClaudeAgentSDK.query(prompt: stream) do |message|
144
144
  end
145
145
  ```
146
146
 
147
- See [examples/streaming_input_example.rb](examples/streaming_input_example.rb) and [examples/quick_start.rb](examples/quick_start.rb).
147
+ See [examples/streaming_input_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/streaming_input_example.rb) and [examples/quick_start.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/quick_start.rb).
148
148
 
149
149
  ## `Client` — Bidirectional Sessions
150
150
 
@@ -220,49 +220,49 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
220
220
 
221
221
  | Example | Description |
222
222
  |---------|-------------|
223
- | [quick_start.rb](examples/quick_start.rb) | Basic `query()` usage with options |
224
- | [client_example.rb](examples/client_example.rb) | Interactive Client usage |
225
- | [message_types_example.rb](examples/message_types_example.rb) | Handling all 24 SDK message types |
226
- | [streaming_input_example.rb](examples/streaming_input_example.rb) | Streaming input for multi-turn conversations |
227
- | [session_resumption_example.rb](examples/session_resumption_example.rb) | Multi-turn conversations with session persistence |
228
- | [structured_output_example.rb](examples/structured_output_example.rb) | JSON schema structured output |
229
- | [error_handling_example.rb](examples/error_handling_example.rb) | Error handling with `AssistantMessage.error` |
230
- | [bare_mode_example.rb](examples/bare_mode_example.rb) | Minimal startup with `bare: true` |
231
- | [sandbox_example.rb](examples/sandbox_example.rb) | Full sandbox settings (network, filesystem, violations) |
223
+ | [quick_start.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/quick_start.rb) | Basic `query()` usage with options |
224
+ | [client_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/client_example.rb) | Interactive Client usage |
225
+ | [message_types_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/message_types_example.rb) | Handling all 24 SDK message types |
226
+ | [streaming_input_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/streaming_input_example.rb) | Streaming input for multi-turn conversations |
227
+ | [session_resumption_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/session_resumption_example.rb) | Multi-turn conversations with session persistence |
228
+ | [structured_output_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/structured_output_example.rb) | JSON schema structured output |
229
+ | [error_handling_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/error_handling_example.rb) | Error handling with `AssistantMessage.error` |
230
+ | [bare_mode_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/bare_mode_example.rb) | Minimal startup with `bare: true` |
231
+ | [sandbox_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/sandbox_example.rb) | Full sandbox settings (network, filesystem, violations) |
232
232
 
233
233
  ### MCP Servers
234
234
 
235
235
  | Example | Description |
236
236
  |---------|-------------|
237
- | [mcp_calculator.rb](examples/mcp_calculator.rb) | Custom tools with SDK MCP servers |
238
- | [mcp_resources_prompts_example.rb](examples/mcp_resources_prompts_example.rb) | MCP resources and prompts |
239
- | [http_mcp_server_example.rb](examples/http_mcp_server_example.rb) | HTTP/SSE MCP server configuration |
237
+ | [mcp_calculator.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/mcp_calculator.rb) | Custom tools with SDK MCP servers |
238
+ | [mcp_resources_prompts_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/mcp_resources_prompts_example.rb) | MCP resources and prompts |
239
+ | [http_mcp_server_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/http_mcp_server_example.rb) | HTTP/SSE MCP server configuration |
240
240
 
241
241
  ### Hooks & Permissions
242
242
 
243
243
  | Example | Description |
244
244
  |---------|-------------|
245
- | [hooks_example.rb](examples/hooks_example.rb) | Using hooks to control tool execution |
246
- | [advanced_hooks_example.rb](examples/advanced_hooks_example.rb) | Typed hook inputs/outputs |
247
- | [lifecycle_hooks_example.rb](examples/lifecycle_hooks_example.rb) | All 27 hook events |
248
- | [permission_callback_example.rb](examples/permission_callback_example.rb) | Dynamic tool permission control |
245
+ | [hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/hooks_example.rb) | Using hooks to control tool execution |
246
+ | [advanced_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/advanced_hooks_example.rb) | Typed hook inputs/outputs |
247
+ | [lifecycle_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/lifecycle_hooks_example.rb) | All 27 hook events |
248
+ | [permission_callback_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/permission_callback_example.rb) | Dynamic tool permission control |
249
249
 
250
250
  ### Advanced
251
251
 
252
252
  | Example | Description |
253
253
  |---------|-------------|
254
- | [budget_control_example.rb](examples/budget_control_example.rb) | Budget control with `max_budget_usd` |
255
- | [fallback_model_example.rb](examples/fallback_model_example.rb) | Fallback model configuration |
256
- | [extended_thinking_example.rb](examples/extended_thinking_example.rb) | Extended thinking |
257
- | [e2b_transport_example.rb](examples/e2b_transport_example.rb) | Custom transport running CLI in an E2B microVM |
254
+ | [budget_control_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/budget_control_example.rb) | Budget control with `max_budget_usd` |
255
+ | [fallback_model_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/fallback_model_example.rb) | Fallback model configuration |
256
+ | [extended_thinking_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/extended_thinking_example.rb) | Extended thinking |
257
+ | [e2b_transport_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/e2b_transport_example.rb) | Custom transport running CLI in an E2B microVM |
258
258
 
259
259
  ### Observability & Rails
260
260
 
261
261
  | Example | Description |
262
262
  |---------|-------------|
263
- | [otel_langfuse_example.rb](examples/otel_langfuse_example.rb) | OpenTelemetry tracing with Langfuse backend |
264
- | [rails_actioncable_example.rb](examples/rails_actioncable_example.rb) | ActionCable streaming to frontend |
265
- | [rails_background_job_example.rb](examples/rails_background_job_example.rb) | Background jobs with session resumption |
263
+ | [otel_langfuse_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/otel_langfuse_example.rb) | OpenTelemetry tracing with Langfuse backend |
264
+ | [rails_actioncable_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/rails_actioncable_example.rb) | ActionCable streaming to frontend |
265
+ | [rails_background_job_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/rails_background_job_example.rb) | Background jobs with session resumption |
266
266
 
267
267
  ## Available Tools
268
268
 
data/docs/client.md CHANGED
@@ -76,7 +76,7 @@ client = ClaudeAgentSDK::Client.new(
76
76
 
77
77
  ### Reference: running `claude` inside an E2B sandbox
78
78
 
79
- [`examples/e2b_transport_example.rb`](../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:
79
+ [`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:
80
80
 
81
81
  ```
82
82
  ClaudeAgentSDK::Client (host)
@@ -35,7 +35,7 @@ ClaudeAgentSDK.query(prompt: "Create a profile for a software engineer", options
35
35
  end
36
36
  ```
37
37
 
38
- See [examples/structured_output_example.rb](../examples/structured_output_example.rb).
38
+ See [examples/structured_output_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/structured_output_example.rb).
39
39
 
40
40
  ## Thinking Configuration
41
41
 
@@ -91,7 +91,7 @@ ClaudeAgentSDK.query(prompt: "Explain recursion", options: options) do |message|
91
91
  end
92
92
  ```
93
93
 
94
- See [examples/budget_control_example.rb](../examples/budget_control_example.rb).
94
+ See [examples/budget_control_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/budget_control_example.rb).
95
95
 
96
96
  ## Fallback Model
97
97
 
@@ -102,7 +102,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
102
102
  )
103
103
  ```
104
104
 
105
- See [examples/fallback_model_example.rb](../examples/fallback_model_example.rb).
105
+ See [examples/fallback_model_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/fallback_model_example.rb).
106
106
 
107
107
  ## Beta Features
108
108
 
@@ -155,7 +155,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
155
155
  )
156
156
  ```
157
157
 
158
- See [examples/sandbox_example.rb](../examples/sandbox_example.rb).
158
+ See [examples/sandbox_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/sandbox_example.rb).
159
159
 
160
160
  ## Bare Mode
161
161
 
@@ -186,7 +186,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
186
186
 
187
187
  **What still works:** skills (via `/skill-name`), explicit `--add-dir` CLAUDE.md, `--settings`, `--mcp-config`, `--agents`, `--plugin-dir`, API key from `ANTHROPIC_API_KEY` env var.
188
188
 
189
- See [examples/bare_mode_example.rb](../examples/bare_mode_example.rb).
189
+ See [examples/bare_mode_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/bare_mode_example.rb).
190
190
 
191
191
  ## File Checkpointing & Rewind
192
192
 
data/docs/errors.md CHANGED
@@ -18,7 +18,7 @@ ClaudeAgentSDK.query(prompt: "Hello") do |message|
18
18
  end
19
19
  ```
20
20
 
21
- See [examples/error_handling_example.rb](../examples/error_handling_example.rb).
21
+ See [examples/error_handling_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/error_handling_example.rb).
22
22
 
23
23
  ## Exception Handling
24
24
 
@@ -92,4 +92,4 @@ end
92
92
  | `CLIJSONDecodeError` | JSON parsing issues |
93
93
  | `MessageParseError` | Message parsing issues |
94
94
 
95
- See [lib/claude_agent_sdk/errors.rb](../lib/claude_agent_sdk/errors.rb) for all error types.
95
+ See [lib/claude_agent_sdk/errors.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/lib/claude_agent_sdk/errors.rb) for all error types.
@@ -19,7 +19,7 @@ All hook input objects include common fields like `session_id`, `transcript_path
19
19
  - `SubagentStart` → `SubagentStartHookInput` (`agent_id`, `agent_type`)
20
20
  - `PermissionRequest` → `PermissionRequestHookInput` (`tool_name`, `tool_input`, `permission_suggestions`)
21
21
 
22
- All 27 hook events have typed input classes. See [`ClaudeAgentSDK::HOOK_EVENTS`](../lib/claude_agent_sdk/types.rb) and [examples/lifecycle_hooks_example.rb](../examples/lifecycle_hooks_example.rb).
22
+ All 27 hook events have typed input classes. See [`ClaudeAgentSDK::HOOK_EVENTS`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/lib/claude_agent_sdk/types.rb) and [examples/lifecycle_hooks_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/lifecycle_hooks_example.rb).
23
23
 
24
24
  ### Example: Blocking Dangerous Commands
25
25
 
@@ -67,7 +67,7 @@ Async do
67
67
  end.wait
68
68
  ```
69
69
 
70
- See [examples/hooks_example.rb](../examples/hooks_example.rb), [examples/advanced_hooks_example.rb](../examples/advanced_hooks_example.rb), and [examples/lifecycle_hooks_example.rb](../examples/lifecycle_hooks_example.rb).
70
+ 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).
71
71
 
72
72
  ## Permission Callbacks
73
73
 
@@ -107,4 +107,4 @@ Async do
107
107
  end.wait
108
108
  ```
109
109
 
110
- See [examples/permission_callback_example.rb](../examples/permission_callback_example.rb).
110
+ See [examples/permission_callback_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/permission_callback_example.rb).
data/docs/mcp-servers.md CHANGED
@@ -150,4 +150,4 @@ server = ClaudeAgentSDK.create_sdk_mcp_server(
150
150
  )
151
151
  ```
152
152
 
153
- See [examples/mcp_calculator.rb](../examples/mcp_calculator.rb) and [examples/mcp_resources_prompts_example.rb](../examples/mcp_resources_prompts_example.rb) for complete examples.
153
+ See [examples/mcp_calculator.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/mcp_calculator.rb) and [examples/mcp_resources_prompts_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/mcp_resources_prompts_example.rb) for complete examples.
@@ -147,4 +147,4 @@ end
147
147
  options = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [MyObserver.new])
148
148
  ```
149
149
 
150
- See [examples/otel_langfuse_example.rb](../examples/otel_langfuse_example.rb) for a complete multi-tool example.
150
+ See [examples/otel_langfuse_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/otel_langfuse_example.rb) for a complete multi-tool example.
data/docs/rails.md CHANGED
@@ -193,7 +193,7 @@ end
193
193
  Then every `ClaudeAgentSDK.query` and `Client` session automatically gets traced — no per-call wiring needed. The lambda factory ensures each request gets its own observer with isolated span state, safe for concurrent Puma/Sidekiq workers.
194
194
 
195
195
  See:
196
- - [examples/rails_actioncable_example.rb](../examples/rails_actioncable_example.rb)
197
- - [examples/rails_background_job_example.rb](../examples/rails_background_job_example.rb)
198
- - [examples/session_resumption_example.rb](../examples/session_resumption_example.rb)
199
- - [examples/http_mcp_server_example.rb](../examples/http_mcp_server_example.rb)
196
+ - [examples/rails_actioncable_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/rails_actioncable_example.rb)
197
+ - [examples/rails_background_job_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/rails_background_job_example.rb)
198
+ - [examples/session_resumption_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/session_resumption_example.rb)
199
+ - [examples/http_mcp_server_example.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/http_mcp_server_example.rb)
data/docs/sessions.md CHANGED
@@ -152,6 +152,12 @@ during resume materialization, default `60_000`).
152
152
  > `.claude/*` still applies (it resolves from `cwd`), and hooks/options passed
153
153
  > programmatically via `ClaudeAgentOptions` are unaffected. This matches the
154
154
  > Python and TypeScript SDKs.
155
+ >
156
+ > The temp dir is deleted at disconnect — **unless the mirror dropped batches**
157
+ > (adapter failures that exhausted retries, surfaced as `MirrorErrorMessage`):
158
+ > the store copy is then incomplete and the temp dir holds the only copy of the
159
+ > dropped turns, so the SDK scrubs the credential copies, keeps the transcripts,
160
+ > and warns with the preserved path so you can import them into the store.
155
161
 
156
162
  ### Implementing an adapter
157
163
 
@@ -166,7 +172,7 @@ ClaudeAgentSDK::Testing.run_session_store_conformance(-> { MyStore.new(...) })
166
172
  ```
167
173
 
168
174
  Copy-in reference adapters for **S3, Redis, and Postgres** live in
169
- [`examples/session_stores/`](../examples/session_stores/README.md), each with a
175
+ [`examples/session_stores/`](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/examples/session_stores/README.md), each with a
170
176
  production checklist.
171
177
 
172
178
  ### Store-backed helpers
data/docs/types.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Types Reference
2
2
 
3
- See [lib/claude_agent_sdk/types.rb](../lib/claude_agent_sdk/types.rb) for complete type definitions.
3
+ See [lib/claude_agent_sdk/types.rb](https://github.com/ya-luotao/claude-agent-sdk-ruby/blob/main/lib/claude_agent_sdk/types.rb) for complete type definitions.
4
4
 
5
5
  ## Message Types
6
6
 
@@ -376,7 +376,14 @@ module ClaudeAgentSDK
376
376
  end
377
377
 
378
378
  def load_settings_file(path)
379
- raise CLIConnectionError, "Settings file not found: #{path}" unless File.file?(path)
379
+ # Missing file: warn and continue with sandbox-only settings (Python
380
+ # parity: logger.warning("Settings file not found: ...") and an empty
381
+ # settings object). Raising here turned a misconfiguration the CLI
382
+ # tolerates into a hard connect failure.
383
+ unless File.file?(path)
384
+ warn "Claude SDK: Settings file not found: #{path}"
385
+ return {}
386
+ end
380
387
 
381
388
  JSON.parse(File.read(path))
382
389
  end
@@ -50,16 +50,32 @@ module ClaudeAgentSDK
50
50
  @root_span = nil
51
51
  @root_context = nil
52
52
  @tool_spans = {} # tool_use_id => span
53
- @first_user_input = nil # capture first user prompt for trace input
53
+ @first_user_input = nil # first user prompt of the current trace
54
+ @pending_prompt = nil # prompt that belongs to the NEXT trace (see on_user_prompt)
54
55
  @last_assistant_text = nil # capture last assistant text for trace output
55
56
  end
56
57
 
57
58
  def on_user_prompt(prompt)
58
- return if @first_user_input # only capture the first prompt
59
-
60
- @first_user_input = prompt.to_s
61
- # If root span already exists, set immediately; otherwise start_trace will apply it
62
- @root_span&.set_attribute('input.value', truncate(@first_user_input)) unless @first_user_input.empty?
59
+ text = prompt.to_s
60
+ return if text.empty?
61
+
62
+ if @root_span.nil?
63
+ # Pre-init: buffered for the trace start_trace is about to open.
64
+ # First prompt wins as the trace input.
65
+ @first_user_input ||= text
66
+ elsif @first_user_input.nil?
67
+ # Open trace with no input yet (init arrived before the prompt).
68
+ @first_user_input = text
69
+ @root_span.set_attribute('input.value', truncate(text))
70
+ else
71
+ # The open trace already has its input, so this prompt belongs to
72
+ # the NEXT trace: either the current turn was interrupted (/clear —
73
+ # a superseding init follows with no ResultMessage) or the user
74
+ # queued the next turn mid-stream (its init follows the current
75
+ # ResultMessage). A first-prompt-forever latch here dropped these
76
+ # prompts from the new trace entirely.
77
+ @pending_prompt ||= text
78
+ end
63
79
  end
64
80
 
65
81
  def on_message(message)
@@ -96,6 +112,10 @@ module ClaudeAgentSDK
96
112
  def on_close
97
113
  finish_open_spans
98
114
  reset_session_buffers
115
+ # Unlike the per-trace buffers, the pending next-trace prompt survives
116
+ # end_trace/supersede resets by design — but the session is over now,
117
+ # and a reused observer must not leak it into the next session.
118
+ @pending_prompt = nil
99
119
  end
100
120
 
101
121
  private
@@ -112,6 +132,11 @@ module ClaudeAgentSDK
112
132
  superseding = !@root_span.nil?
113
133
  finish_open_spans
114
134
  reset_session_buffers if superseding
135
+ # A prompt that arrived while the previous trace already had its input
136
+ # was buffered for THIS trace; it wins over a younger post-end prompt
137
+ # because it came first chronologically.
138
+ @first_user_input = @pending_prompt if @pending_prompt
139
+ @pending_prompt = nil
115
140
 
116
141
  attrs = {
117
142
  # gen_ai semantic conventions (recognized by Langfuse, Datadog, etc.)
@@ -77,6 +77,16 @@ module ClaudeAgentSDK
77
77
  @closed = false
78
78
  @initialization_result = nil
79
79
  @transcript_mirror_batcher = nil
80
+
81
+ # Cross-thread close marshaling (see #close). Thread::Queue is the one
82
+ # primitive that is both fiber-scheduler-aware on the reactor side and
83
+ # thread-safe on the caller side (push -> scheduler#unblock).
84
+ @close_requests = ::Thread::Queue.new
85
+ @close_watcher = nil
86
+ @owning_scheduler = nil
87
+ # First-caller-wins guard for close_now (see there).
88
+ @close_mutex = Mutex.new
89
+ @close_started = false
80
90
  end
81
91
 
82
92
  # Initialize control protocol if in streaming mode
@@ -178,7 +188,24 @@ module ClaudeAgentSDK
178
188
  parent = Async::Task.current?
179
189
  raise CLIConnectionError, 'Query#start must be called inside an Async{} block (e.g. wrap Client#connect in Async{...})' unless parent
180
190
 
191
+ @owning_scheduler = Fiber.scheduler
181
192
  @task = parent.async { read_messages }
193
+ # Reactor-side agent for #close calls arriving from foreign threads
194
+ # (FiberBoundary callbacks, plain user threads): Async::Task#stop needs
195
+ # the owning thread's Fiber.scheduler, so the off-thread caller hands the
196
+ # whole close over and waits. Transient: must never keep the reactor
197
+ # alive, and is stopped automatically when the parent task finishes.
198
+ # One-shot: after serving a close it is done; a reactor-side close wakes
199
+ # it via @close_requests.close (pop -> nil) so it exits without serving.
200
+ @close_watcher = parent.async(transient: true) do
201
+ if (reply = @close_requests.pop)
202
+ begin
203
+ close
204
+ ensure
205
+ reply << true
206
+ end
207
+ end
208
+ end
182
209
  end
183
210
 
184
211
  # Spawn a child task that is stopped by #close (mirrors the Python SDK's
@@ -206,6 +233,14 @@ module ClaudeAgentSDK
206
233
  @transcript_mirror_batcher = batcher
207
234
  end
208
235
 
236
+ # True when the mirror dropped at least one batch (store copy incomplete).
237
+ # Meaningful after #close, which runs the final flush. Consulted by the
238
+ # resume-from-store teardown to decide whether the materialized temp dir
239
+ # holds the only copy of some turns and must be preserved.
240
+ def mirror_batches_dropped?
241
+ !!@transcript_mirror_batcher&.batches_dropped?
242
+ end
243
+
209
244
  # Synthesize a `mirror_error` system message and put it on the SDK message
210
245
  # stream so consumers learn a mirror batch was dropped after exhausting
211
246
  # retries. Non-blocking: the message queue is unbounded, so unlike the
@@ -1152,8 +1187,45 @@ module ClaudeAgentSDK
1152
1187
  end
1153
1188
  end
1154
1189
 
1155
- # Close the query and transport
1190
+ # Close the query and transport.
1191
+ #
1192
+ # Callable from any thread. Async::Task#stop needs the reactor's
1193
+ # Fiber.scheduler (per-thread), which foreign callers — FiberBoundary
1194
+ # workers running a tool handler / hook that calls client.disconnect, or
1195
+ # plain user threads — don't have; stopping from one raised NoMethodError
1196
+ # and left the read/child tasks running. Such callers hand the close to
1197
+ # the reactor-side watcher (spawned in #start) and wait for it to finish,
1198
+ # so close semantics are identical regardless of the calling thread.
1156
1199
  def close
1200
+ if @close_watcher&.alive? && !Fiber.scheduler.equal?(@owning_scheduler)
1201
+ marshal_close_to_reactor
1202
+ else
1203
+ # Same scheduler (reactor-side caller, including the watcher itself),
1204
+ # or no live watcher: when the reactor is gone its task fibers are
1205
+ # dead, so stopping them no longer touches Fiber.scheduler.
1206
+ close_now
1207
+ end
1208
+ end
1209
+
1210
+ private
1211
+
1212
+ def close_now
1213
+ # First caller wins: a reactor-side close racing a watcher-served
1214
+ # foreign close (or a repeated disconnect) must not re-run teardown
1215
+ # against half-torn-down state — transport.close can suspend mid-reap,
1216
+ # and a second pass would race it. Later callers return immediately;
1217
+ # the SDK's outer teardown ensures (query() / Client#disconnect) close
1218
+ # the transport independently, so nothing is left dangling even if the
1219
+ # first pass failed partway.
1220
+ first_caller = @close_mutex.synchronize do
1221
+ if @close_started
1222
+ false
1223
+ else
1224
+ @close_started = true
1225
+ end
1226
+ end
1227
+ return unless first_caller
1228
+
1157
1229
  @closed = true
1158
1230
  # Wake pending control-request waiters (same shape as the read-loop
1159
1231
  # rescue broadcast): close stops the read task with Async::Stop, which
@@ -1171,10 +1243,43 @@ module ClaudeAgentSDK
1171
1243
  # Stop tracked child tasks (e.g. stream_input) before the read task and
1172
1244
  # transport so a parked input stream can never keep the reactor alive
1173
1245
  # (mirrors Python close() cancelling _child_tasks).
1174
- @child_tasks.each(&:stop)
1175
- @child_tasks.clear
1176
- @task&.stop
1246
+ begin
1247
+ @child_tasks.each(&:stop)
1248
+ @child_tasks.clear
1249
+ @task&.stop
1250
+ rescue NoMethodError, FiberError => e
1251
+ # Schedulerless fallback close (the watcher died unserved) racing
1252
+ # reactor teardown: a task fiber can still be unwinding, and stopping
1253
+ # it needs the owning thread's Fiber.scheduler. The dying reactor
1254
+ # stops its own tasks; the transport close below (plain IO + kill)
1255
+ # still runs. On the reactor itself this is a real bug — re-raise.
1256
+ raise unless Fiber.scheduler.nil?
1257
+
1258
+ warn "Claude SDK: skipped stopping tasks during off-reactor close: #{e.message}"
1259
+ end
1177
1260
  @transport.close
1261
+ # Release a still-parked close watcher: pop returns nil and it exits
1262
+ # without serving. Any foreign-thread close arriving after this point
1263
+ # falls back to a direct close (safe — the fibers are now dead).
1264
+ @close_requests.close
1265
+ end
1266
+
1267
+ # Hand the close to the reactor and wait for completion. Polls watcher
1268
+ # liveness instead of waiting forever: if the reactor shuts down
1269
+ # concurrently (the transient watcher is stopped without serving the
1270
+ # request), no reply will ever arrive — fall back to a direct close,
1271
+ # which is safe once the reactor's fibers are dead.
1272
+ def marshal_close_to_reactor
1273
+ reply = ::Thread::Queue.new
1274
+ @close_requests << reply
1275
+ loop do
1276
+ return if reply.pop(timeout: 0.1)
1277
+ break unless @close_watcher&.alive?
1278
+ end
1279
+ close_now
1280
+ rescue ClosedQueueError
1281
+ # The push raced a reactor-side close_now that closed @close_requests.
1282
+ close_now
1178
1283
  end
1179
1284
  end
1180
1285
  end
@@ -25,16 +25,23 @@ module ClaudeAgentSDK
25
25
  end
26
26
 
27
27
  # A prebuilt JSON Schema is detected by type == 'object' (String or Symbol)
28
- # AND a Hash properties value. Deliberately stricter than Python's rule:
29
- # Ruby's simple-schema idiom uses Symbols as type VALUES, so
28
+ # with properties either ABSENT or a Hash. Properties-present-but-not-Hash
29
+ # falls through to the simple idiom, which uses Symbols as type VALUES
30
30
  # { type: :string, properties: :string } is a legal simple schema with
31
- # params literally named type/properties.
31
+ # params literally named type/properties. Propertyless object schemas
32
+ # ({ type: 'object', additionalProperties: ... } / oneOf / empty-object
33
+ # accept-anything forms) previously fell into the simple branch and were
34
+ # mangled into nonsense parameter lists ("additionalProperties" as a
35
+ # required string param). A $ref-only schema without type: 'object' remains
36
+ # indistinguishable from a params hash — declare the type alongside $ref.
32
37
  def self.prebuilt_json_schema?(schema)
33
38
  return false unless schema.is_a?(Hash)
34
39
 
35
40
  type_val = schema[:type] || schema['type']
36
- props_val = schema[:properties] || schema['properties']
37
- (type_val.is_a?(String) || type_val.is_a?(Symbol)) && type_val.to_s == 'object' && props_val.is_a?(Hash)
41
+ return false unless (type_val.is_a?(String) || type_val.is_a?(Symbol)) && type_val.to_s == 'object'
42
+ return true unless schema.key?(:properties) || schema.key?('properties')
43
+
44
+ (schema[:properties] || schema['properties']).is_a?(Hash)
38
45
  end
39
46
 
40
47
  # Single source of truth for tool input schemas: prebuilt schemas are
@@ -28,6 +28,23 @@ module ClaudeAgentSDK
28
28
  def cleanup
29
29
  SessionResume.rmtree_with_retry(@config_dir)
30
30
  end
31
+
32
+ # Teardown when the transcript mirror dropped batches: the CLI's
33
+ # authoritative transcript lives in this temp dir, and the store copy is
34
+ # missing the dropped turns — deleting the dir would permanently lose
35
+ # them. Keep the transcripts (projects/), remove the redacted credential
36
+ # copies, and tell the user where the data is so they can import it into
37
+ # the store manually. Never raises.
38
+ def preserve_transcripts
39
+ ['.credentials.json', '.claude.json'].each do |name|
40
+ FileUtils.rm_f(File.join(@config_dir, name))
41
+ end
42
+ warn "Claude SDK: transcript mirror dropped batches; the session store copy is incomplete. " \
43
+ "Preserving the session transcript under #{File.join(@config_dir, 'projects')} instead of " \
44
+ 'deleting it — import it into your session store, then remove the directory.'
45
+ rescue StandardError => e
46
+ warn "Claude SDK: failed to scrub preserved transcript dir #{@config_dir}: #{e.message}"
47
+ end
31
48
  end
32
49
 
33
50
  # Materialize a SessionStore-backed resume into a temp CLAUDE_CONFIG_DIR.
@@ -151,9 +168,15 @@ module ClaudeAgentSDK
151
168
  end
152
169
  return nil if sessions.nil? || sessions.empty?
153
170
 
171
+ sidechain_flags = sidechain_flags_from_summaries(store, project_key, timeout_s)
172
+
154
173
  sessions.sort_by { |s| -sortable_mtime(s['mtime']) }.each do |cand|
155
174
  sid = cand['session_id']
156
175
  next unless sid.is_a?(String) && sid.match?(Sessions::UUID_RE)
176
+ # Skip known sidechains without downloading their transcript: the
177
+ # newest keys are often sidechains, and full-loading each one made
178
+ # --continue O(sum of transcript sizes) instead of O(candidates).
179
+ next if sidechain_flags&.fetch(sid, false)
157
180
 
158
181
  loaded = load_candidate(store, project_key, sid, timeout_s)
159
182
  next if loaded.nil?
@@ -166,6 +189,26 @@ module ClaudeAgentSDK
166
189
  nil
167
190
  end
168
191
 
192
+ # session_id => true for sessions the summary sidecar marks as sidechains;
193
+ # nil when the store doesn't implement list_session_summaries or the call
194
+ # fails (callers then fall back to checking each full load). The per-load
195
+ # isSidechain check above stays even on the summary path: a missing or
196
+ # stale sidecar row costs one extra load, never a wrong resume.
197
+ def sidechain_flags_from_summaries(store, project_key, timeout_s)
198
+ return nil unless SessionStore.implements?(store, :list_session_summaries)
199
+
200
+ rows = with_timeout(timeout_s, 'SessionStore#list_session_summaries') do
201
+ store.list_session_summaries(project_key)
202
+ end
203
+ Array(rows).each_with_object({}) do |row, acc|
204
+ sid = row.is_a?(Hash) ? row['session_id'] : nil
205
+ acc[sid] = row.dig('data', 'is_sidechain') == true if sid
206
+ end
207
+ # NotImplementedError is a ScriptError that with_timeout does not wrap.
208
+ rescue StandardError, NotImplementedError
209
+ nil
210
+ end
211
+
169
212
  # Adapters contractually report mtime as an epoch-ms Numeric (the
170
213
  # conformance suite asserts it), but SQL timestamps naturally arrive as
171
214
  # ISO-8601 Strings through JSON. Unary minus on a String is String#-@
@@ -213,6 +213,65 @@ module ClaudeAgentSDK
213
213
  result
214
214
  end
215
215
 
216
+ # Byte-scan for `"key":"` like extract_json_string_field, but verify each
217
+ # match by JSON-parsing its containing line and reading the key at the TOP
218
+ # LEVEL of the entry. The raw scan also matches keys nested inside
219
+ # tool_use inputs (real transcripts carry unescaped `"summary":"..."` in
220
+ # subagent/teammate tool arguments), which made the disk path report
221
+ # tool-argument text as the session summary/title while the store fold —
222
+ # which reads only top-level keys — disagreed. A line that doesn't parse
223
+ # (truncated at the head/tail window edge) keeps the raw-scan value: its
224
+ # top-level shape can't be checked, and dropping it would regress the
225
+ # common case of a true entry cut by the 64KB window.
226
+ def extract_top_level_string_field(text, key, last: false)
227
+ positions = field_match_positions(text, key)
228
+ positions.reverse! if last
229
+ parsed_lines = {}
230
+ positions.each do |idx, value_start|
231
+ line_start = (text.rindex("\n", idx) || -1) + 1
232
+ entry = parsed_lines.fetch(line_start) do
233
+ parsed_lines[line_start] = parse_containing_line(text, line_start, idx)
234
+ end
235
+ if entry
236
+ value = entry[key]
237
+ return value if value.is_a?(String)
238
+
239
+ next # parseable line without a top-level string value: nested/false match
240
+ end
241
+ value = extract_json_string_value(text, value_start)
242
+ return unescape_json_string(value) if value
243
+ end
244
+ nil
245
+ end
246
+
247
+ # Match positions of both compact and spaced `"key":` patterns, sorted by
248
+ # position so first/last selection is by document order (the pattern-major
249
+ # order of extract_json_string_field is wrong when spacings mix).
250
+ def field_match_positions(text, key)
251
+ positions = []
252
+ ["\"#{key}\":\"", "\"#{key}\": \""].each do |pattern|
253
+ pos = 0
254
+ while (idx = text.index(pattern, pos))
255
+ value_start = idx + pattern.length
256
+ positions << [idx, value_start]
257
+ pos = value_start
258
+ end
259
+ end
260
+ positions.sort_by!(&:first)
261
+ positions
262
+ end
263
+
264
+ # Parse the JSONL line containing byte offset +idx+. Returns the entry
265
+ # Hash, {} for parseable non-Hash lines (a match inside one is nested by
266
+ # definition), or nil when the line doesn't parse (window truncation).
267
+ def parse_containing_line(text, line_start, idx)
268
+ line_end = text.index("\n", idx) || text.length
269
+ entry = JSON.parse(text[line_start...line_end])
270
+ entry.is_a?(Hash) ? entry : {}
271
+ rescue StandardError
272
+ nil
273
+ end
274
+
216
275
  # Extract string value starting at pos (handles escapes)
217
276
  def extract_json_string_value(text, start)
218
277
  pos = start
@@ -314,14 +373,26 @@ module ClaudeAgentSDK
314
373
  head, tail = read_head_tail(file_path, stat.size)
315
374
 
316
375
  # Check first line for sidechain
317
- first_line = head.lines.first || ''
318
- return nil if first_line.include?('"isSidechain":true') || first_line.include?('"isSidechain": true')
376
+ return nil if sidechain_first_line?(head.lines.first || '')
319
377
 
320
378
  build_session_info(file_path, head, tail, stat, project_path)
321
379
  rescue StandardError
322
380
  nil
323
381
  end
324
382
 
383
+ # Sidechain classification parses the first line and reads the top-level
384
+ # key — the same key the store fold reads (`entry['isSidechain'] == true`).
385
+ # The old raw substring scan also matched "isSidechain":true nested inside
386
+ # a structured field, hiding the session from disk listings only. A first
387
+ # line truncated by the read window can't be shape-checked and keeps the
388
+ # substring heuristic.
389
+ def sidechain_first_line?(line)
390
+ entry = JSON.parse(line)
391
+ entry.is_a?(Hash) && entry['isSidechain'] == true
392
+ rescue StandardError
393
+ line.include?('"isSidechain":true') || line.include?('"isSidechain": true')
394
+ end
395
+
325
396
  def read_head_tail(file_path, size)
326
397
  head = tail = nil
327
398
  File.open(file_path, 'rb') do |f|
@@ -341,15 +412,19 @@ module ClaudeAgentSDK
341
412
  # Head fallback covers short sessions where the title entry may not be in tail.
342
413
  # Each candidate passes through presence so a blank value (e.g. a
343
414
  # trailing title-clearing entry) falls through instead of short-circuiting.
344
- custom_title = presence(extract_json_string_field(tail, 'customTitle', last: true)) ||
345
- presence(extract_json_string_field(head, 'customTitle', last: true)) ||
346
- presence(extract_json_string_field(tail, 'aiTitle', last: true)) ||
347
- presence(extract_json_string_field(head, 'aiTitle', last: true))
415
+ # Summary-chain fields use the top-level-verified scan: a raw byte scan
416
+ # also matches these keys nested inside tool_use inputs, reporting tool
417
+ # arguments as the session title/summary (and diverging from the store
418
+ # fold, which reads top-level keys only).
419
+ custom_title = presence(extract_top_level_string_field(tail, 'customTitle', last: true)) ||
420
+ presence(extract_top_level_string_field(head, 'customTitle', last: true)) ||
421
+ presence(extract_top_level_string_field(tail, 'aiTitle', last: true)) ||
422
+ presence(extract_top_level_string_field(head, 'aiTitle', last: true))
348
423
  first_prompt = extract_first_prompt_from_head(head)
349
424
  # lastPrompt tail entry shows what the user was most recently doing.
350
425
  summary = custom_title ||
351
- presence(extract_json_string_field(tail, 'lastPrompt', last: true)) ||
352
- presence(extract_json_string_field(tail, 'summary', last: true)) ||
426
+ presence(extract_top_level_string_field(tail, 'lastPrompt', last: true)) ||
427
+ presence(extract_top_level_string_field(tail, 'summary', last: true)) ||
353
428
  first_prompt
354
429
  return nil if summary.nil? || summary.strip.empty?
355
430
 
@@ -911,11 +986,23 @@ module ClaudeAgentSDK
911
986
  # encoding: transcripts are UTF-8 regardless of locale; without it a
912
987
  # LANG=C process raises Encoding::InvalidByteSequenceError on the first
913
988
  # multibyte line, aborting the import mid-way (Python pins utf-8 here).
914
- File.foreach(file_path, encoding: 'UTF-8') do |line|
989
+ File.foreach(file_path, encoding: 'UTF-8').with_index(1) do |line, lineno|
915
990
  line = line.chomp
916
991
  next if line.empty?
917
992
 
918
- batch << JSON.parse(line)
993
+ begin
994
+ entry = JSON.parse(line)
995
+ rescue JSON::ParserError
996
+ # A truncated trailing line is an ordinary interrupted-CLI artifact;
997
+ # every read path tolerates it (parse_jsonl_entries skips bad
998
+ # lines), and raising here aborted mid-import, leaving a partial
999
+ # store import behind. Skip the line, but say so — import is an
1000
+ # explicit user operation.
1001
+ warn "Claude SDK: import_session_to_store skipped unparseable line #{lineno} of #{file_path}"
1002
+ next
1003
+ end
1004
+
1005
+ batch << entry
919
1006
  nbytes += line.bytesize
920
1007
  next unless batch.length >= batch_size || nbytes >= TranscriptMirrorBatcher::MAX_PENDING_BYTES
921
1008
 
@@ -13,10 +13,13 @@ module ClaudeAgentSDK
13
13
 
14
14
  module_function
15
15
 
16
- # Assert the 15 SessionStore behavioral contracts against an adapter.
16
+ # Assert the 16 SessionStore behavioral contracts against an adapter.
17
17
  #
18
18
  # Contracts 1-14 mirror the Python SDK's run_session_store_conformance.
19
- # Contract 15 is a Ruby SDK extension locking empty-subpath delete
19
+ # Contract 16 (Ruby extension) locks one-row-per-session `list_sessions`
20
+ # under multiple appends — the naive one-row-per-append implementation
21
+ # passed every other contract and then showed N duplicate sessions in
22
+ # pickers. Contract 15 is a Ruby SDK extension locking empty-subpath delete
20
23
  # coherence ('' == no subpath, the same addressing append/load already
21
24
  # use in every implementation in both SDKs); it runs only for stores
22
25
  # implementing #delete, and skip_optional: %w[delete] excludes the
@@ -38,7 +41,15 @@ module ClaudeAgentSDK
38
41
  # @param skip_optional [Array<String>] optional method names to skip.
39
42
  # Contracts for an optional method are also skipped automatically when the
40
43
  # store does not override it.
41
- def run_session_store_conformance(make_store, skip_optional: [])
44
+ # @param check_uuid_dedupe [Boolean] additionally assert the ADVISORY
45
+ # uuid-dedupe recommendation: re-appending a batch that overlaps a prior
46
+ # write (exactly what the mirror batcher's retry can produce) must not
47
+ # duplicate entries sharing a uuid. The SessionStore contract phrases
48
+ # this as a "should" and the shipped reference adapters deliberately
49
+ # skip it (duplicates largely self-heal through last-wins summary folds,
50
+ # though resumed transcripts can still show repeated turns), so this
51
+ # defaults to off. Turn it on if your adapter dedupes.
52
+ def run_session_store_conformance(make_store, skip_optional: [], check_uuid_dedupe: false)
42
53
  skip_optional = skip_optional.map(&:to_s)
43
54
  invalid = skip_optional - OPTIONAL_METHODS
44
55
  raise ConformanceError, "unknown optional methods in skip_optional: #{invalid}" unless invalid.empty?
@@ -56,6 +67,7 @@ module ClaudeAgentSDK
56
67
  check_list_session_summaries(fresh, has_list_sessions, has_delete) if has_list_summaries
57
68
  check_delete(fresh, has_list_subkeys, has_list_sessions) if has_delete
58
69
  check_list_subkeys(fresh) if has_list_subkeys
70
+ check_uuid_dedupe_contract(fresh) if check_uuid_dedupe
59
71
  nil
60
72
  end
61
73
 
@@ -86,11 +98,21 @@ module ClaudeAgentSDK
86
98
  entry('uuid' => 'm', 'n' => 3), entry('uuid' => 'b', 'n' => 4)],
87
99
  'multiple appends must preserve call order')
88
100
 
89
- # 4. append([]) is a no-op.
101
+ # 4. append([]) is a no-op — including on a never-written key, which
102
+ # must NOT come into existence (a phantom key surfaces as an empty
103
+ # session in listings; documented on InMemorySessionStore, previously
104
+ # never asserted).
90
105
  store = fresh.call
91
106
  store.append(key, [entry('uuid' => 'a', 'n' => 1)])
92
107
  store.append(key, [])
93
108
  assert_eq(store.load(key), [entry('uuid' => 'a', 'n' => 1)], 'append([]) must be a no-op')
109
+ phantom = { 'project_key' => key['project_key'], 'session_id' => 'phantom' }
110
+ store.append(phantom, [])
111
+ assert(store.load(phantom).nil?, 'append([]) to an unwritten key must not create it')
112
+ if has_list_sessions
113
+ listed = store.list_sessions(key['project_key']).map { |s| s['session_id'] }
114
+ assert(!listed.include?('phantom'), 'append([]) must not surface a phantom session in list_sessions')
115
+ end
94
116
 
95
117
  # 5. subpath keys are stored independently of main.
96
118
  store = fresh.call
@@ -134,6 +156,16 @@ module ClaudeAgentSDK
134
156
  [entry('n' => 1)])
135
157
  assert_eq(store.list_sessions('proj').map { |s| s['session_id'] }, ['main'],
136
158
  'list_sessions must exclude subagent subpaths')
159
+
160
+ # 16. one row per session regardless of how many appends built it. A
161
+ # one-row-per-append implementation passed every other contract (the
162
+ # multi-append guard existed only for list_session_summaries) and then
163
+ # showed N duplicate sessions in pickers.
164
+ store = fresh.call
165
+ multi = { 'project_key' => 'proj', 'session_id' => 'multi' }
166
+ 3.times { |i| store.append(multi, [entry('uuid' => "u#{i}", 'n' => i)]) }
167
+ assert_eq(store.list_sessions('proj').map { |s| s['session_id'] }, ['multi'],
168
+ 'list_sessions must return exactly one row per session after multiple appends')
137
169
  end
138
170
 
139
171
  # -- Optional: list_session_summaries ----------------------------------
@@ -259,6 +291,19 @@ module ClaudeAgentSDK
259
291
  'list_subkeys of an unknown session must be empty')
260
292
  end
261
293
 
294
+ # -- Opt-in: uuid dedupe (advisory) --------------------------------------
295
+
296
+ # A retried mirror batch can overlap a prior write (timeout abandons the
297
+ # in-flight append, which may still land). Adapters that opt in promise
298
+ # load reflects each uuid once, in first-seen order.
299
+ def check_uuid_dedupe_contract(fresh)
300
+ store = fresh.call
301
+ store.append(key, [entry('uuid' => 'r1', 'n' => 1), entry('uuid' => 'r2', 'n' => 2)])
302
+ store.append(key, [entry('uuid' => 'r2', 'n' => 2), entry('uuid' => 'r3', 'n' => 3)]) # retry overlap
303
+ assert_eq(store.load(key).map { |e| e['uuid'] }, %w[r1 r2 r3],
304
+ 'a retried batch overlapping a prior write must dedupe by entry uuid')
305
+ end
306
+
262
307
  # -- helpers -----------------------------------------------------------
263
308
 
264
309
  def key
@@ -303,7 +348,7 @@ module ClaudeAgentSDK
303
348
  end
304
349
 
305
350
  private_class_method :check_append_and_load, :check_list_sessions, :check_list_session_summaries,
306
- :check_delete, :check_list_subkeys, :key, :entry, :epoch_ms?, :optional?,
307
- :summaries_by_id, :assert, :assert_eq
351
+ :check_delete, :check_list_subkeys, :check_uuid_dedupe_contract, :key, :entry,
352
+ :epoch_ms?, :optional?, :summaries_by_id, :assert, :assert_eq
308
353
  end
309
354
  end
@@ -55,12 +55,24 @@ module ClaudeAgentSDK
55
55
  @pending = []
56
56
  @pending_entries = 0
57
57
  @pending_bytes = 0
58
+ # Batches that exhausted retries (or could not be keyed) and never
59
+ # reached the store. Written only under @lock; read cross-thread by
60
+ # #batches_dropped? (a plain Integer read is safe under the GVL).
61
+ @dropped_batches = 0
58
62
  # Fiber-aware lock: the critical section blocks on SessionStore#append
59
63
  # (a thread hop), so a Thread::Mutex would deadlock the reactor. The
60
64
  # semaphore serializes drains so append ordering matches enqueue order.
61
65
  @lock = Async::Semaphore.new(1)
62
66
  end
63
67
 
68
+ # True when at least one batch of entries never reached the store — the
69
+ # mirror copy is incomplete. Consulted at teardown by the resume-from-store
70
+ # cleanup so the materialized temp dir (which then holds the only copy of
71
+ # the dropped turns) is preserved instead of deleted.
72
+ def batches_dropped?
73
+ @dropped_batches.positive?
74
+ end
75
+
64
76
  # Buffer a frame; schedule an eager background flush if thresholds are
65
77
  # exceeded. Synchronous and fire-and-forget.
66
78
  #
@@ -112,27 +124,47 @@ module ClaudeAgentSDK
112
124
  @pending_bytes = 0
113
125
 
114
126
  errors = []
115
- @lock.acquire do
116
- # Emptiness is checked INSIDE the lock (matching the Python batcher):
117
- # an empty #flush/#close still serializes behind any in-flight or
118
- # queued drain, so they are true barriers at result-yield and at
119
- # teardown the store really is up to date, and Query#close can't stop
120
- # the read task while a detached batch is still being appended.
121
- next if items.empty?
122
-
123
- do_flush(items, errors)
124
- rescue StandardError => e
125
- # do_flush already guards each append; this guards any remaining path
126
- # so the "never raises" contract holds against future regressions.
127
- warn "Claude SDK: TranscriptMirrorBatcher drain failed: #{e.message}"
128
- end
129
-
130
- # Report errors after releasing the lock so a slow on_error callback can't
131
- # block subsequent drains (which only need the lock for append ordering).
132
- errors.each do |key, message|
133
- @on_error.call(key, message)
134
- rescue StandardError => e
135
- warn "Claude SDK: TranscriptMirrorBatcher on_error callback raised: #{e.message}"
127
+ # Cancellation (Async::Stop — not a StandardError) delivered while
128
+ # waiting on the lock or inside the append's thread join loses the
129
+ # detached items without either rescue firing. Teardown would then read
130
+ # batches_dropped? as false and delete a materialized resume dir holding
131
+ # the only copy of these entries count the batch as dropped unless the
132
+ # flush path ran to completion (do_flush counts its own failures).
133
+ accounted = items.empty?
134
+ begin
135
+ @lock.acquire do
136
+ # Emptiness is checked INSIDE the lock (matching the Python batcher):
137
+ # an empty #flush/#close still serializes behind any in-flight or
138
+ # queued drain, so they are true barriers at result-yield and at
139
+ # teardown the store really is up to date, and Query#close can't stop
140
+ # the read task while a detached batch is still being appended.
141
+ next if items.empty?
142
+
143
+ begin
144
+ do_flush(items, errors)
145
+ rescue StandardError => e
146
+ # do_flush already guards each append; this guards any remaining path
147
+ # so the "never raises" contract holds against future regressions.
148
+ @dropped_batches += 1
149
+ warn "Claude SDK: TranscriptMirrorBatcher drain failed: #{e.message}"
150
+ end
151
+ accounted = true
152
+
153
+ # Report errors BEFORE releasing the lock: flush/close are barriers, so
154
+ # a caller observing flush completion must also observe the error
155
+ # report. Reporting after release let an in-flight eager drain enqueue
156
+ # its MirrorErrorMessage after the read loop's 'end' sentinel — past
157
+ # the point where consumers stop dequeuing, i.e. never delivered. The
158
+ # production on_error (Query#report_mirror_error) is a non-blocking
159
+ # queue push, so holding the lock across it costs nothing.
160
+ errors.each do |key, message|
161
+ @on_error.call(key, message)
162
+ rescue StandardError => e
163
+ warn "Claude SDK: TranscriptMirrorBatcher on_error callback raised: #{e.message}"
164
+ end
165
+ end
166
+ ensure
167
+ @dropped_batches += 1 unless accounted
136
168
  end
137
169
  end
138
170
 
@@ -149,6 +181,7 @@ module ClaudeAgentSDK
149
181
 
150
182
  key = SessionStores.file_path_to_session_key(file_path, @projects_dir)
151
183
  if key.nil?
184
+ @dropped_batches += 1
152
185
  warn "Claude SDK: [SessionStore] dropping mirror frame: filePath #{file_path} is not under " \
153
186
  "#{@projects_dir} -- subprocess CLAUDE_CONFIG_DIR likely differs from parent (custom env / container?)"
154
187
  next
@@ -180,8 +213,11 @@ module ClaudeAgentSDK
180
213
  end
181
214
  end
182
215
 
183
- errors << [key, last_err.to_s] unless succeeded
184
- warn "Claude SDK: TranscriptMirrorBatcher flush failed for #{file_path}: #{last_err}" unless succeeded
216
+ return if succeeded
217
+
218
+ @dropped_batches += 1
219
+ errors << [key, last_err.to_s]
220
+ warn "Claude SDK: TranscriptMirrorBatcher flush failed for #{file_path}: #{last_err}"
185
221
  end
186
222
 
187
223
  # Run SessionStore#append (user code) on a plain thread via FiberBoundary,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.20.0'
4
+ VERSION = '0.22.0'
5
5
  end
@@ -555,8 +555,13 @@ module ClaudeAgentSDK
555
555
  ensure
556
556
  # Remove the materialized resume temp dir (which holds a redacted
557
557
  # .credentials.json copy) AFTER the subprocess has exited, even when
558
- # close itself raises.
559
- materialized&.cleanup
558
+ # close itself raises — unless the mirror dropped batches: the store
559
+ # copy is then incomplete and the temp dir holds the only copy of
560
+ # the dropped turns, so it is preserved (scrubbed of credentials)
561
+ # with a warning instead of deleted.
562
+ if materialized
563
+ query_handler&.mirror_batches_dropped? ? materialized.preserve_transcripts : materialized.cleanup
564
+ end
560
565
  end
561
566
  end
562
567
  end.wait
@@ -909,6 +914,10 @@ module ClaudeAgentSDK
909
914
  # state, and removes the materialized temp dir (which holds a redacted
910
915
  # .credentials.json copy) — so disconnect can never leave the client
911
916
  # half-open or leak the temp dir. The original error still propagates.
917
+ # Keep a handle on the query handler past the nil-out below: whether the
918
+ # mirror dropped batches is only final AFTER #close ran its last flush,
919
+ # and the materialized-dir decision at the bottom needs to ask it.
920
+ query_handler = @query_handler
912
921
  begin
913
922
  @query_handler&.close
914
923
  ensure
@@ -918,9 +927,17 @@ module ClaudeAgentSDK
918
927
  ensure
919
928
  @transport = nil
920
929
  @connected = false
921
- # Remove the materialized resume temp dir AFTER the subprocess exited.
930
+ # Remove the materialized resume temp dir AFTER the subprocess
931
+ # exited — unless the mirror dropped batches: the store copy is then
932
+ # incomplete and the temp dir holds the only copy of the dropped
933
+ # turns, so it is preserved (scrubbed of credentials) with a warning
934
+ # instead of deleted.
922
935
  if @materialized
923
- @materialized.cleanup
936
+ if query_handler&.mirror_batches_dropped?
937
+ @materialized.preserve_transcripts
938
+ else
939
+ @materialized.cleanup
940
+ end
924
941
  @materialized = nil
925
942
  end
926
943
  end
@@ -997,22 +1014,13 @@ module ClaudeAgentSDK
997
1014
  # yielding deadlocked connect — and serialized Hash messages with
998
1015
  # to_s (Ruby inspect, not JSON). stream_input JSON-generates Hashes
999
1016
  # and is tracked on the Query so close() stops it. Stream errors are
1000
- # notified to observers once, then swallowed-with-warn by
1001
- # stream_input (Python parity) they no longer abort connect.
1017
+ # swallowed-with-warn by stream_input (Python parity) — they don't
1018
+ # abort connect, and observers are NOT notified (the documented
1019
+ # Observer#on_error contract; notifying a swallowed error would mark
1020
+ # a still-live OTel trace as failed). Same behavior as query()'s
1021
+ # streaming path.
1002
1022
  observed = ClaudeAgentSDK.observing_prompt_stream(prompt, @resolved_observers)
1003
- notifying = error_notifying_stream(observed)
1004
- @query_handler.spawn_task { @query_handler.stream_input(notifying) }
1005
- end
1006
- end
1007
-
1008
- # Wrap a stream so a raising user enumerator fires on_error exactly once
1009
- # before stream_input's swallow-with-warn handling takes over.
1010
- def error_notifying_stream(stream)
1011
- Enumerator.new do |yielder|
1012
- stream.each { |message| yielder << message }
1013
- rescue StandardError => e
1014
- notify_error(e)
1015
- raise
1023
+ @query_handler.spawn_task { @query_handler.stream_input(observed) }
1016
1024
  end
1017
1025
  end
1018
1026
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.20.0
4
+ version: 0.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors