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.
@@ -4,6 +4,7 @@ require 'json'
4
4
  require 'securerandom'
5
5
  require 'fileutils'
6
6
  require_relative 'sessions'
7
+ require_relative 'session_store'
7
8
 
8
9
  module ClaudeAgentSDK
9
10
  # Session mutation functions: rename, tag, delete, and fork sessions.
@@ -114,7 +115,7 @@ module ClaudeAgentSDK
114
115
  # @return [ForkSessionResult] Result containing the new session ID
115
116
  # @raise [ArgumentError] if session_id or up_to_message_id is invalid
116
117
  # @raise [Errno::ENOENT] if the session file cannot be found
117
- def fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil) # rubocop:disable Metrics/MethodLength
118
+ def fork_session(session_id:, directory: nil, up_to_message_id: nil, title: nil)
118
119
  raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
119
120
 
120
121
  raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !up_to_message_id.match?(Sessions::UUID_RE)
@@ -127,60 +128,13 @@ module ClaudeAgentSDK
127
128
  raise ArgumentError, "Session #{session_id} has no messages to fork" if file_size.zero?
128
129
 
129
130
  transcript, content_replacements = parse_fork_transcript(file_path, session_id)
130
- transcript.reject! { |e| e['isSidechain'] }
131
- raise ArgumentError, "Session #{session_id} has no messages to fork" if transcript.empty?
132
-
133
- if up_to_message_id
134
- cutoff = transcript.index { |e| e['uuid'] == up_to_message_id }
135
- raise ArgumentError, "Message #{up_to_message_id} not found in session #{session_id}" unless cutoff
136
-
137
- transcript = transcript[0..cutoff]
138
- end
139
-
140
- # Build UUID mapping (including progress entries for parentUuid chain walk)
141
- uuid_mapping = {}
142
- transcript.each { |e| uuid_mapping[e['uuid']] = SecureRandom.uuid }
143
-
144
- by_uuid = transcript.to_h { |e| [e['uuid'], e] }
145
-
146
- # Filter out progress messages from written output
147
- writable = transcript.reject { |e| e['type'] == 'progress' }
148
- raise ArgumentError, "Session #{session_id} has no messages to fork" if writable.empty?
149
-
150
- forked_session_id = SecureRandom.uuid
151
- now = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
152
-
153
- lines = writable.each_with_index.map do |original, i|
154
- build_forked_entry(original, i, writable.size, uuid_mapping, by_uuid,
155
- forked_session_id, session_id, now)
156
- end
157
-
158
- # Append content-replacement entry if any. The entry needs `uuid` and
159
- # `timestamp` so a *second* fork of this forked session can re-ingest
160
- # it — `parse_fork_transcript` gates content-replacement on the entry
161
- # being a valid hash with a matching `sessionId`, and the CLI's own
162
- # tools index entries by uuid. Matches Python's `_emit_fork_to_disk`.
163
- if content_replacements && !content_replacements.empty?
164
- lines << JSON.generate({
165
- 'type' => 'content-replacement',
166
- 'sessionId' => forked_session_id,
167
- 'replacements' => content_replacements,
168
- 'uuid' => SecureRandom.uuid,
169
- 'timestamp' => now
170
- })
171
- end
172
-
173
- # Derive title — only read head/tail chunks when we need to generate one
174
- fork_title = title&.strip
175
- fork_title = "#{derive_fork_title(file_path, file_size)} (fork)" if fork_title.nil? || fork_title.empty?
176
-
177
- lines << JSON.generate({
178
- 'type' => 'custom-title',
179
- 'sessionId' => forked_session_id,
180
- 'customTitle' => fork_title,
181
- 'uuid' => SecureRandom.uuid,
182
- 'timestamp' => now
183
- })
131
+ # The fork transform is shared with fork_session_via_store; the disk path
132
+ # derives the fallback title from the file's head/tail bytes (only when no
133
+ # explicit title is given).
134
+ forked_session_id, lines = build_fork_lines(
135
+ transcript, content_replacements, session_id, up_to_message_id, title,
136
+ -> { derive_fork_title(file_path, file_size) }
137
+ )
184
138
 
185
139
  fork_path = File.join(project_dir, "#{forked_session_id}.jsonl")
186
140
  io = nil
@@ -199,6 +153,104 @@ module ClaudeAgentSDK
199
153
  ForkSessionResult.new(session_id: forked_session_id)
200
154
  end
201
155
 
