Repository navigation
🪶 perf: Load Tool-Call Previews and Fetch Full Parts on Demand - #16753
Conversation
Long agent conversations shipped every tool output, argument string and subagent transcript on load, though the cards render collapsed: 12-18 MB of message JSON per chat, enough to crash iPhone Safari tabs. A client that adds ?toolPreviews=1 to the conversation load now receives bounded previews of settled tool calls (start and end of long outputs, JSON arguments and outputs kept valid with only their longest strings shortened, exit-status trailers kept whole, subagent transcripts omitted) with explicit markers and full lengths. Opening a card or a subagent panel fetches the stored part from a new owner-scoped endpoint, GET /api/messages/:conversationId/:messageId/parts/:partIndex, cached under its own React Query key. Clients that do not ask, exports and share links keep the full payload; bounds are configurable under toolCallPreviews in librechat.yaml, and a client-authored message that carries previews is refused rather than saved.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8fda87f087
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } | ||
| }); | ||
|
|
||
| router.get('/:conversationId/:messageId/parts/:partIndex', validateMessageReq, readToolCallPart); |
There was a problem hiding this comment.
Parallelize validation with the tool-part read
validateMessageReq awaits getConvo before readToolCallPart can start its user-scoped getMessages query, so every tool expansion incurs two serial database reads. This adds avoidable latency to the new on-demand path; use the existing prepared-validation pattern to start the message read alongside authorization, while still withholding the response until validation succeeds.
AGENTS.md reference: AGENTS.md:L186-L189
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in a322168. The part route no longer sits behind validateMessageReq: createToolCallPartHandler takes validate (the shared createMessageRequestValidation) and starts the user-scoped getMessages read beside it, then sends nothing until the verdict passes; a refused verdict is answered with the validation response and the read result is discarded. parts.spec.ts asserts the read starts before validation resolves and that a refusal never leaks the part.
| * every load in full, as before previews existed. | ||
| */ | ||
| export const toolCallPreviewsConfigSchema = z.object({ | ||
| enabled: z.boolean().default(true), |
There was a problem hiding this comment.
Keep tool-call previews opt-in by default
When an existing deployment upgrades without adding toolCallPreviews, this default automatically switches message loads from full records to preview/on-demand semantics. That changes the established response behavior and also exposes rolling deployments to expansion requests reaching an older replica that lacks the part endpoint; default the new capability to disabled so the absence of configuration preserves the prior full-load behavior.
AGENTS.md reference: AGENTS.md:L90-L92
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Keeping the default on, deliberately. The reason this lever exists is that long agent conversations crash mobile tabs on load; defaulting it off would leave every deployment with the crash unless an operator discovers the setting. What readers see is today's behavior: collapsed rows read the same verdicts from the preview (both edges kept, JSON kept valid, exit trailers whole), and opening a card renders the stored content. The wire contract is also unchanged for anything that does not ask: previews are opt-in per request (toolPreviews=1), so older clients, exports, share links and direct API callers get full payloads with no config at all. enabled: false restores full payloads byte for byte for operators who want that.
On rolling deploys: a client that loaded previews from a new replica and expands against an old one gets a 404 for the part, shows the error with Retry, and keeps the preview; the next attempt that lands on a new replica succeeds. No data is lost or shown wrong in that window.
| if (awaitsStoredPart && selection.durable == null && storedToolCall == null) { | ||
| panelState = storedPart.isError ? 'error' : 'loading'; |
There was a problem hiding this comment.
Add a retry path for previewed subagent parts
When the full-part request for a foreground subagent fails after its automatic retry, this branch renders the panel's generic error state without any way to call storedPart.refetch(). Because useToolCallPartQuery also disables refetch on mount, reconnect, and focus, closing and reopening the panel leaves the cached query permanently failed until a page reload; expose a retry action as the normal tool-card hydration path does.
AGENTS.md reference: AGENTS.md:L49-L52
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in a322168. When the stored part fails, the panel now renders an alert with a Retry button that calls storedPart.refetch(), next to the existing error state (and the activity still never falls back to the shortened output). Covered in SubagentThreadPanel.test.tsx.
| } | ||
| }); | ||
|
|
||
| router.get('/:conversationId/:messageId/parts/:partIndex', validateMessageReq, readToolCallPart); |
There was a problem hiding this comment.
Preserve active-job access for full-part reads
A conversation restored through the active-job fallback can be loaded and previewed by GET /:conversationId, but expanding one of those previews reaches this route and gets a 404: canReadActiveJobConversation explicitly refuses its fallback whenever req.params.messageId is present. This breaks on-demand hydration during the exact pre-conversation-save recovery window that the main message route supports, so the part endpoint needs equivalent active-job authorization while retaining its user-scoped message lookup.
AGENTS.md reference: AGENTS.md:L49-L52
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in a322168. createMessageRequestValidation takes { activeJobMessageRead: true }, which lets canReadActiveJobConversation apply its fallback for this route despite the :messageId param; ownership, tenant and liveness checks on the job are unchanged, and the message lookup stays user-scoped. Mutation and message-by-id routes keep refusing as before. The route spec now covers an owned live job (200), another user's job and another tenant's job (404), and a finished job (404).
| const parsed = parseJsonContainer(output); | ||
| const shrunk = parsed === undefined ? undefined : shrinkJsonToFit(parsed, maxChars); | ||
| return shrunk ?? previewOutputText(output, maxChars, commandTrailerLength(toolCall, output)); |
There was a problem hiding this comment.
Keep JSON previews parseable when structure exceeds the cap
When valid JSON cannot fit even with every string capped—such as a check_background_task result containing several task records—shrinkJsonToFit returns undefined and this fallback splices the raw JSON head and tail into invalid JSON. parseBackgroundTaskOutput then returns null, so the collapsed row loses task failure, partial-result, and warning semantics and can present an incomplete poll as an ordinary successful check; leave such calls unpreviewed or emit a valid bounded JSON representation instead of falling back to text.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in a322168 for the calls whose collapsed row parses the JSON: check_background_task output that cannot fit a valid structure-preserving preview is now sent whole instead of as text. Measured on the three repro conversations, 978 of 985 background-task outputs over the bound still preview as valid JSON; the other 7 now go whole. Other JSON tools (MCP lists and queries) are only parsed in the expanded body, which hydrates first, so they keep the bounded text fallback rather than shipping up to a megabyte each.
| /** | ||
| * Loads a conversation's messages for the shared `[messages, id]` cache, with settled tool calls | ||
| * as bounded previews. Every writer of that cache uses this, so the cache never flips between | ||
| * preview and full payloads; a reader that needs every byte (export) asks for a full load. | ||
| */ | ||
| export function fetchConversationMessages(conversationId: string): Promise<t.TMessage[]> { | ||
| return dataService.getMessagesByConvoId(conversationId, { toolPreviews: true }); |
There was a problem hiding this comment.
Keep fork and duplicate responses out of the preview cache
The new helper does not actually cover every writer of [messages, id]: useDuplicateConversationMutation, useForkConvoMutation, and useForkSharedConvoMutation still put their full data.messages responses directly into that cache. Forking or duplicating one of the tool-heavy conversations this change targets therefore downloads and retains the entire multi-megabyte history—the same memory spike that previews are meant to prevent—until a later refetch replaces it; make those responses preview-aware or avoid caching their full message arrays.
AGENTS.md reference: AGENTS.md:L186-L189
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in a322168. Fork, duplicate and shared-link fork requests now carry toolPreviews=1, and their routes apply withToolCallPreviews (using the config the route already resolved), so the messages they return to seed [messages, id] are previews too. Opening a card in the new conversation fetches from the part endpoint, which the requester owns.
| /** | ||
| * Tools whose cards render their full record without an expand step, so a preview would | ||
| * be what the reader sees: the question-and-answer record reads both fields directly. | ||
| */ | ||
| const FULL_CONTENT_TOOLS: ReadonlySet<string> = new Set(['ask_user_question']); |
There was a problem hiding this comment.
Hydrate image-generation details before showing them
Successful image_gen_oai, image_edit_oai, and gemini_image_gen calls bypass every disclosure hook that requests full content, but they are not included in this exemption set. Detailed image prompts commonly exceed the 512-character argument bound, so the image details dialog receives the shortened args.prompt and permanently presents it as the original prompt with no loading, truncation, or retry indication; either exempt these tools or request the full part when their image/details dialog opens.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in a322168. image_gen_oai, image_edit_oai and gemini_image_gen are added to the tools that are never previewed, since their details dialog reads the prompt without going through a card disclosure.
The full-part read now starts beside conversation-access validation instead of after it, and keeps the active-job fallback the conversation read has, so a conversation still live before its first save can expand its previews. Fork, duplicate and shared-link fork responses honor the preview parameter, so they no longer seed the conversation cache with the full history. Background-task results whose structure cannot fit a valid JSON preview go whole, image cards keep their prompts, and the subagent panel offers a retry when its stored part fails to load.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a32216863f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| staleTime: Infinity, | ||
| retry: 1, | ||
| refetchOnWindowFocus: false, | ||
| refetchOnReconnect: false, | ||
| refetchOnMount: false, |
There was a problem hiding this comment.
Refresh hydrated parts when the stored tool call changes
When a previewed backgrounded code call is opened before its detached task settles, this caches the dispatch-handle version indefinitely. createBackgroundCodeResultHandler later patches the output on the same messageId/toolCallId (packages/api/src/agents/harvest.ts:291-296), but after the messages cache receives that update, PreviewedToolCallPart still prefers this cached query.data; with every automatic refetch disabled, the card continues showing the old running handle instead of the completed output until the query is manually cleared or the page reloads. Invalidate this key when its message changes, include a message revision in the key, or otherwise revalidate mutable calls.
AGENTS.md reference: AGENTS.md:L49-L52
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in f7d8985, two ways. The full-part query key now includes the preview's revision (getToolCallPreviewRevision: output length, stored lengths, transcript part count and the preview's tail), so when the conversation cache receives a changed call, such as a harvested background result, the card's next render keys a fresh fetch instead of reusing the old copy. And withFullToolCall now swaps in only the fields the server shortened (outputTruncated → output, argsTruncated → args, subagentContentOmitted → transcript), so a fetched copy can never roll back a field the cache already holds in full, which covers the dispatch-handle case where only the arguments were previewed. Both behaviours are covered in hydration.test.tsx.
| if (!isAgentToolCall(toolCall) || !hasOutput(toolCall)) { | ||
| return toolCall; |
There was a problem hiding this comment.
Preview terminal subagents with empty output
A subagent can finish with progress === 1 and no final output text, as the existing handling in client/src/components/Chat/Messages/Content/outcome.ts:125-130 explicitly accounts for. This early return therefore leaves that completed call's entire subagent_content transcript in every conversation response, even when it contains no pending approval; long empty-result subagent runs consequently retain the multi-megabyte payloads this change is intended to prevent. Treat an explicit terminal status/progress as settled for transcript omission instead of requiring a nonempty output.
AGENTS.md reference: AGENTS.md:L58-L60
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in f7d8985. A call now counts as settled when it has output, or its run step closed (runStepStatus), or it reached progress >= 1, and it is not waiting on an approval. A subagent that finished with no final text therefore has its transcript omitted like any other, while a transcript holding an unresolved approval is still kept. previews.spec.ts covers both terminal signals.
| const indexed = getAgentToolCall(content[partIndex]); | ||
| if (indexed != null && (toolCallId == null || indexed.id === toolCallId)) { | ||
| return { partIndex, toolCall: indexed }; |
There was a problem hiding this comment.
Disambiguate fallback reads for repeated tool-call IDs
When client-only content shifts partIndex, accepting an indexed part solely because its provider ID matches can hydrate the wrong call: the client already documents that provider tool-call IDs may repeat across agents in one response (client/src/components/Chat/Messages/Content/ContentParts.tsx:166-170), while both this fast path and the later scan distinguish calls only by toolCallId. A shifted index landing on another occurrence, or the scan finding it first, replaces the card with that other agent's arguments, output, or subagent transcript. Include the settled stepId/agent identity in the request and lookup, or reject ambiguous fallback matches.
AGENTS.md reference: AGENTS.md:L44-L47
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in f7d8985. The client now sends the call's host stepId and the part's agentId with toolCallId, and the server matches on all of them. The indexed part is accepted only when its identity matches; otherwise the scan accepts a single match only, and an ambiguous one resolves to 404 instead of to another agent's call. parts.spec.ts seeds two agents reusing one provider id and checks lookup by step, by agent, an indexed mismatch, and the ambiguous case.
| if (Array.isArray(value)) { | ||
| return value.map((item) => capJsonStrings(item, maxChars)); |
There was a problem hiding this comment.
Bound JSON traversal depth before previewing
A valid tool output containing several thousand nested JSON arrays is accepted by JSON.parse, but this recursive map over the parsed value exhausts the JavaScript call stack. Because previewToolCallOutput does not catch that RangeError, the outer messages route returns 500 for the entire conversation whenever previews are requested, even though the same stored string was loadable before this change and remains loadable by clients requesting the full payload. Traverse iteratively, enforce a depth limit, or fall back to the bounded text preview when JSON shrinking fails.
AGENTS.md reference: AGENTS.md:L49-L52
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in f7d8985. The structure-preserving path now bounds nesting depth: the string count is iterative and treats anything nested past 64 levels as unfit, so the recursive cap and serialization never run on it and the output falls back to the bounded text preview. Separately, each call's preview is guarded, so a call that cannot be previewed is sent as stored rather than failing the conversation load. The test uses a 20,000-deep array, which JSON.parse accepts and which previously overflowed the stack in the recursive map.
A fetched part is now cached under the preview's revision and replaces only the fields the server shortened, so a call whose stored output changes later (a harvested background result) is fetched again rather than rolled back. Part lookups carry the run-step and agent identity and refuse an ambiguous fallback, since provider tool-call ids can repeat within one response. A subagent that finished without final text has its transcript omitted like any other settled call, and JSON nested past a fixed depth falls back to a text preview; a call whose preview fails is sent whole instead of failing the conversation load.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f7d8985352
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| QueryKeys.toolCallPart, | ||
| conversationId, |
There was a problem hiding this comment.
Evict hydrated tool parts with their conversation
After a preview is opened, this separate query caches the full arguments, output, and subagent transcript, but the checked retainMessages policy in client/src/data-provider/Messages/retention.ts only tracks and removes queries rooted at QueryKeys.messages. Leaving a conversation can therefore evict its bounded history while multi-megabyte toolCallPart entries survive for React Query's default five-minute cache time; opening large parts across several conversations recreates the cross-conversation heap accumulation and mobile crash this change is intended to prevent. Remove these conversation-scoped part entries whenever the parent history is released or explicitly cleared.
AGENTS.md reference: AGENTS.md:L44-L47
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 7c9b3c8. retainMessages now removes a conversation's unobserved [toolCallPart, conversationId, …] entries whenever that conversation's history query is removed, whether retention released it or something cleared it (clearMessagesCache). Fetched parts therefore live exactly as long as the history they belong to. Covered in retention.spec.ts: both release and explicit clear drop the parts of that conversation, and parts of an open conversation stay.
| if (typeof current === 'string') { | ||
| count++; | ||
| } else if (Array.isArray(current)) { | ||
| for (const item of current) { | ||
| stack.push([item, depth + 1]); |
There was a problem hiding this comment.
Bound total JSON nodes before repeatedly cloning them
The new depth guard still counts only string values, so a wide JSON result such as a multi-megabyte array of numbers passes this check with a count of zero. shrinkJsonToFit then clones the entire tree once and clones plus serializes it again on every binary-search iteration before eventually determining that the fixed structure cannot fit; one stored numeric tool result can therefore create many times its payload size in synchronous allocations and block or exhaust the API process whenever the conversation is loaded with previews. Bound total visited nodes or structural size here and fall back to the text preview before the repeated cloning begins.
AGENTS.md reference: AGENTS.md:L186-L189
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 7c9b3c8. The pre-check is now mayFitJson, an iterative walk that counts every value as well as strings and bails out as soon as either could not fit at any cap: each value serializes to at least two characters, each string to at least eight, and nesting is still capped at 64. A wide numeric array therefore falls back to the text preview without any clone or serialization; the test spies on JSON.stringify to prove it is never called for a 200,000-number array.
| const result: { [key: string]: JsonValue } = {}; | ||
| for (const key in value) { | ||
| result[key] = capJsonStrings(value[key], maxChars); | ||
| } |
There was a problem hiding this comment.
Preserve
__proto__ as an ordinary JSON data key
When valid tool arguments or output contain an own __proto__ property, assigning it into this ordinary {} invokes the legacy prototype setter instead of creating an own property. Because shrinkJsonToFit subsequently calls capJsonStrings again, enumerable fields from that value are traversed as inherited properties and can be serialized at the top level while the original key disappears, corrupting the preview and potentially changing a collapsed JSON consumer's interpretation. Build the result with a null prototype or define/copy keys without invoking prototype setters.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 7c9b3c8. The capped object is built on a null prototype, so an own __proto__ key is assigned as data and survives serialization. The test ({"__proto__":{"mode":"x"},…}) fails against the previous {} and passes now.
|
|
||
| const MAX_PART_INDEX = 100_000; | ||
| const MAX_TOOL_CALL_ID_LENGTH = 512; | ||
| const PART_INDEX_PATTERN = /^\d{1,6}$/; |
There was a problem hiding this comment.
Remove the unpaired part-index ceiling
For a stored message whose content array exceeds 100,001 compact parts, previewMessagesToolCalls still previews calls at every index, but this unrelated ceiling makes the full-part endpoint reject any request past index 100,000 with 400. Opening such a card can therefore never replace its shortened output or omitted transcript, and Retry only repeats the same failure. Remove the ceiling, or make it a documented configuration limit and ensure the preview pass never emits an unfetchable preview.
AGENTS.md reference: AGENTS.md:L90-L92
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 7c9b3c8. The ceiling is removed: any non-negative safe integer is accepted, and an index past the content simply resolves to 404 after the user-scoped read. Non-integer and unsafe values are still rejected with 400 before reading.
Full tool-call parts fetched for a conversation are removed when its message history is released or cleared, so they cannot outlive it under their own key. The structure-preserving JSON preview now bails out before cloning when the value has more nodes or strings than could ever fit, keeps a __proto__ key as ordinary data, and the part endpoint accepts any safe-integer index instead of an arbitrary ceiling.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7c9b3c8cdd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } else if (current != null && typeof current === 'object') { | ||
| for (const key in current) { | ||
| stack.push([current[key], depth + 1]); | ||
| } |
There was a problem hiding this comment.
Count JSON property names before cloning previews
A valid tool output such as an object with one multi-megabyte property name and a tiny value passes this guard because the object branch counts only the value node, not key.length. shrinkJsonToFit then repeatedly clones and serializes that enormous key during its binary search, multiplying synchronous allocations and potentially blocking or exhausting the API process whenever the conversation is loaded with previews. Fresh evidence after the prior node-bound fix is that this branch still pushes only current[key]; include property-name size in the pre-check so oversized structures fall back before cloning.
AGENTS.md reference: AGENTS.md:L186-L189
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in the follow-up commit on this branch. mayFitJson now adds each property name's length (plus quotes and colon) to its budget and returns unfit as soon as names alone exceed the bound, so an object with an oversized key falls back to the text preview before any clone or serialization. The test uses a 200,000-character key and spies on JSON.stringify to prove it is never called; it fails without the guard.
Property names are never shortened, so the pre-check now adds their length to its budget and falls back to the text preview before any cloning when they alone could not fit.
|
@codex review the latest head, final review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 261aa5c1a2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const storedToolCall = awaitsStoredPart ? storedPart.data?.tool_call : undefined; | ||
| const persistedContent = storedToolCall?.subagent_content ?? selection.persistedContent; | ||
| let legacyOutput = selection.legacyOutput; | ||
| if (awaitsStoredPart && legacyOutput != null) { | ||
| legacyOutput = storedToolCall?.output; |
There was a problem hiding this comment.
Rehydrate the subagent prompt from the stored part
When a completed foreground subagent has a prompt longer than argsChars, the preview shortens that prompt. Opening the panel fetches the full call, but this block only hydrates subagent_content and output; adaptLivePersistedActivity still receives selection.prompt, which was derived from the previewed arguments. The panel therefore continues showing a truncated prompt after hydration succeeds. Recompute the prompt from storedToolCall.args when the stored part is available.
AGENTS.md reference: AGENTS.md:L49-L52
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in c7b5447. The panel now derives the prompt from the stored part's arguments once they load (falling back to selection.prompt until then), and that prompt feeds both the live activity and the conversation-turn summary. The extraction moved to a shared Subagents/prompt.ts so SubagentCall and the panel read prompts the same way. SubagentThreadPanel.test.tsx now checks that the full stored prompt replaces the previewed one.
| * as bounded previews. Every writer of that cache uses this, so the cache never flips between | ||
| * preview and full payloads; a reader that needs every byte (export) asks for a full load. | ||
| */ | ||
| export function fetchConversationMessages(conversationId: string): Promise<t.TMessage[]> { | ||
| return dataService.getMessagesByConvoId(conversationId, { toolPreviews: true }); |
There was a problem hiding this comment.
Keep mutation responses bounded in the messages cache
The claim that every writer of [messages, id] uses previews is still false for message mutations: editing an artifact returns the message's full content and assigns it at client/src/data-provider/Messages/mutations.ts:77-80, while branching a parallel response returns and appends the full saved message at lines 154-165. If either response contains the multi-megabyte tool output or subagent transcript this feature targets, that payload replaces or enters the bounded cache and remains there without a preview-aware refetch. Preserve previewed tool parts while merging these mutations, or make both mutation responses preview-aware.
AGENTS.md reference: AGENTS.md:L186-L189
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in c7b5447, after sweeping every writer of [messages, id]:
- Conversation loads, fork, duplicate and shared-link fork were already preview-aware.
- Artifact edit and parallel branch now send
toolPreviews=1, and their routes return the message throughwithMessageToolCallPreviews(config from the request), so the content they merge into the cache is bounded too. The route spec checks a branched response comes back previewed while storage keeps the full transcript and output. - The only remaining full writer is the response being generated (SSE final and resume). Its content already streamed into memory in full, so it stays full until the conversation is next loaded, which is the intended behaviour for the live message. The PR description now says this instead of claiming the cache only ever holds previews.
| tool_call?: FullToolCall; | ||
| } | ||
|
|
||
| const MAX_TOOL_CALL_ID_LENGTH = 512; |
There was a problem hiding this comment.
Align the identifier ceiling with preview eligibility
When a custom or local provider persists a tool-call ID longer than 512 characters, the preview pass still shortens that call and the client always sends its ID when requesting the full part, but parseIdentifier rejects the request with 400 before the indexed lookup. Retry can therefore never hydrate an otherwise valid preview. Fresh evidence after the part-index ceiling fix is that Agents.ToolCall.id has no corresponding length bound and preview generation does not enforce this one; remove the ceiling, make it configurable, or leave calls with unservable identities unpreviewed.
AGENTS.md reference: AGENTS.md:L90-L92
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in c7b5447. The identifier ceiling is removed: toolCallId, stepId and agentId only need to be non-empty strings, so any call the preview pass shortens can be fetched. Request size is still bounded by the HTTP server's own URL and header limits. parts.spec.ts now hydrates a call with a 2,000-character provider id.
…rompts Artifact edits and parallel-response branches merge the message the server returns into the conversation cache, so both requests now ask for previews and their routes honor it. The subagent panel reads the prompt from the stored arguments once they load, and the part endpoint no longer caps identifier length, so any previewed call can be fetched.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c7b5447b9a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const indexed = getAgentToolCall(content[partIndex]); | ||
| if (indexed != null && matchesIdentity(content[partIndex], indexed, identity)) { | ||
| return { partIndex, toolCall: indexed }; |
There was a problem hiding this comment.
Require an identity before hydrating an indexed part
When a previewed call has no id, stepId, or agentId—all optional for stored tool calls—matchesIdentity has no constraints, so this fast path accepts whichever tool call now occupies partIndex. If client-only content shifted the index, which the fallback directly above is designed to handle, opening the card replaces it with another call’s arguments, output, or transcript. Either leave identity-less calls unpreviewed or reject hydration when no stable identity was supplied. This remains distinct from the earlier repeated-ID fix because the fresh case is a call with no identity at all.
AGENTS.md reference: AGENTS.md:L44-L47
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in the follow-up commit on this branch, by making it impossible rather than patching the lookup: the preview pass now leaves any call without an id whole, so every previewed call carries a provider id, and the part endpoint requires toolCallId (400 without it). The indexed fast path therefore always confirms the call's identity, and the scan still accepts only a unique match. Tests cover both: id-less calls stay unpreviewed, and a request without toolCallId is rejected before any read.
A call without an id has nothing but its position to name it, and the position can shift on the client, so it is left whole instead of being previewed. The part endpoint now requires a tool-call id, so an indexed lookup always confirms the call it returns.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 318f6eeed4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const indexed = getAgentToolCall(content[partIndex]); | ||
| if (indexed != null && matchesIdentity(content[partIndex], indexed, identity)) { | ||
| return { partIndex, toolCall: indexed }; |
There was a problem hiding this comment.
Disambiguate indexed matches when provider IDs repeat
When a legacy or custom-provider response contains repeated toolCallId values without stepId or top-level agentId, a client-side content shift can make partIndex point to the other occurrence; this fast path still accepts it because the only supplied identity is the duplicated ID, returning the wrong call’s arguments, output, or transcript. Fresh evidence at the current head is that uniqueness is enforced only in the fallback scan, while the indexed candidate bypasses that check; reject ambiguous indexed matches when no additional discriminator is available.
AGENTS.md reference: AGENTS.md:L44-L47
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in the follow-up commit by closing the class rather than the case. The endpoint no longer trusts the client's index at all: it scans for the exact identity (provider id, run-step id, agent; an absent step or agent matches only a call that has none) and returns the single match, or 404. The preview pass, in turn, only shortens calls that have an id and whose exact identity is unique in their message, so a previewed call can always be named and an ambiguous identity is never previewed. parts.spec.ts covers exact twins refused at either index, and an index pointing at the other agent's call losing to the identity; previews.spec.ts covers repeated identities left whole.
| if ( | ||
| parsed !== undefined && | ||
| typeof toolCall.name === 'string' && | ||
| PARSED_OUTPUT_TOOLS.has(toolCall.name) | ||
| ) { | ||
| return output; | ||
| } | ||
| return previewOutputText(output, maxChars, commandTrailerLength(toolCall, output)); |
There was a problem hiding this comment.
Preserve legacy web-search error markers in previews
For a settled legacy web_search call without runStepStatus, the client classifies any output containing error processing as cancelled rather than completed (WebSearch.tsx lines 90-109). If an output longer than outputChars has that marker only in the elided middle, this text fallback removes the marker, so the restored card is mislabeled as a successful search; preserve that marker when building the preview or leave these legacy error outputs unpreviewed.
AGENTS.md reference: AGENTS.md:L49-L52
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in the follow-up commit. A web_search output that contains the legacy error processing phrase is now sent whole, so the collapsed card keeps classifying it the same way; other search outputs are still previewed. Covered in previews.spec.ts.
The part endpoint now finds a call by its exact identity (provider id, run-step id, agent) instead of trusting the client's index, and the preview pass only shortens calls whose identity is unique in their message, so every preview names exactly one stored part. Legacy web-search failures that carry their error phrase mid-output go whole, since the collapsed card reads that phrase anywhere.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 68b1f1a9ea
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } else if (Array.isArray(current)) { | ||
| for (const item of current) { | ||
| stack.push([item, depth + 1]); |
There was a problem hiding this comment.
Bound child pushes before walking wide JSON arrays
When a valid JSON output is a very wide array, this loop pushes every child tuple before nodes is incremented and checked again, so a multi-million-element array still creates a multi-million-entry work stack even though the configured preview may allow only a few hundred nodes. Fresh evidence after the prior node-bound fix is this unguarded bulk push, which can again block or exhaust the API process while loading a tool-heavy conversation; account for the remaining node budget before pushing children or stop as soon as that budget is reached.
AGENTS.md reference: AGENTS.md:L186-L189
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in the follow-up commit. mayFitJson now checks nodes + queued + children against the node budget before queueing an array's elements, and checks each object key before queueing its value, so the work list can never exceed the budget (a few hundred entries at the default bound). The test spies on Array.prototype.push while previewing a 300,000-element array and requires fewer than 1,000 pushes; it fails without the check.
| const step = typeof stepId === 'string' ? stepId : ''; | ||
| const agent = typeof part.agentId === 'string' ? part.agentId : ''; |
There was a problem hiding this comment.
Keep empty identity components fetchable
When a stored call has stepId: '' or its part has agentId: '', these lines treat that value as a valid identity component and allow the call to be previewed. The client endpoint builder then drops empty query values, while matchesIdentity compares the stored empty string against an absent value, so opening the preview always returns 404 and Retry cannot hydrate it; either leave calls with empty discriminator fields unpreviewed or normalize the preview and lookup paths identically.
AGENTS.md reference: AGENTS.md:L44-L47
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in the follow-up commit. The lookup now normalizes an empty stepId or agentId to absent on both sides, the same way the preview pass builds its identity key and the client omits empty values from the request. A call stored with stepId: '' and agentId: '' is previewed and fetched back; parts.spec.ts covers it and fails without the normalization.
…entities The structure check now counts an array's or object's children against the node budget before queueing them, so a very wide value is refused without building a work list its size. An empty run-step or agent id is treated as absent by the part lookup, matching how the preview pass and the client already treat it.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7e5ff7b1be
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return [ | ||
| output.length, | ||
| toolCall.outputLength ?? '', | ||
| toolCall.argsLength ?? '', | ||
| toolCall.subagentContentParts ?? '', | ||
| output.slice(-48), |
There was a problem hiding this comment.
Include all mutable content in the preview revision
When an already-hydrated call later receives a same-length output update whose final 48 characters are unchanged—for example, output with a fixed status trailer—every component of this revision remains identical even though the preview's head and stored full output changed. useToolCallPartQuery then reuses the old entry with staleTime: Infinity and all automatic refetches disabled (client/src/data-provider/Messages/queries.ts:104-121), so reopening the card continues showing stale content. Fresh evidence after the earlier revision-key fix is that the current key fingerprints only lengths and the output tail; include the complete preview content or a server-provided mutation revision.
AGENTS.md reference: AGENTS.md:L44-L47
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9f223b1. The revision now has two parts:
- a server-provided
previewRevisionon every preview, set to the message'supdatedAt(Mongoose bumps it on any save, including the background harvest's patch), so a change the preview cannot show still changes the key; - an FNV-1a fingerprint of the whole preview content (output, arguments, stored lengths, transcript part count) instead of lengths plus the tail.
packages/data-provider/src/previews.spec.ts covers a same-length head change with an identical tail and an identical preview whose message changed; previews.spec.ts covers the stamping.
|
|
||
| const output = typeof toolCall.output === 'string' ? toolCall.output : ''; | ||
| const outputPreview = previewToolCallOutput(toolCall, output, limits.outputChars); | ||
| const argsPreview = previewToolCallArgs(toolCall.args, limits.argsChars); |
There was a problem hiding this comment.
Hydrate web-search arguments when details open
When a completed web_search has a query longer than argsChars, this shortens the query, but opening the search details hover card does not request the stored part: WebSearch.tsx:206-217 requests hydration only through the source-list disclosure, while lines 286-317 render the query directly when showDetails opens. The details therefore show a truncated query until the user separately expands the sources, and when the search has no source rows that disclosure is disabled so the full query is never reachable; request full content from showDetails as the handoff details surface does, or exempt this tool from argument previews.
AGENTS.md reference: AGENTS.md:L49-L52
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9f223b1. WebSearch now calls useToolContentRequest(showDetails), so opening the details card requests the stored call the same way the handoff details do, independent of the source-list disclosure. I also swept the other cards for detail surfaces that bypass the card disclosure: the ask-user record and image cards are never previewed, the handoff and search details request content, and the remaining local toggles (raw background output, tool parameters) sit inside bodies that hydrate when they open. WebSearch.test.tsx checks that focusing the details trigger issues the request.
…ails Each preview now carries `previewRevision`, when its message last changed, and the client keys fetched parts to that plus a fingerprint of the whole preview, so any change to the stored call is fetched again. The web-search details card asks for the stored call when it opens, so its query is never left shortened.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9f223b1c44
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| QueryKeys.toolCallPart, | ||
| conversationId, | ||
| messageId, | ||
| partIndex, |
There was a problem hiding this comment.
Key hydrated parts by stable identity
When client-only content is inserted or removed and shifts partIndex, the same stored tool call receives a different query key, so React Query downloads and caches another copy of the potentially multi-megabyte full part until the old entry is collected. The part endpoint explicitly treats the index as untrusted and locates the call by messageId plus its exact tool/step/agent identity, so exclude partIndex from the cache identity while retaining it in the request URL.
AGENTS.md reference: AGENTS.md:L186-L189
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9782a7e. useToolCallPartQuery keys on conversation, message, and the exact tool/step/agent identity (plus the preview revision); partIndex only rides along in the request URL. The endpoint already requires toolCallId and resolves by identity, so the key cannot alias two calls. hydration.test.tsx now renders the same call at a shifted index on a shared client and asserts one fetch.
|
|
||
| return ( | ||
| <ToolContentRequestContext.Provider value={full == null ? request : null}> | ||
| {children(effectivePart)} |
There was a problem hiding this comment.
Prevent copying previews while hydration is pending
When a previewed tool card is opened over a slow or failed part request, this continues rendering the shortened arguments/output with the card's normal copy controls enabled; for example, BashCall passes the preview directly as copyText, so a user who clicks Copy before hydration completes gets an elided command or result rather than the stored value. Disable content actions until the full part arrives, or make those actions await hydration, so the new loading and failure states cannot silently export truncated data.
AGENTS.md reference: AGENTS.md:L49-L52
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 9782a7e. PreviewedToolCallPart now provides ToolContentPendingContext (true until the stored part arrives, including after a failed fetch), and every copy control a tool card renders (OutputRenderer, CodeWindowHeader, BashCall, AgentHandoff) disables its button and no-ops its handler while pending; CopyButton/ActionButton gained a disabled prop. Covered in OutputRenderer.test.tsx (disabled, no copy while pending) and hydration.test.tsx (pending flips false once hydrated).
The part query key drops the client-side index, which only rides along in the request; the server locates the call by message and exact identity, so a shifted index serves the cached copy instead of downloading it again. Copy controls inside a previewed part are disabled until the stored content arrives, so a slow or failed fetch cannot export shortened text.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9782a7e47f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return res.status(404).json({ message: 'Shared conversation not found' }); | ||
| } | ||
| return res.status(201).json(result); | ||
| return res.status(201).json(withToolCallPreviews(req, result)); |
There was a problem hiding this comment.
Apply the recipient's preview config to shared forks
In a cross-tenant shared-link fork, sharedLinkConfigMiddleware has populated req.config from the share owner's tenant (packages/api/src/shared-links/config.ts:22-46), while forkSharedConversation creates the resulting conversation for req.user.tenantId. Using that config here means an owner with previews disabled can return the entire tool-heavy history into a recipient whose tenant enabled previews, recreating the cache/memory spike, while the inverse ignores a recipient's opt-out. Keep the owner config for shared-content preflight, but shape the fork response with the authenticated recipient tenant's preview settings.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 77977ef. The shared-link fork now shapes its response with prepareResultToolCallPreviews(req, { getAppConfig }), which resolves the bounds from the authenticated requester (getAppConfigOptionsFromUser(req.user): their user, role and tenant), started beside the fork. req.config stays the share owner's and is still used only for the shared-content preflight. previews.spec.ts asserts the requester's config wins over the route's and an opt-out is honored; share.spec.js asserts the cross-tenant route passes the viewer and returns the shaped result.
| return undefined; | ||
| } | ||
| try { | ||
| return JSON.parse(text) as JsonValue; |
There was a problem hiding this comment.
Preserve JSON numbers losslessly in previews
When a long JSON argument or output contains a number outside JavaScript's lossless range, parsing it here already changes the stored value before the preview is serialized. For example, 9007199254740993 becomes 9007199254740992, and valid JSON such as 1e400 becomes Infinity and is then emitted by JSON.stringify as null; the collapsed tool card and trace inspector can therefore display a different ID or value than the tool actually returned. Use a lossless JSON representation for the structure-preserving path, or fall back without structurally previewing JSON whose numeric tokens cannot round-trip exactly.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 77977ef. Structure-preserving JSON previews now require every number token in the text to come back as the same value after parsing into doubles and serializing again (compared as canonical decimals, so 1.50/15e-1 still count as exact). An integer past 2^53, an exponent past the double range, an underflowing exponent or an over-precise decimal sends the call down the existing fallback: text preview for arguments and output, or the whole output for tools whose row parses it (check_background_task). Covered in previews.spec.ts (JSON numbers in previews).
…umbers Exact A shared-link fork resolves the share owner's config for its content checks, but the forked conversation belongs to the requester, so its preview bounds now come from the requester's tenant and role (prepareResultToolCallPreviews). A structure-preserving JSON preview now requires every number in the text to survive a parse and re-serialization as the same value; an integer past 2^53, an out-of-range exponent or an over-precise decimal falls back to the text preview (or the whole output for parsed-output tools) instead of showing a changed value.
|
Codex Review: Didn't find any major issues. What shall we delve into next? Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
…t-previews # Conflicts: # client/src/components/Messages/Content/ActionButton.tsx
…t-previews # Conflicts: # client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
Summary
Long agent conversations crash iPhone Safari tabs. Every conversation load shipped each tool call's full output, its full arguments and, for subagent calls, the subagent's whole transcript, even though those cards render collapsed. Three real "Lia" conversations (101 to 210 messages, 2,500 to 3,900 content parts) carry 12 to 19 MB of message JSON each; at 375x812 one open chat peaked around 110 MB of JS heap on dev, and switching between them climbed to 315 to 507 MB. #16747 releases inactive conversations' message caches, but a single long chat still downloaded and held everything. Of about 29 MB of message JSON across two of these chats (259 messages, 5,721 parts),
tool_call.outputwas 16.9 MB (58%),tool_call.args4.1 MB,tool_call.subagent_content4.2 MB and the remaining tool-call fields 1.1 MB, while everything a reader actually sees (text, reasoning, summaries, activity labels) was about 1.2 MB. #14901 already defers rendering collapsed bodies, but the data was still downloaded and cached.This PR has the web client ask for bounded previews of settled tool calls when it loads a conversation, and fetch one tool call in full when the reader opens its card or its subagent panel. Measured against the same three conversations, the
/api/messages/:conversationIdpayload drops from 18.8 / 18.2 / 12.3 MB to 4.3 / 3.8 / 2.9 MB (77 to 79% less).How it works
Preview format. A client adds
?toolPreviews=1toGET /api/messages/:conversationId(and to the cursor readGET /api/messages?conversationId=). For each settled agent tool call, the server shortens content that exceeds the bounds and marks what it shortened, changing only fields inside thetool_call(content positions never change). A call counts as settled when it has output, its run step closed, or it reached full progress, and it is not waiting on an approval.{ "type": "tool_call", "tool_call": { "id": "call_66e2…", "name": "bash_tool", "executor": "attached_workspace", "args": "{\"intent\":\"Reading LibreChat's repository instructions\",\"command\":\"set -e\\nprintf '%s\\\\n' '--- repo…\"}", "argsTruncated": true, "argsLength": 2427, "output": "[starting directory: \"workspace/\"]\nstdout:\n--- repository ---\nremote: https://github.com/…\n…\n…ed in the corresponding pull request for tracking purposes.\n\n[exit code: 0]", "outputTruncated": true, "outputLength": 46012, "progress": 1 } }\n…\n. Error prefixes and first error lines live at the start; validation feedback (… Please fix your mistakes.), the last line of a traceback and the attached-workspace exit-status trailer live at the end, so collapsed rows keep reading the right verdict. That trailer is always kept whole, using the same regex the card parses with (now shared fromlibrechat-data-providerasCOMMAND_RESULT_TRAILER). Output that is JSON (background-task results, detached handles) stays valid JSON. Acheck_background_taskresult whose structure cannot fit goes whole, because its collapsed row parses it. Before any cloning, an iterative pre-check sends JSON to the text fallback when it has more values, strings or property-name characters than could ever fit, or is nested more than 64 levels deep. JSON whose numbers would not come back as the same value once parsed into doubles (an integer past 2^53, an exponent past the double range, an over-precise decimal) is never structurally previewed: it takes the text preview, or goes whole for tools whose row parses it. A call whose preview fails for any reason is sent as stored rather than failing the load.intent,command,path,lang,subagent_type,run_in_background) stay exact while a file body or heredoc absorbs the cut. Non-JSON arguments fall back to a prefix.subagent_contentis left out, withsubagentContentOmitted: trueandsubagentContentParts: <count>.ask_user_questionrecords and image-generation calls, whose cards read both fields without going through a card disclosureFork, duplicate and shared-link fork responses honor the same parameter (a shared-link fork uses the recipient's own
toolCallPreviewssettings, not the share owner's, since the forked conversation is the recipient's), and so do the artifact-edit and parallel-branch responses, since all of them are written into the same client cache. The one deliberate exception is the response being generated: it streams in full and keeps its full content until the conversation is next loaded, at which point it arrives as previews like the rest.Bounds are configurable under
toolCallPreviewsinlibrechat.yaml(enabled,outputChars,argsChars; documented inlibrechat.example.yaml). The config read starts beside the message read rather than ahead of it, and only for requests that asked for previews.Full-part endpoint.
GET /api/messages/:conversationId/:messageId/parts/:partIndex?toolCallId=&stepId=&agentId=returns{ conversationId, messageId, partIndex, tool_call }with the stored tool call, transcript included.requireJwtAuthand runs the same conversation-access validation as the message routes: ownership, the 404 for durable subagent child threads, and thecanReadActiveJobConversationfallback. That fallback is enabled for this read, as it is for the conversation read, so a conversation that is still live before its first save can expand its previews.getMessages({ user, conversationId, messageId })) and runs through the tenant-isolation plugin, so another user's or another tenant's message id resolves to 404.CLIENT_MESSAGE_SELECT, so the part leaves with exactly the fields a full conversation load sends for it today (backgroundTask.resultClaimandcompletionWakeupexcluded). The message routes apply no other read-time filter, andstripUIResourceMarkersapplies only to share links, which this PR does not change.packages/api/src/messages/parts.ts;api/server/routes/messages.jsonly registers the handler.Client.
Opening a card fetches the stored part once and renders it in place of the preview. The index is sent with the request but is not part of the cache key, so a call whose index shifts is still served from cache. Until the stored part arrives (or after a failed fetch), the card's copy controls are disabled, so a preview is never exported as if it were the stored value. Only the fields the server shortened are swapped in, and the markers are dropped; everything else stays as the cache has it. The key includes the preview's revision: the server stamps each preview with when its message last changed (
previewRevision), and the client adds a fingerprint of the preview content, so a stored call that changes later (a harvested background result) is fetched again rather than shown stale. Cards that open on their own (the auto-expand preference, a sole call in its group) request it too, and reopening is served from cache. The full parts live under their own query key and are never merged into[messages, id], so a conversation refetch cannot drop them and server-loaded history in that cache stays as previews. The message-retention policy from #16747 removes a conversation's fetched parts together with its history, whether the history is released or cleared, so they never outlive it. The subagent activity panel loads the stored part when its selection carriescontentPreview, and shows its existing loading and error states, with a Retry, instead of passing the shortened output off as the activity. All writers of the[messages, id]cache (the conversation query, the resumable-stream reconcile, the share dialog, the trace viewer's reader) use one shared fetcher, so the cache never flips between preview and full payloads.Consumer audit (
client/src).isError,firstErrorLine) reads both edges.parseJsonField,isBashProgrammaticToolCall,getToolCallIntent,extractSubagentType,parseSubagentBackgroundHandle) find their fields.argskey is always present.utils/tokens.ts) useoutputLength/argsLength, so the context gauge and breakdown still reflect the stored conversation.utils/approval.ts,groupToolCalls.ts) keep working. A pending approval only exists on a call without output, and the server never shortens those or omits a transcript that holds one. HITLeditedArgumentsare seeded from the interrupt payload'srequest.arguments, not from the cached args.useStepHandler/useEventHandlersand the SSE final message carry full content and no markers.createPayloadnever includes cached content (new test).POST /api/messages/:conversationIdsaves a client-authored message verbatim and has no in-app caller. It now refuses a body carrying preview markers (rejectToolCallPreviewWrites), so a preview can never replace stored content.useExportConversation) asks for the full payload. Copy-message reads text parts only. Search results do not apply previews.Type of change
Testing
Real data: a throwaway instance built from dev (565976b) holding the three conversations served the "before" numbers read-only. For "after", this branch ran on another port against a copy of those three conversations in a separate database, so the repro database was never written. The heap figures use one client build A/B'd with
toolCallPreviews.enabled: false.What remains in a previewed load is about 1.2 MB of bounded output previews, 0.8 MB of bounded arguments, 0.9 MB of other tool-call fields (ids, step ids, timings, background-task receipts, the markers) and about 1.2 MB of readable content. The non-tool content alone is about 13% of the original, so previews by themselves cannot reach 85%; trimming the remaining tool-call metadata would be a separate change.
Heap at 375x812 in Chromium (
performance.memory, two runs each; open chat 1, switch to 2, 3, then back to 1):enabled: false)In the running branch, opening a previewed bash card in the first chat issued one
GET …/parts/0?toolCallId=…(47 KB) and rendered the full command and output.Tested environments/configuration:
Automated tests:
packages/api/src/messages/previews.spec.ts: bounds and markers, both edges kept, surrogate-safe cuts, JSON arguments and outputs kept valid with short fields exact, trailer kept whole only for the attached-workspace executor, pending approvals and running calls untouched, transcripts with pending approvals kept, legacy andask_user_questionuntouched, copy-on-write, config gating, write guard.packages/api/src/messages/parts.spec.ts(mongodb-memory-server, supertest):resultClaimexcluded, and a preview round-trips back to the stored transcript.api/server/routes/__tests__/messages-tool-previews.spec.js(real router, real validation middleware, mongodb-memory-server):POST /api/messages/:conversationId(refused), branching or forking.client/.../__tests__/hydration.test.tsx: no fetch while collapsed; fetch on open with a loading status; error with Retry; auto-opened cards fetch once and reopen from cache; a shifted index is served from cache; copy stays pending until hydrated; full parts pass through untouched; the merge swaps only content fields.SubagentThreadPanel.test.tsx: a previewed selection loads the stored part by identity and revision, renders its transcript, reports errors with a working Retry, and never renders the shortened output.approval.spec.ts: a nested pause is still tagged in a message whose settled siblings are previews.tokens.spec.ts: previews are estimated from their stored lengths.createPayload.spec.ts: regenerate, continue and edit-rerun payloads carry no cached tool content.npx tsc --noEmitinpackages/data-provider,packages/data-schemasandpackages/api, plusnpm run typecheckinclient; ESLint and import order on touched files.Screenshots / recordings
Not captured. The only new visible element is a one-line status under a tool card while its full content loads (a spinner with "Loading the full tool call…", or "Couldn't load the full tool call." with Retry). It uses existing semantic text roles and the shared
Spinner, and appears only on cards the server sent as previews.Risk / compatibility
toolPreviews=1, so a newer server sends it full payloads exactly as before.1), so a later format change can be negotiated.toolCallPreviews.enableddefaults totrue. This is the point of the change, and what readers see is unchanged: collapsed rows read the same verdicts, and opened cards show the stored content.enabled: falserestores full payloads byte for byte.Checklist
toolCallPreviewsto follow