openclacky 1.3.4 → 1.3.5

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  4. data/lib/clacky/agent/session_serializer.rb +3 -2
  5. data/lib/clacky/agent/tool_executor.rb +0 -12
  6. data/lib/clacky/agent.rb +74 -9
  7. data/lib/clacky/api_extension.rb +81 -0
  8. data/lib/clacky/api_extension_loader.rb +13 -1
  9. data/lib/clacky/client.rb +14 -17
  10. data/lib/clacky/default_agents/_panels/time_machine/panel.js +22 -0
  11. data/lib/clacky/default_agents/base_prompt.md +1 -0
  12. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  13. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  14. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  15. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  16. data/lib/clacky/default_skills/media-gen/SKILL.md +63 -0
  17. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  18. data/lib/clacky/json_ui_controller.rb +1 -1
  19. data/lib/clacky/media/base.rb +60 -0
  20. data/lib/clacky/media/dashscope.rb +385 -21
  21. data/lib/clacky/media/gemini.rb +9 -0
  22. data/lib/clacky/media/generator.rb +52 -0
  23. data/lib/clacky/media/openai_compat.rb +166 -0
  24. data/lib/clacky/null_ui_controller.rb +13 -0
  25. data/lib/clacky/plain_ui_controller.rb +1 -1
  26. data/lib/clacky/providers.rb +50 -2
  27. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  28. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  29. data/lib/clacky/server/http_server.rb +144 -9
  30. data/lib/clacky/server/session_registry.rb +4 -2
  31. data/lib/clacky/server/web_ui_controller.rb +3 -2
  32. data/lib/clacky/skill_loader.rb +14 -2
  33. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  34. data/lib/clacky/tools/terminal.rb +0 -43
  35. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  36. data/lib/clacky/ui2/ui_controller.rb +140 -31
  37. data/lib/clacky/ui_interface.rb +10 -1
  38. data/lib/clacky/utils/encoding.rb +25 -0
  39. data/lib/clacky/version.rb +1 -1
  40. data/lib/clacky/web/app.css +145 -22
  41. data/lib/clacky/web/components/onboard.js +1 -14
  42. data/lib/clacky/web/features/brand/view.js +8 -5
  43. data/lib/clacky/web/features/channels/store.js +1 -20
  44. data/lib/clacky/web/features/mcp/store.js +1 -20
  45. data/lib/clacky/web/features/profile/store.js +1 -13
  46. data/lib/clacky/web/features/profile/view.js +16 -4
  47. data/lib/clacky/web/features/skills/store.js +6 -21
  48. data/lib/clacky/web/features/version/store.js +2 -0
  49. data/lib/clacky/web/i18n.js +24 -1
  50. data/lib/clacky/web/index.html +15 -0
  51. data/lib/clacky/web/sessions.js +141 -51
  52. data/lib/clacky/web/settings.js +34 -2
  53. data/lib/clacky/web/ws-dispatcher.js +11 -3
  54. data/lib/clacky.rb +12 -5
  55. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d5879a7f2c6eb0a1846dd3049fa79abd3ca53119750bd0a43b337339f146dad
4
- data.tar.gz: 12a39e3d860e9e9e691424289543b73813bcb4652e11fe0719e72aa9e6c72760
3
+ metadata.gz: 5a7474760c07220891bc62795e95ab0f5b9f83387b4881e1cc9eec4133545222
4
+ data.tar.gz: '099b846d3a8b44af563403c05f5bc8c855b0925122aabeb3f01b77f8f0c8d18f'
5
5
  SHA512:
