opencode-ruby 0.0.1.alpha2 → 0.0.1.alpha6

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: ebac8d73ce010347ee830b23a1280b45a6ff71effd1109aad787743ec60fae45
4
+ data.tar.gz: c815940b8e87bcd5724535e987833e360ee9a725081f753cd451f5ae6938f70d
5
5
  SHA512:
6
- metadata.gz: 8a73ddf71df4c272aa3676cdddd5deb9d0db71a10466f4f355df6f9e63ada55a9231773df56f74cf7c44544069bdfc5fa0be82ea5dc8c89732192f87d4bc980d
7
- data.tar.gz: e8015d9f32e8f3e1712e1cdf21824eacb7f26f3244831d20c8bfdff513ebb8d692afa2360aaa90450625cf935a0eb744fed16a62c7e3ed2b8b5f421819fd288f
6
+ metadata.gz: 902785c4c78de0f30affcb638bec979844f2e1481cabbb4ce3a2a0fc23ca8d384ed6e2cdbd1e49da7050599728dfb619b1eb7b7c3f011e7b18a793a00e00ca40
7
+ data.tar.gz: 7e7cc7a596386dd3846dbe1baa91f3cbeba049e46bc8748e05823375ddd07b145849a217ef249d97ff732d3e9f90c3e3e2de1ee7b6ba2021ef30e8cd04bf362d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.1.alpha6 - 2026-07-18
4
+
5
+ ### Fixed
6
+
7
+ - Make `Opencode::Client#stream` wait for OpenCode's initial
8
+ `server.connected` SSE readiness frame before submitting `prompt_async`,
9
+ closing the fast-response window where a turn could emit events before the
10
+ client was listening.
11
+ - Keep prompt submission at-most-once across automatic SSE reconnects. A
12
+ reconnect now reopens only the event stream; it never posts the user prompt
13
+ again, and prompt transport failures remain visible to the caller.
14
+
15
+ ## 0.0.1.alpha5 - 2026-07-15
16
+
17
+ ### Added
18
+
19
+ - Extend `Opencode::Client#create_session` with OpenCode's native parent,
20
+ agent, model, metadata, and workspace fields while preserving the existing
21
+ title and permission call shape. Session model strings are encoded with the
22
+ session endpoint's `{ providerID, id }` shape rather than the message
23
+ endpoint's `{ providerID, modelID }` shape.
24
+
25
+ ## 0.0.1.alpha4 - 2026-07-12
26
+
27
+ ### Fixed
28
+
29
+ - End SSE streams on current OpenCode `session.status` idle events while
30
+ retaining compatibility with legacy `session.idle` events.
31
+ - Reconcile every assistant message in the current user turn after multi-step
32
+ tool loops, preserving stream-only parts without duplicating final text.
33
+ - Parse terminal tool parts in standalone Ruby clients without relying on the
34
+ Rails-loaded `Object#in?` extension.
35
+
36
+ ## 0.0.1.alpha3 - 2026-07-10
37
+
38
+ ### Added
39
+
40
+ - `Opencode::Client#update_session` for applying permission rules through
41
+ OpenCode's session PATCH endpoint. OpenCode appends these rules, so hosts
42
+ should fingerprint their ordered policy and call this only when it changes.
43
+
3
44
  ## 0.0.1.alpha2 — 2026-05-20
4
45
 
5
46
  ### 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,13 +98,24 @@ 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", ...
82
119
  end
83
120
  ```
84
121
 
@@ -101,7 +138,7 @@ begin
101
138
  rescue Opencode::ConnectionError # server unreachable
102
139
  rescue Opencode::TimeoutError # client-side timeout
103
140
  rescue Opencode::SessionNotFoundError # 404 on a session
104
- rescue Opencode::StaleSessionError # session.idle never arrived
141
+ rescue Opencode::StaleSessionError # no session event arrived after the prompt
105
142
  rescue Opencode::IdleStreamError # mid-turn SSE wedge
106
143
  rescue Opencode::ServerError # 5xx
107
144
  rescue Opencode::BadRequestError # 4xx other than 404
@@ -155,7 +192,14 @@ bundle install
155
192
  bundle exec rake test
156
193
  ```
