opencode-ruby 0.0.1.alpha2
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 +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE +18 -0
- data/README.md +162 -0
- data/examples/conversation_recipe.rb +153 -0
- data/lib/opencode/client.rb +564 -0
- data/lib/opencode/error.rb +28 -0
- data/lib/opencode/instrumentation.rb +76 -0
- data/lib/opencode/part_source.rb +62 -0
- data/lib/opencode/prompts.rb +87 -0
- data/lib/opencode/reply.rb +549 -0
- data/lib/opencode/reply_observer.rb +101 -0
- data/lib/opencode/response_parser.rb +169 -0
- data/lib/opencode/todo.rb +43 -0
- data/lib/opencode/tool_part.rb +152 -0
- data/lib/opencode/tracer.rb +50 -0
- data/lib/opencode/version.rb +5 -0
- data/lib/opencode-ruby.rb +26 -0
- data/opencode-ruby.gemspec +45 -0
- metadata +129 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5c85499ae01e9b85854d1738508f079c8e4b41d00d926f7c81cd537db2a30474
|
|
4
|
+
data.tar.gz: 9034a4c5697390f12f2c1d4c0566de11f1e44321c9f2497bc9b8c54d514e43fb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8a73ddf71df4c272aa3676cdddd5deb9d0db71a10466f4f355df6f9e63ada55a9231773df56f74cf7c44544069bdfc5fa0be82ea5dc8c89732192f87d4bc980d
|
|
7
|
+
data.tar.gz: e8015d9f32e8f3e1712e1cdf21824eacb7f26f3244831d20c8bfdff513ebb8d692afa2360aaa90450625cf935a0eb744fed16a62c7e3ed2b8b5f421819fd288f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.0.1.alpha2 — 2026-05-20
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `Opencode::Instrumentation.notify(name, payload)` — fire-and-forget
|
|
8
|
+
emission for point-in-time events that don't need duration measurement
|
|
9
|
+
(apply_patch.artifacts_dropped, session.recreated, etc.). Adapter
|
|
10
|
+
receives an empty block so AS::Notifications-shaped sinks see a
|
|
11
|
+
zero-duration event. Complements the existing block-form
|
|
12
|
+
`.instrument(name, payload) { ... }`.
|
|
13
|
+
|
|
14
|
+
### Why
|
|
15
|
+
|
|
16
|
+
The block-form `.instrument(name, payload) { }` with an empty block was
|
|
17
|
+
awkward at fire-and-forget call sites in opencode-rails. Two named
|
|
18
|
+
verbs (`instrument` for wrap-a-block, `notify` for fire-and-forget)
|
|
19
|
+
match the host-side mental model and read better at the call site.
|
|
20
|
+
|
|
21
|
+
## 0.0.1.alpha1 — Unreleased
|
|
22
|
+
|
|
23
|
+
First public alpha. HTTP + SSE client for OpenCode REST API.
|
|
24
|
+
|
|
25
|
+
### What's in
|
|
26
|
+
|
|
27
|
+
- `Opencode::Client` — Net::HTTP-based HTTP client with SSE streaming + automatic reconnection.
|
|
28
|
+
- `#create_session(title:, permissions:)`, `#get_messages(session_id)`, `#list_sessions`, `#delete_session(id)`, `#abort_session(id)`.
|
|
29
|
+
- `#send_message(session_id, text, model:, ...)` — synchronous send-and-poll.
|
|
30
|
+
- `#send_message_async(session_id, text, ...)` — async send.
|
|
31
|
+
- `#stream(session_id, text, ...) { |part| ... } → Opencode::Reply::Result` — **the headline.** Block-form streaming with internal Reply accumulation and final-exchange merge.
|
|
32
|
+
- `#stream_events(session_id:, ...) { |event| ... }` — lower-level SSE event firehose for power users.
|
|
33
|
+
- `#reply_question(request_id:, answers:)` / `#reply_permission(request_id:, reply:)` — answer interactive prompts.
|
|
34
|
+
- `Opencode::Reply` — live state machine accumulating SSE events into the assistant's reply. Documented observer protocol (`Opencode::ReplyObserver`).
|
|
35
|
+
- `Opencode::Reply::Result` — typed Struct value object returned by `Client#stream` and `Reply#result`. Fields: `:parts_json`, `:full_text`, `:reasoning_text`, `:tool_parts`.
|
|
36
|
+
- `Opencode::Instrumentation` — pluggable adapter (default no-op). Plug in `ActiveSupport::Notifications`, OpenTelemetry, stdout, etc.
|
|
37
|
+
- `Opencode::ResponseParser`, `Opencode::ToolPart`, `Opencode::PartSource`, `Opencode::Todo` — wire-format helpers used by `Reply` and reusable by callers building their own SSE handling.
|
|
38
|
+
- `Opencode::Prompts` — per-Reply registry of pending question/permission prompts (used by `Reply` internally; exposed for callers that need to peek).
|
|
39
|
+
- `Opencode::Tracer` — callable that prefixes event names before forwarding to a host emitter.
|
|
40
|
+
- Error hierarchy: `Opencode::Error` and seven subclasses (`ConnectionError`, `TimeoutError`, `SessionNotFoundError`, `StaleSessionError`, `IdleStreamError`, `ServerError`, `BadRequestError`).
|
|
41
|
+
|
|
42
|
+
### What's out
|
|
43
|
+
|
|
44
|
+
- ActiveRecord-backed session lifecycle, `acts_as_opencode_session`, generators — deferred to `opencode-rails` if external demand materializes. See `examples/conversation_recipe.rb` for the canonical Rails wiring pattern.
|
|
45
|
+
- Multi-tenant per-user Docker container orchestration — application glue, not a gem's concern.
|
|
46
|
+
|
|
47
|
+
### Compatibility
|
|
48
|
+
|
|
49
|
+
- Ruby ≥ 3.2
|
|
50
|
+
- OpenCode server ≥ 1.15 (tested against the message bus schema in `packages/opencode/src/session/message-v2.ts`)
|
|
51
|
+
- Runtime dependency: `activesupport (>= 6.1)` for `blank?`/`present?`/`presence`/`truncate`/`duplicable?`/`megabytes`. ActiveSupport is *not* Rails — it's a standalone helpers gem.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ajaynomics
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
15
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
16
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
18
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# opencode-ruby
|
|
2
|
+
|
|
3
|
+
Idiomatic Ruby client for [OpenCode](https://opencode.ai). Block-form streaming, value-object responses, automatic SSE reconnection.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
require "opencode-ruby"
|
|
7
|
+
|
|
8
|
+
client = Opencode::Client.new(base_url: "http://localhost:4096")
|
|
9
|
+
session = client.create_session(title: "My session")
|
|
10
|
+
|
|
11
|
+
reply = client.stream(session[:id], "Explain monads in two sentences.") do |part|
|
|
12
|
+
print part["content"] if part["type"] == "text"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
puts
|
|
16
|
+
puts reply.full_text
|
|
17
|
+
puts "(#{reply.tool_parts.size} tool calls, #{reply.parts_json.size} parts total)"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Three lines of setup, four lines of work. Block fires every time a part appears, grows, finalizes, or (for tool calls) advances state. The final return value is a typed `Opencode::Reply::Result` you can persist or inspect.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# Gemfile
|
|
26
|
+
gem "opencode-ruby"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
gem install opencode-ruby
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then `require "opencode-ruby"`.
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
client = Opencode::Client.new(
|
|
41
|
+
base_url: "http://localhost:4096", # or ENV["OPENCODE_BASE_URL"]
|
|
42
|
+
password: "secret", # or ENV["OPENCODE_SERVER_PASSWORD"]
|
|
43
|
+
timeout: 120 # or ENV["OPENCODE_TIMEOUT"], seconds
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Multi-tenant apps construct multiple clients with different `base_url`s — each `Opencode::Client` holds its own Net::HTTP connection, no shared state.
|
|
48
|
+
|
|
49
|
+
## Core API
|
|
50
|
+
|
|
51
|
+
### Streaming (the headline)
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
reply = client.stream(session_id, "What's 2 + 2?") do |part|
|
|
55
|
+
case part["type"]
|
|
56
|
+
when "text" then print part["content"]
|
|
57
|
+
when "reasoning" then # ignore, or render in a separate UI
|
|
58
|
+
when "tool" then puts " [tool: #{part['tool']} → #{part['status']}]"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
reply.full_text # => "2 + 2 = 4."
|
|
63
|
+
reply.tool_parts # => array of terminal tool-call parts
|
|
64
|
+
reply.reasoning_text # => the model's hidden reasoning, if any
|
|
65
|
+
reply.parts_json # => the full ordered parts array, ready for persistence
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Synchronous send (no streaming)
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
result = client.send_message(session_id, "Quick yes/no: is Ruby fun?")
|
|
72
|
+
# result is the OpenCode response hash; see API docs for fields.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Lower-level event firehose
|
|
76
|
+
|
|
77
|
+
If you need raw SSE events (every server tick, todo update, prompt asked/replied), use `stream_events` directly:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
client.stream_events(session_id: session_id) do |event|
|
|
81
|
+
puts event[:type] # "message.part.delta", "todo.updated", "session.idle", ...
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Interactive prompts
|
|
86
|
+
|
|
87
|
+
When the agent uses the `question` or `permission` tools, opencode emits `question.asked` / `permission.asked` events. Answer them via:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
client.reply_question(request_id: "que_...", answers: [["yes"]])
|
|
91
|
+
client.reply_permission(request_id: "per_...", reply: "always")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Error model
|
|
95
|
+
|
|
96
|
+
Every method that hits the network raises `Opencode::Error` (or a subclass) on failure. Catch the parent or the specific subclass:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
begin
|
|
100
|
+
client.health
|
|
101
|
+
rescue Opencode::ConnectionError # server unreachable
|
|
102
|
+
rescue Opencode::TimeoutError # client-side timeout
|
|
103
|
+
rescue Opencode::SessionNotFoundError # 404 on a session
|
|
104
|
+
rescue Opencode::StaleSessionError # session.idle never arrived
|
|
105
|
+
rescue Opencode::IdleStreamError # mid-turn SSE wedge
|
|
106
|
+
rescue Opencode::ServerError # 5xx
|
|
107
|
+
rescue Opencode::BadRequestError # 4xx other than 404
|
|
108
|
+
rescue Opencode::Error # catch-all
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Instrumentation
|
|
113
|
+
|
|
114
|
+
Want to see what the gem is doing? Plug in an adapter. Default behaviour is silent no-op — the gem ships zero opinion about your observability stack.
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
# stdout for debugging:
|
|
118
|
+
Opencode::Instrumentation.adapter = ->(name, payload, &block) {
|
|
119
|
+
puts "[#{name}] #{payload.inspect}"
|
|
120
|
+
block.call
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# ActiveSupport::Notifications in a Rails app:
|
|
124
|
+
Opencode::Instrumentation.adapter = ->(name, payload, &block) {
|
|
125
|
+
ActiveSupport::Notifications.instrument(name, payload, &block)
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Event names emitted today:
|
|
130
|
+
|
|
131
|
+
| Event | Payload |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `opencode.request` | `:method`, `:path` |
|
|
134
|
+
|
|
135
|
+
## Want this in a Rails app?
|
|
136
|
+
|
|
137
|
+
See [`examples/conversation_recipe.rb`](examples/conversation_recipe.rb) for a ~60-line plain-ActiveRecord blueprint covering session lifecycle (`with_lock`, `update_columns` mid-stream snapshots, CAS-safe finalize). Drop it into your app and adapt.
|
|
138
|
+
|
|
139
|
+
If enough Rails developers do that and want it as a one-liner, we'll ship `opencode-rails` with `acts_as_opencode_session`. **File an issue if that's you** — your issue is the signal.
|
|
140
|
+
|
|
141
|
+
## Position against `opencode_client`
|
|
142
|
+
|
|
143
|
+
Want every OpenCode endpoint auto-generated from the OpenAPI spec? Use [`opencode_client`](https://rubygems.org/gems/opencode_client). This gem is the hand-rolled idiomatic alternative — smaller surface, opinionated defaults, block-form streaming. Pick whichever fits how you want to write Ruby.
|
|
144
|
+
|
|
145
|
+
## Compatibility
|
|
146
|
+
|
|
147
|
+
- Ruby ≥ 3.2
|
|
148
|
+
- OpenCode server ≥ 1.15
|
|
149
|
+
- Runtime dependency: `activesupport (>= 6.1)` — *not* Rails. ActiveSupport is a standalone helpers gem (`blank?`, `present?`, `presence`, `truncate`, etc.).
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
```sh
|
|
154
|
+
bundle install
|
|
155
|
+
bundle exec rake test
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
12-test smoke covers Client end-to-end against WebMock-stubbed OpenCode endpoints.
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rails integration recipe — copy + adapt.
|
|
4
|
+
#
|
|
5
|
+
# This is NOT part of opencode-ruby. It's the canonical pattern showing
|
|
6
|
+
# how to wire the gem's primitives into a Rails ActiveRecord app. Drop
|
|
7
|
+
# this file into your `app/models/` (rename it), adapt the schema, and
|
|
8
|
+
# you have a working block-streaming chat with row-locked session
|
|
9
|
+
# lifecycle and CAS-safe finalize.
|
|
10
|
+
#
|
|
11
|
+
# What this recipe demonstrates:
|
|
12
|
+
#
|
|
13
|
+
# 1. Schema (below in a comment) — the migration you'll need
|
|
14
|
+
# 2. Session lifecycle — idempotent ensure! with with_lock
|
|
15
|
+
# 3. Mid-stream parts persistence via update_columns (bypasses
|
|
16
|
+
# AR callbacks so Turbo broadcasts don't fire per-part)
|
|
17
|
+
# 4. CAS-safe finalize — concurrent cancel wins
|
|
18
|
+
# 5. Recovery from SessionNotFoundError — recreate once + retry
|
|
19
|
+
#
|
|
20
|
+
# If you want this as a one-liner (`acts_as_opencode_session`), open
|
|
21
|
+
# an issue on the repo. The gem ships this recipe instead of a concern
|
|
22
|
+
# because the right shape depends on the host app's conventions — and
|
|
23
|
+
# shipping a half-built concern is worse than shipping a clear
|
|
24
|
+
# blueprint you can adapt.
|
|
25
|
+
#
|
|
26
|
+
# Suggested schema (adapt naming to your domain):
|
|
27
|
+
#
|
|
28
|
+
# create_table :conversations do |t|
|
|
29
|
+
# t.references :user, null: false, foreign_key: true
|
|
30
|
+
# t.string :title
|
|
31
|
+
# t.string :opencode_session_id
|
|
32
|
+
# t.timestamps
|
|
33
|
+
# t.index :opencode_session_id, unique: true,
|
|
34
|
+
# where: "opencode_session_id IS NOT NULL" # partial unique
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# create_table :messages do |t|
|
|
38
|
+
# t.references :conversation, null: false, foreign_key: true
|
|
39
|
+
# t.string :role, null: false # "user" or "assistant"
|
|
40
|
+
# t.integer :status, null: false, default: 0 # see enum below
|
|
41
|
+
# t.text :content, null: false, default: ""
|
|
42
|
+
# t.json :parts_json, null: false, default: []
|
|
43
|
+
# t.json :tool_calls_json, null: false, default: []
|
|
44
|
+
# t.decimal :cost, precision: 10, scale: 6
|
|
45
|
+
# t.integer :input_tokens
|
|
46
|
+
# t.integer :output_tokens
|
|
47
|
+
# t.timestamps
|
|
48
|
+
# end
|
|
49
|
+
|
|
50
|
+
class Conversation < ApplicationRecord
|
|
51
|
+
belongs_to :user
|
|
52
|
+
has_many :messages, dependent: :destroy
|
|
53
|
+
|
|
54
|
+
# Returns the OpenCode session id for this conversation, creating one
|
|
55
|
+
# if needed. Idempotent. Race-safe via row-lock + double-check.
|
|
56
|
+
def ensure_opencode_session!(client)
|
|
57
|
+
return opencode_session_id if opencode_session_id.present?
|
|
58
|
+
|
|
59
|
+
with_lock do
|
|
60
|
+
return opencode_session_id if opencode_session_id.present?
|
|
61
|
+
session = client.create_session(title: title)
|
|
62
|
+
update!(opencode_session_id: session[:id] || session["id"])
|
|
63
|
+
end
|
|
64
|
+
opencode_session_id
|
|
65
|
+
rescue ActiveRecord::RecordNotUnique
|
|
66
|
+
# Another worker raced past the partial unique index. Loser reloads.
|
|
67
|
+
reload
|
|
68
|
+
opencode_session_id
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Replace a stale upstream session. Used by SessionNotFoundError
|
|
72
|
+
# recovery in the streaming job below.
|
|
73
|
+
def recreate_opencode_session!(client)
|
|
74
|
+
pre_id = opencode_session_id
|
|
75
|
+
with_lock do
|
|
76
|
+
return opencode_session_id if opencode_session_id.present? && opencode_session_id != pre_id
|
|
77
|
+
session = client.create_session(title: title)
|
|
78
|
+
update!(opencode_session_id: session[:id] || session["id"])
|
|
79
|
+
end
|
|
80
|
+
opencode_session_id
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class Message < ApplicationRecord
|
|
85
|
+
belongs_to :conversation
|
|
86
|
+
|
|
87
|
+
enum status: { pending: 0, streaming: 1, completed: 2, cancelled: 3, errored: 4 }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The streaming job. Compose Opencode::Client + ActiveRecord; that's it.
|
|
91
|
+
class GenerateAssistantReplyJob < ApplicationJob
|
|
92
|
+
def perform(message_id, user_prompt)
|
|
93
|
+
message = Message.find(message_id)
|
|
94
|
+
return unless message.pending?
|
|
95
|
+
|
|
96
|
+
client = Opencode::Client.new(
|
|
97
|
+
base_url: ENV.fetch("OPENCODE_BASE_URL"),
|
|
98
|
+
password: ENV["OPENCODE_SERVER_PASSWORD"]
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
session_id = message.conversation.ensure_opencode_session!(client)
|
|
102
|
+
message.update!(status: :streaming)
|
|
103
|
+
|
|
104
|
+
attempted_recreate = false
|
|
105
|
+
begin
|
|
106
|
+
reply = client.stream(session_id, user_prompt) do |part|
|
|
107
|
+
# Mid-stream snapshot: update_columns bypasses AR callbacks so
|
|
108
|
+
# an after_update_commit broadcasts_refreshes_to(conversation)
|
|
109
|
+
# doesn't fire per-part and clobber per-part Turbo broadcasts
|
|
110
|
+
# you might be doing separately. The final write below uses
|
|
111
|
+
# update! to fire callbacks deliberately.
|
|
112
|
+
message.update_columns(
|
|
113
|
+
parts_json: reply_parts_so_far(part, message),
|
|
114
|
+
updated_at: Time.current
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# CAS-safe finalize: only land the final state if no concurrent
|
|
119
|
+
# cancel got there first.
|
|
120
|
+
message.with_lock do
|
|
121
|
+
return unless message.reload.pending? || message.streaming?
|
|
122
|
+
message.update!(
|
|
123
|
+
status: :completed,
|
|
124
|
+
content: reply.full_text,
|
|
125
|
+
parts_json: reply.parts_json,
|
|
126
|
+
tool_calls_json: reply.tool_parts
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
rescue Opencode::SessionNotFoundError, Opencode::StaleSessionError
|
|
130
|
+
raise if attempted_recreate
|
|
131
|
+
message.conversation.recreate_opencode_session!(client)
|
|
132
|
+
attempted_recreate = true
|
|
133
|
+
retry
|
|
134
|
+
end
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
message&.update!(status: :errored, content: "An error occurred: #{e.message.truncate(200)}")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
# Builds the parts array up to (and including) the current part by
|
|
142
|
+
# poking the gem's internal Reply state. In practice you'd capture
|
|
143
|
+
# the Reply instance from the block via a closure, OR derive from
|
|
144
|
+
# `part` if you only need the latest part.
|
|
145
|
+
def reply_parts_so_far(part, message)
|
|
146
|
+
parts = (message.parts_json || []).dup
|
|
147
|
+
# Trivial dedup: replace or append by part id, if your wire-format
|
|
148
|
+
# includes one. For real merge logic, lift Opencode::Reply's
|
|
149
|
+
# part_index_by_id / append_part pattern.
|
|
150
|
+
parts << part unless parts.any? { |existing| existing["id"] == part["id"] }
|
|
151
|
+
parts
|
|
152
|
+
end
|
|
153
|
+
end
|