claude-agent-sdk 0.16.9 → 0.17.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.
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../session_store'
4
+ require_relative '../session_summary'
5
+
6
+ module ClaudeAgentSDK
7
+ # Test helpers shipped in the gem for third-party SessionStore adapter authors.
8
+ module Testing # rubocop:disable Metrics/ModuleLength
9
+ # Raised by run_session_store_conformance when a behavioral contract fails.
10
+ class ConformanceError < StandardError; end
11
+
12
+ OPTIONAL_METHODS = %w[list_sessions list_session_summaries delete list_subkeys].freeze
13
+
14
+ module_function
15
+
16
+ # Assert the 15 SessionStore behavioral contracts against an adapter.
17
+ #
18
+ # Framework-agnostic: raises ConformanceError on the first violated
19
+ # contract, otherwise returns nil. Call it from any test framework, e.g.
20
+ #
21
+ # it 'conforms' do
22
+ # ClaudeAgentSDK::Testing.run_session_store_conformance(-> { MyStore.new })
23
+ # end
24
+ #
25
+ # @param make_store [#call] invoked once per contract to provide isolation;
26
+ # returns a fresh SessionStore (or duck-typed adapter).
27
+ # @param skip_optional [Array<String>] optional method names to skip.
28
+ # Contracts for an optional method are also skipped automatically when the
29
+ # store does not override it.
30
+ def run_session_store_conformance(make_store, skip_optional: [])
31
+ skip_optional = skip_optional.map(&:to_s)
32
+ invalid = skip_optional - OPTIONAL_METHODS
33
+ raise ConformanceError, "unknown optional methods in skip_optional: #{invalid}" unless invalid.empty?
34
+
35
+ fresh = -> { make_store.call }
36
+
37
+ probe = fresh.call
38
+ has_list_sessions = optional?(probe, 'list_sessions', skip_optional)
39
+ has_list_summaries = optional?(probe, 'list_session_summaries', skip_optional)
40
+ has_delete = optional?(probe, 'delete', skip_optional)
41
+ has_list_subkeys = optional?(probe, 'list_subkeys', skip_optional)
42
+
43
+ check_append_and_load(fresh, has_list_sessions)
44
+ check_list_sessions(fresh) if has_list_sessions
45
+ check_list_session_summaries(fresh, has_list_sessions, has_delete) if has_list_summaries
46
+ check_delete(fresh, has_list_subkeys, has_list_sessions) if has_delete
47
+ check_list_subkeys(fresh) if has_list_subkeys
48
+ nil
49
+ end
50
+
51
+ # -- Required: append + load -------------------------------------------
52
+
53
+ def check_append_and_load(fresh, has_list_sessions) # rubocop:disable Metrics/MethodLength
54
+ # 1. append then load returns same entries in same order.
55
+ store = fresh.call
56
+ store.append(key, [entry('uuid' => 'b', 'n' => 1), entry('uuid' => 'a', 'n' => 2)])
57
+ assert_eq(store.load(key), [entry('uuid' => 'b', 'n' => 1), entry('uuid' => 'a', 'n' => 2)],
58
+ 'append then load must return the same entries in order')
59
+
60
+ # 2. load unknown key returns nil.
61
+ store = fresh.call
62
+ assert(store.load('project_key' => 'proj', 'session_id' => 'nope').nil?,
63
+ 'load of an unwritten session must return nil')
64
+ store.append(key, [entry('uuid' => 'x', 'n' => 1)])
65
+ assert(store.load(key.merge('subpath' => 'nope')).nil?,
66
+ 'load of an unwritten subpath must return nil')
67
+
68
+ # 3. multiple append calls preserve call order.
69
+ store = fresh.call
70
+ store.append(key, [entry('uuid' => 'z', 'n' => 1)])
71
+ store.append(key, [entry('uuid' => 'a', 'n' => 2), entry('uuid' => 'm', 'n' => 3)])
72
+ store.append(key, [entry('uuid' => 'b', 'n' => 4)])
73
+ assert_eq(store.load(key),
74
+ [entry('uuid' => 'z', 'n' => 1), entry('uuid' => 'a', 'n' => 2),
75
+ entry('uuid' => 'm', 'n' => 3), entry('uuid' => 'b', 'n' => 4)],
76
+ 'multiple appends must preserve call order')
77
+
78
+ # 4. append([]) is a no-op.
79
+ store = fresh.call
80
+ store.append(key, [entry('uuid' => 'a', 'n' => 1)])
81
+ store.append(key, [])
82
+ assert_eq(store.load(key), [entry('uuid' => 'a', 'n' => 1)], 'append([]) must be a no-op')
83
+
84
+ # 5. subpath keys are stored independently of main.
85
+ store = fresh.call
86
+ sub = key.merge('subpath' => 'subagents/agent-1')
87
+ store.append(key, [entry('uuid' => 'm', 'n' => 1)])
88
+ store.append(sub, [entry('uuid' => 's', 'n' => 1)])
89
+ assert_eq(store.load(key), [entry('uuid' => 'm', 'n' => 1)], 'main transcript must be independent of subpath')
90
+ assert_eq(store.load(sub), [entry('uuid' => 's', 'n' => 1)], 'subpath transcript must be independent of main')
91
+
92
+ # 6. project_key isolation.
93
+ store = fresh.call
94
+ store.append({ 'project_key' => 'A', 'session_id' => 's1' }, [entry('from' => 'A')])
95
+ store.append({ 'project_key' => 'B', 'session_id' => 's1' }, [entry('from' => 'B')])
96
+ assert_eq(store.load('project_key' => 'A', 'session_id' => 's1'), [entry('from' => 'A')],
97
+ 'project_key A must be isolated')
98
+ assert_eq(store.load('project_key' => 'B', 'session_id' => 's1'), [entry('from' => 'B')],
99
+ 'project_key B must be isolated')
100
+ return unless has_list_sessions
101
+
102
+ assert_eq(store.list_sessions('A').length, 1, 'project A must list one session')
103
+ assert_eq(store.list_sessions('B').length, 1, 'project B must list one session')
104
+ end
105
+
106
+ # -- Optional: list_sessions -------------------------------------------
107
+
108
+ def check_list_sessions(fresh)
109
+ # 7. list_sessions returns session_ids for project.
110
+ store = fresh.call
111
+ store.append({ 'project_key' => 'proj', 'session_id' => 'a' }, [entry('n' => 1)])
112
+ store.append({ 'project_key' => 'proj', 'session_id' => 'b' }, [entry('n' => 1)])
113
+ store.append({ 'project_key' => 'other', 'session_id' => 'c' }, [entry('n' => 1)])
114
+ sessions = store.list_sessions('proj')
115
+ assert_eq(sessions.map { |s| s['session_id'] }.sort, %w[a b], 'list_sessions must scope to the project')
116
+ assert(sessions.all? { |s| epoch_ms?(s['mtime']) }, 'list_sessions mtime must be epoch-ms (> 1e12)')
117
+ assert_eq(store.list_sessions('never-appended-project'), [], 'unknown project must list no sessions')
118
+
119
+ # 8. list_sessions excludes subagent subpaths.
120
+ store = fresh.call
121
+ store.append({ 'project_key' => 'proj', 'session_id' => 'main' }, [entry('n' => 1)])
122
+ store.append({ 'project_key' => 'proj', 'session_id' => 'main', 'subpath' => 'subagents/agent-1' },
123
+ [entry('n' => 1)])
124
+ assert_eq(store.list_sessions('proj').map { |s| s['session_id'] }, ['main'],
125
+ 'list_sessions must exclude subagent subpaths')
126
+ end
127
+
128
+ # -- Optional: list_session_summaries ----------------------------------
129
+
130
+ def check_list_session_summaries(fresh, has_list_sessions, has_delete)
131
+ # 14. persisted fold output round-trips through fold_session_summary.
132
+ store = fresh.call
133
+ summ_key = { 'project_key' => 'proj', 'session_id' => 'summ-sess' }
134
+ store.append(summ_key, [entry('timestamp' => '2024-01-01T00:00:00.000Z', 'customTitle' => 'first'),
135
+ entry('timestamp' => '2024-01-01T00:00:01.000Z')])
136
+ store.append(summ_key, [entry('timestamp' => '2024-01-01T00:00:02.000Z', 'customTitle' => 'second')])
137
+ store.append({ 'project_key' => 'other', 'session_id' => 'elsewhere' },
138
+ [entry('timestamp' => '2024-01-01T00:00:00.000Z')])
139
+
140
+ by_id = summaries_by_id(store, 'proj', ['summ-sess'],
141
+ 'list_session_summaries must return exactly one row per session, scoped to the project')
142
+ summ = by_id['summ-sess']
143
+ assert(epoch_ms?(summ['mtime']), 'summary mtime must be epoch-ms (> 1e12)')
144
+
145
+ if has_list_sessions
146
+ ls_by_id = store.list_sessions('proj').to_h { |e| [e['session_id'], e['mtime']] }
147
+ assert(summ['mtime'] >= ls_by_id['summ-sess'],
148
+ 'summary mtime must share a clock with (and be >=) list_sessions mtime')
149
+ end
150
+
151
+ assert(summ['data'].is_a?(Hash), 'summary data must be a Hash')
152
+ refolded = SessionSummary.fold_session_summary(summ, summ_key, [entry('timestamp' => '2024-01-01T00:00:03.000Z')])
153
+ assert_eq(refolded['session_id'], 'summ-sess', 'refold must preserve session_id')
154
+ assert_eq(refolded['mtime'], summ['mtime'], 'fold must preserve prev mtime verbatim')
155
+
156
+ # Subagent appends must NOT affect the main session's summary.
157
+ store.append(summ_key.merge('subpath' => 'subagents/agent-1'),
158
+ [entry('timestamp' => '2024-01-01T00:00:09.000Z', 'customTitle' => 'subagent')])
159
+ after_sub = summaries_by_id(store, 'proj', ['summ-sess'],
160
+ 'list_session_summaries must still return one row per session after a subagent append')
161
+ assert_eq(after_sub['summ-sess']['data'], summ['data'], 'subagent appends must not change the main summary')
162
+ assert_eq(store.list_session_summaries('never-appended-project'), [], 'unknown project must list no summaries')
163
+
164
+ return unless has_delete
165
+
166
+ store.delete(summ_key)
167
+ assert_eq(store.list_session_summaries('proj'), [], 'delete must clear the session summary')
168
+ end
169
+
170
+ # -- Optional: delete --------------------------------------------------
171
+
172
+ def check_delete(fresh, has_list_subkeys, has_list_sessions) # rubocop:disable Metrics/MethodLength
173
+ # 9. delete main then load returns nil (delete of never-written is a no-op).
174
+ store = fresh.call
175
+ store.delete('project_key' => 'proj', 'session_id' => 'never-written')
176
+ store.append(key, [entry('n' => 1)])
177
+ store.delete(key)
178
+ assert(store.load(key).nil?, 'load after delete must return nil')
179
+
180
+ # 10. delete main cascades to subkeys; siblings/other projects survive.
181
+ store = fresh.call
182
+ sub1 = key.merge('subpath' => 'subagents/agent-1')
183
+ sub2 = key.merge('subpath' => 'subagents/agent-2')
184
+ other = { 'project_key' => 'proj', 'session_id' => 'sess2' }
185
+ other_proj = { 'project_key' => 'other-proj', 'session_id' => key['session_id'] }
186
+ [key, sub1, sub2, other, other_proj].each { |k| store.append(k, [entry('n' => 1)]) }
187
+
188
+ store.delete(key)
189
+
190
+ assert(store.load(key).nil?, 'delete must remove the main transcript')
191
+ assert(store.load(sub1).nil?, 'delete must cascade to subkey 1')
192
+ assert(store.load(sub2).nil?, 'delete must cascade to subkey 2')
193
+ assert((store.load(other) || []).length == 1, 'sibling session must survive delete')
194
+ assert((store.load(other_proj) || []).length == 1, 'other project must survive delete')
195
+ assert_eq(store.list_subkeys(key), [], 'list_subkeys must be empty after cascade delete') if has_list_subkeys
196
+ if has_list_sessions
197
+ listed = store.list_sessions(key['project_key']).map { |s| s['session_id'] }
198
+ assert(!listed.include?(key['session_id']), 'deleted session must not be listed')
199
+ end
200
+
201
+ # 15. delete with an empty-string subpath behaves as a main-transcript
202
+ # delete (empty subpath == "no subpath"), so it cascades to subkeys rather
203
+ # than orphaning them. Runs before test 11's has_list_subkeys early return.
204
+ store = fresh.call
205
+ [key, sub1, sub2].each { |k| store.append(k, [entry('n' => 1)]) }
206
+ store.delete(key.merge('subpath' => ''))
207
+ assert(store.load(key).nil?, 'delete with empty subpath must remove the main transcript')
208
+ assert(store.load(sub1).nil?, 'delete with empty subpath must cascade to subkeys')
209
+ if has_list_sessions
210
+ listed = store.list_sessions(key['project_key']).map { |s| s['session_id'] }
211
+ assert(!listed.include?(key['session_id']), 'session deleted via empty subpath must not be listed')
212
+ end
213
+
214
+ # 11. delete with subpath removes only that subkey.
215
+ store = fresh.call
216
+ [key, sub1, sub2].each { |k| store.append(k, [entry('n' => 1)]) }
217
+ store.delete(sub1)
218
+ assert(store.load(sub1).nil?, 'targeted subpath delete must remove that subkey')
219
+ assert((store.load(sub2) || []).length == 1, 'targeted subpath delete must spare other subkeys')
220
+ assert((store.load(key) || []).length == 1, 'targeted subpath delete must spare the main transcript')
221
+ return unless has_list_subkeys
222
+
223
+ assert_eq(store.list_subkeys(key), ['subagents/agent-2'], 'only the deleted subkey must be gone')
224
+ end
225
+
226
+ # -- Optional: list_subkeys --------------------------------------------
227
+
228
+ def check_list_subkeys(fresh)
229
+ # 12. list_subkeys returns subpaths (scoped to the session).
230
+ store = fresh.call
231
+ store.append(key, [entry('n' => 1)])
232
+ store.append(key.merge('subpath' => 'subagents/agent-1'), [entry('n' => 1)])
233
+ store.append(key.merge('subpath' => 'subagents/agent-2'), [entry('n' => 1)])
234
+ store.append({ 'project_key' => key['project_key'], 'session_id' => 'other-sess',
235
+ 'subpath' => 'subagents/agent-x' }, [entry('n' => 1)])
236
+ subkeys = store.list_subkeys(key)
237
+ assert_eq(subkeys.sort, ['subagents/agent-1', 'subagents/agent-2'], "list_subkeys must return this session's subpaths")
238
+ assert(!subkeys.include?('subagents/agent-x'), "list_subkeys must not leak another session's subkeys")
239
+
240
+ # 13. list_subkeys excludes the main transcript.
241
+ store = fresh.call
242
+ store.append(key, [entry('n' => 1)])
243
+ assert_eq(store.list_subkeys(key), [], 'list_subkeys must exclude the main transcript')
244
+ assert_eq(store.list_subkeys('project_key' => 'proj', 'session_id' => 'never-appended'), [],
245
+ 'list_subkeys of an unknown session must be empty')
246
+ end
247
+
248
+ # -- helpers -----------------------------------------------------------
249
+
250
+ def key
251
+ { 'project_key' => 'proj', 'session_id' => 'sess' }
252
+ end
253
+
254
+ # Build a test entry satisfying SessionStoreEntry (type is required). The
255
+ # value of type is irrelevant to the contracts — entries are opaque blobs.
256
+ def entry(extra = {})
257
+ { 'type' => 'x' }.merge(extra)
258
+ end
259
+
260
+ def epoch_ms?(value)
261
+ value.is_a?(Numeric) && value.finite? && value > 1e12
262
+ end
263
+
264
+ # Fetch summaries, asserting on the RAW rows before collapsing into a hash:
265
+ # a store returning one row per append (every historical fold version)
266
+ # would otherwise pass — and then surface duplicate sessions from
267
+ # list_sessions_from_store.
268
+ def summaries_by_id(store, project, expected_ids, message)
269
+ rows = Array(store.list_session_summaries(project))
270
+ assert_eq(rows.map { |s| s['session_id'] }.sort, expected_ids.sort, message)
271
+ rows.to_h { |s| [s['session_id'], s] }
272
+ end
273
+
274
+ def optional?(store, method, skip_optional)
275
+ return false if skip_optional.include?(method)
276
+
277
+ SessionStore.implements?(store, method.to_sym)
278
+ end
279
+
280
+ def assert(condition, message)
281
+ raise ConformanceError, "SessionStore conformance failed: #{message}" unless condition
282
+ end
283
+
284
+ def assert_eq(actual, expected, message)
285
+ return if actual == expected
286
+
287
+ raise ConformanceError,
288
+ "SessionStore conformance failed: #{message}\n expected: #{expected.inspect}\n actual: #{actual.inspect}"
289
+ end
290
+
291
+ private_class_method :check_append_and_load, :check_list_sessions, :check_list_session_summaries,
292
+ :check_delete, :check_list_subkeys, :key, :entry, :epoch_ms?, :optional?,
293
+ :summaries_by_id, :assert, :assert_eq
294
+ end
295
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'async'
5
+ require 'async/semaphore'
6
+ require_relative 'fiber_boundary'
7
+ require_relative 'session_store'
8
+
9
+ module ClaudeAgentSDK
10
+ # Batching layer between `transcript_mirror` stdout frames and a SessionStore.
11
+ #
12
+ # The CLI subprocess emits
13
+ # `{"type":"transcript_mirror","filePath":...,"entries":[...]}` frames
14
+ # interleaved with normal SDK messages. The Query read loop peels these off
15
+ # and hands them to #enqueue, which accumulates them and flushes to
16
+ # SessionStore#append either when a `result` message arrives (explicit
17
+ # #flush) or when the pending buffer exceeds size thresholds (eager
18
+ # background flush). This keeps adapter latency off the hot path during
19
+ # model streaming.
20
+ #
21
+ # Adapter failures are retried (MIRROR_APPEND_MAX_ATTEMPTS total) with short
22
+ # backoff; timeouts are not retried since the in-flight call may still land.
23
+ # Failures never raise — the local-disk transcript is already durable, so the
24
+ # session continues unaffected — and are reported via the +on_error+ callback
25
+ # (which surfaces them as a MirrorErrorMessage). Adapters should dedupe by
26
+ # entry["uuid"] when present, since a retried batch may overlap a prior write.
27
+ #
28
+ # The semaphore serializes appends, but a #send that exceeds send_timeout is
29
+ # abandoned (its worker thread keeps running) and the next drain proceeds, so
30
+ # two #append calls for the SAME key can briefly overlap. SessionStore#append
31
+ # must be thread-safe per key (see that method's contract).
32
+ class TranscriptMirrorBatcher
33
+ # Eager-flush thresholds (exposed for tests).
34
+ MAX_PENDING_ENTRIES = 500
35
+ MAX_PENDING_BYTES = 1 << 20 # 1 MiB
36
+ SEND_TIMEOUT_SECONDS = 60.0
37
+
38
+ # Bounded retry for transient adapter failures. The backoff list length is
39
+ # MIRROR_APPEND_MAX_ATTEMPTS - 1 (one delay between each pair of attempts).
40
+ MIRROR_APPEND_MAX_ATTEMPTS = 3
41
+ MIRROR_APPEND_BACKOFF_S = [0.2, 0.8].freeze
42
+
43
+ # @param store [SessionStore] the adapter to mirror into
44
+ # @param projects_dir [String] base dir for file_path -> SessionKey mapping
45
+ # @param on_error [#call] called as on_error.call(key, message) after a batch
46
+ # exhausts retries; must not raise
47
+ def initialize(store:, projects_dir:, on_error:, send_timeout: SEND_TIMEOUT_SECONDS,
48
+ max_pending_entries: MAX_PENDING_ENTRIES, max_pending_bytes: MAX_PENDING_BYTES)
49
+ @store = store
50
+ @projects_dir = projects_dir
51
+ @on_error = on_error
52
+ @send_timeout = send_timeout
53
+ @max_pending_entries = max_pending_entries
54
+ @max_pending_bytes = max_pending_bytes
55
+ @pending = []
56
+ @pending_entries = 0
57
+ @pending_bytes = 0
58
+ # Fiber-aware lock: the critical section blocks on SessionStore#append
59
+ # (a thread hop), so a Thread::Mutex would deadlock the reactor. The
60
+ # semaphore serializes drains so append ordering matches enqueue order.
61
+ @lock = Async::Semaphore.new(1)
62
+ end
63
+
64
+ # Buffer a frame; schedule an eager background flush if thresholds are
65
+ # exceeded. Synchronous and fire-and-forget.
66
+ #
67
+ # +entries+ are deep-stringified because the transport parses CLI output
68
+ # with symbolized keys, but SessionStore entries are opaque JSON blobs that
69
+ # must round-trip through string keys (Postgres JSONB / Redis) and feed
70
+ # fold_session_summary, which reads string keys.
71
+ def enqueue(file_path, entries)
72
+ entries = deep_stringify(Array(entries))
73
+ # An empty frame mirrors nothing (do_flush skips empty keys anyway), so
74
+ # drop it here: otherwise its 2-byte "[]" inflates @pending_bytes and, in
75
+ # eager mode (thresholds 0), schedules a no-op background drain per frame.
76
+ return if entries.empty?
77
+
78
+ # Approximate wire size — one stringify per frame (not per entry).
79
+ size = JSON.generate(entries).bytesize
80
+ @pending << { file_path: file_path, entries: entries }
81
+ @pending_entries += entries.length
82
+ @pending_bytes += size
83
+ return unless @pending_entries > @max_pending_entries || @pending_bytes > @max_pending_bytes
84
+
85
+ task = Async::Task.current?
86
+ # Fire-and-forget on the reactor; @lock in #drain serializes against any
87
+ # in-flight flush so append ordering holds. #drain never raises.
88
+ task ? task.async { drain } : drain
89
+ end
90
+
91
+ # Flush all pending entries, serialized after any in-flight eager flush.
92
+ def flush
93
+ drain
94
+ end
95
+
96
+ # Final flush before teardown. Never raises.
97
+ def close
98
+ flush
99
+ rescue StandardError => e
100
+ warn "Claude SDK: TranscriptMirrorBatcher close flush failed: #{e.message}"
101
+ end
102
+
103
+ private
104
+
105
+ # Detach the pending buffer, await any prior flush, then send. Detaching
106
+ # before acquiring the lock lets #enqueue keep accumulating into a fresh
107
+ # buffer while a prior flush is in flight. Never raises.
108
+ def drain
109
+ items = @pending
110
+ @pending = []
111
+ @pending_entries = 0
112
+ @pending_bytes = 0
113
+
114
+ 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}"
136
+ end
137
+ end
138
+
139
+ def do_flush(items, errors)
140
+ # Coalesce by file_path: one append per unique file per flush instead of
141
+ # one per frame. First-seen file order preserved; entries keep enqueue order.
142
+ by_path = {}
143
+ items.each do |item|
144
+ (by_path[item[:file_path]] ||= []).concat(item[:entries])
145
+ end
146
+
147
+ by_path.each do |file_path, entries|
148
+ next if entries.empty? # avoid phantom keys in adapters that touch storage on append([])
149
+
150
+ key = SessionStores.file_path_to_session_key(file_path, @projects_dir)
151
+ if key.nil?
152
+ warn "Claude SDK: [SessionStore] dropping mirror frame: filePath #{file_path} is not under " \
153
+ "#{@projects_dir} -- subprocess CLAUDE_CONFIG_DIR likely differs from parent (custom env / container?)"
154
+ next
155
+ end
156
+
157
+ append_with_retry(file_path, key, entries, errors)
158
+ end
159
+ end
160
+
161
+ def append_with_retry(file_path, key, entries, errors)
162
+ last_err = nil
163
+ succeeded = false
164
+
165
+ MIRROR_APPEND_MAX_ATTEMPTS.times do |attempt|
166
+ sleep_backoff(MIRROR_APPEND_BACKOFF_S[attempt - 1]) if attempt.positive?
167
+ status, err = invoke_append(key, entries)
168
+ case status
169
+ when :ok
170
+ succeeded = true
171
+ break
172
+ when :timeout
173
+ # Don't retry on timeout: the in-flight call may still land, so a
174
+ # retry would launch a concurrent duplicate. Also bounds worst-case
175
+ # lock hold at ~send_timeout rather than ~3x.
176
+ last_err = err
177
+ break
178
+ else # :error — retryable
179
+ last_err = err
180
+ end
181
+ end
182
+
183
+ errors << [key, last_err.to_s] unless succeeded
184
+ warn "Claude SDK: TranscriptMirrorBatcher flush failed for #{file_path}: #{last_err}" unless succeeded
185
+ end
186
+
187
+ # Run SessionStore#append (user code) on a plain thread via FiberBoundary,
188
+ # bounded by send_timeout (enforced with or without an active reactor).
189
+ # Returns [:ok, nil] / [:timeout, err] / [:error, err]. On timeout the
190
+ # worker thread is left running (cancellation is best-effort; the in-flight
191
+ # call may still land) and not retried.
192
+ def invoke_append(key, entries)
193
+ FiberBoundary.invoke(timeout: @send_timeout) { @store.append(key, entries) }
194
+ [:ok, nil]
195
+ rescue FiberBoundary::JoinTimeout
196
+ [:timeout, "append timed out after #{@send_timeout}s"]
197
+ rescue StandardError, NotImplementedError => e
198
+ # NotImplementedError is a ScriptError, not a StandardError: the base
199
+ # SessionStore stubs raise it, and letting it escape here would kill the
200
+ # whole reactor instead of surfacing a MirrorErrorMessage.
201
+ [:error, e]
202
+ end
203
+
204
+ # Sleep that yields the reactor when one is active, else a plain sleep.
205
+ def sleep_backoff(seconds)
206
+ task = Async::Task.current?
207
+ task ? task.sleep(seconds) : sleep(seconds)
208
+ end
209
+
210
+ def deep_stringify(obj)
211
+ case obj
212
+ when Hash then obj.each_with_object({}) { |(k, v), acc| acc[k.to_s] = deep_stringify(v) }
213
+ when Array then obj.map { |elem| deep_stringify(elem) }
214
+ else obj
215
+ end
216
+ end
217
+ end
218
+ end
@@ -290,6 +290,13 @@ module ClaudeAgentSDK
290
290
  attr_accessor :uuid, :session_id, :content