157
194
 
158
- 12-test smoke covers Client end-to-end against WebMock-stubbed OpenCode endpoints.
195
+ The smoke suite covers Client end-to-end against WebMock-stubbed OpenCode
196
+ endpoints, including subscription-before-prompt ordering and
197
+ reconnect-without-repost.
198
+
199
+ Releases use RubyGems trusted publishing. After the repository's
200
+ `release.yml` workflow is registered as a trusted publisher with the `release`
201
+ environment, pushing a `v*` tag builds, attests, and publishes the gem without
202
+ a long-lived RubyGems API key.
159
203
 
160
204
  ## License
161
205
 
@@ -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
@@ -263,6 +299,20 @@ module Opencode
263
299
  def stream_events(session_id:, timeout: 600, first_event_timeout: 120,
264
300
  idle_stream_timeout: nil,
265
301
  reply: nil, on_activity_tick: nil, &block)
302
+ consume_event_stream(
303
+ session_id: session_id,
304
+ timeout: timeout,
305
+ first_event_timeout: first_event_timeout,
306
+ idle_stream_timeout: idle_stream_timeout,
307
+ reply: reply,
308
+ on_activity_tick: on_activity_tick,
309
+ &block
310
+ )
311
+ end
312
+
313
+ private def consume_event_stream(session_id:, timeout:, first_event_timeout:,
314
+ idle_stream_timeout:, reply:, on_activity_tick:,
315
+ on_subscribed: nil, &block)
266
316
  uri = build_uri("/event")
267
317
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
268
318
  first_event_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + first_event_timeout
@@ -297,6 +347,8 @@ module Opencode
297
347
  http.open_timeout = 10
298
348
  http.read_timeout = 30
299
349
 
350
+ subscription_callback_error = nil
351
+ subscription_ready = on_subscribed.nil?
300
352
  begin
301
353
  buffer = String.new
302
354
 
@@ -330,6 +382,36 @@ module Opencode
330
382
  event = parse_sse_event(raw_event, session_id)
331
383
  next unless event
332
384
 
385
+ unless subscription_ready
386
+ # Every supported OpenCode server starts /event with this
387
+ # frame. Receiving it proves the stream body is flowing; on
388
+ # current servers the bus listener is registered eagerly,
389
+ # and on older lazy-stream servers it is the strongest
390
+ # available readiness handshake before prompting.
391
+ next unless event[:type] == "server.connected"
392
+
393
+ begin
394
+ turn_started = on_subscribed.call
395
+ rescue StandardError => error
396
+ # Prompt submission happens inside the open SSE response.
397
+ # Do not mistake its transport failure for an SSE disconnect
398
+ # and hide it behind a reconnect/first-event timeout.
399
+ subscription_callback_error = error
400
+ raise
401
+ end
402
+ if turn_started
403
+ # Before this fix stream_events began only after the prompt
404
+ # POST returned. Preserve those timeout semantics: the turn
405
+ # and first-session-event windows begin after prompt_async
406
+ # succeeds, not while establishing the readiness handshake.
407
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
408
+ deadline = started_at + timeout
409
+ first_event_deadline = started_at + first_event_timeout
410
+ last_meaningful_event_at = started_at
411
+ end
412
+ subscription_ready = true
413
+ end
414
+
333
415
  unless event[:type]&.start_with?("server.")
334
416
  received_session_event = true
335
417
  last_meaningful_event_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -341,13 +423,16 @@ module Opencode
341
423
  # the reaper doesn't kill it mid-wait.
342
424
  on_activity_tick&.call(event)
343
425
  block.call(event)
344
- return if event[:type] == "session.idle"
426
+ return if terminal_session_event?(event)
345
427
  end
346
428
  end
347
429
  end
348
430
  rescue *TRANSIENT_SSE_ERRORS
431
+ raise if subscription_callback_error
432
+
349
433
  # Treat transport-level SSE disconnects like clean EOF: reconnect
