Skip to content

🪶 perf: Load Tool-Call Previews and Fetch Full Parts on Demand - #16753

Merged
danny-avila merged 14 commits into
devfrom
danny-avila/tool-output-previews
Oct 5, 2026
Merged

danny-avila merged 14 commits into
devfrom
danny-avila/tool-output-previews

Conversation

@danny-avila

@danny-avila danny-avila commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

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.output was 16.9 MB (58%), tool_call.args 4.1 MB, tool_call.subagent_content 4.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/:conversationId payload 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=1 to GET /api/messages/:conversationId (and to the cursor read GET /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 the tool_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
  }
}
  • Output (default 512 characters): half from the start and half from the end, joined by \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 from librechat-data-provider as COMMAND_RESULT_TRAILER). Output that is JSON (background-task results, detached handles) stays valid JSON. A check_background_task result 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.
  • Arguments (default 512 characters): JSON stays valid JSON with every key, array item and scalar present; only the longest strings are shortened, by binary-searching the largest per-string cap that fits. Short fields that collapsed rows read (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 transcripts: subagent_content is left out, with subagentContentOmitted: true and subagentContentParts: <count>.
  • Never shortened:
    • calls that are still running or waiting on an approval
    • any subagent transcript that still contains an unresolved approval
    • ask_user_question records and image-generation calls, whose cards read both fields without going through a card disclosure
    • legacy Assistants tool-call shapes

Fork, duplicate and shared-link fork responses honor the same parameter (a shared-link fork uses the recipient's own toolCallPreviews settings, 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 toolCallPreviews in librechat.yaml (enabled, outputChars, argsChars; documented in librechat.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.

  • It sits behind requireJwtAuth and runs the same conversation-access validation as the message routes: ownership, the 404 for durable subagent child threads, and the canReadActiveJobConversation fallback. 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.
  • The user-scoped read starts beside validation (no serial round trip), and nothing is sent until validation passes.
  • The read itself is also scoped to the authenticated user (getMessages({ user, conversationId, messageId })) and runs through the tenant-isolation plugin, so another user's or another tenant's message id resolves to 404.
  • It reads with CLIENT_MESSAGE_SELECT, so the part leaves with exactly the fields a full conversation load sends for it today (backgroundTask.resultClaim and completionWakeup excluded). The message routes apply no other read-time filter, and stripUIResourceMarkers applies only to share links, which this PR does not change.
  • The part is located by its exact identity (provider call id, host run-step id, agent id), never by the client's index, which client-only cards can shift. Provider ids can repeat within a response, so the preview pass only shortens calls that have an id and whose identity is unique in their message; every preview names exactly one stored part, and a repeated identity resolves to 404, never to another call's content.
  • Malformed coordinates are rejected with 400 before any read, and a read failure returns a generic 500.
  • The logic lives in packages/api/src/messages/parts.ts; api/server/routes/messages.js only registers the handler.

Client.

PartWithContext (ContentParts.tsx)
  PreviewedToolCallPart (hydration.tsx)   only for parts carrying preview markers
    ToolContentRequestContext             request() latches once
      Part → any tool card
        useToolExpansion(...)             the shared disclosure hook every card uses;
          useToolContentRequest(expanded) calls request() while the card is open
    ToolContentPendingContext             true until the stored part arrives; copy controls disable
  useToolCallPartQuery                    [toolCallPart, convo, message, id, step, agent, revision]
  status line                             "Loading the full tool call…" / error + Retry

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 carries contentPreview, 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).

  • Collapsed-row readers keep working on previews:
    • Error detection (isError, firstErrorLine) reads both edges.
    • The attached-workspace exit trailer is kept whole.
    • Background-task and subagent handles stay valid JSON.
    • Routing, labels and intents (parseJsonField, isBashProgrammaticToolCall, getToolCallIntent, extractSubagentType, parseSubagentBackgroundHandle) find their fields.
    • Emptiness checks see non-empty output.
    • The args key is always present.
  • Expanded bodies hydrate before showing details: ToolCall/ToolCallInfo, Bash, ExecuteCode, ReadFile, FileAuthoring diffs, Memory, Skill, WebSearch, Retrieval, BackgroundTask, AgentHandoff (it has its own disclosure, so it calls the request hook directly), and OutputRenderer JSON joining. Copy controls (OutputRenderer, CodeWindowHeader, Bash, AgentHandoff) stay disabled until the stored part arrives.
  • Subagent panel: fetches before rendering activity.
  • Token estimates (utils/tokens.ts) use outputLength/argsLength, so the context gauge and breakdown still reflect the stored conversation.
  • Trace inspector: marks previewed tool input and output as truncated, where it already capped content at 4,000 characters.
  • Approvals (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. HITL editedArguments are seeded from the interrupt payload's request.arguments, not from the cached args.
  • Live and streaming:
    • Parts written by useStepHandler/useEventHandlers and the SSE final message carry full content and no markers.
    • A finished message reloaded from the server gets previews.
    • A card that is open across that swap re-requests its part.
  • Edit, regenerate, continue, fork, branch, artifact edit, feedback, share creation and duplicate send ids or user text only, and the server reads stored content from the database. createPayload never includes cached content (new test).
  • POST /api/messages/:conversationId saves 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.
  • Export (useExportConversation) asks for the full payload. Copy-message reads text parts only. Search results do not apply previews.
  • Share links keep full content. Shares are viewed without an account, so an on-demand fetch would need a share-scoped authorization path rather than conversation ownership; that is better done as its own change.

Type of change

  • Performance improvement
  • Feature

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.

Chat (messages, parts) dev payload this PR, previews change previewed calls
8cc7948b (210, 3,889) 18,761,045 B 4,277,953 B (about 760 KB gzip) −77.2% 2,135
d5a5ab82 (158, 3,209) 18,220,878 B 3,818,683 B −79.0% 1,916
fa34ac0e (101, 2,512) 12,320,345 B 2,878,871 B −76.6% 1,466

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):

after first load peak while switching
full payload (enabled: false) 94 to 104 MB 188 to 204 MB
previews (default) 75 to 91 MB 139 to 154 MB

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:

  • Browser: Chromium (Claude browser pane), 375x812 viewport
  • Database: MongoDB 8 (repro data copied to a separate database for the branch)
  • OS: Windows 11 host, WSL2 Ubuntu 22.04

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 and ask_user_question untouched, copy-on-write, config gating, write guard.
  • packages/api/src/messages/parts.spec.ts (mongodb-memory-server, supertest):
    • The part equals what a full load sends for it, with resultClaim excluded, and a preview round-trips back to the stored transcript.
    • Lookup is by exact identity regardless of index, tells repeated ids apart by step and agent, and refuses a repeated identity.
    • A missing part, a non-tool part or an unknown id gets 404; so do another user, another conversation id and another tenant.
    • The read starts before validation resolves, and a refused verdict never leaks the part.
    • Malformed coordinates get 400 without a read; a failed read gets a generic 500.
  • api/server/routes/__tests__/messages-tool-previews.spec.js (real router, real validation middleware, mongodb-memory-server):
    • Old clients get the full payload; the cursor read previews too.
    • The part endpoint serves only its owner, serves an owned live job but not another user's or tenant's or a finished one, and refuses durable child threads.
    • A client holding previews never overwrites stored output, arguments or transcripts through a content-part edit, a text edit, 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.
  • Existing message route, queries and ShareButton suites updated for the new argument and handler.
  • npx tsc --noEmit in packages/data-provider, packages/data-schemas and packages/api, plus npm run typecheck in client; 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

  • Mixed versions. Previews are opt-in per request.
    • An older client never sends toolPreviews=1, so a newer server sends it full payloads exactly as before.
    • A newer client talking to an older server gets full payloads without markers. Nothing renders as a preview, nothing is fetched, and the part endpoint is never called.
    • The parameter carries a version (1), so a later format change can be negotiated.
  • Default on. toolCallPreviews.enabled defaults to true. 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: false restores full payloads byte for byte.
  • Server cost. Previewing runs one pass over each message's content after the read. Each full-part request reads one message document under the existing user and tenant scoping.
  • Rolling deploys. A client that loaded previews from a new replica and then reaches an old replica on expand gets a 404 for the part. It shows the error state and keeps the preview.

Checklist

  • I reviewed my own changes
  • Relevant tests have been added or updated
  • Existing relevant tests pass
  • The change does not introduce new warnings or errors
  • User-facing or complex behavior is documented where necessary
  • Required dependency changes have been merged/published
  • Required documentation PR: librechat.ai docs for toolCallPreviews to follow

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.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Pass 1 of 4 on head 8fda87f.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-05T03:25:50.569954Z 77977ef Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@codegraph-librechat codegraph-librechat Bot added the 🗺️ Chat Features codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) label Oct 4, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread api/server/routes/messages.js Outdated
}
});

