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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +1 -1
- data/docs/sessions.md +79 -0
- data/lib/claude_agent_sdk/command_builder.rb +4 -0
- data/lib/claude_agent_sdk/fiber_boundary.rb +14 -2
- data/lib/claude_agent_sdk/message_parser.rb +1 -0
- data/lib/claude_agent_sdk/query.rb +62 -8
- data/lib/claude_agent_sdk/session_mutations.rb +248 -63
- data/lib/claude_agent_sdk/session_resume.rb +444 -0
- data/lib/claude_agent_sdk/session_store.rb +353 -0
- data/lib/claude_agent_sdk/session_summary.rb +188 -0
- data/lib/claude_agent_sdk/sessions.rb +460 -7
- data/lib/claude_agent_sdk/subprocess_cli_transport.rb +84 -0
- data/lib/claude_agent_sdk/testing/session_store_conformance.rb +295 -0
- data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +218 -0
- data/lib/claude_agent_sdk/types.rb +43 -11
- data/lib/claude_agent_sdk/version.rb +1 -1
- data/lib/claude_agent_sdk.rb +292 -62
- metadata +9 -4
|
@@ -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
|