350
- # until session.idle, the overall timeout, or first-event timeout.
434
+ # until an idle session event, the overall timeout, or first-event
435
+ # timeout.
351
436
  ensure
352
437
  begin
353
438
  http&.finish if http&.started?
@@ -381,13 +466,16 @@ module Opencode
381
466
  # the caller's reply is still a usable Result either way.
382
467
  def merge_final_exchange(session_id, reply)
383
468
  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?
469
+ polled = current_turn_parts(exchange)
470
+ return if polled.empty?
471
+
472
+ merged = merge_stream_only_parts(reply.result.parts_json, polled)
473
+ reply.sync_recovered_parts(merged)
474
+ # sync_recovered_parts intentionally never deletes live parts because it
475
+ # is also used during mid-stream recovery. This is the terminal poll, so
476
+ # the wire snapshot is authoritative: remove any replayed trailing wire
477
+ # part after observers have seen recovered additions/updates.
478
+ reply.replace_parts(merged) unless reply.result.parts_json == merged
391
479
  rescue Opencode::Error
392
480
  # Stream's result is still complete; the merge was a polish, not a
393
481
  # requirement.
@@ -404,6 +492,45 @@ module Opencode
404
492
  deadline
405
493
  end
406
494
 
495
+ def terminal_session_event?(event)
496
+ return true if event[:type] == "session.idle"
497
+ return false unless event[:type] == "session.status"
498
+
499
+ status = event.dig(:properties, :status)
500
+ status = status[:type] || status["type"] if status.is_a?(Hash)
501
+ status == "idle"
502
+ end
503
+
504
+ # OpenCode persists one assistant message per model step. A tool loop can
505
+ # therefore produce several assistant messages for one user turn (for
506
+ # example skill -> task -> final text). Reconcile the complete current turn
507
+ # instead of aligning the live parts array with only the last assistant
508
+ # message, which corrupts tool parts and duplicates final text.
509
+ def current_turn_parts(exchange)
510
+ messages = Array(exchange)
511
+ last_user_index = messages.rindex { |message| message.dig(:info, :role) == "user" }
512
+ current_turn = last_user_index ? messages.drop(last_user_index + 1) : messages
513
+
514
+ current_turn
515
+ .select { |message| message.dig(:info, :role) == "assistant" }
516
+ .flat_map { |message| Opencode::ResponseParser.extract_interleaved_parts(message) }
517
+ end
518
+
519
+ def merge_stream_only_parts(stream_parts, wire_parts)
520
+ remaining_wire = Array(wire_parts).dup
521
+ merged = []
522
+
523
+ Array(stream_parts).each do |part|
524
+ if Opencode::PartSource.stream_only?(part)
525
+ merged << part
526
+ elsif remaining_wire.any?
527
+ merged << remaining_wire.shift
528
+ end
529
+ end
530
+
531
+ merged.concat(remaining_wire)
532
+ end
533
+
407
534
  def prompt_payload(text, parts:, model:, agent:, system:, message_id:, no_reply:, tools:, format:, variant:)
408
535
  message_parts = parts || [ { type: "text", text: text } ]
409
536
  {
@@ -427,6 +554,14 @@ module Opencode
427
554
  { providerID: provider, modelID: model_id }
428
555
  end
429
556
 
557
+ def format_session_model(model)
558
+ return nil unless model
559
+ return model if model.is_a?(Hash)
560
+
561
+ provider, model_id = model.split("/", 2)
562
+ { providerID: provider, id: model_id }
563
+ end
564
+
430
565
  def post(path, body)
431
566
  uri = build_uri(path)
432
567
  request = Net::HTTP::Post.new(uri)
@@ -434,6 +569,13 @@ module Opencode
434
569
  execute(request)
435
570
  end
436
571
 
572
+ def patch(path, body)
573
+ uri = build_uri(path)
574
+ request = Net::HTTP::Patch.new(uri)
575
+ request.body = body.to_json
576
+ execute(request)
577
+ end
578
+
437
579
  def build_uri(path, scoped: true)
438
580
  uri = @uri.dup
439
581
  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.alpha6"
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.alpha6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ajay Krishnan