openclacky 1.5.11 → 1.5.13

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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/cost_tracker.rb +2 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +53 -2
  6. data/lib/clacky/agent/session_serializer.rb +72 -19
  7. data/lib/clacky/agent/time_machine.rb +10 -1
  8. data/lib/clacky/agent.rb +107 -26
  9. data/lib/clacky/agent_config.rb +40 -0
  10. data/lib/clacky/agent_profile.rb +5 -5
  11. data/lib/clacky/billing/billing_record.rb +3 -0
  12. data/lib/clacky/billing/billing_store.rb +31 -5
  13. data/lib/clacky/billing/platform_billing.rb +231 -0
  14. data/lib/clacky/brand_config.rb +4 -3
  15. data/lib/clacky/cancel_flag.rb +23 -0
  16. data/lib/clacky/cli.rb +4 -2
  17. data/lib/clacky/client.rb +107 -0
  18. data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
  19. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
  20. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
  21. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
  22. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
  23. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
  24. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  25. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
  26. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  27. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  28. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  29. data/lib/clacky/extension/packager.rb +4 -2
  30. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
  31. data/lib/clacky/extension/verifier.rb +14 -1
  32. data/lib/clacky/fanout.rb +49 -2
  33. data/lib/clacky/json_ui_controller.rb +3 -2
  34. data/lib/clacky/message_format/open_ai.rb +5 -1
  35. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  36. data/lib/clacky/message_history.rb +43 -1
  37. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  38. data/lib/clacky/plain_ui_controller.rb +15 -5
  39. data/lib/clacky/providers.rb +24 -7
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
  41. data/lib/clacky/server/channel/channel_manager.rb +93 -18
  42. data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
  43. data/lib/clacky/server/dir_picker.rb +154 -0
  44. data/lib/clacky/server/git_panel.rb +61 -9
  45. data/lib/clacky/server/http_server.rb +366 -42
  46. data/lib/clacky/server/web_ui_controller.rb +7 -5
  47. data/lib/clacky/session_manager.rb +74 -17
  48. data/lib/clacky/tools/ask_user.rb +3 -2
  49. data/lib/clacky/tools/terminal.rb +30 -3
  50. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  51. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  52. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  53. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  54. data/lib/clacky/ui2/ui_controller.rb +2 -2
  55. data/lib/clacky/ui_interface.rb +2 -2
  56. data/lib/clacky/utils/model_pricing.rb +92 -4
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +915 -142
  59. data/lib/clacky/web/components/code-editor.js +33 -1
  60. data/lib/clacky/web/components/composer.js +315 -0
  61. data/lib/clacky/web/components/custom-select.js +136 -0
  62. data/lib/clacky/web/components/mentions.js +655 -0
  63. data/lib/clacky/web/components/model-picker.js +527 -0
  64. data/lib/clacky/web/components/onboard.js +29 -55
  65. data/lib/clacky/web/core/aside.js +3 -3
  66. data/lib/clacky/web/core/ext.js +38 -0
  67. data/lib/clacky/web/features/billing/store.js +2 -6
  68. data/lib/clacky/web/features/billing/view.js +32 -16
  69. data/lib/clacky/web/features/extensions/view.js +22 -10
  70. data/lib/clacky/web/features/new-session/store.js +15 -1
  71. data/lib/clacky/web/features/new-session/view.js +80 -34
  72. data/lib/clacky/web/features/trash/view.js +31 -19
  73. data/lib/clacky/web/features/workspace/store.js +1 -0
  74. data/lib/clacky/web/features/workspace/view.js +766 -80
  75. data/lib/clacky/web/i18n.js +107 -17
  76. data/lib/clacky/web/index.html +125 -45
  77. data/lib/clacky/web/sessions.js +704 -635
  78. data/lib/clacky/web/settings.js +164 -190
  79. data/lib/clacky/web/skills.js +11 -17
  80. data/lib/clacky/web/ws-dispatcher.js +11 -33
  81. data/lib/clacky.rb +4 -0
  82. metadata +12 -1
data/lib/clacky/fanout.rb CHANGED
@@ -27,7 +27,14 @@ module Clacky
27
27
 
