opencode-ruby 0.0.1.alpha2 → 0.0.1.alpha7

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: 5c85499ae01e9b85854d1738508f079c8e4b41d00d926f7c81cd537db2a30474
4
- data.tar.gz: 9034a4c5697390f12f2c1d4c0566de11f1e44321c9f2497bc9b8c54d514e43fb
3
+ metadata.gz: 7a4ddecebacda21e25ac50bd14d6d36c8ad61b35d23dee985b620d82a2b94b36
4
+ data.tar.gz: 3aa46267d464d9f95ddc96cb0a7d59cff04a9559c1dffb8d5c6d99c84f46b7b5
5
5
  SHA512:
6
- metadata.gz: 8a73ddf71df4c272aa3676cdddd5deb9d0db71a10466f4f355df6f9e63ada55a9231773df56f74cf7c44544069bdfc5fa0be82ea5dc8c89732192f87d4bc980d
7
- data.tar.gz: e8015d9f32e8f3e1712e1cdf21824eacb7f26f3244831d20c8bfdff513ebb8d692afa2360aaa90450625cf935a0eb744fed16a62c7e3ed2b8b5f421819fd288f
6
+ metadata.gz: 2b7a4ef58b44d2d179c3dd389a0b3a2593557188fe03e62d8e63fa19b766de22f75cef35309f2155d3eb6af80bb79c4f44f3251448602aade9fc928761561a4e
7
+ data.tar.gz: 141450c28378875563cb27742eea0ae60d08ef325c37b40923bb0b4103e26f9ce82dd603dc5db313578a12e8a7a160bbbea5aefdeed7e5f87751acb364410cd6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.1.alpha7 - 2026-07-18
4
+
5
+ ### Fixed
6
+
7
+ - Add an at-most-once `on_subscribed` hook to the lower-level
8
+ `Opencode::Client#stream_events` API. Higher-level orchestrators can now
9
+ wait for `server.connected` before submitting `prompt_async` without
10
+ abandoning their own Reply observers, persistence, or recovery pipeline.
11
+ - Propagate subscription-hook failures directly and never invoke the hook
12
+ again on SSE reconnect. Ambiguous prompt transport failures therefore
13
+ cannot silently become duplicate turns.
14
+
15
+ ## 0.0.1.alpha6 - 2026-07-18
16
+
17
+ ### Fixed
18
+
19
+ - Make `Opencode::Client#stream` wait for OpenCode's initial
20
+ `server.connected` SSE readiness frame before submitting `prompt_async`,
21
+ closing the fast-response window where a turn could emit events before the
22
+ client was listening.
23
+ - Keep prompt submission at-most-once across automatic SSE reconnects. A
24
+ reconnect now reopens only the event stream; it never posts the user prompt
25
+ again, and prompt transport failures remain visible to the caller.
26
+
27
+ ## 0.0.1.alpha5 - 2026-07-15
28
+
29
+ ### Added
30
+
31
+ - Extend `Opencode::Client#create_session` with OpenCode's native parent,
32
+ agent, model, metadata, and workspace fields while preserving the existing
33
+ title and permission call shape. Session model strings are encoded with the
34
+ session endpoint's `{ providerID, id }` shape rather than the message
35
+ endpoint's `{ providerID, modelID }` shape.
36
+
37
+ ## 0.0.1.alpha4 - 2026-07-12
38
+
39
+ ### Fixed
40
+
41
+ - End SSE streams on current OpenCode `session.status` idle events while
42
+ retaining compatibility with legacy `session.idle` events.
43
+ - Reconcile every assistant message in the current user turn after multi-step
44
+ tool loops, preserving stream-only parts without duplicating final text.
45
+ - Parse terminal tool parts in standalone Ruby clients without relying on the
46
+ Rails-loaded `Object#in?` extension.
47
+
48
+ ## 0.0.1.alpha3 - 2026-07-10
49
+
50
+ ### Added
51
+
52
+ - `Opencode::Client#update_session` for applying permission rules through
53
+ OpenCode's session PATCH endpoint. OpenCode appends these rules, so hosts
54
+ should fingerprint their ordered policy and call this only when it changes.
55
+
3
56
  ## 0.0.1.alpha2 — 2026-05-20