291
291
  end
292
292
 
293
+ # Emitted when a session_store mirror batch exhausts its retries and is
294
+ # dropped. The local-disk transcript is still durable; this is the consumer's
295
+ # only signal that the external store missed a batch (at-most-once delivery).
296
+ class MirrorErrorMessage < SystemMessage
297
+ attr_accessor :uuid, :session_id, :error, :key
298
+ end
299
+
293
300
  # Hook started system message
294
301
  class HookStartedMessage < SystemMessage
295
302
  attr_accessor :uuid, :session_id, :hook_id, :hook_name, :hook_event
@@ -1344,17 +1351,19 @@ module ClaudeAgentSDK
1344
1351
 
1345
1352
  # Sandbox network configuration
1346
1353
  class SandboxNetworkConfig < Type
1347
- attr_accessor :allowed_domains, :allow_managed_domains_only,
1354
+ attr_accessor :allowed_domains, :denied_domains, :allow_managed_domains_only,
1348
1355
  :allow_unix_sockets, :allow_all_unix_sockets, :allow_local_binding,
1349
- :http_proxy_port, :socks_proxy_port
1356
+ :allow_mach_lookup, :http_proxy_port, :socks_proxy_port
1350
1357
 
1351
1358
  def to_h
1352
1359
  result = {}