6
- metadata.gz: cdb47cb9da7cccae4329aa0d4b98f03d9e5cadf763c12b9cd255060e2f230154eed3e7404e494f9a0b7e7b54cdb569f2dc25a1ea69c0b8af386a4a1122942d65
7
- data.tar.gz: 196c48e418e2119201664b920871aa489ff3531128a8aa8b981f73d6ddc95a7d3ef23ecb79da0611810b8af02735f83340ce0712ddb18ad30de0f94421dbd0a4
6
+ metadata.gz: e494c9032f35cf631a91dbbff72c89004c1aa8991a6f630dc21c65a9d97b45bceb2afa9c69b2f25b0d4c0e909d317b64eff5ed7b972fd475edc2f19e81a2f779
7
+ data.tar.gz: 7fb32e090e6cfd780cd0b4218bbfb00a0f939ce77089cc124e35d03a8870abdbb27f09de976304526b8e905d14107fc861914ba08f80d447a72e4d24a6711c8f
data/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.5] - 2026-06-29
9
+
10
+ ### Added
11
+ - Run agent inside meeting sessions; launch new meetings directly from session context
12
+ - Long video generation support
13
+ - Show raw LLM error message in error card with collapsible detail
14
+ - Fake tool call detector to catch hallucinated tool invocations
15
+ - Accent color customization for Web UI theme
16
+
17
+ ### Improved
18
+ - Refine auto-confirm wait TUI display
19
+ - Improve split button UX and billing period tab hover behavior
20
+
21
+ ### Fixed
22
+ - Terminal: adaptive GBK/UTF-8 decoding for PTY output to fix garbled text on Windows
23
+ - Resolve vision model detection against the actual request model
24
+ - Fix FrozenError when pressing Ctrl+U to clear text in modal
25
+ - Detect scrollbar-drag correctly to prevent unwanted auto-scroll in chat
26
+ - Fix split button dropdown closing when clicking inside button wrap area
27
+ - Show success toast when saving profile or memory
28
+ - Preserve single line breaks in profile/memory preview
29
+ - Make text selection visible in profile/memory editor
30
+ - Preserve memory card expand state across re-render
31
+ - Refresh time machine on task completion
32
+ - Re-check version on WebSocket reconnect so upgrade badge updates
33
+ - Harden DashScope TTS routing and error handling
34
+
8
35
  ## [1.3.4] - 2026-06-25
9
36
 
10
37
  ### Added
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ class Agent
5
+ module FakeToolCallDetector
6
+ FAKE_TOOL_CALL_PATTERNS = [
7
+ /<\s*invoke\s+name\s*=\s*["'][\w\-]+["']/i,
8
+ /<\s*function_calls\s*>/i,
9
+ /<\s*tool_use\s*[\s>]/i,
10
+ /<\s*antml:invoke\s+name\s*=/i,
11
+ /<\s*antml:function_calls\s*>/i
12
+ ].freeze
13
+
14
+ MAX_FAKE_TOOL_CALL_RETRIES = 2
15
+
16
+ private def fake_tool_call_in_content?(content)
17
+ return false if content.nil? || content.empty?
18
+ FAKE_TOOL_CALL_PATTERNS.any? { |re| content.match?(re) }
19
+ end
20
+
21
+ private def handle_fake_tool_call(response)
22
+ @task_fake_tool_call_count = (@task_fake_tool_call_count || 0) + 1
23
+
24
+ Clacky::Logger.warn("agent.fake_tool_call_detected",
25
+ session_id: @session_id,
26
+ iteration: @iterations,
27
+ retry_count: @task_fake_tool_call_count,
28
+ content_head: response[:content].to_s[0, 200],
29
+ finish_reason: response[:finish_reason].to_s
30
+ )
31
+
32
+ if @task_fake_tool_call_count > MAX_FAKE_TOOL_CALL_RETRIES
33
+ @ui&.show_error("Model repeatedly emitted text-formatted tool calls instead of using the tool_calls API. Stopping.")
34
+ emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content]) if response[:content] && !response[:content].empty?
35
+ return :stop
36
+ end
37
+
38
+ @history.append({ role: "assistant", content: response[:content].to_s })
39
+ @history.append({
40
+ role: "user",
41
+ content: "Your previous reply contained tool-call XML written as text " \
42
+ "(e.g. `<invoke name=\"...\">`). That syntax is NOT executed — " \
43
+ "it was rendered to the user as raw text. " \
44
+ "Re-issue the call using the structured tool_calls field provided by the runtime, " \
45
+ "or, if no tool is needed, just answer normally.",
46
+ system_injected: true
47
+ })
48
+ :retry
49
+ end
50
+ end
51
+ end
52
+ end
@@ -153,7 +153,7 @@ module Clacky
153
153
  # @param status [Symbol] Status of the last task: :success, :error, or :interrupted