28
28
  # @param jobs [Array<#call>] each job is invoked with no arguments
29
29
  # @return [Array<Result>] one entry per job, aligned to the input order
30
- def run(jobs)
30
+ #
31
+ # If the calling thread is interrupted (e.g. Thread#raise AgentInterrupted
32
+ # when a newer task supersedes this one) while waiting on the workers, the
33
+ # interrupt is forwarded to every live worker so their jobs — subagents that
34
+ # would otherwise keep calling the LLM on detached threads — unwind through
35
+ # their own ensure blocks (phase_end, transcript capture). The optional
36
+ # on_cancel callback fires first so callers can flip a shared cancel flag.
37
+ def run(jobs, on_cancel: nil)
31
38
  return [] if jobs.empty?
32
39
 
33
40
  pending = build_queue(jobs)
@@ -37,7 +44,20 @@ module Clacky
37
44
  workers = Array.new([@max_concurrency, jobs.size].min) do
38
45
  Clacky::ThreadRegistry.spawn(name: "fanout-worker") { drain(pending, results, deadline) }
39
46
  end
40
- join_all(workers, deadline)
47
+
48
+ begin
49
+ join_all(workers, deadline)
50
+ rescue Exception => e # rubocop:disable Lint/RescueException
51
+ # Includes AgentInterrupted. The cleanup below must not itself be cut
52
+ # short by a second asynchronous Thread#raise, so defer any further
53
+ # interrupts on this thread until the workers have been unwound.
54
+ Thread.handle_interrupt(Object => :never) do
55
+ on_cancel&.call
56
+ drain_queue(pending)
57
+ interrupt_workers(workers)
58
+ end
59
+ raise e
60
+ end
41
61
 
42
62
  fill_unfinished(results)
43
63
  end
@@ -69,6 +89,33 @@ module Clacky
69
89
  Result.new(index: index, value: nil, error: e, duration: monotonic_now - started)
70
90
  end
71
91
 
92
+ private def drain_queue(pending)
93
+ loop { pending.pop(true) }
94
+ rescue ThreadError
95
+ nil
96
+ end
97
+
98
+ # Forward an interrupt to workers still running a job so they unwind through
99
+ # their own ensure blocks, then give them a bounded window to finish. A
100
+ # worker that ignores the raise is killed so the caller isn't held hostage.
101
+ INTERRUPT_GRACE_SECONDS = 5.0
102
+
103
+ private def interrupt_workers(workers)
104
+ live = workers.select(&:alive?)
105
+ live.each do |worker|
106
+ begin
107
+ worker.raise(Clacky::AgentInterrupted, "Fan-out batch cancelled")
108
+ rescue ThreadError
109
+ nil
110
+ end
111
+ end
112
+ deadline = monotonic_now + INTERRUPT_GRACE_SECONDS
113
+ live.each do |worker|
114
+ remaining = deadline - monotonic_now
115
+ worker.join(remaining.positive? ? remaining : 0) or worker.kill
116
+ end
117
+ end
118
+
72
119
  private def join_all(workers, deadline)
73
120
  workers.each do |worker|
74
121
  if deadline
@@ -28,7 +28,7 @@ module Clacky
28
28
 
29
29
  # === Output display ===
30
30
 
31
- def show_assistant_message(content, files:, interim: false)
31
+ def show_assistant_message(content, files:, interim: false, created_at: nil)
32
32
  return if (content.nil? || content.strip.empty?) && files.empty?
33
33
 
34
34
  data = { content: content.to_s }
@@ -78,8 +78,9 @@ module Clacky
78
78
  emit("token_usage", **token_data)
79
79
  end
80
80
 
81
- def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil)
81
+ def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil, task_id: nil)
82
82
  data = { iterations: iterations, cost: cost }
83
+ data[:task_id] = task_id if task_id
83
84
  data[:duration] = duration if duration
84
85
  data[:cache_stats] = cache_stats if cache_stats
85
86
  data[:awaiting_user_feedback] = awaiting_user_feedback if awaiting_user_feedback
@@ -215,7 +215,11 @@ module Clacky
215
215
  #
216
216
  # Zero side-effects: when reasoning_effort is nil/empty the body is
