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 +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +64 -3
- data/lib/opencode/client.rb +180 -21
- data/lib/opencode/response_parser.rb +2 -2
- data/lib/opencode/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7a4ddecebacda21e25ac50bd14d6d36c8ad61b35d23dee985b620d82a2b94b36
|
|
4
|
+
data.tar.gz: 3aa46267d464d9f95ddc96cb0a7d59cff04a9559c1dffb8d5c6d99c84f46b7b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
data/lib/opencode/client.rb
CHANGED
|
@@ -25,8 +25,24 @@ module Opencode
|
|
|
25
25
|
@workspace = workspace
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def create_session(
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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)
|
|
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
|
|
45
|
+
next unless TERMINAL_STATUSES.include?(status)
|
|
46
46
|
|
|
47
47
|
build_tool_summary(part)
|
|
48
48
|
else
|
data/lib/opencode/version.rb
CHANGED