1353
1360
  result[:allowedDomains] = @allowed_domains if @allowed_domains
1361
+ result[:deniedDomains] = @denied_domains if @denied_domains
1354
1362
  result[:allowManagedDomainsOnly] = @allow_managed_domains_only unless @allow_managed_domains_only.nil?
1355
1363
  result[:allowUnixSockets] = @allow_unix_sockets unless @allow_unix_sockets.nil?
1356
1364
  result[:allowAllUnixSockets] = @allow_all_unix_sockets unless @allow_all_unix_sockets.nil?
1357
1365
  result[:allowLocalBinding] = @allow_local_binding unless @allow_local_binding.nil?
1366
+ result[:allowMachLookup] = @allow_mach_lookup if @allow_mach_lookup
1358
1367
  result[:httpProxyPort] = @http_proxy_port if @http_proxy_port
1359
1368
  result[:socksProxyPort] = @socks_proxy_port if @socks_proxy_port
1360
1369
  result
@@ -1475,26 +1484,33 @@ module ClaudeAgentSDK
1475
1484
  :output_format, :max_budget_usd, :max_thinking_tokens,
1476
1485
  :fallback_model, :plugins, :debug_stderr,
1477
1486
  :betas, :tools, :sandbox, :append_allowed_tools,
1478
- :thinking, :effort, :observers, :task_budget
1487
+ :thinking, :effort, :observers, :task_budget,
1488
+ :session_store, :session_store_flush, :load_timeout_ms
1479
1489
  attr_reader :bare, :fork_session, :enable_file_checkpointing,