156
+ # ---- SessionStore-backed mutations (store counterparts to the disk ops) ----
157
+
158
+ # Rename a session by appending a custom-title entry to a SessionStore.
159
+ # Store-backed counterpart to rename_session. Unlike the disk variant, the
160
+ # appended entry carries a fresh uuid + ISO timestamp so adapters that dedupe
161
+ # by entry["uuid"] (per the SessionStore#append contract) treat it correctly.
162
+ #
163
+ # @raise [ArgumentError] if session_id is invalid or title is empty
164
+ def rename_session_via_store(session_store:, session_id:, title:, directory: nil)
165
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
166
+
167
+ stripped = title.strip
168
+ raise ArgumentError, 'title must be non-empty' if stripped.empty?
169
+
170
+ key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
171
+ session_store.append(key, [{
172
+ 'type' => 'custom-title',
173
+ 'customTitle' => stripped,
174
+ 'sessionId' => session_id,
175
+ 'uuid' => SecureRandom.uuid,
176
+ 'timestamp' => iso_now
177
+ }])
178
+ nil
179
+ end
180
+
181
+ # Tag a session by appending a tag entry to a SessionStore. Store-backed
182
+ # counterpart to tag_session. Pass nil to clear the tag. Tags are
183
+ # Unicode-sanitized before storing.
184
+ #
185
+ # @raise [ArgumentError] if session_id is invalid or tag is empty after sanitization
186
+ def tag_session_via_store(session_store:, session_id:, tag:, directory: nil)
187
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
188
+
189
+ if tag
190
+ sanitized = sanitize_unicode(tag).strip
191
+ raise ArgumentError, 'tag must be non-empty (use nil to clear)' if sanitized.empty?
192
+
193
+ tag = sanitized
194
+ end
195
+
196
+ key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
197
+ session_store.append(key, [{
198
+ 'type' => 'tag',
199
+ 'tag' => tag || '',
200
+ 'sessionId' => session_id,
201
+ 'uuid' => SecureRandom.uuid,
202
+ 'timestamp' => iso_now
203
+ }])
204
+ nil
205
+ end
206
+
207
+ # Delete a session from a SessionStore. Store-backed counterpart to
208
+ # delete_session. If the store does not implement #delete, deletion is a
209
+ # no-op (appropriate for WORM/append-only backends, per the SessionStore
210
+ # contract). Whether subagent subkeys are also removed depends on the
211
+ # store's delete({session_id}) cascade semantics (InMemorySessionStore
212
+ # cascades; custom stores may not).
213
+ #
214
+ # @raise [ArgumentError] if session_id is invalid
215
+ def delete_session_via_store(session_store:, session_id:, directory: nil)
216
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
217
+ return unless SessionStore.implements?(session_store, :delete)
218
+
219
+ key = { 'project_key' => Sessions.project_key_for_directory(directory), 'session_id' => session_id }
220
+ session_store.delete(key)
221
+ nil
222
+ end
223
+
224
+ # Fork a session into a new branch with fresh UUIDs via a SessionStore.
225
+ # Store-backed counterpart to fork_session. Runs the fork transform directly
226
+ # over the objects returned by store.load — no JSONL round-trip on disk. A
227
+ # storage-layer copy is NOT sufficient: the transform remaps every UUID,
228
+ # rewrites sessionId, and stamps forkedFrom, so the data must pass through
229
+ # this process once.
230
+ #
231
+ # @raise [ArgumentError] if session_id/up_to_message_id is invalid or the session has no messages
232
+ # @raise [Errno::ENOENT] if the source session is not found in the store
233
+ def fork_session_via_store(session_store:, session_id:, directory: nil, up_to_message_id: nil, title: nil)
234
+ raise ArgumentError, "Invalid session_id: #{session_id}" unless session_id.match?(Sessions::UUID_RE)
235
+ raise ArgumentError, "Invalid up_to_message_id: #{up_to_message_id}" if up_to_message_id && !up_to_message_id.match?(Sessions::UUID_RE)
236
+
237
+ project_key = Sessions.project_key_for_directory(directory)
238
+ raw = session_store.load('project_key' => project_key, 'session_id' => session_id)
239
+ raise Errno::ENOENT, "Session #{session_id} not found" if raw.nil? || raw.empty?
240
+
241
+ transcript, content_replacements = partition_fork_entries(raw, session_id)
242
+ forked_session_id, lines = build_fork_lines(
243
+ transcript, content_replacements, session_id, up_to_message_id, title,
244
+ -> { derive_title_from_entries(raw) }
245
+ )
246
+
247
+ dst_key = { 'project_key' => project_key, 'session_id' => forked_session_id }
248
+ # build_fork_lines emits compact JSON strings; re-parse to objects so the
249
+ # store receives the same shape it would from the mirror path.
250
+ session_store.append(dst_key, lines.map { |line| JSON.parse(line) })
251
+ ForkSessionResult.new(session_id: forked_session_id)
252
+ end
253
+
202
254
  # -- Private helpers --