154
154
  # @param error_message [String] Error message if status is :error
155
155
  # @return [Hash] Session data ready for serialization
156
- def to_session_data(status: :success, error_message: nil, updated_at: nil, preserve_updated_at: false)
156
+ def to_session_data(status: :success, error_message: nil, raw_message: nil, updated_at: nil, preserve_updated_at: false)
157
157
  stats_data = {
158
158
  total_tasks: @total_tasks,
159
159
  total_iterations: @iterations,
@@ -167,7 +167,8 @@ module Clacky
167
167
  }
168
168
 
169
169
  # Add error message if status is error
170
- stats_data[:last_error] = error_message if status == :error && error_message
170
+ stats_data[:last_error] = error_message if status == :error && error_message
171
+ stats_data[:last_error_raw] = raw_message if status == :error && raw_message
171
172
 
172
173
  {
173
174
  session_id: @session_id,
@@ -254,18 +254,6 @@ module Clacky
254
254
  }
255
255
  end
256
256
 
257
- # Show countdown before auto-executing in auto_approve mode.
258
- # Gives the user time to see what's happening and Ctrl+C to cancel.
259
- # @param seconds [Integer] Countdown duration
260
- private def auto_approve_countdown(seconds: 10)
261
- return unless @ui
262
-
263
- seconds.downto(1) do |remaining|
264
- @ui.show_info(" Auto-executing in #{remaining}s... (Ctrl+C to cancel)", prefix_newline: false)
265
- sleep 1
266
- end
267
- end
268
-
269
257
  # Check if a tool is potentially slow and should show progress
270
258
  # @param tool_name [String] Name of the tool
271
259
  # @param args [Hash] Tool arguments
data/lib/clacky/agent.rb CHANGED
@@ -5,6 +5,7 @@ require "json"
5
5
  require "cgi"
6
6
  require "tty-prompt"
7
7
  require "set"
8
+ require_relative "null_ui_controller"
8
9
  require_relative "utils/arguments_parser"
9
10
  require_relative "utils/file_processor"
10
11
  require_relative "utils/environment_detector"
@@ -23,6 +24,7 @@ require_relative "agent/memory_updater"
23
24
  require_relative "agent/skill_evolution"
24
25
  require_relative "agent/skill_reflector"
25
26
  require_relative "agent/skill_auto_creator"
27
+ require_relative "agent/fake_tool_call_detector"
26
28
 
27
29
  module Clacky
28
30
  class Agent
@@ -39,6 +41,7 @@ module Clacky
39
41
  include SkillEvolution
40
42
  include SkillReflector
41
43
  include SkillAutoCreator
44
+ include FakeToolCallDetector
42
45
 
43
46
  attr_reader :session_id, :name, :history, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
44
47
  :cache_stats, :cost_source, :ui, :skill_loader, :agent_profile,
@@ -274,6 +277,7 @@ module Clacky
274
277
  else
275
278
  @start_time = Time.now
276
279
  @task_truncation_count = 0 # Reset truncation counter for each task
280
+ @task_fake_tool_call_count = 0 # Reset fake tool-call counter for each task
277
281
  @task_timeout_hint_injected = false # Reset read-timeout hint injection (see LlmCaller)
278
282
  @task_upstream_truncation_hint_injected = false # Reset upstream-truncation hint injection (see LlmCaller)
279
283
  @task_cost_source = :estimated # Reset for new task
@@ -488,6 +492,18 @@ module Clacky
488
492
  Clacky::Logger.warn("agent.think_response.log_failed", error: e.message)
489
493
  end
490
494
 
495
+ # Detect fake tool-calls written as XML/text in content (model bug
496
+ # where it emits `<invoke name="...">` instead of using the
497
+ # structured tool_calls field). Only triggers when tool_calls is
498
+ # absent — a real call alongside stray XML is not our problem here.
499
+ if (response[:tool_calls].nil? || response[:tool_calls].empty?) &&
500
+ fake_tool_call_in_content?(response[:content])
501
+ case handle_fake_tool_call(response)
502
+ when :retry then next
503
+ when :stop then break
504
+ end
505
+ end
506
+
491
507
  # Check if done (no more tool calls needed).