1480
- :include_partial_messages, :continue_conversation
1490
+ :include_partial_messages, :continue_conversation,
1491
+ :include_hook_events, :strict_mcp_config
1481
1492
 
1482
1493
  def initialize(attributes = {})
1483
1494
  self.fork_session = false
1484
1495
  self.continue_conversation = false
1485
1496
  self.include_partial_messages = false
1486
1497
  self.enable_file_checkpointing = false
1498
+ self.include_hook_events = false
1499
+ self.strict_mcp_config = false
1487
1500
 
1488
1501
  super(merge_with_defaults(attributes || {}))
1489
1502
 
1490
1503
  # Non-nil defaults for options that need them.
1491
- self.env ||= {}
1492
- self.extra_args ||= {}
1493
- self.mcp_servers ||= {}
1494
- self.add_dirs ||= []
1495
- self.observers ||= []
1496
- self.allowed_tools ||= []
1497
- self.disallowed_tools ||= []
1504
+ self.env ||= {}
1505
+ self.extra_args ||= {}
1506
+ self.mcp_servers ||= {}
1507
+ self.add_dirs ||= []
1508
+ self.observers ||= []
1509
+ self.allowed_tools ||= []
1510
+ self.disallowed_tools ||= []
1511
+ self.session_store_flush ||= 'batched'
1512
+ # 0 is a valid (immediate) timeout, so only fill in the default for nil.
1513
+ self.load_timeout_ms = 60_000 if load_timeout_ms.nil?
1498
1514
  end
1499
1515
 
1500
1516
  def dup_with(**changes)
@@ -1543,6 +1559,22 @@ module ClaudeAgentSDK
1543
1559
  @continue_conversation = coerce_boolean(value)
1544
1560
  end
1545
1561
 
1562
+ def include_hook_events?
1563
+ !!include_hook_events
1564
+ end
1565
+
1566
+ def include_hook_events=(value)
1567
+ @include_hook_events = coerce_boolean(value)
1568
+ end
1569
+
1570
+ def strict_mcp_config?
1571
+ !!strict_mcp_config
1572
+ end
1573
+
1574
+ def strict_mcp_config=(value)
1575
+ @strict_mcp_config = coerce_boolean(value)
1576
+ end
1577
+
1546
1578
  private
1547
1579
 
1548
1580
  # Strict key validation: unlike other Type subclasses (which silently drop
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.16.9'
4
+ VERSION = '0.17.0'
5
5
  end