203
255
 
204
256
  # Locate the JSONL file for a session and return [file_path, project_dir].
@@ -286,9 +338,138 @@ module ClaudeAgentSDK
286
338
  [transcript, content_replacements]
287
339
  end
288
340
 
341
+ # Current UTC time as a millisecond-precision ISO-8601 'Z' string, matching
342
+ # the timestamp shape the CLI writes into transcripts.
343
+ def iso_now
344
+ Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%3NZ')
345
+ end
346
+
347
+ # Core fork transform shared by the disk and SessionStore paths. Filters
348
+ # sidechains, applies the optional up_to_message_id slice (inclusive),
349
+ # remaps every UUID (keeping progress entries in the chain walk but out of
350
+ # the written output), rewrites sessionId/forkedFrom, and appends the
351
+ # content-replacement and custom-title trailers (each with a fresh uuid +
352
+ # timestamp). Returns [forked_session_id, lines] where each line is a
353
+ # compact JSON string with no trailing newline.
354
+ #
355
+ # +derive_title+ is a callable invoked ONLY when no explicit +title+ is
356
+ # given, so the disk path's head/tail byte scan and the store path's
357
+ # entry-object scan each run only when needed.
358
+ def build_fork_lines(transcript, content_replacements, session_id, up_to_message_id, title, derive_title) # rubocop:disable Metrics/MethodLength
359
+ transcript = transcript.reject { |e| e['isSidechain'] }
360
+ raise ArgumentError, "Session #{session_id} has no messages to fork" if transcript.empty?
361
+
362
+ if up_to_message_id
363
+ cutoff = transcript.index { |e| e['uuid'] == up_to_message_id }
364
+ raise ArgumentError, "Message #{up_to_message_id} not found in session #{session_id}" unless cutoff
365
+
366
+ transcript = transcript[0..cutoff]
367
+ end
368
+
369
+ # Build UUID mapping (including progress entries for the parentUuid chain walk).
370
+ uuid_mapping = {}
371
+ transcript.each { |e| uuid_mapping[e['uuid']] = SecureRandom.uuid }
372
+ by_uuid = transcript.to_h { |e| [e['uuid'], e] }
373
+
374
+ # Filter progress messages out of the written output (UI-only chain links).
375
+ writable = transcript.reject { |e| e['type'] == 'progress' }
376
+ raise ArgumentError, "Session #{session_id} has no messages to fork" if writable.empty?
377
+
378
+ forked_session_id = SecureRandom.uuid
379
+ now = iso_now
380
+
381
+ lines = writable.each_with_index.map do |original, i|
382
+ build_forked_entry(original, i, writable.size, uuid_mapping, by_uuid,
383
+ forked_session_id, session_id, now)
384
+ end
385
+
386
+ # Append content-replacement entry if any. The entry needs `uuid` and
387
+ # `timestamp` so a *second* fork of this forked session can re-ingest it,
388
+ # and so adapters that dedupe by uuid handle it correctly.
389
+ if content_replacements && !content_replacements.empty?
390
+ lines << JSON.generate({
391
+ 'type' => 'content-replacement',
392
+ 'sessionId' => forked_session_id,
393
+ 'replacements' => content_replacements,
394
+ 'uuid' => SecureRandom.uuid,
395
+ 'timestamp' => now
396
+ })
397
+ end
398
+
399
+ # Derive title: explicit > original customTitle > original aiTitle > first
400
+ # prompt, suffixed with " (fork)" when derived. listSessions reads the LAST
401
+ # custom-title from the tail, so this trailer is what surfaces.
402
+ fork_title = title&.strip
403
+ fork_title = "#{derive_title.call || 'Forked session'} (fork)" if fork_title.nil? || fork_title.empty?
404
+
405
+ lines << JSON.generate({
406
+ 'type' => 'custom-title',
407
+ 'sessionId' => forked_session_id,
408
+ 'customTitle' => fork_title,
409
+ 'uuid' => SecureRandom.uuid,
410
+ 'timestamp' => now
411
+ })
412
+
413
+ [forked_session_id, lines]
414
+ end
415
+
416
+ # Partition already-parsed store entries into [transcript, content_replacements],
417
+ # mirroring parse_fork_transcript for the store path (which has no JSONL file
418
+ # to stream). Only TRANSCRIPT_TYPES entries with a string uuid form the body;
419
+ # content-replacement records whose sessionId matches the source are collected
420
+ # (concatenated across compaction rounds).
421
+ def partition_fork_entries(raw, source_session_id)
422
+ transcript = []
423
+ content_replacements = []
424
+ raw.each do |entry|
425
+ next unless entry.is_a?(Hash)
426
+
427
+ entry_type = entry['type']
428
+ if TRANSCRIPT_TYPES.include?(entry_type) && entry['uuid'].is_a?(String)
429
+ transcript << entry
430
+ elsif entry_type == 'content-replacement' && entry['sessionId'] == source_session_id &&
431
+ entry['replacements'].is_a?(Array)
432
+ content_replacements.concat(entry['replacements'])
433
+ end
434
+ end
435
+ [transcript, content_replacements]
436
+ end
437
+
438
+ # Derive a fork title by scanning already-parsed store entries — the store
439
+ # path's analogue of derive_fork_title's head/tail byte scan. Last occurrence
440
+ # wins for both customTitle and aiTitle; customTitle beats aiTitle; the first
441
+ # user prompt is the final fallback. Returns nil when nothing is found (the
442
+ # caller supplies the "Forked session" default). This scans the RAW entries,
443
+ # not the partitioned transcript (which drops customTitle/aiTitle metadata) —
444
+ # the store half of #837's P0-1 fix.
445
+ def derive_title_from_entries(raw)
446
+ custom = nil
447
+ ai = nil
448
+ raw.each do |e|
449
+ next unless e.is_a?(Hash)
450
+
451
+ ct = e['customTitle']
452
+ custom = ct if ct.is_a?(String) && !ct.empty?
453
+ at = e['aiTitle']
454
+ ai = at if at.is_a?(String) && !at.empty?
455
+ end
456
+ return custom if custom
457
+ return ai if ai
458
+
459
+ # First-prompt fallback: re-serialize to a JSONL string and reuse the head
460
+ # extractor so skip-patterns/truncation match the disk path exactly.
461
+ # extract_first_prompt_from_head returns '' (truthy in Ruby!) when no
462
+ # prompt qualifies — normalize to nil so the caller's 'Forked session'
463
+ # default actually fires (Python appends `or None` here for this reason).
464
+ jsonl = "#{raw.map { |e| JSON.generate(e) }.join("\n")}\n"
465
+ title = Sessions.extract_first_prompt_from_head(jsonl)
466
+ title.nil? || title.empty? ? nil : title
467
+ end
468
+
289
469
  # Derive a fork title from the source file's head/tail chunks without