492
508
  #
493
509
  # Defensive rule: we ONLY exit on empty/missing tool_calls.
@@ -949,10 +965,9 @@ module Clacky
949
965
  end
950
966
 
951
967
  # Special handling for request_user_feedback
952
- if call[:name] == "request_user_feedback"
953
- # In auto_approve mode, give user time to see and cancel before auto-answering
954
- auto_approve_countdown(seconds: 10) if @config.permission_mode == :auto_approve
955
- else
968
+ # The interactive countdown (auto_approve) is handled after the tool
969
+ # executes, once the question itself has been rendered to the user.
970
+ unless call[:name] == "request_user_feedback"
956
971
  @ui&.show_tool_call(call[:name], redact_tool_args(call[:arguments]))
957
972
  end
958
973
 
@@ -1037,11 +1052,26 @@ module Clacky
1037
1052
  @ui&.show_tool_call(call[:name], call[:arguments])
1038
1053
 
1039
1054
  if @config.permission_mode == :auto_approve
1040
- # auto_approve means no human is watching (unattended/scheduled tasks).
1041
- # Inject an auto_reply so the LLM makes a reasonable decision and keeps going.
1042
- result = result.merge(
1043
- auto_reply: "No user is available. Please make a reasonable decision based on the context and continue."
1044
- )
1055
+ # auto_approve means the agent runs unattended by default, but a
1056
+ # human MAY be watching the terminal. Show a short interactive
1057
+ # countdown: if the user steps in, hand control over and wait for
1058
+ # their answer; otherwise auto-decide and keep going.
1059
+ countdown = @ui&.request_feedback_with_countdown(seconds: 10)
1060
+
1061
+ if @ui.nil? || countdown == :timeout
1062
+ result = result.merge(
1063
+ auto_reply: "No user is available. Please make a reasonable decision based on the context and continue."
1064
+ )
1065
+ elsif countdown.is_a?(String) && !countdown.strip.empty?
1066
+ # User stepped in and typed an answer right away. Route it through
1067
+ # the denied+feedback path so the agent responds to it immediately
1068
+ # instead of breaking and forcing the user to re-type.
1069
+ denied = true
1070
+ feedback = countdown
1071
+ else
1072
+ # User stepped in but gave no text — hand control back to the CLI.
1073
+ awaiting_feedback = true
1074
+ end
1045
1075
  else
1046
1076
  # confirm_all / confirm_safes — a human is present, truly wait for user input.
1047
1077
  awaiting_feedback = true
@@ -1288,6 +1318,41 @@ module Clacky
1288
1318
  @tool_registry.register(Tools::Browser.new)
1289
1319
  end
1290
1320
 
1321
+ # Run a one-off task on a forked subagent and return its final reply text,
1322
+ # WITHOUT mutating this (parent) agent's history. Used by extensions that
1323
+ # need a side analysis (e.g. meeting annotate) which must reuse the parent's
1324
+ # cached context + unified billing, but must NOT pollute the main conversation.
1325
+ #
1326
+ # The subagent deep-clones the parent history (cache prefix + task state), runs
1327
+ # to completion, and is discarded. Only the cost is merged back into the parent.
1328
+ #
1329
+ # @param task [String] The task/prompt for the subagent
1330
+ # @param model [String, nil] Model name ("lite" for the lite companion, nil = current)
1331
+ # @param forbidden_tools [Array<String>] Tool names to block at runtime
1332
+ # @return [String] Subagent's final assistant reply (empty string if none)
1333
+ def run_detached(task, model: nil, forbidden_tools: [])
1334
+ subagent = fork_subagent(
1335
+ model: model,
1336
+ forbidden_tools: forbidden_tools,
1337
+ system_prompt_suffix: "You are running a one-off background analysis. Do the task and return only the requested output. Do not ask follow-up questions."
1338
+ )
1339
+ # Detached runs must stay invisible: a real UI (e.g. WebUIController bound
1340
+ # to the parent's session_id) would broadcast the subagent's raw output
1341
+ # into the parent chat transcript. Swap in a no-op UI so nothing leaks.
1342
+ subagent.instance_variable_set(:@ui, NullUIController.new)
1343
+ parent_count = subagent.instance_variable_get(:@parent_message_count) || 0
1344
+ result = subagent.run(task)
1345
+
1346
+ @total_cost += result[:total_cost_usd] || 0.0
1347
+
1348
+ new_messages = subagent.history.to_a[parent_count..] || []
1349
+ new_messages
1350
+ .reverse
1351
+ .find { |m| m[:role] == "assistant" && m[:content] && !m[:content].to_s.empty? }
1352
+ &.dig(:content)
1353
+ .to_s
1354
+ end
1355
+
1291
1356
  # Fork a subagent with specified configuration
