fix(chat): eager pending AssistantMessage to fix Turbo subscribe race (#1657) (#1658)

* fix(chat): persist eager pending assistant message to fix subscribe race

When the LLM replies in ~1-2s the assistant message broadcast could
fire before the client's Turbo stream subscription was established,
leaving the UI stuck on the thinking indicator while the response was
already persisted.

Create the AssistantMessage as `pending` synchronously in
`Chat#ask_assistant_later`, so it is rendered server-side on the chat
show page with a "Thinking ..." inline placeholder. The worker then
finds and updates the existing row via `append_text!`, which flips the
status to `complete` and broadcasts updates against a DOM id that is
already in the page — no race possible. On error, the placeholder is
destroyed if no content streamed, otherwise demoted to `failed`.

Replaces the standalone thinking indicator partial and the
`Assistant::Broadcastable` thinking helpers, both now redundant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): bind each assistant job to its specific pending placeholder

Addressing review feedback on #1658:

1. The pending placeholder lookup based on `last pending` was racy —
   back-to-back user messages would let one job fill another job's
   placeholder. Pass the placeholder through the job arguments
   (`AssistantResponseJob.perform_later(user_message, pending)`) so
   each turn is bound to its own row.

2. In `Assistant::External#respond_to`, the configured/authorized
   guards raise before the local was bound, leaving rescue cleanup
   with `nil` and the placeholder visible forever. Bind the parameter
   first so cleanup can destroy it on the misconfigured path.

The kwarg defaults to nil so the API#retry path
(`AssistantResponseJob.perform_later(new_message)`) and the model-level
test calls continue to work — they fall back to an in-memory new
message, restoring the original test count assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): i18n the pending assistant placeholder string

Move the hardcoded "Thinking ..." indicator into the locale file per
CLAUDE.md i18n guidelines. With i18n.fallbacks enabled, non-en locales
fall back to English until translated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add thinking label translations

* Fix chat pending assistant expectations

* Fix external assistant pending test lookup

* Scope chat stream targets per chat

* Update message broadcast target tests

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal Tajchert
2026-05-03 20:33:29 +02:00
committed by GitHub
parent 50936000e7
commit ccd6a53071
28 changed files with 91 additions and 92 deletions

View File

@@ -37,7 +37,7 @@ class Api::V1::MessagesControllerTest < ActionDispatch::IntegrationTest
end
test "should create message with write scope" do
assert_difference "Message.count" do
assert_difference "UserMessage.count" do
post "/api/v1/chats/#{@chat.id}/messages",
params: { content: "Test message", model: "gpt-4" },
headers: bearer_auth_header(@write_token)

View File

@@ -12,7 +12,7 @@ class AssistantMessageTest < ActiveSupport::TestCase
streams = capture_turbo_stream_broadcasts(@chat)
assert_equal 2, streams.size
assert_equal "append", streams.first["action"]
assert_equal "messages", streams.first["target"]
assert_equal @chat.messages_target, streams.first["target"]
assert_equal "update", streams.last["action"]
assert_equal "assistant_message_#{message.id}", streams.last["target"]
end

View File