217
217
  # unchanged, preserving the provider default for all models.
218
- private_class_method def self.apply_reasoning_params(body, model, reasoning_effort)
218
+ #
219
+ # Single source of truth for reasoning param mapping — shared with
220
+ # MessageFormat::OpenAIResponses (the Responses API accepts the same
221
+ # top-level fields).
222
+ def self.apply_reasoning_params(body, model, reasoning_effort)
219
223
  effort_str = reasoning_effort.to_s
220
224
 
221
225
  if model.to_s.match?(/\Aglm-5[-.]3/i)
@@ -0,0 +1,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module MessageFormat
5
+ # Static helpers for the OpenAI Responses API (/v1/responses).
6
+ #
7
+ # The Responses API uses a different request/response shape than the
8
+ # classic Chat Completions API:
9
+ #
10
+ # - Request: `input` array of typed items (not `messages`)
11
+ # - Response: `output` array of typed items (not `choices[].message`)
12
+ # - Token limit field: `max_output_tokens` (not `max_tokens`)
13
+ # - Tool calls are standalone `function_call` output items
14
+ # - Tool results are `function_call_output` input items
15
+ # - Usage: `input_tokens` / `output_tokens` (not `prompt_tokens` / `completion_tokens`)
16
+ #
17
+ # This module converts between the canonical internal format (which is
18
+ # Chat Completions-shaped) and the Responses API shape.
19
+ module OpenAIResponses
20
+ module_function
21
+
22
+ # ── Request building ──────────────────────────────────────────────────────
23
+
24
+ # Build a Responses API request body from canonical messages.
25
+ #
26
+ # @param messages [Array<Hash>] canonical messages (OpenAI chat format)
27
+ # @param model [String]
28
+ # @param tools [Array<Hash>] OpenAI-style tool definitions
29
+ # @param max_tokens [Integer]
30
+ # @param caching_enabled [Boolean] kept for signature compatibility; the
31
+ # Responses API has no content-level cache_control field, so Clacky's
32
+ # cache markers are intentionally not applied on this path
33
+ # @param vision_supported [Boolean] whether the target model accepts images
34
+ # @param reasoning_effort [String, nil] reasoning effort level
35
+ # @return [Hash] Responses API request body
36
+ def build_request_body(messages, model, tools, max_tokens, _caching_enabled, vision_supported: true, reasoning_effort: nil)
37
+ input_items = messages.flat_map { |msg| convert_message_to_input_items(msg, vision_supported: vision_supported) }
38
+
39
+ body = {
40
+ model: model,
41
+ input: input_items,
42
+ max_output_tokens: max_tokens
43
+ }
44
+
45
+ if tools&.any?
46
+ # No cache_control markers here: the Responses API has no
47
+ # content-level cache_control field (that is Anthropic syntax).
48
+ # OpenAI's Responses prompt caching is automatic server-side;
49
+ # the request-level prompt_cache_key is a routing hint that does
50
+ # not map to Clacky's breakpoint convention. caching_enabled is
51
+ # kept in the signature for compatibility but intentionally
52
+ # unused on this path.
53
+ body[:tools] = convert_tools_to_responses_format(tools)
54
+ end
55
+
56
+ OpenAI.apply_reasoning_params(body, model, reasoning_effort)
57
+
58
+ body
59
+ end
60
+
61
+ # ── Canonical message -> Responses API input items ────────────────────────
62
+
63
+ # Convert a single canonical message into one or more Responses API
64
+ # input items.
65
+ #
66
+ # System messages become {type:"message", role:"developer"} items.
67
+ # Assistant messages with tool_calls are split into a message item
68
+ # plus separate function_call items.
69
+ # Tool result messages become function_call_output items.
70
+ #
71
+ # @param msg [Hash] canonical message
72
+ # @param vision_supported [Boolean]
73
+ # @return [Array<Hash>] Responses API input items
74
+ def convert_message_to_input_items(msg, vision_supported:)
75
+ role = msg[:role].to_s
76
+
77
+ # Tool result message -> function_call_output item
78
+ if role == "tool" && msg[:tool_call_id]
79
+ content = msg[:content]
80
+ content = JSON.generate(content) if content.is_a?(Array) || content.is_a?(Hash)
81
+ return [{
82
+ type: "function_call_output",
83
+ call_id: msg[:tool_call_id],
84
+ output: content.to_s
85
+ }]
86
+ end
87
+
88
+ items = []
89
+
90
+ # Map system -> developer (Responses API convention; "system" also works
91
+ # but "developer" is the documented role).
92
+ api_role = role == "system" ? "developer" : role
93
+
94
+ # Build the message item (skip when assistant message has no text
95
+ # content but only tool_calls - the Responses API rejects null content)
96
+ content = msg[:content]
97
+ has_content = content && !(content.is_a?(String) && content.empty?) &&
98
+ !(content.is_a?(Array) && content.empty?)
99
+
100
+ if has_content || role != "assistant"
101
+ items << {
102
+ type: "message",
103
+ role: api_role,
104
+ content: normalize_content(content, vision_supported: vision_supported)
105
+ }
106
+ end
107
+
108
+ # If the assistant message has tool_calls, emit separate function_call items
109
+ if role == "assistant" && msg[:tool_calls]
110
+ msg[:tool_calls].each do |tc|
111
+ func = tc[:function] || tc # Handle both nested and flat formats
112
+ items << {
113
+ type: "function_call",
114
+ call_id: tc[:id],
115
+ name: func[:name],
116
+ arguments: serialize_arguments(func[:arguments])
117
+ }
118
+ end
119
+ end
120
+
121
+ items
122
+ end
123
+
124
+ # Normalize canonical content to Responses API content format.
125
+ #
126
+ # String content passes through as-is.
127
+ # Array content: text blocks -> input_text, image_url -> input_image
128
+ # (non-vision models get a text placeholder for images).
129
+ #
130
+ # @param content [String, Array, nil]
131
+ # @param vision_supported [Boolean]
132
+ # @return [String, Array]
133
+ def normalize_content(content, vision_supported:)
134
+ return content.to_s unless content.is_a?(Array)
135
+
136
+ blocks = content.map { |b| normalize_block(b, vision_supported: vision_supported) }.compact
137
+ blocks = [{ type: "input_text", text: "..." }] if blocks.empty?
138
+ blocks
139
+ end
140
+
141
+ # Normalize a single content block for the Responses API input side.
142
+ #
143
+ # @param block [Hash] canonical content block
144
+ # @param vision_supported [Boolean]
145
+ # @return [Hash, nil]
146
+ def normalize_block(block, vision_supported:)
147
+ return block unless block.is_a?(Hash)
148
+
149
+ case block[:type]
150
+ when "text"
151
+ text = block[:text]
152
+ return nil if text.nil? || text.empty?
153
+
154
+ # Any cache_control marker (Anthropic syntax) is dropped here:
155
+ # the Responses API does not recognize content-level cache_control.
156
+ { type: "input_text", text: text }
157
+ when "image_url"
158
+ if vision_supported
159
+ # Responses API uses input_image with image_url sub-field
160
+ { type: "input_image", image_url: block[:image_url] }
161
+ else
162
+ { type: "input_text", text: "[Image content removed - current model does not support vision input]" }
163
+ end
164
+ else
165
+ block
166
+ end
167
+ end
168
+
169
+ # ── Response parsing ──────────────────────────────────────────────────────
170
+
171
+ # Parse a Responses API response into canonical internal format.
172
+ #
173
+ # @param data [Hash] parsed JSON response body
174
+ # @return [Hash] canonical response: { content, tool_calls, finish_reason, usage, raw_api_usage }
175
+ def parse_response(data)
176
+ output = data["output"] || []
177
+
178
+ # Extract text content from message-type output items
179
+ text_content = extract_output_text(output)
180
+
181
+ # Extract function calls from function_call-type output items
182
+ tool_calls = extract_function_calls(output)
183
+
184
+ # Parse usage (field names differ from Chat Completions)
185
+ usage = data["usage"] || {}
186
+ raw_api_usage = usage.dup
187
+
188
+ usage_data = {
189
+ prompt_tokens: usage["input_tokens"],
190
+ completion_tokens: usage["output_tokens"],
191
+ total_tokens: usage["total_tokens"] || (usage["input_tokens"].to_i + usage["output_tokens"].to_i)
192
+ }
193
+
194
+ # Responses API stores cache info under input_tokens_details
195
+ if (details = usage["input_tokens_details"])
196
+ usage_data[:cache_read_input_tokens] = details["cached_tokens"] if details["cached_tokens"].to_i > 0
197
+ usage_data[:cache_creation_input_tokens] = details["cache_write_tokens"] if details["cache_write_tokens"].to_i > 0
198
+ end
199
+
200
+ # OpenRouter may also send output_tokens_details
201
+ if (out_details = usage["output_tokens_details"])
202
+ usage_data[:reasoning_tokens] = out_details["reasoning_tokens"] if out_details["reasoning_tokens"].to_i > 0
203
+ end
204
+
205
+ usage_data[:api_cost] = usage["cost"] if usage["cost"]
206
+
207
+ # Determine finish_reason from status + output content
208
+ finish_reason = determine_finish_reason(data, tool_calls)
209
+
210
+ result = {
211
+ content: text_content,
212
+ tool_calls: tool_calls.empty? ? nil : tool_calls,
213
+ finish_reason: finish_reason,
214
+ usage: usage_data,
215
+ raw_api_usage: raw_api_usage
216
+ }
217
+
218
+ # Preserve reasoning content if present in the output
219
+ reasoning = extract_reasoning(output)
220
+ result[:reasoning_content] = reasoning if reasoning
221
+
222
+ result
223
+ end
224
+
225
+ # ── Tool result formatting ────────────────────────────────────────────────
226
+
227
+ # Format tool results into canonical messages to append to @messages.
228
+ # Returns canonical format (role: "tool") - conversion to Responses API
229
+ # function_call_output items happens inside convert_message_to_input_items
230
+ # on the next request.
231
+ #
232
+ # @param response [Hash] canonical response from parse_response
233
+ # @param tool_results [Array<Hash>] tool execution results
234
+ # @return [Array<Hash>] canonical tool messages
235
+ def format_tool_results(response, tool_results)
236
+ results_map = tool_results.each_with_object({}) { |r, h| h[r[:id]] = r }
237
+
238
+ response[:tool_calls].map do |tc|
239
+ result = results_map[tc[:id]]
240
+ raw_content = result ? result[:content] : { error: "Tool result missing" }.to_json
241
+
242
+ content = raw_content.is_a?(Array) ? JSON.generate(raw_content) : raw_content
243
+
244
+ {
245
+ role: "tool",
246
+ tool_call_id: tc[:id],
247
+ content: content
248
+ }
249
+ end
250
+ end
251
+
252
+ # ── Private helpers ───────────────────────────────────────────────────────
253
+
254
+ # The Responses API expects function_call arguments as a JSON *string*.
255
+ # Canonical tool_calls normally carry a JSON string already, but a Hash
256
+ # or Array value must be JSON-encoded — #to_s would emit a Ruby literal
257
+ # (e.g. {:city=>"Tokyo"}) that the tool side cannot parse.
258
+ private_class_method def self.serialize_arguments(arguments)
259
+ return JSON.generate(arguments) if arguments.is_a?(Hash) || arguments.is_a?(Array)
260
+
261
+ arguments.to_s
262
+ end
263
+
264
+ # Extract text content from output items.
265
+ # Looks for message-type items with output_text content blocks.
266
+ #
267
+ # @param output [Array<Hash>] response output items
268
+ # @return [String, nil]
269
+ private_class_method def self.extract_output_text(output)
270
+ texts = []
271
+
272
+ output.each do |item|
273
+ next unless item["type"] == "message"
274
+
275
+ content = item["content"]
276
+ next unless content.is_a?(Array)
277
+
278
+ content.each do |block|
279
+ next unless block["type"] == "output_text"
280
+ texts << block["text"].to_s
281
+ end
282
+ end
283
+
284
+ texts.empty? ? nil : texts.join
285
+ end
286
+
287
+ # Extract reasoning content from output items.
288
+ # Supports both shapes seen in the wild:
289
+ # 1. OpenAI official — a "reasoning" block inside a message item,
290
+ # with summary[] and/or content[] of reasoning_text blocks.
291
+ # 2. DeepSeek — a top-level "reasoning" item whose content array
292
+ # carries {"type":"reasoning_text","text":...} blocks.
293
+ # Prefers full reasoning text (reasoning_text blocks); falls back to
294
+ # summary text when only a summary is present.
295
+ #
296
+ # @param output [Array<Hash>] response output items
297
+ # @return [String, nil]
298
+ private_class_method def self.extract_reasoning(output)
299
+ texts = []
300
+
301
+ output.each do |item|
302
+ content = item["content"]
303
+
304
+ case item["type"]
305
+ when "reasoning"
306
+ next unless content.is_a?(Array)
307
+ before = texts.length
308
+ collect_reasoning_texts(content, texts)
309
+ collect_summary_texts(content, texts) if texts.length == before
310
+ when "message"
311
+ next unless content.is_a?(Array)
312
+ content.each do |block|
313
+ case block["type"]
314
+ when "reasoning"
315
+ before = texts.length
316
+ collect_reasoning_texts(block["content"], texts)
317
+ collect_summary_texts(block["summary"], texts) if texts.length == before
318
+ when "output_text"
319
+ # Some providers surface reasoning as output_text blocks
320
+ # carrying a summary attribute.
321
+ collect_summary_texts(block["summary"], texts)
322
+ end
323
+ end
324
+ end
325
+ end
326
+
327
+ texts.empty? ? nil : texts.join
328
+ end
329
+
330
+ private_class_method def self.collect_reasoning_texts(blocks, texts)
331
+ return unless blocks.is_a?(Array)
332
+ blocks.each do |b|
333
+ texts << b["text"].to_s if b.is_a?(Hash) && b["type"] == "reasoning_text" && b["text"]
334
+ end
335
+ end
336
+
337
+ private_class_method def self.collect_summary_texts(summary, texts)
338
+ return unless summary.is_a?(Array)
339
+ summary.each do |b|
340
+ texts << b["text"].to_s if b.is_a?(Hash) && b["type"] == "summary_text" && b["text"]
341
+ end
342
+ end
343
+
344
+ # Extract function calls from output items.
345
+ # Converts Responses API function_call items to canonical tool_calls format.
346
+ #
347
+ # @param output [Array<Hash>] response output items
348
+ # @return [Array<Hash>] canonical tool_calls
349
+ private_class_method def self.extract_function_calls(output)
350
+ # each_with_object instead of filter_map to stay Ruby 2.6 compatible.
351
+ output.each_with_object([]) do |item, calls|
352
+ next unless item["type"] == "function_call"
353
+
354
+ calls << {
355
+ id: item["call_id"],
356
+ type: "function",
357
+ name: item["name"],
358
+ arguments: item["arguments"].to_s
359
+ }
360
+ end
361
+ end
362
+
363
+ # Determine the canonical finish_reason from Responses API status + output.
364
+ #
365
+ # @param data [Hash] full response body
366
+ # @param tool_calls [Array<Hash>] extracted tool calls
367
+ # @return [String]
368
+ private_class_method def self.determine_finish_reason(data, tool_calls)
369
+ # If there are tool calls, the model wants to call tools
370
+ return "tool_calls" unless tool_calls.empty?
371
+
372
+ status = data["status"]
373
+ case status
374
+ when "completed" then "stop"
375
+ when "incomplete" then incomplete_finish_reason(data)
376
+ else "stop"
377
+ end
378
+ end
379
+
380
+ # Map an "incomplete" response to a canonical finish_reason via
381
+ # incomplete_details.reason: "content_filter" means the response was
382
+ # blocked by safety filtering (typically empty) and must not be
383
+ # mislabelled as a mere token limit ("length").
384
+ #
385
+ # @param data [Hash] full response body
386
+ # @return [String]
387
+ private_class_method def self.incomplete_finish_reason(data)
388
+ reason = data.dig("incomplete_details", "reason")
389
+ reason == "content_filter" ? "content_filter" : "length"
390
+ end
391
+ # Convert Chat Completions tool definitions to Responses API format.
392
+ # Chat Completions: {type: "function", function: {name:, description:, parameters:}}
393
+ # Responses API: {type: "function", name:, description:, parameters:}
394
+ # Tools already in flat format (no "function" key) pass through unchanged.
395
+ private_class_method def self.convert_tools_to_responses_format(tools)
396
+ tools.map do |tool|
397
+ func = tool[:function] || tool["function"]
398
+ next tool unless func.is_a?(Hash)
399
+ {
400
+ type: tool[:type] || tool["type"] || "function",
401
+ name: func[:name] || func["name"],
402
+ description: func[:description] || func["description"],
403
+ parameters: func[:parameters] || func["parameters"]
404
+ }
405
+ end
406
+ end
407
+ end
408
+ end
409
+ end
@@ -15,8 +15,11 @@ module Clacky
15
15
  subagent_instructions subagent_result subagent_transcript token_usage