4
57
 
5
58
  ### Added
data/README.md CHANGED
@@ -48,6 +48,27 @@ Multi-tenant apps construct multiple clients with different `base_url`s — each
48
48
 
49
49
  ## Core API
50
50
 
51
+ ### Configured and parent-linked sessions
52
+
53
+ OpenCode can create a session under an existing parent and select its agent,
54
+ model, metadata, workspace, and permission policy in the same request:
55
+
56
+ ```ruby
57
+ child = client.create_session(
58
+ title: "Destination curator",
59
+ parent_id: parent_session_id,
60
+ agent: "destination-list-curator",
61
+ model: "openai/gpt-5.5",
62
+ metadata: { run_id: "9" },
63
+ workspace_id: workspace_id,
64
+ permissions: permission_rules
65
+ )
66
+ ```
67
+
68
+ Model strings use OpenCode's `provider/model` form; a preformatted model hash
69
+ with `providerID` and `id` keys is also accepted. These configured-session
70
+ fields require OpenCode 1.16.1 or newer.
71
+
51
72
  ### Streaming (the headline)
52
73
 
53
74
  ```ruby
@@ -65,6 +86,11 @@ reply.reasoning_text # => the model's hidden reasoning, if any
65
86
  reply.parts_json # => the full ordered parts array, ready for persistence
66
87
  ```
67
88
 
89
+ `stream` waits for OpenCode's initial `server.connected` SSE readiness frame
90
+ before it submits the asynchronous prompt. If the event connection drops
91
+ afterward, the client reconnects only the subscription; it never reposts the
92
+ prompt. This prevents both missed fast responses and duplicate turns.
93
+
68
94
  ### Synchronous send (no streaming)
69
95
 
70
96
  ```ruby
@@ -72,16 +98,44 @@ result = client.send_message(session_id, "Quick yes/no: is Ruby fun?")
72
98
  # result is the OpenCode response hash; see API docs for fields.
73
99
  ```
74
100
 
101
+ ### Updating session permissions
102
+
103
+ ```ruby
104
+ client.update_session(session_id, permissions: permission_rules)
105
+ ```
106
+
107
+ OpenCode appends PATCHed permission rules and evaluates the last matching
108
+ rule. Hosts should send a complete ordered policy and fingerprint it so the
109
+ same policy is not appended on every turn. This endpoint requires OpenCode
110
+ 1.16.1 or newer; the rest of the client remains compatible with 1.15.
111
+
75
112
  ### Lower-level event firehose
76
113
 
77
114
  If you need raw SSE events (every server tick, todo update, prompt asked/replied), use `stream_events` directly:
78
115
 
79
116
  ```ruby
80
117
  client.stream_events(session_id: session_id) do |event|
81
- puts event[:type] # "message.part.delta", "todo.updated", "session.idle", ...
118
+ puts event[:type] # "message.part.delta", "todo.updated", "session.status", ...
119
+ end
120
+ ```
121
+
122
+ Orchestrators that submit their own async prompt must do so through
123
+ `on_subscribed`; the callback runs only after the first `server.connected`
124
+ frame and at most once across automatic reconnects:
125
+
126
+ ```ruby
127
+ client.stream_events(
128
+ session_id: session_id,
129
+ on_subscribed: -> { client.send_message_async(session_id, prompt) }
130
+ ) do |event|
131
+ reply.apply(event)
82
132
  end
83
133
  ```
84
134
 
135
+ If the callback raises, `stream_events` propagates that error and does not
136
+ retry it. This is intentional: a timed-out prompt response is ambiguous, so
137
+ reposting could duplicate the model turn and its cost.
138
+
85
139
  ### Interactive prompts