@@ -226,11 +226,13 @@ class AssistantTest < ActiveSupport::TestCase
"EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat",
"EXTERNAL_ASSISTANT_TOKEN" => "test-token"
) do
assert_difference "AssistantMessage.count", 1 do
assistant.respond_to(@message)
assistant_message = pending_assistant_message
assert_no_difference "AssistantMessage.count" do
assistant.respond_to(@message, assistant_message: assistant_message)
end
response_msg = @chat.messages.where(type: "AssistantMessage").last
response_msg = assistant_message.reload
assert_equal "Your net worth is $124,200.", response_msg.content
assert_equal "ext-agent:main", response_msg.ai_model
end
@@ -368,12 +370,13 @@ class AssistantTest < ActiveSupport::TestCase
"EXTERNAL_ASSISTANT_TOKEN" => "test-token"
) do
assistant = Assistant::External.new(@chat)
assistant.respond_to(@message)
assistant_message = pending_assistant_message
assistant.respond_to(@message, assistant_message: assistant_message)
@chat.reload
assert_nil @chat.error
response = @chat.messages.where(type: "AssistantMessage").last
response = assistant_message.reload
assert_equal "Based on your accounts, your net worth is $50,000.", response.content
assert_equal "ext-agent:main", response.ai_model
end
@@ -414,9 +417,10 @@ class AssistantTest < ActiveSupport::TestCase
"EXTERNAL_ASSISTANT_URL" => "http://localhost:18789/v1/chat",
"EXTERNAL_ASSISTANT_TOKEN" => "test-token"
) do
assistant.respond_to(@message)
assistant_message = pending_assistant_message
assistant.respond_to(@message, assistant_message: assistant_message)
response = @chat.messages.where(type: "AssistantMessage").last
response = assistant_message.reload
assert_equal "ext-agent:custom", response.ai_model
end
end
@@ -536,6 +540,10 @@ class AssistantTest < ActiveSupport::TestCase
capture
end
def pending_assistant_message
@chat.messages.where(type: "AssistantMessage", status: "pending").order(:created_at).last
end
def provider_function_request(id:, call_id:, function_name:, function_args:)
Provider::LlmConcept::ChatFunctionRequest.new(
id: id,

View File

@@ -18,14 +18,25 @@ class ChatTest < ActiveSupport::TestCase
assert_equal 3, chat.conversation_messages.count
end
test "uses chat-scoped stream targets" do
first_chat = chats(:one)
second_chat = chats(:two)
assert_not_equal "messages", first_chat.messages_target
assert_not_equal "chat-error", first_chat.error_target
assert_not_equal first_chat.messages_target, second_chat.messages_target
assert_not_equal first_chat.error_target, second_chat.error_target
end
test "creates with initial message" do
prompt = "Test prompt"
assert_difference "@user.chats.count", 1 do
chat = @user.chats.start!(prompt, model: "gpt-4.1")
assert_equal 1, chat.messages.count
assert_equal 2, chat.messages.count
assert_equal 1, chat.messages.where(type: "UserMessage").count
assert_equal 1, chat.messages.where(type: "AssistantMessage", status: "pending").count
end
end
@@ -35,8 +46,8 @@ class ChatTest < ActiveSupport::TestCase
assert_difference "@user.chats.count", 1 do
chat = @user.chats.start!(prompt, model: nil)
assert_equal 1, chat.messages.count
assert_equal Provider::Openai::DEFAULT_MODEL, chat.messages.first.ai_model
assert_equal 2, chat.messages.count
assert_equal Provider::Openai::DEFAULT_MODEL, chat.messages.find_by!(type: "UserMessage").ai_model
end
end
@@ -46,8 +57,8 @@ class ChatTest < ActiveSupport::TestCase
assert_difference "@user.chats.count", 1 do
chat = @user.chats.start!(prompt, model: "")
assert_equal 1, chat.messages.count
assert_equal Provider::Openai::DEFAULT_MODEL, chat.messages.first.ai_model
assert_equal 2, chat.messages.count
assert_equal Provider::Openai::DEFAULT_MODEL, chat.messages.find_by!(type: "UserMessage").ai_model
end
end
@@ -57,7 +68,7 @@ class ChatTest < ActiveSupport::TestCase
with_env_overrides OPENAI_MODEL: "custom-model" do
chat = @user.chats.start!(prompt, model: "")
assert_equal "custom-model", chat.messages.first.ai_model
assert_equal "custom-model", chat.messages.find_by!(type: "UserMessage").ai_model
end
end

View File

@@ -14,7 +14,7 @@ class UserMessageTest < ActiveSupport::TestCase
streams = capture_turbo_stream_broadcasts(@chat)
assert_equal 2, streams.size
assert_equal "append", streams.first["action"]
assert_equal "messages", streams.first["target"]
assert_equal @chat.messages_target, streams.first["target"]
assert_equal "update", streams.last["action"]
assert_equal "user_message_#{message.id}", streams.last["target"]
end