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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Opencode
|
|
6
|
+
# A Part's provenance — where it came from in the OpenCode wire model.
|
|
7
|
+
#
|
|
8
|
+
# Two source classes exist:
|
|
9
|
+
#
|
|
10
|
+
# - Wire parts: emitted by the OpenCode message-parts pipeline and
|
|
11
|
+
# echoed back by `GET /session/:id/message`. These are authoritative
|
|
12
|
+
# for finalization — when the final exchange poll lands, wire parts
|
|
13
|
+
# overwrite whatever streaming captured.
|
|
14
|
+
#
|
|
15
|
+
# - Stream-only parts: synthesized from bus events that OpenCode does
|
|
16
|
+
# NOT persist as message parts. The host's Opencode::Reply
|
|
17
|
+
# materializes them so per-product ReplyStream observers can render
|
|
18
|
+
# them through the same tool partials as real tool parts, and
|
|
19
|
+
# Opencode::Turn preserves them across exchange-finalization so the
|
|
20
|
+
# final assistant message keeps what the user watched live.
|
|
21
|
+
#
|
|
22
|
+
# `todo.updated` is the first stream-only source (OpenCode emits the
|
|
23
|
+
# full todo list on a bus event but never records it as a message part).
|
|
24
|
+
# Future sources land here too: add the constant, add it to STREAM_ONLY,
|
|
25
|
+
# both `Reply#append_part` callers and `Turn#stream_only_part?` keep
|
|
26
|
+
# working with no further edits.
|
|
27
|
+
#
|
|
28
|
+
# This module exists because the previous shape coupled Reply and Turn
|
|
29
|
+
# through a magic-string comparison of `metadata.source ==
|
|
30
|
+
# Opencode::Reply::TODO_STREAM_SOURCE`. Two classes carrying the same
|
|
31
|
+
# discriminator string is a "next time someone adds a source they'll
|
|
32
|
+
# only update one place" bug waiting to happen. The source-of-truth
|
|
33
|
+
# now lives here; both consumers go through `stream_only?(part)`.
|
|
34
|
+
module PartSource
|
|
35
|
+
TODO_UPDATED = "todo.updated"
|
|
36
|
+
STREAM_ONLY = Set[TODO_UPDATED].freeze
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# True iff the part's metadata.source is one of the stream-only
|
|
41
|
+
# sources. Tolerates non-Hash input (returns false) so callers don't
|
|
42
|
+
# have to guard before asking.
|
|
43
|
+
def stream_only?(part)
|
|
44
|
+
return false unless part.is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
STREAM_ONLY.include?(part.dig("metadata", "source"))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Stamps `source:` into part_hash's metadata. Raises ArgumentError on
|
|
50
|
+
# an unknown source so typos surface at write time, not at the next
|
|
51
|
+
# `stream_only?` check (which would silently return false).
|
|
52
|
+
# Mutates and returns the input hash for chaining.
|
|
53
|
+
def stamp(part_hash, source:)
|
|
54
|
+
raise ArgumentError, "unknown stream-only source #{source.inspect}; " \
|
|
55
|
+
"register it in Opencode::PartSource::STREAM_ONLY first" unless STREAM_ONLY.include?(source)
|
|
56
|
+
|
|
57
|
+
part_hash["metadata"] ||= {}
|
|
58
|
+
part_hash["metadata"]["source"] = source
|
|
59
|
+
part_hash
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencode
|
|
4
|
+
# Per-Reply registry of interactive prompts (questions + permissions)
|
|
5
|
+
# opencode has asked the user but not yet resolved. Lives on
|
|
6
|
+
# Opencode::Reply for the lifetime of one streaming turn.
|
|
7
|
+
#
|
|
8
|
+
# Two access patterns:
|
|
9
|
+
#
|
|
10
|
+
# * by request id ("que_..." or "per_...") — for the controller
|
|
11
|
+
# posting a user's answer back.
|
|
12
|
+
# * by {message_id, call_id} — for the order-race fix where
|
|
13
|
+
# `question.asked` may arrive before the matching tool part.
|
|
14
|
+
#
|
|
15
|
+
# The registry also exposes a `prompt_blocked?` predicate that
|
|
16
|
+
# Opencode::Client uses to suspend the SSE deadline check while
|
|
17
|
+
# a healthy wait is in progress.
|
|
18
|
+
class Prompts
|
|
19
|
+
Entry = Struct.new(:kind, :request, :asked_at, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@entries = {}
|
|
23
|
+
@by_call = {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def record_question(request)
|
|
27
|
+
record(:question, request)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def record_permission(request)
|
|
31
|
+
record(:permission, request)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the raw request hash (not the Entry wrapper) so callers
|
|
35
|
+
# don't depend on internal bookkeeping shape.
|
|
36
|
+
def find(request_id)
|
|
37
|
+
@entries[request_id]&.request
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns the raw request hash, same shape as #find.
|
|
41
|
+
def find_by_call(message_id:, call_id:)
|
|
42
|
+
key = call_key(message_id, call_id)
|
|
43
|
+
@by_call[key]&.request
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resolve(request_id)
|
|
47
|
+
entry = @entries.delete(request_id)
|
|
48
|
+
return unless entry
|
|
49
|
+
|
|
50
|
+
tool = entry.request[:tool]
|
|
51
|
+
return unless tool
|
|
52
|
+
|
|
53
|
+
@by_call.delete(call_key(tool[:messageID], tool[:callID]))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def each_pending
|
|
57
|
+
@entries.each_value { |entry| yield(entry.kind, entry.request) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def any_pending?
|
|
61
|
+
@entries.any?
|
|
62
|
+
end
|
|
63
|
+
alias_method :prompt_blocked?, :any_pending?
|
|
64
|
+
|
|
65
|
+
def asked_at(request_id)
|
|
66
|
+
@entries[request_id]&.asked_at
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def record(kind, request)
|
|
72
|
+
entry = Entry.new(
|
|
73
|
+
kind: kind,
|
|
74
|
+
request: request,
|
|
75
|
+
asked_at: Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
76
|
+
)
|
|
77
|
+
@entries[request[:id]] = entry
|
|
78
|
+
|
|
79
|
+
tool = request[:tool]
|
|
80
|
+
@by_call[call_key(tool[:messageID], tool[:callID])] = entry if tool
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def call_key(message_id, call_id)
|
|
84
|
+
[ message_id, call_id ].join(":")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencode
|
|
4
|
+
# An assistant's reply as it is being composed, live, from OpenCode SSE
|
|
5
|
+
# events. A Reply accumulates parts (text, reasoning, tool invocations)
|
|
6
|
+
# in the order the agent emits them and notifies observers of domain
|
|
7
|
+
# transitions — parts appearing, parts growing, tools advancing,
|
|
8
|
+
# sessions erroring.
|
|
9
|
+
#
|
|
10
|
+
# Responsibilities
|
|
11
|
+
# ----------------
|
|
12
|
+
#
|
|
13
|
+
# * Translate raw OpenCode SSE events into domain callbacks.
|
|
14
|
+
# * Own the canonical state of an in-flight reply (parts list, indices,
|
|
15
|
+
# first-token seen, message info).
|
|
16
|
+
# * Apply the tail-drop safety net: when part.updated carries
|
|
17
|
+
# authoritative :text that differs from what deltas accumulated
|
|
18
|
+
# (z.ai GLM-5.1 drops trailing deltas), rewrite the part's content.
|
|
19
|
+
# * Preserve the original tool name when OpenCode later renames a tool
|
|
20
|
+
# to "invalid" mid-stream.
|
|
21
|
+
#
|
|
22
|
+
# Not responsibilities
|
|
23
|
+
# --------------------
|
|
24
|
+
#
|
|
25
|
+
# * Rendering HTML or broadcasting Turbo Streams (observer concern).
|
|
26
|
+
# * Persisting parts to a database (observer concern).
|
|
27
|
+
# * Fetching the event stream (Opencode::Client).
|
|
28
|
+
# * Retry / session recovery (job concern).
|
|
29
|
+
#
|
|
30
|
+
# Event contract
|
|
31
|
+
# --------------
|
|
32
|
+
#
|
|
33
|
+
# Events match OpenCode's bus schema (packages/opencode/src/session/
|
|
34
|
+
# message-v2.ts, status.ts, todo.ts):
|
|
35
|
+
#
|
|
36
|
+
# message.part.delta { properties: { partID, field, delta, ... } }
|
|
37
|
+
# message.part.updated { properties: { part: { id, type, ... } } }
|
|
38
|
+
# message.updated { properties: { info: { tokens, cost, ... } } }
|
|
39
|
+
# session.status { properties: { status: { type, ... } } }
|
|
40
|
+
# session.error { properties: { error: { name, data, ... } } }
|
|
41
|
+
# todo.updated { properties: { todos: [...] } }
|
|
42
|
+
#
|
|
43
|
+
# Observer callbacks
|
|
44
|
+
# ------------------
|
|
45
|
+
#
|
|
46
|
+
# See Opencode::ReplyObserver for the full callback surface. Observers
|
|
47
|
+
# are duck-typed — only the callbacks they define are invoked.
|
|
48
|
+
#
|
|
49
|
+
# Example
|
|
50
|
+
# -------
|
|
51
|
+
#
|
|
52
|
+
# reply = Opencode::Reply.new
|
|
53
|
+
# reply.add_observer(MyApp::ReplyStream.new(message:)) # your observer
|
|
54
|
+
# client.stream_events(session_id: id) { |event| reply.apply(event) }
|
|
55
|
+
# reply.result
|
|
56
|
+
# # => Opencode::Reply::Result with parts_json, full_text, reasoning_text, tool_parts
|
|
57
|
+
#
|
|
58
|
+
class Reply
|
|
59
|
+
STREAMABLE_TYPES = %w[text reasoning tool].freeze
|
|
60
|
+
TERMINAL_TOOL_STATUSES = %w[completed error].freeze
|
|
61
|
+
TODO_TOOLS = %w[todowrite todoread].freeze
|
|
62
|
+
|
|
63
|
+
# The denormalized output of a Reply once streaming completes (or
|
|
64
|
+
# recovery via Reply.distill produces an equivalent shape). Symmetric
|
|
65
|
+
# with Opencode::Turn::Result. Accessible by both message-style
|
|
66
|
+
# (`result.full_text`) and hash-style (`result[:full_text]`) syntax
|
|
67
|
+
# — Struct supports both natively — but the typed shape stops
|
|
68
|
+
# callers from poking arbitrary keys.
|
|
69
|
+
Result = Struct.new(:parts_json, :full_text, :reasoning_text, :tool_parts, keyword_init: true)
|
|
70
|
+
|
|
71
|
+
attr_reader :parts, :info, :total_cost, :total_input_tokens, :total_output_tokens, :prompts
|
|
72
|
+
|
|
73
|
+
def initialize
|
|
74
|
+
@parts = []
|
|
75
|
+
@part_index_by_id = {}
|
|
76
|
+
@part_type_by_id = {}
|
|
77
|
+
@observers = []
|
|
78
|
+
@first_text_seen = false
|
|
79
|
+
@info = nil
|
|
80
|
+
@total_cost = 0.0
|
|
81
|
+
@total_input_tokens = 0
|
|
82
|
+
@total_output_tokens = 0
|
|
83
|
+
@todo_part_index = nil
|
|
84
|
+
@prompts = Opencode::Prompts.new
|
|
85
|
+
# Keyed by [message_id, call_id]: question.asked payloads that
|
|
86
|
+
# arrived before their matching tool part. Drained when the tool
|
|
87
|
+
# part shows up in apply_tool_state.
|
|
88
|
+
@pending_question_payloads = {}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# True while any interactive prompt (question or permission) is
|
|
92
|
+
# awaiting a user reply. Opencode::Client uses this to suspend the
|
|
93
|
+
# SSE inactivity deadline — a wait on the human is healthy, not a
|
|
94
|
+
# hang.
|
|
95
|
+
def prompt_blocked?
|
|
96
|
+
@prompts.prompt_blocked?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def add_observer(observer)
|
|
100
|
+
@observers << observer
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Drive the state machine forward with one SSE event. Unknown event
|
|
105
|
+
# types are ignored — OpenCode may add new events, and we shouldn't
|
|
106
|
+
# crash on them.
|
|
107
|
+
def apply(event)
|
|
108
|
+
case event[:type]
|
|
109
|
+
when "message.part.delta" then apply_part_delta(event)
|
|
110
|
+
when "message.part.updated" then apply_part_updated(event)
|
|
111
|
+
when "message.updated" then apply_message_updated(event)
|
|
112
|
+
when "session.status" then apply_session_status(event)
|
|
113
|
+
when "session.error" then apply_session_error(event)
|
|
114
|
+
when "todo.updated" then apply_todo_updated(event)
|
|
115
|
+
when "question.asked" then apply_question_asked(event)
|
|
116
|
+
when "question.replied" then apply_question_replied(event)
|
|
117
|
+
when "question.rejected" then apply_question_rejected(event)
|
|
118
|
+
when "permission.asked" then apply_permission_asked(event)
|
|
119
|
+
when "permission.replied" then apply_permission_replied(event)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Treat `recovered_parts` as a clean-slate baseline: replace parts,
|
|
124
|
+
# clear the id→index map (recovered parts have no OpenCode part IDs),
|
|
125
|
+
# and reset the running cost/token totals plus the first-text flag.
|
|
126
|
+
#
|
|
127
|
+
# Why reset totals: step-finish events that produced the pre-crash
|
|
128
|
+
# totals are not in the recovery payload; keeping them would
|
|
129
|
+
# double-count when post-recovery step-finish events accumulate
|
|
130
|
+
# against the same counters.
|
|
131
|
+
#
|
|
132
|
+
# Used only by the recovery path — during normal streaming, parts
|
|
133
|
+
# accrete via apply_* helpers and totals flow through step-finish.
|
|
134
|
+
def replace_parts(recovered_parts)
|
|
135
|
+
@parts = recovered_parts
|
|
136
|
+
@part_index_by_id.clear
|
|
137
|
+
@part_type_by_id.clear
|
|
138
|
+
@total_cost = 0.0
|
|
139
|
+
@total_input_tokens = 0
|
|
140
|
+
@total_output_tokens = 0
|
|
141
|
+
@first_text_seen = false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Bring the live reply up to a recovered/polled exchange snapshot and
|
|
145
|
+
# notify observers for new or changed parts. This is the streaming
|
|
146
|
+
# counterpart to replace_parts: when the SSE connection ends before
|
|
147
|
+
# OpenCode's multi-message tool loop has produced final text, Turn polls
|
|
148
|
+
# the message exchange. Those recovered parts still need to hit Turbo as
|
|
149
|
+
# incremental append/update events, not only the final row replacement.
|
|
150
|
+
def sync_recovered_parts(recovered_parts)
|
|
151
|
+
Array(recovered_parts).each_with_index do |part, index|
|
|
152
|
+
next if @parts[index] == part
|
|
153
|
+
|
|
154
|
+
part = deep_dup_part(part)
|
|
155
|
+
if index < @parts.length
|
|
156
|
+
@parts[index] = part
|
|
157
|
+
notify_recovered_part_updated(part, index)
|
|
158
|
+
else
|
|
159
|
+
@parts << part
|
|
160
|
+
notify(:part_added, part: part, index: index)
|
|
161
|
+
notify_recovered_part_updated(part, index)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
@first_text_seen ||= part["type"] == "text" && part["content"].present?
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Record a part that originated OUTSIDE the OpenCode event stream —
|
|
169
|
+
# used when an observer synthesizes a part (e.g., a session error
|
|
170
|
+
# notice) that isn't a real message.part.* event but should still
|
|
171
|
+
# appear in the persisted parts_json. Returns the new index.
|
|
172
|
+
#
|
|
173
|
+
# Does NOT fire part_added — the injecting observer has already done
|
|
174
|
+
# whatever rendering it needed. Other observers can poll `parts` if
|
|
175
|
+
# they care about injected content.
|
|
176
|
+
def inject_part(part_hash)
|
|
177
|
+
@parts << part_hash
|
|
178
|
+
@parts.size - 1
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def first_text_seen?
|
|
182
|
+
@first_text_seen
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def tool_count
|
|
186
|
+
@parts.count { |p| p["type"] == "tool" }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The denormalized result once streaming completes, matching the
|
|
190
|
+
# shape jobs persist to the message table: full_text for :content,
|
|
191
|
+
# reasoning_text for :reasoning, tool_parts for :tool_calls_json,
|
|
192
|
+
# and parts_json for :parts_json.
|
|
193
|
+
def result
|
|
194
|
+
self.class.distill(@parts)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Pure function: given a parts array, return the denormalized result
|
|
198
|
+
# as an Opencode::Reply::Result value object. Exposed so a recovery
|
|
199
|
+
# path (fetch messages from the session API and map them through
|
|
200
|
+
# ResponseParser.extract_interleaved_parts) produces the same shape
|
|
201
|
+
# as live streaming.
|
|
202
|
+
def self.distill(parts)
|
|
203
|
+
Result.new(
|
|
204
|
+
parts_json: parts,
|
|
205
|
+
full_text: join_content(parts, "text"),
|
|
206
|
+
reasoning_text: join_content(parts, "reasoning"),
|
|
207
|
+
tool_parts: parts.select { |p| p["type"] == "tool" && TERMINAL_TOOL_STATUSES.include?(p["status"]) }
|
|
208
|
+
)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self.join_content(parts, type)
|
|
212
|
+
parts.select { |p| p["type"] == type }.map { |p| p["content"].to_s }.join("\n\n")
|
|
213
|
+
end
|
|
214
|
+
private_class_method :join_content
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
def apply_part_delta(event)
|
|
219
|
+
field = event.dig(:properties, :field)
|
|
220
|
+
return unless %w[text reasoning].include?(field)
|
|
221
|
+
|
|
222
|
+
part_id = event.dig(:properties, :partID)
|
|
223
|
+
delta = event.dig(:properties, :delta).to_s
|
|
224
|
+
return if delta.empty?
|
|
225
|
+
|
|
226
|
+
index = @part_index_by_id[part_id]
|
|
227
|
+
if index.nil?
|
|
228
|
+
# Delta before part.updated. Pre-1.2 OpenCode streams occasionally
|
|
229
|
+
# emit in this order; downstream part.updated for this id will
|
|
230
|
+
# reconcile via reconcile_final_content.
|
|
231
|
+
type = @part_type_by_id[part_id] || (field == "reasoning" ? "reasoning" : "text")
|
|
232
|
+
index = append_part({ "type" => type, "content" => +"" }, part_id: part_id)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
@parts[index]["content"] << delta
|
|
236
|
+
@first_text_seen ||= (field == "text" && @parts[index]["type"] == "text")
|
|
237
|
+
|
|
238
|
+
notify(:part_changed, part: @parts[index], index: index, delta: delta)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def apply_part_updated(event)
|
|
242
|
+
part = event.dig(:properties, :part) || {}
|
|
243
|
+
part_id = part[:id]
|
|
244
|
+
part_type = part[:type]
|
|
245
|
+
|
|
246
|
+
case part_type
|
|
247
|
+
when "step-finish"
|
|
248
|
+
cost = part[:cost].to_f
|
|
249
|
+
tokens = part[:tokens] || {}
|
|
250
|
+
@total_cost += cost
|
|
251
|
+
@total_input_tokens += tokens[:input].to_i
|
|
252
|
+
@total_output_tokens += tokens[:output].to_i
|
|
253
|
+
notify(:step_finished, cost: cost, tokens: tokens)
|
|
254
|
+
when "text", "reasoning"
|
|
255
|
+
@part_type_by_id[part_id] = part_type if part_id
|
|
256
|
+
if @part_index_by_id.key?(part_id)
|
|
257
|
+
reconcile_final_content(part_id, part)
|
|
258
|
+
elsif part[:text].present?
|
|
259
|
+
# Extreme tail-drop path: part.updated carries the full text
|
|
260
|
+
# but no deltas ever arrived. Materialize it as a one-shot part
|
|
261
|
+
# so the content isn't lost.
|
|
262
|
+
append_part({ "type" => part_type, "content" => part[:text].dup }, part_id: part_id)
|
|
263
|
+
end
|
|
264
|
+
when "tool"
|
|
265
|
+
register_tool(part_id, part) unless @part_index_by_id.key?(part_id)
|
|
266
|
+
apply_tool_state(part_id, part)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def apply_message_updated(event)
|
|
271
|
+
info = event.dig(:properties, :info)
|
|
272
|
+
return unless info.is_a?(Hash)
|
|
273
|
+
|
|
274
|
+
@info = info
|
|
275
|
+
notify(:message_updated, info: info)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def apply_session_status(event)
|
|
279
|
+
case event.dig(:properties, :status, :type)
|
|
280
|
+
when "retry"
|
|
281
|
+
notify(:session_retried,
|
|
282
|
+
attempt: event.dig(:properties, :status, :attempt),
|
|
283
|
+
message: event.dig(:properties, :status, :message).to_s)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def apply_session_error(event)
|
|
288
|
+
error = event.dig(:properties, :error) || {}
|
|
289
|
+
name = error[:name].to_s
|
|
290
|
+
message = error.dig(:data, :message).to_s
|
|
291
|
+
text = [ name, message ].reject(&:blank?).join(": ")
|
|
292
|
+
|
|
293
|
+
notify(:session_errored, text: text, raw: error)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Close out a text/reasoning part: always fires :part_finalized so
|
|
297
|
+
# observers can flush any throttled broadcast, and rewrites content if
|
|
298
|
+
# part.updated carries an authoritative :text that diverges from the
|
|
299
|
+
# deltas we accumulated (tail-drop safety net for providers like
|
|
300
|
+
# z.ai GLM-5.1 that sometimes drop trailing deltas).
|
|
301
|
+
def reconcile_final_content(part_id, part)
|
|
302
|
+
index = @part_index_by_id[part_id]
|
|
303
|
+
final = part[:text]
|
|
304
|
+
return if final.blank?
|
|
305
|
+
|
|
306
|
+
@parts[index]["content"] = final.dup unless @parts[index]["content"] == final
|
|
307
|
+
notify(:part_finalized, part: @parts[index], index: index)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def register_tool(part_id, part)
|
|
311
|
+
append_part({
|
|
312
|
+
"type" => "tool",
|
|
313
|
+
"tool" => part[:tool],
|
|
314
|
+
"status" => part.dig(:state, :status)
|
|
315
|
+
}, part_id: part_id)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Merge an incoming `message.part.updated` event state into the
|
|
319
|
+
# existing tool record. Delegates the field-by-field shape to
|
|
320
|
+
# Opencode::ToolPart so the streaming and recovery paths share one
|
|
321
|
+
# canonical definition of what a tool part looks like.
|
|
322
|
+
def apply_tool_state(part_id, part)
|
|
323
|
+
index = @part_index_by_id[part_id]
|
|
324
|
+
return unless index
|
|
325
|
+
|
|
326
|
+
record = @parts[index]
|
|
327
|
+
Opencode::ToolPart.merge_streaming_state(record, part)
|
|
328
|
+
@todo_part_index = index if todo_tool_part?(record)
|
|
329
|
+
|
|
330
|
+
notify(:tool_progressed,
|
|
331
|
+
part: record,
|
|
332
|
+
index: index,
|
|
333
|
+
status: record["status"],
|
|
334
|
+
raw: part)
|
|
335
|
+
|
|
336
|
+
drain_pending_question_payload(record)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def apply_todo_updated(event)
|
|
340
|
+
todos = event.dig(:properties, :todos) || []
|
|
341
|
+
notify(:todos_changed, todos: todos)
|
|
342
|
+
return unless todos.is_a?(Array)
|
|
343
|
+
|
|
344
|
+
canonical_todos = Opencode::Todo.canonicalize_all(todos)
|
|
345
|
+
|
|
346
|
+
index = current_todo_part_index
|
|
347
|
+
if index
|
|
348
|
+
refresh_existing_todo_part(index, canonical_todos, event)
|
|
349
|
+
else
|
|
350
|
+
@todo_part_index = append_part(Opencode::PartSource.stamp({
|
|
351
|
+
"type" => "tool",
|
|
352
|
+
"tool" => "todowrite",
|
|
353
|
+
"status" => "completed",
|
|
354
|
+
"input" => { "todos" => canonical_todos }
|
|
355
|
+
}, source: Opencode::PartSource::TODO_UPDATED))
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Refresh path for an existing todo part — either a real `todowrite`
|
|
360
|
+
# tool part materialized from message.part.updated, OR our own
|
|
361
|
+
# previously-stamped stream-only part. Either way we MERGE into
|
|
362
|
+
# `input` rather than replace it, so any non-todos fields a real
|
|
363
|
+
# tool call carried survive the refresh.
|
|
364
|
+
#
|
|
365
|
+
# We intentionally do NOT touch `part["title"]`. Upstream opencode's
|
|
366
|
+
# title is "N remaining todos" (a progress indicator like "2 todos"
|
|
367
|
+
# when 2 of 3 are still incomplete, "0 todos" when all done) and is
|
|
368
|
+
# set on the original message.part.updated event. Stomping it with
|
|
369
|
+
# our own value would clobber that semantic.
|
|
370
|
+
def refresh_existing_todo_part(index, canonical_todos, event)
|
|
371
|
+
part = @parts[index]
|
|
372
|
+
part["status"] = part["status"].presence || "completed"
|
|
373
|
+
part["input"] = (part["input"] || {}).merge("todos" => canonical_todos)
|
|
374
|
+
notify(:tool_progressed, part: part, index: index, status: part["status"], raw: event)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def current_todo_part_index
|
|
378
|
+
return @todo_part_index if @todo_part_index && todo_tool_part?(@parts[@todo_part_index])
|
|
379
|
+
|
|
380
|
+
@todo_part_index = @parts.rindex { |part| todo_tool_part?(part) }
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def todo_tool_part?(part)
|
|
384
|
+
part.is_a?(Hash) && part["type"] == "tool" && TODO_TOOLS.include?(part["tool"].to_s)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def deep_dup_part(part)
|
|
388
|
+
case part
|
|
389
|
+
when Hash
|
|
390
|
+
part.transform_values { |value| deep_dup_part(value) }
|
|
391
|
+
when Array
|
|
392
|
+
part.map { |value| deep_dup_part(value) }
|
|
393
|
+
else
|
|
394
|
+
part.duplicable? ? part.dup : part
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def notify_recovered_part_updated(part, index)
|
|
399
|
+
case part["type"]
|
|
400
|
+
when "tool"
|
|
401
|
+
notify(:tool_progressed, part: part, index: index, status: part["status"], raw: {})
|
|
402
|
+
when "text", "reasoning"
|
|
403
|
+
notify(:part_finalized, part: part, index: index)
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def append_part(part_hash, part_id: nil)
|
|
408
|
+
@parts << part_hash
|
|
409
|
+
index = @parts.size - 1
|
|
410
|
+
if part_id
|
|
411
|
+
@part_index_by_id[part_id] = index
|
|
412
|
+
@part_type_by_id[part_id] = part_hash["type"]
|
|
413
|
+
end
|
|
414
|
+
notify(:part_added, part: @parts[index], index: index)
|
|
415
|
+
index
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def notify(callback, **payload)
|
|
419
|
+
@observers.each do |observer|
|
|
420
|
+
observer.public_send(callback, **payload) if observer.respond_to?(callback)
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# --- interactive prompts -----------------------------------------
|
|
425
|
+
|
|
426
|
+
def apply_question_asked(event)
|
|
427
|
+
request = (event[:properties] || {}).dup
|
|
428
|
+
return unless request[:id].is_a?(String)
|
|
429
|
+
|
|
430
|
+
@prompts.record_question(request)
|
|
431
|
+
|
|
432
|
+
if (tool = request[:tool])
|
|
433
|
+
@pending_question_payloads[[ tool[:messageID].to_s, tool[:callID].to_s ]] = request
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
merge_pending_question_into_existing_tool_part(request)
|
|
437
|
+
|
|
438
|
+
notify(:question_asked, request: request, raw: event)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def apply_question_replied(event)
|
|
442
|
+
props = event[:properties] || {}
|
|
443
|
+
request_id = props[:requestID]
|
|
444
|
+
answers = props[:answers] || []
|
|
445
|
+
return unless request_id
|
|
446
|
+
|
|
447
|
+
asked_at = @prompts.asked_at(request_id)
|
|
448
|
+
@prompts.resolve(request_id)
|
|
449
|
+
notify(:question_replied, request_id: request_id, answers: answers, raw: event, asked_at: asked_at)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def apply_question_rejected(event)
|
|
453
|
+
props = event[:properties] || {}
|
|
454
|
+
request_id = props[:requestID]
|
|
455
|
+
return unless request_id
|
|
456
|
+
|
|
457
|
+
asked_at = @prompts.asked_at(request_id)
|
|
458
|
+
@prompts.resolve(request_id)
|
|
459
|
+
notify(:question_rejected, request_id: request_id, raw: event, asked_at: asked_at)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def apply_permission_asked(event)
|
|
463
|
+
request = (event[:properties] || {}).dup
|
|
464
|
+
return unless request[:id].is_a?(String)
|
|
465
|
+
|
|
466
|
+
@prompts.record_permission(request)
|
|
467
|
+
notify(:permission_asked, request: request, raw: event)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def apply_permission_replied(event)
|
|
471
|
+
props = event[:properties] || {}
|
|
472
|
+
request_id = props[:requestID]
|
|
473
|
+
return unless request_id
|
|
474
|
+
|
|
475
|
+
asked_at = @prompts.asked_at(request_id)
|
|
476
|
+
@prompts.resolve(request_id)
|
|
477
|
+
notify(:permission_replied,
|
|
478
|
+
request_id: request_id,
|
|
479
|
+
reply: props[:reply],
|
|
480
|
+
raw: event,
|
|
481
|
+
asked_at: asked_at)
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Merge a pending question payload into the matching tool part if
|
|
485
|
+
# the tool part exists. Reads record["callID"] / record["messageID"]
|
|
486
|
+
# which are persisted by ToolPart.merge_streaming_state (per Task 2.0).
|
|
487
|
+
# Decorates the part's "input" with both the question content AND the
|
|
488
|
+
# opencode identifiers the view + controller need.
|
|
489
|
+
#
|
|
490
|
+
# Called from two paths:
|
|
491
|
+
# 1. apply_question_asked, when the tool part already exists
|
|
492
|
+
# 2. apply_tool_state, when the tool part arrives AFTER question.asked
|
|
493
|
+
def merge_pending_question_into_existing_tool_part(request)
|
|
494
|
+
tool = request[:tool]
|
|
495
|
+
return unless tool
|
|
496
|
+
|
|
497
|
+
call_id = tool[:callID].to_s
|
|
498
|
+
message_id = tool[:messageID].to_s
|
|
499
|
+
return if call_id.empty?
|
|
500
|
+
|
|
501
|
+
index = @parts.index do |part|
|
|
502
|
+
part.is_a?(Hash) && part["type"] == "tool" && part["tool"] == "question" &&
|
|
503
|
+
part["callID"] == call_id
|
|
504
|
+
end
|
|
505
|
+
return unless index
|
|
506
|
+
|
|
507
|
+
part = @parts[index]
|
|
508
|
+
# Stringify keys so the in-memory shape matches what's persisted
|
|
509
|
+
# via the parts_json JSON column round-trip. Otherwise direct-render
|
|
510
|
+
# callers (e.g., integration tests, future debug tooling) hit
|
|
511
|
+
# symbol-keyed nested hashes while the partials read string keys —
|
|
512
|
+
# silent broken HTML.
|
|
513
|
+
input = (part["input"] || {}).merge(
|
|
514
|
+
"questions" => deep_stringify_keys(request[:questions]),
|
|
515
|
+
"opencode_request_id" => request[:id],
|
|
516
|
+
"opencode_message_id" => message_id,
|
|
517
|
+
"opencode_call_id" => call_id
|
|
518
|
+
)
|
|
519
|
+
part["input"] = input
|
|
520
|
+
|
|
521
|
+
notify(:tool_progressed, part: part, index: index, status: part["status"],
|
|
522
|
+
raw: { type: "question.asked.synthesized" })
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Order-race fix: if question.asked arrived before this tool part,
|
|
526
|
+
# its payload is parked in @pending_question_payloads keyed by
|
|
527
|
+
# {messageID, callID}. Drain it now so the part's input carries
|
|
528
|
+
# the questions + opencode_* identifiers the view expects.
|
|
529
|
+
def drain_pending_question_payload(record)
|
|
530
|
+
return unless record["tool"] == "question" && record["callID"].present?
|
|
531
|
+
|
|
532
|
+
key = [ record["messageID"].to_s, record["callID"].to_s ]
|
|
533
|
+
pending = @pending_question_payloads.delete(key)
|
|
534
|
+
merge_pending_question_into_existing_tool_part(pending) if pending
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Recursively converts hash keys to strings — used at the SSE/JSON
|
|
538
|
+
# boundary so in-memory parts match the shape they have after a
|
|
539
|
+
# parts_json (JSON column) round-trip. Same semantics as Rails'
|
|
540
|
+
# Hash#deep_stringify_keys but iterates arrays too.
|
|
541
|
+
def deep_stringify_keys(obj)
|
|
542
|
+
case obj
|
|
543
|
+
when Hash then obj.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
|
|
544
|
+
when Array then obj.map { |x| deep_stringify_keys(x) }
|
|
545
|
+
else obj
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
end
|