1292
1357
  # The subagent inherits all messages and tools from parent agent
1293
1358
  # Tools are not modified (for cache reuse), but forbidden tools are blocked at runtime via hooks
@@ -238,6 +238,87 @@ module Clacky
238
238
  @http_server&.instance_variable_get(:@agent_config)
239
239
  end
240
240
 
241
+ def registry
242
+ @http_server&.instance_variable_get(:@registry)
243
+ end
244
+
245
+ # Create a brand-new session and optionally kick off its first task.
246
+ # Returns the new session_id. When a prompt is given, the task is
247
+ # submitted immediately (the session starts running); display_message
248
+ # controls the user-facing bubble shown in place of the raw prompt.
249
+ def create_session(name: nil, prompt: nil, working_dir: nil, profile: "general",
250
+ source: :manual, display_message: nil)
251
+ error!("server not ready", status: 503) unless @http_server
252
+
253
+ session_id = @http_server.send(
254
+ :build_session,
255
+ name: name,
256
+ working_dir: working_dir,
257
+ profile: profile,
258
+ source: source
259
+ )
260
+
261
+ submit_task(session_id, prompt, display_message: display_message) if prompt && !prompt.strip.empty?
262
+
263
+ session_id
264
+ end
265
+
266
+ # Submit a prompt to an existing session for execution.
267
+ # The session must be idle; returns the session_id on success.
268
+ # Raises Halt (409) if the session is already running.
269
+ def submit_task(session_id, prompt, display_message: nil)
270
+ reg = registry
271
+ error!("server not ready", status: 503) unless reg
272
+
273
+ unless reg.exist?(session_id)
274
+ reg.ensure(session_id)
275
+ error!("session not found: #{session_id}", status: 404) unless reg.exist?(session_id)
276
+ end
277
+
278
+ session = reg.get(session_id)
279
+ error!("session is busy", status: 409) if session[:status] == :running
280
+
281
+ @http_server.send(:run_session_task, session_id, prompt, display_message: display_message)
282
+ session_id
283
+ end
284
+
285
+ # Run a one-off side task on an existing session's agent and return its
286
+ # reply text SYNCHRONOUSLY, without polluting the main conversation.
287
+ #
288
+ # Unlike submit_task (which enqueues a turn into the live conversation and
289
+ # returns immediately), this forks the session's agent — reusing its cached
290
+ # context and unified billing — runs the task to completion on the fork, and
291
+ # returns the fork's final reply. The main conversation is never touched.
292
+ #
293
+ # Strategy A (parent-busy → skip): if the session is currently running, or the
294
+ # server is at its concurrency limit, this returns { busy: true } without
295
+ # running. Callers (e.g. periodic analysis) should treat that as "try later".
296
+ #
297
+ # @param session_id [String]
298
+ # @param prompt [String]
299
+ # @param model [String, nil] "lite" for the lite companion, nil = current
300
+ # @param forbidden_tools [Array<String>] tool names blocked in the fork
301
+ # @return [Hash] { text: "..." } on success, or { busy: true } when skipped
302
+ def dispatch_to_session(session_id, prompt, model: nil, forbidden_tools: [])
303
+ reg = registry
304
+ error!("server not ready", status: 503) unless reg
305
+
306
+ unless reg.exist?(session_id)
307
+ reg.ensure(session_id)
308
+ error!("session not found: #{session_id}", status: 404) unless reg.exist?(session_id)
309
+ end
310
+
311
+ return { busy: true } if reg.respond_to?(:running_full?) && reg.running_full?
312
+
313
+ session = reg.get(session_id)
314
+ return { busy: true } if session[:status] == :running
315
+
316
+ agent = session[:agent]
317
+ error!("session agent not available", status: 503) unless agent
318
+
319
+ { text: agent.run_detached(prompt, model: model, forbidden_tools: forbidden_tools) }
320
+ end
321
+
241
322
  def server_start_time