router.get('/:conversationId/:messageId/parts/:partIndex', validateMessageReq, readToolCallPart);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1253 to +1254
if (awaitsStoredPart && selection.durable == null && storedToolCall == null) {
panelState = storedPart.isError ? 'error' : 'loading';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread api/server/routes/messages.js Outdated
}
});

router.get('/:conversationId/:messageId/parts/:partIndex', validateMessageReq, readToolCallPart);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread packages/api/src/messages/previews.ts Outdated
Comment on lines +255 to +257
const parsed = parseJsonContainer(output);
const shrunk = parsed === undefined ? undefined : shrinkJsonToFit(parsed, maxChars);
return shrunk ?? previewOutputText(output, maxChars, commandTrailerLength(toolCall, output));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +83 to +89
/**
* 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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/api/src/messages/previews.ts Outdated
Comment on lines +29 to +33
/**
* 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']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Pass 2 of 4 on head a322168.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +106 to +110
staleTime: Infinity,
retry: 1,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/api/src/messages/previews.ts Outdated
Comment on lines +295 to +296
if (!isAgentToolCall(toolCall) || !hasOutput(toolCall)) {
return toolCall;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/api/src/messages/parts.ts Outdated
Comment on lines +70 to +72
const indexed = getAgentToolCall(content[partIndex]);
if (indexed != null && (toolCallId == null || indexed.id === toolCallId)) {
return { partIndex, toolCall: indexed };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +149 to +150
if (Array.isArray(value)) {
return value.map((item) => capJsonStrings(item, maxChars));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Pass 3 of 4 on head f7d8985.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +106 to +107
QueryKeys.toolCallPart,
conversationId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +195 to +199
if (typeof current === 'string') {
count++;
} else if (Array.isArray(current)) {
for (const item of current) {
stack.push([item, depth + 1]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/api/src/messages/previews.ts Outdated
Comment on lines +176 to +179
const result: { [key: string]: JsonValue } = {};
for (const key in value) {
result[key] = capJsonStrings(value[key], maxChars);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/api/src/messages/parts.ts Outdated

const MAX_PART_INDEX = 100_000;
const MAX_TOOL_CALL_ID_LENGTH = 512;
const PART_INDEX_PATTERN = /^\d{1,6}$/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

This is the final review for this PR (pass 4 of 4) of head 7c9b3c8. Please flag only blocking issues: correctness, security, or data loss.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +213 to +216
} else if (current != null && typeof current === 'object') {
for (const key in current) {
stack.push([current[key], depth + 1]);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review the latest head, final review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +796 to +800
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +85 to +89
* 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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 through withMessageToolCallPreviews (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.

Comment thread packages/api/src/messages/parts.ts Outdated
tool_call?: FullToolCall;
}

const MAX_TOOL_CALL_ID_LENGTH = 512;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Head: c7b5447

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/api/src/messages/parts.ts Outdated
Comment on lines +91 to +93
const indexed = getAgentToolCall(content[partIndex]);
if (indexed != null && matchesIdentity(content[partIndex], indexed, identity)) {
return { partIndex, toolCall: indexed };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Head: 318f6ee

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/api/src/messages/parts.ts Outdated
Comment on lines +91 to +93
const indexed = getAgentToolCall(content[partIndex]);
if (indexed != null && matchesIdentity(content[partIndex], indexed, identity)) {
return { partIndex, toolCall: indexed };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +324 to +331
if (
parsed !== undefined &&
typeof toolCall.name === 'string' &&
PARSED_OUTPUT_TOOLS.has(toolCall.name)
) {
return output;
}
return previewOutputText(output, maxChars, commandTrailerLength(toolCall, output));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Head: 68b1f1a

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +240 to +242
} else if (Array.isArray(current)) {
for (const item of current) {
stack.push([item, depth + 1]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +120 to +121
const step = typeof stepId === 'string' ? stepId : '';
const agent = typeof part.agentId === 'string' ? part.agentId : '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Head: 7e5ff7b

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/data-provider/src/previews.ts Outdated
Comment on lines +36 to +41
return [
output.length,
toolCall.outputLength ?? '',
toolCall.argsLength ?? '',
toolCall.subagentContentParts ?? '',
output.slice(-48),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9f223b1. The revision now has two parts:

  • a server-provided previewRevision on every preview, set to the message's updatedAt (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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Head: 9f223b1

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danny-avila

danny-avila commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

@codex review

Review of head 9782a7e.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread api/server/routes/share.js Outdated
return res.status(404).json({ message: 'Shared conversation not found' });
}
return res.status(201).json(result);
return res.status(201).json(withToolCallPreviews(req, result));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Review of head 77977ef.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 77977ef36a

ℹ️ 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".

…t-previews

# Conflicts:
#	client/src/components/Messages/Content/ActionButton.tsx
…t-previews

# Conflicts:
#	client/src/components/Chat/Subagents/SubagentThreadPanel.tsx
@danny-avila
danny-avila merged commit 3f442e4 into dev Oct 5, 2026
17 checks passed
@danny-avila
danny-avila deleted the danny-avila/tool-output-previews branch October 5, 2026 11:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🗺️ Chat Features codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) 🛡️ security review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant