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.
@@ -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