290
470
  # slurping the entire file. Matches the lookup order used for
291
- # SDKSessionInfo.custom_title / ai_title / first_prompt.
471
+ # SDKSessionInfo.custom_title / ai_title / first_prompt. Returns nil when
472
+ # nothing is found (build_fork_lines supplies the "Forked session" default).
292
473
  def derive_fork_title(file_path, file_size)
293
474
  buf_size = [Sessions::LITE_READ_BUF_SIZE, file_size].min
294
475
  File.open(file_path, 'rb') do |f|
@@ -299,12 +480,15 @@ module ClaudeAgentSDK
299
480
  else
300
481
  head
301
482
  end
302
- Sessions.extract_json_string_field(tail, 'customTitle', last: true) ||
303
- Sessions.extract_json_string_field(head, 'customTitle', last: true) ||
304
- Sessions.extract_json_string_field(tail, 'aiTitle', last: true) ||
305
- Sessions.extract_json_string_field(head, 'aiTitle', last: true) ||
306
- Sessions.extract_first_prompt_from_head(head) ||
307
- 'Forked session'
483
+ title = Sessions.extract_json_string_field(tail, 'customTitle', last: true) ||
484
+ Sessions.extract_json_string_field(head, 'customTitle', last: true) ||
485
+ Sessions.extract_json_string_field(tail, 'aiTitle', last: true) ||
486
+ Sessions.extract_json_string_field(head, 'aiTitle', last: true) ||
487
+ Sessions.extract_first_prompt_from_head(head)
488
+ # extract_first_prompt_from_head returns '' (truthy in Ruby!) when no
489
+ # prompt qualifies — normalize to nil so the 'Forked session' default
490
+ # fires (Python appends `or None` here for the same reason).
491
+ title.nil? || title.empty? ? nil : title
308
492
  end
309
493
  end
310
494
 
@@ -449,6 +633,7 @@ module ClaudeAgentSDK
449
633
  :find_in_directory, :try_project_dir, :find_in_all_projects,
450
634
  :parse_fork_transcript, :derive_fork_title, :build_forked_entry, :resolve_parent_uuid,
451
635
  :append_to_session, :append_to_session_in_directory,
452
- :append_to_session_global, :try_append, :sanitize_unicode, :unicode_category
636
+ :append_to_session_global, :try_append, :sanitize_unicode, :unicode_category,
637
+ :iso_now, :build_fork_lines, :partition_fork_entries, :derive_title_from_entries
453
638
  end
454
639
  end