16
16
  compressed_summary chunk_path truncated transient
17
17
  chunk_index chunk_count ext_events skill_command skill_command_display
18
+ display_files display_references
18
19
  ].freeze
19
20
 
21
+ INTERNAL_CONTENT_BLOCK_FIELDS = %i[image_path image_name].freeze
22
+
20
23
  # Cap on persisted ext_events per message. These are milestone events
21
24
  # (progress chatter is transient), so a handful per message is the norm —
22
25
  # the cap only exists to stop a runaway extension. Oldest are dropped first.
@@ -96,6 +99,43 @@ module Clacky
96
99
  self
97
100
  end
98
101
 
102
+ # Settle an interrupted fan-out whose assistant.tool_calls turn is dangling
103
+ # because the batch was cancelled before any tool result was written. Mirrors
104
+ # repair_tool_call_pairing (same helpers, same scan) but PERSISTS the result:
105
+ # for each unanswered tool_call_id it appends a real tool result into
106
+ # @messages marking it interrupted, riding any captured subagent trails on
107
+ # the matching one — the same anchor the normal completion path uses. This
108
+ # keeps the turn protocol-valid so drop_dangling_tool_calls! leaves it alone,
109
+ # and lets replay render the transcripts. transcripts_by_id maps
110
+ # tool_call_id => [trail, ...]. No-op unless the last message is a dangling
111
+ # assistant.tool_calls turn.
112
+ def settle_interrupted_tool_calls(transcripts_by_id = {})
113
+ return self unless pending_tool_calls?
114
+
115
+ assistant = @messages.last
116
+ expected_ids = Array(assistant[:tool_calls]).map { |tc| tc[:id] }.compact
117
+ answered = []
118
+ @messages.reverse_each do |m|
119
+ break if m.equal?(assistant)
120
+
121
+ answered.concat(tool_result_ids(m)) if tool_result_message?(m)
122
+ end
123
+
124
+ (expected_ids - answered).each do |id|
125
+ result = {
126
+ role: "tool",
127
+ tool_call_id: id,
128
+ content: '{"interrupted":true,"message":"Interrupted by user before completion"}',
129
+ task_id: assistant[:task_id],
130
+ created_at: Time.now.to_f
131
+ }
132
+ trails = transcripts_by_id[id]
133
+ result[:subagent_transcript] = Array(trails) if trails && !trails.empty?
134
+ @messages << deep_sanitize_utf8(result)
135
+ end
136
+ self
137
+ end
138
+
99
139
  # Append a custom extension event onto the most recent message so it is
100
140
  # persisted with the session and can be replayed after a reload.
101
141
  # Events are anchored to the last message: on replay they are re-emitted
@@ -320,7 +360,9 @@ module Clacky
320
360
  next nil
321
361
  end
322
362
 
323
- block.key?(:image_path) ? block.reject { |k, _| k == :image_path } : block
363
+ next block if (block.keys & INTERNAL_CONTENT_BLOCK_FIELDS).empty?
364
+
365
+ block.reject { |key, _| INTERNAL_CONTENT_BLOCK_FIELDS.include?(key) }
324
366
  end
325
367
 
326
368
  return msg if cleaned == content