86
140
 
87
141
  When the agent uses the `question` or `permission` tools, opencode emits `question.asked` / `permission.asked` events. Answer them via:
@@ -101,7 +155,7 @@ begin
101
155
  rescue Opencode::ConnectionError # server unreachable
102
156
  rescue Opencode::TimeoutError # client-side timeout
103
157
  rescue Opencode::SessionNotFoundError # 404 on a session
104
- rescue Opencode::StaleSessionError # session.idle never arrived
158
+ rescue Opencode::StaleSessionError # no session event arrived after the prompt
105
159
  rescue Opencode::IdleStreamError # mid-turn SSE wedge
106
160
  rescue Opencode::ServerError # 5xx
107
161
  rescue Opencode::BadRequestError # 4xx other than 404
@@ -155,7 +209,14 @@ bundle install
155
209
  bundle exec rake test
156
210
  ```
157
211
 
158
- 12-test smoke covers Client end-to-end against WebMock-stubbed OpenCode endpoints.
212
+ The smoke suite covers Client end-to-end against WebMock-stubbed OpenCode
213
+ endpoints, including subscription-before-prompt ordering and
214
+ reconnect-without-repost.
215
+
216
+ Releases use RubyGems trusted publishing. After the repository's
217
+ `release.yml` workflow is registered as a trusted publisher with the `release`
218
+ environment, pushing a `v*` tag builds, attests, and publishes the gem without
219
+ a long-lived RubyGems API key.
159
220
 
160
221
  ## License
161
222
 
@@ -25,8 +25,24 @@ module Opencode
25
25
  @workspace = workspace
26
26
  end
27
27
 
28
- def create_session(title: nil, permissions: nil)
29
- body = { title: title, permission: permissions }.compact
28
+ def create_session(
29
+ title: nil,
30
+ permissions: nil,
31
+ parent_id: nil,
32
+ agent: nil,
33
+ model: nil,
34
+ metadata: nil,
35
+ workspace_id: nil
36
+ )
37
+ body = {
38
+ title: title,
39
+ permission: permissions,
40
+ parentID: parent_id,
41
+ agent: agent,
42
+ model: format_session_model(model),
43
+ metadata: metadata,
44
+ workspaceID: workspace_id
45
+ }.compact
30
46
  post("/session", body)
31
47
  end
32
48
 
@@ -115,21 +131,35 @@ module Opencode
115
131
  on_activity_tick: nil,
116
132
  &block
117
133
  )
118
- send_message_async(
119
- session_id, text,
120
- model: model, agent: agent, system: system, message_id: message_id
121
- )
122
-
123
134
  reply = Opencode::Reply.new
124
135
  reply.add_observer(StreamBlockObserver.new(&block)) if block_given?
125
136
 
126
- stream_events(
137
+ # Opening the event stream after prompt_async leaves a race where a fast
138
+ # turn can emit (and finish) before the client is subscribed. Wait for
139
+ # OpenCode's initial server.connected SSE frame, then submit the prompt
140
+ # exactly once. Reconnects invoke on_subscribed again, so mark the attempt
141
+ # before the POST: an ambiguous prompt response must never cause the same
142
+ # turn to be submitted twice.
143
+ prompt_attempted = false
144
+ on_subscribed = lambda do
145
+ next false if prompt_attempted
146
+
147
+ prompt_attempted = true
148
+ send_message_async(
149
+ session_id, text,
150
+ model: model, agent: agent, system: system, message_id: message_id
151
+ )
152
+ true
153
+ end
154
+
155
+ consume_event_stream(
127
156
  session_id: session_id,
128
157
  timeout: stream_timeout,
129
158
  first_event_timeout: first_event_timeout,
130
159
  idle_stream_timeout: idle_stream_timeout,
131
160
  reply: reply,
132
- on_activity_tick: on_activity_tick
161
+ on_activity_tick: on_activity_tick,
162
+ on_subscribed: on_subscribed
133
163
  ) do |event|
134
164
  reply.apply(event)
135
165
  end
@@ -144,6 +174,10 @@ module Opencode
144
174
  execute(request)
145
175
  end
146
176
 
177
+ def update_session(session_id, permissions:)
178
+ patch("/session/#{session_id}", { permission: permissions })
179
+ end
180
+
147
181
  def children(session_id)
148
182
  uri = build_uri("/session/#{session_id}/children")
149
183
  request = Net::HTTP::Get.new(uri)
@@ -233,8 +267,10 @@ module Opencode
233
267
  ].freeze
234
268
 
235
269
  # Opens SSE connection to GET /event, yields parsed events filtered by session_id.
236
- # Blocks until session goes idle or timeout, reconnecting across dropped
237
- # event-stream connections.
270
+ # Blocks until the session reports idle or timeout, reconnecting across
271
+ # dropped event-stream connections. Current OpenCode emits
272
+ # `session.status` with `status.type == "idle"`; older versions emitted the
273
+ # standalone `session.idle` event, so both remain terminal.
238
274
  #
239
275
  # first_event_timeout: seconds to wait for a session-specific event before
240
276
  # declaring the session stale. Server heartbeats don't count — they're global
@@ -249,6 +285,13 @@ module Opencode
249
285
  # without nuking real reasoning. Callers that know their agent is
250
286
  # short-prompt + fast can pass a lower value.
251
287
  #
288
+ # on_subscribed: optional callable invoked at most once, after the first
289
+ # `server.connected` frame proves the SSE response body is flowing. This
290
+ # is the safe place for higher-level orchestrators to submit prompt_async;
291
+ # reconnects wait for their own connected frame but never invoke it again.
292
+ # A raised callback error is propagated directly and is never treated as
293
+ # a reconnectable SSE transport failure.
294
+ #
252
295
  # idle_stream_timeout: seconds to wait BETWEEN meaningful events once
253
296
  # the session has started producing them. Default nil = no check
254
297
  # (preserves the overall `timeout` ceiling behavior). Opt-in heartbeat
@@ -262,12 +305,28 @@ module Opencode
262
305
  # translate into a user-visible error / retry affordance.
263
306
  def stream_events(session_id:, timeout: 600, first_event_timeout: 120,
264
307
  idle_stream_timeout: nil,
265
- reply: nil, on_activity_tick: nil, &block)
308
+ reply: nil, on_activity_tick: nil, on_subscribed: nil, &block)
309
+ consume_event_stream(
310
+ session_id: session_id,
311
+ timeout: timeout,
312
+ first_event_timeout: first_event_timeout,
313
+ idle_stream_timeout: idle_stream_timeout,
314
+ reply: reply,
315
+ on_activity_tick: on_activity_tick,
316
+ on_subscribed: on_subscribed,
317
+ &block
318
+ )
319
+ end
320
+
321
+ private def consume_event_stream(session_id:, timeout:, first_event_timeout:,
322
+ idle_stream_timeout:, reply:, on_activity_tick:,
323
+ on_subscribed: nil, &block)
266
324
  uri = build_uri("/event")
267
325
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
268
326
  first_event_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + first_event_timeout
269
327
  received_session_event = false
270
328
  last_meaningful_event_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
329
+ subscription_callback_attempted = on_subscribed.nil?
271
330
 
272
331
  loop do
273
332
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -297,6 +356,8 @@ module Opencode
297
356
  http.open_timeout = 10
298
357
  http.read_timeout = 30
299
358
 
359
+ subscription_callback_error = nil
360
+ subscription_ready = on_subscribed.nil?
300
361
  begin
301
362
  buffer = String.new
302
363
 
@@ -330,6 +391,44 @@ module Opencode
330
391
  event = parse_sse_event(raw_event, session_id)
331
392
  next unless event
332
393
 
394
+ unless subscription_ready
395
+ # Every supported OpenCode server starts /event with this
396
+ # frame. Receiving it proves the stream body is flowing; on
397
+ # current servers the bus listener is registered eagerly,
398
+ # and on older lazy-stream servers it is the strongest
399
+ # available readiness handshake before prompting.
400
+ next unless event[:type] == "server.connected"
401
+
402
+ turn_started = false
403
+ unless subscription_callback_attempted
404
+ # Mark the attempt before invoking the callback. A timeout
405
+ # can mean the prompt reached OpenCode even though its HTTP
406
+ # response did not reach us; retrying would duplicate the
407
+ # turn. The caller receives the original error instead.
408
+ subscription_callback_attempted = true
409
+ begin
410
+ turn_started = on_subscribed.call
411
+ rescue StandardError => error
412
+ # Prompt submission happens inside the open SSE response.
413
+ # Do not mistake its transport failure for an SSE disconnect
414
+ # and hide it behind a reconnect/first-event timeout.
415
+ subscription_callback_error = error
416
+ raise
417
+ end
418
+ end
419
+ if turn_started
420
+ # Before this fix stream_events began only after the prompt
421
+ # POST returned. Preserve those timeout semantics: the turn
422
+ # and first-session-event windows begin after prompt_async
423
+ # succeeds, not while establishing the readiness handshake.
424
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
425
+ deadline = started_at + timeout
426
+ first_event_deadline = started_at + first_event_timeout
427
+ last_meaningful_event_at = started_at
428
+ end
429
+ subscription_ready = true
430
+ end
431
+
333
432
  unless event[:type]&.start_with?("server.")
334
433
  received_session_event = true
335
434
  last_meaningful_event_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -341,13 +440,16 @@ module Opencode
341
440
  # the reaper doesn't kill it mid-wait.
342
441
  on_activity_tick&.call(event)
343
442
  block.call(event)
344
- return if event[:type] == "session.idle"
443
+ return if terminal_session_event?(event)
345
444
  end
346
445
  end
347
446
  end
348
447
  rescue *TRANSIENT_SSE_ERRORS
448
+ raise if subscription_callback_error
449
+
349
450
  # Treat transport-level SSE disconnects like clean EOF: reconnect
350
- # until session.idle, the overall timeout, or first-event timeout.
451
+ # until an idle session event, the overall timeout, or first-event
452
+ # timeout.
351
453
  ensure
352
454
  begin
353
455
  http&.finish if http&.started?
@@ -381,13 +483,16 @@ module Opencode
381
483
  # the caller's reply is still a usable Result either way.
382
484
  def merge_final_exchange(session_id, reply)
383
485
  exchange = get_messages(session_id)
384
- last_assistant = Array(exchange).reverse_each.find do |message|
385
- message.dig(:info, :role) == "assistant"
386
- end
387
- return unless last_assistant
388
-
389
- polled = Opencode::ResponseParser.extract_interleaved_parts(last_assistant)
390
- reply.sync_recovered_parts(polled) if polled.any?
486
+ polled = current_turn_parts(exchange)
487
+ return if polled.empty?
488
+
489
+ merged = merge_stream_only_parts(reply.result.parts_json, polled)
490
+ reply.sync_recovered_parts(merged)
491
+ # sync_recovered_parts intentionally never deletes live parts because it
492
+ # is also used during mid-stream recovery. This is the terminal poll, so
493
+ # the wire snapshot is authoritative: remove any replayed trailing wire
494
+ # part after observers have seen recovered additions/updates.
495
+ reply.replace_parts(merged) unless reply.result.parts_json == merged
391
496
  rescue Opencode::Error
392
497
  # Stream's result is still complete; the merge was a polish, not a
393
498
  # requirement.
@@ -404,6 +509,45 @@ module Opencode
404
509
  deadline
405
510
  end
406
511
 
512
+ def terminal_session_event?(event)
513
+ return true if event[:type] == "session.idle"
514
+ return false unless event[:type] == "session.status"
515
+
516
+ status = event.dig(:properties, :status)
517
+ status = status[:type] || status["type"] if status.is_a?(Hash)
518
+ status == "idle"
519
+ end
520
+
521
+ # OpenCode persists one assistant message per model step. A tool loop can
522
+ # therefore produce several assistant messages for one user turn (for
523
+ # example skill -> task -> final text). Reconcile the complete current turn
524
+ # instead of aligning the live parts array with only the last assistant
525
+ # message, which corrupts tool parts and duplicates final text.
526
+ def current_turn_parts(exchange)
527
+ messages = Array(exchange)
528
+ last_user_index = messages.rindex { |message| message.dig(:info, :role) == "user" }
529
+ current_turn = last_user_index ? messages.drop(last_user_index + 1) : messages
530
+
531
+ current_turn
532
+ .select { |message| message.dig(:info, :role) == "assistant" }
533
+ .flat_map { |message| Opencode::ResponseParser.extract_interleaved_parts(message) }
534
+ end
535
+
536
+ def merge_stream_only_parts(stream_parts, wire_parts)
537
+ remaining_wire = Array(wire_parts).dup
538
+ merged = []
539
+
540
+ Array(stream_parts).each do |part|
541
+ if Opencode::PartSource.stream_only?(part)
542
+ merged << part
543
+ elsif remaining_wire.any?
544
+ merged << remaining_wire.shift
545
+ end
546
+ end
547
+
548
+ merged.concat(remaining_wire)
549
+ end
550
+
407
551
  def prompt_payload(text, parts:, model:, agent:, system:, message_id:, no_reply:, tools:, format:, variant:)
408
552
  message_parts = parts || [ { type: "text", text: text } ]
409
553
  {
@@ -427,6 +571,14 @@ module Opencode
427
571
  { providerID: provider, modelID: model_id }
428
572
  end
429
573
 
574
+ def format_session_model(model)
575
+ return nil unless model
576
+ return model if model.is_a?(Hash)
577
+
578
+ provider, model_id = model.split("/", 2)
579
+ { providerID: provider, id: model_id }
580
+ end
581
+
430
582
  def post(path, body)
431
583
  uri = build_uri(path)
432
584
  request = Net::HTTP::Post.new(uri)
@@ -434,6 +586,13 @@ module Opencode
434
586
  execute(request)
435
587
  end
436
588
 
589
+ def patch(path, body)
590
+ uri = build_uri(path)
591
+ request = Net::HTTP::Patch.new(uri)
592
+ request.body = body.to_json
593
+ execute(request)
594
+ end
595
+
437
596
  def build_uri(path, scoped: true)
438
597
  uri = @uri.dup
439
598
  uri.path = path
@@ -27,7 +27,7 @@ module Opencode
27
27
  def self.extract_tool_summary(response_body)
28
28
  parts = response_body[:parts] || []
29
29
  parts
30
- .select { |p| p[:type] == "tool" && p.dig(:state, :status).in?(TERMINAL_STATUSES) }
30
+ .select { |p| p[:type] == "tool" && TERMINAL_STATUSES.include?(p.dig(:state, :status)) }
31
31
  .map { |p| build_tool_summary(p) }
32
32
  end
33
33
 
@@ -42,7 +42,7 @@ module Opencode
42
42
  { "type" => "reasoning", "content" => part[:text] }
43
43
  when "tool"
44
44
  status = part.dig(:state, :status)
45
- next unless status.in?(TERMINAL_STATUSES)
45
+ next unless TERMINAL_STATUSES.include?(status)
46
46
 
47
47
  build_tool_summary(part)
48
48
  else
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Opencode
4
- VERSION = "0.0.1.alpha2"
4
+ VERSION = "0.0.1.alpha7"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opencode-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1.alpha2
4
+ version: 0.0.1.alpha7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ajay Krishnan