242
323
  @http_server&.instance_variable_get(:@start_time)
243
324
  end
@@ -12,15 +12,27 @@ module Clacky
12
12
  # isolated: skipped with a logged warning, never aborts the load of others.
13
13
  module ApiExtensionLoader
14
14
  DEFAULT_DIR = File.expand_path("~/.clacky/api_ext")
15
+ BUILTIN_DIR = File.expand_path("../default_extensions", __FILE__)
15
16
  DISABLED_DIR = "_disabled"
16
17
 
17
18
  Result = Struct.new(:loaded, :skipped, keyword_init: true)
18
19
 
19
20
  class << self
20
- def load_all(dir: DEFAULT_DIR)
21
+ def load_all(dir: DEFAULT_DIR, builtin: true)
21
22
  result = Result.new(loaded: [], skipped: [])
22
23
  Clacky::ApiExtension.reset_registry!
23
24
 
25
+ # Load built-in (gem-shipped) extensions first (lowest priority)
26
+ if builtin && Dir.exist?(BUILTIN_DIR)
27
+ Dir.glob(File.join(BUILTIN_DIR, "*", "handler.rb")).sort.each do |handler_path|
28
+ ext_dir = File.dirname(handler_path)
29
+ ext_id = File.basename(ext_dir)
30
+ next if ext_id.start_with?("_")
31
+ load_one(ext_id, ext_dir, handler_path, result)
32
+ end
33
+ end
34
+
35
+ # Load user extensions (higher priority — same ext_id overwrites built-in)
24
36
  if Dir.exist?(dir)
25
37
  Dir.glob(File.join(dir, "*", "handler.rb")).sort.each do |handler_path|
26
38
  ext_dir = File.dirname(handler_path)
data/lib/clacky/client.rb CHANGED
@@ -34,11 +34,6 @@ module Clacky
34
34
  # some OpenRouter-compatible relays only honour Bearer — send both).
35
35
  @provider_id = provider_id
36
36
 
37
- # Determine vision support once at construction time.
38
- # Non-vision models (DeepSeek, Kimi, MiniMax, etc.) reject image_url
39
- # content blocks; the conversion layer strips them when this is false.
40
- @vision_supported = Providers.supports?(provider_id, :vision, model_name: @model)
41
-
42
37
  # Optional override for Faraday read_timeout (e.g. benchmark calls).
43
38
  # nil means use the default (300s for streaming).
44
39
  @read_timeout = read_timeout
@@ -343,9 +338,12 @@ module Clacky
343
338
  # OpenRouter proxies Claude with the same cache_control field convention as Anthropic direct.
344
339
  messages = apply_message_caching(messages) if caching_enabled
345
340
 
341
+ # Vision support is resolved against the request's actual model (which may
342
+ # differ from @model after a runtime switch or fallback override), so the
343
+ # conversion layer strips image_url blocks for non-vision models.
346
344
  body = MessageFormat::OpenAI.build_request_body(
347
345
  messages, model, tools, max_tokens, caching_enabled,
348
- vision_supported: @vision_supported,
346
+ vision_supported: Providers.supports?(@provider_id, :vision, model_name: model),
349
347
  reasoning_effort: reasoning_effort
350
348
  )
351
349
  return send_openai_stream_request(body, on_chunk) if on_chunk
@@ -641,35 +639,34 @@ module Clacky
641
639
  raise InsufficientCreditError.new(
642
640
  "[LLM] #{I18n.t("llm.error.insufficient_credit")}",
643
641
  error_code: "insufficient_credit",
644
- provider_id: @provider_id
642
+ provider_id: @provider_id,
643
+ raw_message: error_message
645
644
  )
646
645
  end
647
646
 
648
647
  case response.status
