* fix(frontend): strip and parse the <current_uploads> upload context tag
#4174 renamed the upload context block that UploadsMiddleware prepends to
the user message from <uploaded_files> to <current_uploads>, but the
frontend tag vocabulary was not updated, so the raw block (file list plus
usage guidance) rendered inside the user's chat bubble and the file-chip
content fallback stopped matching. Add the new tag to
stripUploadedFilesTag, INTERNAL_MARKER_TAGS (covers export and streamdown
preprocessing), parseUploadedFiles, and the chip fallback detection.
<uploaded_files> stays supported for history and IM-channel messages.
* fix(frontend): parse upload-context sizes to bytes for the file chip
parseUploadedFiles stored the raw leading number of the human-readable
size (e.g. parseInt("177.6 KB") -> 177) into FileInMessage.size, which
is documented as bytes. On the content-fallback chip path (history/IM
messages without additional_kwargs.files) formatBytes then rendered a
177.6 KB file as "0.2 KB".
Convert the backend's "<n> KB" / "<n> MB" form back to bytes so the chip
re-renders at the original magnitude; update the parse tests to assert
byte values.
* fix(frontend): keep leading orphan tool messages visible
#3880 stopped dropping orphan tool messages that arrive after a terminal
group, but left the leading-orphan branch dropping the message with a
console.error on every render. The case is reachable: history pagination
cuts by event seq, not turn boundaries, so the first loaded page can
begin mid-turn with tool results whose AI tool-call message sits on an
unloaded older page — in dev mode the diagnostic surfaces as a
full-screen Next.js error overlay (#4399). Open a processing group for
the leading orphan instead; loading the older page re-groups it under
its real turn.
* test(frontend): lock leading-orphan accumulation invariant; clarify self-heal comment
Address review on #4408:
- The self-heal note only holds for the pagination path. The
hidden-only-precedent case and a truly orphaned tool have no older page to
load, so the processing group persists and renders as an empty
ChainOfThought shell (convertToSteps emits steps only for type === ai).
Document that as an accepted degradation instead of implying it always
self-heals.
- Add a test locking the accumulation invariant: a leading orphan followed by
a real tool-call turn folds into one processing group, not a stranded empty
group beside the real turn.
A reasoning+content AI message with no tool calls was grouped into both an
`assistant:processing` group (the ChainOfThought panel above the bubble) and an
`assistant` group (the bubble's own Reasoning collapsible), so its reasoning
rendered twice. Such a message now becomes only the assistant bubble; the
ChainOfThought panel keeps handling intermediate reasoning and tool steps.
Closes#3868
* fix(frontend): keep orphan tool messages visible
LangGraph `messages-tuple` stream mode can emit tool-result events
out of order or replay them from subagent state (e.g. the bash subagent
under LocalSandboxProvider with allow_host_bash: true). When that
happens, the tool message arrives after a terminal assistant/human
group, so getMessageGroups' lastOpenGroup() returns null.
The previous behaviour was console.error + drop, which silently hid
the tool result from the UI - the user could not see the tool output
or tool-call records even though the agent executed the action
correctly (backend + Langfuse trace were both fine).
Fallback now attaches the orphan tool message to the most recent group
so the UI shows it. Adds a unit test that covers the orphan-tool path
and a duplicate-stream regression test.
* test(frontend): make orphan-tool replay test actually reach the fallback
The previous fixture was `human → ai(tool_calls) → tool → tool(replay)`
with no terminal group in between, so both tool messages hit the
unchanged happy path (`open.messages.push(...)`). Neither message was
an orphan, the new `else if (groups.length > 0)` fallback was never
exercised, and the `>= 1` length assertion was satisfied trivially by
t-1a alone.
Interleave a terminal assistant message between the original tool
result and the replayed one, so t-1b arrives when lastOpenGroup()
returns null. Now the strict assertion that t-1b is reachable can only
pass via the new fallback branch.
Review feedback from @willem-bd on PR #3880.
* fix(frontend): satisfy noUncheckedIndexedAccess in orphan-tool fallback
The fallback branch pushed into `groups[groups.length - 1].messages`
directly, which trips `noUncheckedIndexedAccess` under the project's
strict TS config and breaks lint-frontend, e2e-tests, and the
full-stack render CI layer.
Take `groups[groups.length - 1]` into a local `lastGroup` and check
for `undefined` explicitly. The `else if (groups.length > 0)` guard
becomes redundant once `lastGroup` is checked, so the branches are
folded into the outer `else` to keep the structure flat. Behavior is
unchanged: orphan tools still attach to the most recent group, and
the empty-groups diagnostic still logs at ERROR level.
Review feedback from @willem-bd on PR #3880.
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
* fix(frontend): render full content for multi-part AI messages
Gemini streams the first content block as a {type:text} object carrying
the thinking signature, then emits continuation deltas as bare strings.
The content extractors only kept {type:text} objects and dropped the
string parts, truncating each AI message to its first block.
Handle string elements in extractContentFromMessage, extractTextFromMessage,
and textOfMessage so the full message renders.
Fixes#1000
* test(frontend): cover multi-part AI message content extraction
Add a regression test for the #1000 truncation: an AI message whose
content is [{type:text, signature}, "bare string"] (Gemini's shape after
LangChain merge_content). Fails on main, passes with the extractor fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(frontend): cover textOfMessage multi-part content extraction
Add tests for the bare-string continuation case and the null-when-empty
contract, and document why textOfMessage joins flat ("") while the body
extractors join with "\n". Addresses review feedback on #3649.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(frontend): strip unclosed <think> tags from streaming AI content
During streaming, an opening <think> tag may arrive in one chunk
while the matching </think> arrives in a later chunk. The existing
splitInlineReasoning regex only matched fully closed pairs, so the
mid-flight reasoning was left in message.content and rendered into
the chat bubble via the markdown pipeline's rehypeRaw plugin until
the closing tag landed.
Extend splitInlineReasoning with a second pass: after stripping every
closed <think>...</think> pair, route any remaining content from a
lone opener to the reasoning slot and leave only the preceding
preamble in content. Closed-tag behavior is unchanged.
Covers every provider whose stream emits reasoning inline as <think>
tags (MiniMax streaming path, MindIE, PatchedChatOpenAI, and any
gateway-served DeepSeek/OpenAI-compatible model).
* style(frontend): apply prettier formatting to streaming reasoning tests
* fix(frontend): skip <think> split for literal think tags in inline code
Treats a `<think>` opener immediately preceded by a backtick as part of
markdown inline code rather than a streaming reasoning marker. Prevents
permanent content truncation when an AI message documents the `<think>`
tag literally (e.g. ``Use `<think>` markers``), where the streaming-safe
fallback would otherwise route the rest of the answer into the reasoning
panel because no `</think>` ever arrives.
Adds regression tests for both the post-stream and mid-stream cases.
* fix(middleware): Prevent todo completion reminder IMMessage leak (#2892)
* make format
* fix(middleware): Clear stale todo reminder counts (#2892)
* add size guard for _completion_reminder_counts and add a integration test