649
648
  when 400
650
- # Well-behaved APIs (Anthropic, OpenAI) never put quota/availability issues in 400.
651
- # However, some proxy/relay providers do — so we inspect the message first.
652
- # Also, Bedrock returns ThrottlingException as 400 instead of 429.
653
649
  if error_message.match?(/ThrottlingException|unavailable|quota/i)
654
650
  raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_400")}"
655
651
  end
656
652
 
657
- # True bad request — our message was malformed. Roll back history so the
658
- # broken message is not replayed on the next user turn.
659
653
  raise BadRequestError.new(
660
654
  "[LLM] Client request error: #{error_message}",
661
- display_message: "[LLM] #{I18n.t("llm.error.bad_request")}"
655
+ display_message: "[LLM] #{I18n.t("llm.error.bad_request")}",
656
+ raw_message: error_message
662
657
  )
663
- when 401 then raise AgentError, "[LLM] #{I18n.t("llm.error.invalid_api_key")}"
658
+ when 401
659
+ raise AgentError.new("[LLM] #{I18n.t("llm.error.invalid_api_key")}", raw_message: error_message)
664
660
  when 403
665
661
  i18n_key = "llm.error.403.#{error_code}"
666
662
  translated = I18n.t(i18n_key)
667
663
  translated = I18n.t("llm.error.403.default") if translated == i18n_key
668
- raise AgentError, "[LLM] #{translated}"
669
- when 404 then raise AgentError, "[LLM] #{I18n.t("llm.error.endpoint_not_found")}"
664
+ raise AgentError.new("[LLM] #{translated}", raw_message: error_message)
665
+ when 404
666
+ raise AgentError.new("[LLM] #{I18n.t("llm.error.endpoint_not_found")}", raw_message: error_message)
670
667
  when 429 then raise RetryableError, "[LLM] #{I18n.t("llm.error.rate_limit_429")}"
671
668
  when 500..599 then raise RetryableError, "[LLM] #{I18n.t("llm.error.server_error", status: response.status)}"
672
- else raise AgentError, "[LLM] #{I18n.t("llm.error.unexpected", status: response.status)}"
669
+ else raise AgentError.new("[LLM] #{I18n.t("llm.error.unexpected", status: response.status)}", raw_message: error_message)
673
670
  end
674
671
  end
675
672
 
@@ -19,6 +19,25 @@
19
19
  (() => {
20
20
  if (!window.Clacky || !Clacky.ext) return;
21
21
 
22
+ // The currently mounted panel's state, refreshed on every mount. A single WS
23
+ // hook (registered once below) reloads it when the active session completes a
24
+ // task, so new snapshots appear without a manual refresh. Kept as a closure
25
+ // singleton because WS.onEvent has no unsubscribe and the panel re-mounts on
26
+ // each session switch.
27
+ let _activeState = null;
28
+ let _wsHooked = false;
29
+
30
+ function _hookWs() {
31
+ if (_wsHooked || typeof WS === "undefined") return;
32
+ _wsHooked = true;
33
+ WS.onEvent((ev) => {
34
+ if (ev && ev.type === "complete" && _activeState &&
35
+ ev.session_id === _activeState.sessionId) {
36
+ loadHistory(_activeState);
37
+ }
38
+ });
39
+ }
40
+
22
41
  const t = (k, fallback) => {
23
42
  const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
24
43
  return (v && v !== k) ? v : fallback;
@@ -630,6 +649,9 @@
630
649
  d.mask.onclick = onMaskClick;
631
650
  document.addEventListener("keydown", onKey);
632
651
 
652
+ _activeState = state;
653
+ _hookWs();
654
+
633
655
  loadHistory(state);
634
656
  return root;
635
657
  }, {
@@ -9,6 +9,7 @@
9
9
 
10
10
  - **ALWAYS use `glob` tool to find files — NEVER use shell `find` command for file discovery**
11
11
  - **All operations default to the working directory** (shown in session context)
12
+ - **NEVER write tool calls as text** (e.g. `<invoke name=...>`, `<function_calls>`). Use the structured tool_calls field — text won't execute.
12
13
 
13
14
  ## Response Style
14
15