Skip to content

feat: customizable tool render behavior in ai-core - #561

Merged
mihajlo-stojkovic merged 3 commits into
mainfrom
feat/customizable-tool-render-behavior
Apr 28, 2026
Merged

mihajlo-stojkovic merged 3 commits into
mainfrom
feat/customizable-tool-render-behavior

Conversation

@mihajlo-stojkovic

@mihajlo-stojkovic mihajlo-stojkovic commented Apr 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds a ToolRenderBehavior API on Chat.Root so host apps can customize how tool calls are rendered in FlatAgentRenderer, and fixes a bug where pending tool calls were left in a stuck state when a stream was aborted.

Motivation

FlatAgentRenderer previously hardcoded tool-call presentation: every agent tool call produced a summary line, leaf calls were labeled with their raw tool name (or a reasoning input field, if present), and display names were passed through a built-in humanizeToolName helper with a fixed acronym list.

This works for generic cases but becomes limiting when host apps introduce higher-level tool-call concepts — for example, an orchestrator tool whose only job is to dispatch work to another agent, where rendering a wrapper summary line adds noise without adding information. Host apps also typically have richer context for producing human-readable labels (domain vocabulary, dynamic inputs) than any generic helper baked into the library.

Changes

ai-core — ToolRenderBehavior API

Chat.Root now accepts an optional toolRenderBehavior prop, made available to FlatAgentRenderer via ToolRenderBehaviorContext. The API is split into two concerns:

  • Structural — isPassthroughTool(toolCall): when true, the tool call renders no summary line and its nested calls are flattened into the caller's segment list. Applied recursively in buildFlatSegments and also respected at the top-level parent-summary in FlatAgentRenderer.
  • Display — getToolDisplayName(toolCall) overrides ParentSummaryLine labels; getActivityLabel(toolCall) overrides leaf log-line labels in ActivityLogLine and OrchestratorLogLineInner. Both return undefined to fall back to the default.

All three callbacks are optional; the default behavior is unchanged when toolRenderBehavior is omitted.

Removed humanizeToolName

With host apps now able to provide display names directly, the built-in humanizeToolName helper (and its hardcoded acronym list) is no longer needed and has been removed from the public exports. The default fallback is simply the raw tool name, and the pending state shows "Thinking..." to avoid flashing the raw name before reasoning input is available. Leaf labels that come from a reasoning input field are now rendered in italic to distinguish them from tool names.

Dropped reasoning tail from ParentSummaryLine

Previously parent summary lines appended a masked, italicized reasoning tail next to the tool name. This has been removed in favor of a clean, single-label line — host apps that want richer labels can compose them via getToolDisplayName.

Bug fix — abort leaves tool calls stuck

streamSubAgent now marks any tool calls still in pending or approval-requested state as error with a cancellation message when the abort signal fires. This happens both inside the stream error path and after the main loop. Previously those calls could remain indefinitely in their pre-abort state in the progress snapshot.

Public API additions

Exported from @sqlrooms/ai-core (and re-exported from @sqlrooms/ai):

  • ToolRenderBehavior
  • ToolStructureBehavior
  • ToolDisplayBehavior

Removed exports

  • humanizeToolName

Test plan

  • Existing FlatAgentRenderer usage renders identically when no toolRenderBehavior is passed
  • Passthrough tools flatten nested calls and render no summary line
  • getToolDisplayName / getActivityLabel overrides apply; undefined returns fall back to defaults
  • Aborting a streaming sub-agent leaves no tool calls in pending state

Summary by CodeRabbit

  • New Features

    • Chat component accepts a toolRenderBehavior prop to customize tool labels and structure.
    • New configurable rendering behavior lets hosts control tool display and passthrough handling.
  • Bug Fixes

    • In-flight tool calls are now consistently marked errored and cleaned up when aborted.
  • Refactor

    • Tool rendering system reorganized to support the new configurable behaviors.
  • Chores

    • Cleaned up legacy tool-name helper from public exports; new types re-exported for config.

Introduces a ToolRenderBehavior API on Chat.Root that lets host apps
override how tool calls appear in FlatAgentRenderer:

- isPassthroughTool: collapse wrapper tool calls so their children are
  promoted into the caller's segment list with no summary line
- getToolDisplayName: override the label on agent summary lines
- getActivityLabel: override the label on leaf activity log lines

Also removes the hardcoded humanizeToolName helper (label generation is
now the host app's responsibility) and fixes streamSubAgent so that
pending or approval-requested tool calls are marked cancelled when the
abort signal fires, instead of being left in a stuck state.
@coderabbitai

coderabbitai Bot commented Apr 22, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Introduces a ToolRenderBehavior context and provider to customize tool labeling and structure (passthrough flattening), exposes related types, extends Chat to accept a top-level toolRenderBehavior prop, removes humanizeToolName, and normalizes abort handling to mark in-flight tool calls as errored/closed on abort.

Changes

Cohort / File(s) Summary
Tool rendering & UI surface
packages/ai-core/src/components/FlatAgentRenderer.tsx, packages/ai-core/src/components/Chat.tsx
Add ToolRenderBehaviorContext + types (ToolRenderBehavior, ToolStructureBehavior, ToolDisplayBehavior) and ToolRenderBehaviorProvider; update label selection logic and add passthrough flattening in flat segment builder; Chat accepts toolRenderBehavior prop and provides the context.
Abort handling (agent runtime)
packages/ai-core/src/agents/AgentUtils.ts
Normalize cancellation: pending/approval-requested tool calls are transitioned to error with TOOL_CALL_CANCELLED text, completedAt set, and approvalId cleared in both streaming catch and post-loop abort paths.
Exports & utils
packages/ai-core/src/index.ts, packages/ai/src/index.ts, packages/ai-core/src/utils.ts
Remove humanizeToolName from utils/exports; add type re-exports for ToolRenderBehavior, ToolStructureBehavior, and ToolDisplayBehavior from FlatAgentRenderer in package entry points.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • lixun910
  • dmitriy-kostianetskiy
  • ilyabo

Poem

🐰 I tuned the tools and flattened trees,

Names now whisper as the UI sees.
When aborts arrive, no calls stay pending—
I tidy states, approvals ending.
Hoppity code, all neat and mending. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: introducing customizable tool render behavior to the ai-core package, which is the central feature across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/customizable-tool-render-behavior

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/ai-core/src/agents/AgentUtils.ts (1)

444-455: ⚠️ Potential issue | 🟠 Major

Make the approval wait abortable too.

The new final sweep at Lines 520-532 is not reached if abortSignal fires while this Promise is awaiting user approval. That can still leave an approval-requested tool call and approval UI stuck until the user responds. Route this path through the same cancel/snapshot handling and clear the pending approval on abort.

🐛 Proposed direction
+  const markPendingToolCallsCancelled = () => {
+    const now = Date.now();
+    for (const [id, tc] of toolCallMap) {
+      if (tc.state === 'pending' || tc.state === 'approval-requested') {
+        toolCallMap.set(id, {
+          ...tc,
+          state: 'error',
+          errorText: TOOL_CALL_CANCELLED,
+          completedAt: now,
+        });
+      }
+    }
+  };
+
+  const throwAbortWithSnapshot = (): never => {
+    markPendingToolCallsCancelled();
+    const snapshot = buildAbortSnapshot(
+      parentToolCallId,
+      toolCallMap,
+      finalText,
+      store,
+    );
+    store.getState().ai.writeAbortSnapshot?.(parentToolCallId, snapshot);
+    pushProgress();
+    throw new ToolAbortError(TOOL_CALL_CANCELLED, snapshot);
+  };
+
-    const approved = await new Promise<boolean>((resolve) => {
-      throwIfAborted();
+    const approved = await new Promise<boolean>((resolve, reject) => {
+      if (abortSignal?.aborted) {
+        reject(new ToolAbortError(TOOL_CALL_CANCELLED));
+        return;
+      }
+
+      const onAbort = () => reject(new ToolAbortError(TOOL_CALL_CANCELLED));
+      abortSignal?.addEventListener('abort', onAbort, {once: true});
+
       store.getState().ai.requestSubAgentApproval!({
         toolCallId: pendingApproval!.toolCallId,
         approvalId: pendingApproval!.approvalId,
         toolName: approvedEntry.toolName,
         input: approvedEntry.input,
-        resolve,
+        resolve: (approved) => {
+          abortSignal?.removeEventListener('abort', onAbort);
+          resolve(approved);
+        },
       });
+    }).catch((err) => {
+      if (abortSignal?.aborted) {
+        store.getState().ai.clearSubAgentApproval?.(
+          pendingApproval!.approvalId,
+        );
+        throwAbortWithSnapshot();
+      }
+      throw err;
     });

Also applies to: 520-532

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ai-core/src/agents/AgentUtils.ts` around lines 444 - 455, The
approval-await block using new Promise<boolean> is not abortable: if abortSignal
fires while waiting the pendingApproval and the `approval-requested` tool UI
remain stuck. Make this wait listen to the same `abortSignal`/cancel + snapshot
handling used later (the path around the code at 520-532) by registering an
abort handler before calling `store.getState().ai.requestSubAgentApproval` that
clears `pendingApproval` and invokes the same cancel/snapshot cleanup (or
rejects/resolves the promise) and then removes the abort listener; ensure
`throwIfAborted()` is still called before and after and that the handler clears
the pending approval and resolves/rejects the awaiting Promise so the flow
continues into the existing abort cleanup.
🧹 Nitpick comments (2)
packages/ai-core/src/components/Chat.tsx (1)

35-40: Optional: stabilize the default toolRenderBehavior value to avoid unnecessary consumer re-renders.

Using toolRenderBehavior = {} in the destructuring creates a fresh object on every render of Chat.Root, which causes ToolRenderBehaviorProvider's value identity to change each render and forces all consumers (e.g. FlatAgentRenderer, ActivityLogLine, ParentSummaryLine, OrchestratorLogLineInner) to re-read context. Since these consumers also call useMemo/selectors keyed on isPassthroughTool, a new identity will invalidate buildFlatSegments memoization on every render.

Consider hoisting a shared constant or memoizing:

♻️ Proposed refactor
-const Root: FC<RootProps> = ({children, toolRenderBehavior = {}}) => (
-  <ToolRenderBehaviorProvider value={toolRenderBehavior}>
+const EMPTY_TOOL_RENDER_BEHAVIOR: ToolRenderBehavior = {};
+
+const Root: FC<RootProps> = ({
+  children,
+  toolRenderBehavior = EMPTY_TOOL_RENDER_BEHAVIOR,
+}) => (
+  <ToolRenderBehaviorProvider value={toolRenderBehavior}>
     <SessionChatManager />
     {children}
   </ToolRenderBehaviorProvider>
 );

Note this only fully helps if callers also pass a stable toolRenderBehavior reference (e.g. a module-level const or a useMemo-ed value); worth calling out in the JSDoc for Chat.Root.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ai-core/src/components/Chat.tsx` around lines 35 - 40, The default
prop for toolRenderBehavior in the Root component creates a new object each
render which changes the ToolRenderBehaviorProvider value identity and forces
consumers to re-read context; fix this by replacing the inline default ({}) with
a stable reference—either hoist a module-level constant (e.g.,
DEFAULT_TOOL_RENDER_BEHAVIOR) or compute a memoized value in Root (useMemo) and
pass that to ToolRenderBehaviorProvider when toolRenderBehavior is not provided;
update the JSDoc for Root to recommend callers pass a stable reference too so
components like FlatAgentRenderer, ActivityLogLine, ParentSummaryLine, and
OrchestratorLogLineInner won’t trigger unnecessary re-renders.
packages/ai-core/src/components/FlatAgentRenderer.tsx (1)

667-684: Passthrough check on the synthesized parent tool call sees a stripped-down AgentToolCall.

parentToolCall is synthesized from only {toolCallId, toolName, input, state}, and then passed to isPassthroughTool(parentToolCall) at lines 682‑684 to decide whether to hide the top-level summary. If a host implements isPassthroughTool by looking at fields other than toolName/input (e.g. output, agentToolCalls, startedAt, errorText), the top-level check will behave differently from the recursive check inside buildFlatSegments, which receives the real AgentToolCall objects.

Worth either documenting on ToolStructureBehavior.isPassthroughTool that the predicate should rely only on tool identity/input (so the callback stays pure with respect to toolName), or threading more of the real parent fields through here so both call sites see equivalent shapes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ai-core/src/components/FlatAgentRenderer.tsx` around lines 667 -
684, The synthesized parentToolCall currently only contains {toolCallId,
toolName, input, state} but isPassthroughTool(parentToolCall) is later used to
decide hiding the summary, which can mismatch the recursive check in
buildFlatSegments that receives full AgentToolCall objects; fix by passing the
real/full AgentToolCall to isPassthroughTool (including fields like output,
agentToolCalls, startedAt, errorText) instead of the stripped parentToolCall, or
alternatively document/require that ToolStructureBehavior.isPassthroughTool must
only use tool identity/input (toolName/input) so both call sites behave the
same; update the call in FlatAgentRenderer (the parentToolCall creation/use and
the isPassthroughTool invocation) to use the complete object or adjust the
interface contract accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/ai-core/src/components/FlatAgentRenderer.tsx`:
- Around line 42-55: The current label precedence in ActivityLogLine and
OrchestratorLogLineInner contradicts the JSDoc for
ToolDisplayBehavior.getActivityLabel because reasoning currently wins before the
host callback; change the expression in both ActivityLogLine and
OrchestratorLogLineInner so it prefers getActivityLabel(toolCall) first, then
falls back to reasoning, then to 'Thinking...' if isPending, then to
toolCall.toolName (i.e. label = (getActivityLabel ? getActivityLabel(toolCall) :
undefined) ?? reasoning ?? (isPending ? 'Thinking...' : toolCall.toolName));
update only these expressions so the runtime behavior matches the documented
precedence for getActivityLabel.

---

Outside diff comments:
In `@packages/ai-core/src/agents/AgentUtils.ts`:
- Around line 444-455: The approval-await block using new Promise<boolean> is
not abortable: if abortSignal fires while waiting the pendingApproval and the
`approval-requested` tool UI remain stuck. Make this wait listen to the same
`abortSignal`/cancel + snapshot handling used later (the path around the code at
520-532) by registering an abort handler before calling
`store.getState().ai.requestSubAgentApproval` that clears `pendingApproval` and
invokes the same cancel/snapshot cleanup (or rejects/resolves the promise) and
then removes the abort listener; ensure `throwIfAborted()` is still called
before and after and that the handler clears the pending approval and
resolves/rejects the awaiting Promise so the flow continues into the existing
abort cleanup.

---

Nitpick comments:
In `@packages/ai-core/src/components/Chat.tsx`:
- Around line 35-40: The default prop for toolRenderBehavior in the Root
component creates a new object each render which changes the
ToolRenderBehaviorProvider value identity and forces consumers to re-read
context; fix this by replacing the inline default ({}) with a stable
reference—either hoist a module-level constant (e.g.,
DEFAULT_TOOL_RENDER_BEHAVIOR) or compute a memoized value in Root (useMemo) and
pass that to ToolRenderBehaviorProvider when toolRenderBehavior is not provided;
update the JSDoc for Root to recommend callers pass a stable reference too so
components like FlatAgentRenderer, ActivityLogLine, ParentSummaryLine, and
OrchestratorLogLineInner won’t trigger unnecessary re-renders.

In `@packages/ai-core/src/components/FlatAgentRenderer.tsx`:
- Around line 667-684: The synthesized parentToolCall currently only contains
{toolCallId, toolName, input, state} but isPassthroughTool(parentToolCall) is
later used to decide hiding the summary, which can mismatch the recursive check
in buildFlatSegments that receives full AgentToolCall objects; fix by passing
the real/full AgentToolCall to isPassthroughTool (including fields like output,
agentToolCalls, startedAt, errorText) instead of the stripped parentToolCall, or
alternatively document/require that ToolStructureBehavior.isPassthroughTool must
only use tool identity/input (toolName/input) so both call sites behave the
same; update the call in FlatAgentRenderer (the parentToolCall creation/use and
the isPassthroughTool invocation) to use the complete object or adjust the
interface contract accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f8b1d1cd-1efa-47dc-8f58-773de9993ce2

📥 Commits

Reviewing files that changed from the base of the PR and between a45a7ec and 5e85363.

📒 Files selected for processing (6)
  • packages/ai-core/src/agents/AgentUtils.ts
  • packages/ai-core/src/components/Chat.tsx
  • packages/ai-core/src/components/FlatAgentRenderer.tsx
  • packages/ai-core/src/index.ts
  • packages/ai-core/src/utils.ts
  • packages/ai/src/index.ts
💤 Files with no reviewable changes (1)
  • packages/ai-core/src/utils.ts

Comment thread packages/ai-core/src/components/FlatAgentRenderer.tsx

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a host-app customization surface for how tool calls are labeled/structured in ai-core’s FlatAgentRenderer, removes the now-unneeded humanizeToolName export, and fixes an abort-path issue where in-flight tool calls could remain stuck in non-terminal states.

Changes:

  • Introduces ToolRenderBehavior (structure + display callbacks) plumbed from Chat.Root to FlatAgentRenderer via context.
  • Removes humanizeToolName from ai-core utilities/exports and adjusts default labeling (“Thinking…” while pending).
  • Updates streamSubAgent to mark pending / approval-requested tool calls as cancelled errors on abort.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/ai/src/index.ts Re-exports new ToolRenderBehavior types from @sqlrooms/ai-core.
packages/ai-core/src/utils.ts Removes humanizeToolName helper implementation.
packages/ai-core/src/index.ts Stops exporting humanizeToolName; exports new tool render behavior types.
packages/ai-core/src/components/FlatAgentRenderer.tsx Adds tool-render behavior context/types; passthrough flattening; updated labeling logic and summary-line rendering.
packages/ai-core/src/components/Chat.tsx Adds toolRenderBehavior prop to Chat.Root and wires provider.
packages/ai-core/src/agents/AgentUtils.ts Ensures pending tool calls become cancelled errors when streams abort.
Comments suppressed due to low confidence (1)

packages/ai-core/src/components/FlatAgentRenderer.tsx:551

  • approval-requested tool parts currently fall into the isPending branch, which makes the default label render as "Thinking...". That’s misleading for approval flows (and regresses from previously showing the tool name). Consider mapping part.state === 'approval-requested' to toolCall.state = 'approval-requested' and treating it separately from "pending" so the fallback label remains the tool name (and the UI can distinguish approval vs running).
  const state = part.state;
  const isSuccess =
    state === 'output-available' || state === 'approval-responded';
  const isError = state === 'output-error' || state === 'output-denied';
  const isPending = !isSuccess && !isError;

  const toolCall: AgentToolCall = {
    toolCallId,
    toolName,
    input: part.input,
    output: (part as Record<string, unknown>).output,
    state: isError ? 'error' : isSuccess ? 'success' : 'pending',
  };

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/ai-core/src/components/Chat.tsx Outdated
Comment thread packages/ai-core/src/agents/AgentUtils.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/ai-core/src/agents/AgentUtils.ts (1)

456-467: ⚠️ Potential issue | 🟠 Major

Handle aborts while waiting for sub-agent approval.

Line 456 can wait forever if abortSignal fires after requestSubAgentApproval is surfaced, so the new cancellation normalization is never reached and the tool can remain approval-requested. Add an abort listener around the approval promise and reuse the same snapshot/cancel path.

🐛 Proposed fix
-    const approved = await new Promise<boolean>((resolve) => {
-      throwIfAborted();
-      store.getState().ai.requestSubAgentApproval!({
-        toolCallId: pendingApproval!.toolCallId,
-        approvalId: pendingApproval!.approvalId,
-        toolName: approvedEntry.toolName,
-        input: approvedEntry.input,
-        resolve,
-      });
-    });
-
-    throwIfAborted();
+    let approved: boolean;
+    try {
+      approved = await new Promise<boolean>((resolve, reject) => {
+        const onAbort = () => reject(new ToolAbortError(TOOL_CALL_CANCELLED));
+
+        if (abortSignal?.aborted) {
+          onAbort();
+          return;
+        }
+
+        abortSignal?.addEventListener('abort', onAbort, {once: true});
+        store.getState().ai.requestSubAgentApproval!({
+          toolCallId: pendingApproval!.toolCallId,
+          approvalId: pendingApproval!.approvalId,
+          toolName: approvedEntry.toolName,
+          input: approvedEntry.input,
+          resolve: (value) => {
+            abortSignal?.removeEventListener('abort', onAbort);
+            resolve(value);
+          },
+        });
+      });
+
+      throwIfAborted();
+    } catch (err) {
+      if (abortSignal?.aborted) {
+        store.getState().ai.clearSubAgentApproval?.(pendingApproval.approvalId);
+        markPendingToolCallsAsCancelled(toolCallMap);
+        const snapshot = buildAbortSnapshot(
+          parentToolCallId,
+          toolCallMap,
+          finalText,
+          store,
+        );
+        store.getState().ai.writeAbortSnapshot?.(parentToolCallId, snapshot);
+        pushProgress();
+        throw new ToolAbortError(TOOL_CALL_CANCELLED, snapshot);
+      }
+      throw err;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ai-core/src/agents/AgentUtils.ts` around lines 456 - 467, The
approval Promise can hang if abortSignal fires after requestSubAgentApproval is
emitted; wrap the new Promise in an abort-aware pattern: register an abort
listener on abortSignal before calling
store.getState().ai.requestSubAgentApproval and, if aborted, call the same
snapshot/cancel path used by throwIfAborted (reuse throwIfAborted behavior) to
resolve/reject the Promise and clean up the request (e.g., call the original
cancel logic for pendingApproval), and ensure you remove the abort listener when
the Promise resolves; update the code surrounding pendingApproval,
requestSubAgentApproval, and throwIfAborted to use this abort-listener + cleanup
pattern.
packages/ai-core/src/components/FlatAgentRenderer.tsx (1)

100-109: ⚠️ Potential issue | 🔴 Critical

Add runtime type-check for reasoning before rendering.

toolCall.input is unknown-shaped; the as string | undefined type assertion only silences TypeScript and does not validate runtime values. If a tool sends {reasoning: {...}} or {reasoning: [...]}, the assertion allows the non-string value through, causing it to be assigned to label and rendered in JSX. React will crash when rendering non-string primitives as children.

🛡️ Proposed fix
-  const reasoning = inputObj?.reasoning as string | undefined;
+  const rawReasoning = inputObj?.reasoning;
+  const reasoning =
+    typeof rawReasoning === 'string' ? rawReasoning : undefined;

Apply to both ActivityLogLine (lines 100–109) and OrchestratorLogLineInner (lines 580–589).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ai-core/src/components/FlatAgentRenderer.tsx` around lines 100 -
109, The code assigns reasoning with a TypeScript-only assertion and may pass
non-string values into label causing React to crash; update the runtime check in
ActivityLogLine (and similarly in OrchestratorLogLineInner) so that after
deriving inputObj and reasoning you validate with typeof reasoning === 'string'
before using it (e.g., const reasoning = typeof inputObj?.reasoning === 'string'
? inputObj.reasoning : undefined) and then build label using that validated
string along with getActivityLabel and isPending as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/ai-core/src/components/FlatAgentRenderer.tsx`:
- Around line 375-392: When iterating calls in FlatAgentRenderer, passthrough
tools with no nested calls are being dropped (see isPassthroughTool,
getNestedCalls, buildFlatSegments), causing a blank timeline; change the logic
so that if nestedSegments is empty you still record a placeholder/pending
tool-group (e.g., push the passthrough tool into pendingTools or emit a minimal
'tool-group' segment with display/state indicating "running" instead of
skipping), while preserving the existing handling that merges seg.tools when
seg.kind === 'tool-group' and calling flushTools()/segments.push for other seg
kinds; apply the same fix to the identical block that handles passthroughs
elsewhere in the file (the other buildFlatSegments loop).

---

Outside diff comments:
In `@packages/ai-core/src/agents/AgentUtils.ts`:
- Around line 456-467: The approval Promise can hang if abortSignal fires after
requestSubAgentApproval is emitted; wrap the new Promise in an abort-aware
pattern: register an abort listener on abortSignal before calling
store.getState().ai.requestSubAgentApproval and, if aborted, call the same
snapshot/cancel path used by throwIfAborted (reuse throwIfAborted behavior) to
resolve/reject the Promise and clean up the request (e.g., call the original
cancel logic for pendingApproval), and ensure you remove the abort listener when
the Promise resolves; update the code surrounding pendingApproval,
requestSubAgentApproval, and throwIfAborted to use this abort-listener + cleanup
pattern.

In `@packages/ai-core/src/components/FlatAgentRenderer.tsx`:
- Around line 100-109: The code assigns reasoning with a TypeScript-only
assertion and may pass non-string values into label causing React to crash;
update the runtime check in ActivityLogLine (and similarly in
OrchestratorLogLineInner) so that after deriving inputObj and reasoning you
validate with typeof reasoning === 'string' before using it (e.g., const
reasoning = typeof inputObj?.reasoning === 'string' ? inputObj.reasoning :
undefined) and then build label using that validated string along with
getActivityLabel and isPending as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 79087e78-0f95-4b91-979b-b3e4bb5e778d

📥 Commits

Reviewing files that changed from the base of the PR and between 5e85363 and 7bc2288.

📒 Files selected for processing (3)
  • packages/ai-core/src/agents/AgentUtils.ts
  • packages/ai-core/src/components/Chat.tsx
  • packages/ai-core/src/components/FlatAgentRenderer.tsx

Comment on lines 375 to +392
for (const tc of calls) {
if (isPassthroughTool?.(tc)) {
const nested = getNestedCalls(tc, agentProgress);
const nestedSegments = buildFlatSegments(
nested,
agentProgress,
isPassthroughTool,
);
for (const seg of nestedSegments) {
if (seg.kind === 'tool-group') {
pendingTools.push(...seg.tools);
} else {
flushTools();
segments.push(seg);
}
}
continue;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid a blank timeline before passthrough children arrive.

A pending passthrough tool with no nested calls yet is currently dropped entirely, and a passthrough parent with displayCalls.length === 0 also hides its summary. That can leave users with no visible running state until the first child tool appears.

🛠️ Proposed adjustment
     if (isPassthroughTool?.(tc)) {
       const nested = getNestedCalls(tc, agentProgress);
+      if (nested.length === 0) {
+        pendingTools.push(tc);
+        continue;
+      }
       const nestedSegments = buildFlatSegments(
         nested,
         agentProgress,
         isPassthroughTool,
       );
   const hideParentSummary = !!(
     parentToolCall &&
     isPassthroughTool &&
-    isPassthroughTool(parentToolCall)
+    isPassthroughTool(parentToolCall) &&
+    displayCalls.length > 0
   );

Also applies to: 682-690

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/ai-core/src/components/FlatAgentRenderer.tsx` around lines 375 -
392, When iterating calls in FlatAgentRenderer, passthrough tools with no nested
calls are being dropped (see isPassthroughTool, getNestedCalls,
buildFlatSegments), causing a blank timeline; change the logic so that if
nestedSegments is empty you still record a placeholder/pending tool-group (e.g.,
push the passthrough tool into pendingTools or emit a minimal 'tool-group'
segment with display/state indicating "running" instead of skipping), while
preserving the existing handling that merges seg.tools when seg.kind ===
'tool-group' and calling flushTools()/segments.push for other seg kinds; apply
the same fix to the identical block that handles passthroughs elsewhere in the
file (the other buildFlatSegments loop).

@lixun910 lixun910 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

👍 let's try it and get some feedbacks

@mihajlo-stojkovic
mihajlo-stojkovic merged commit b8cadf5 into main Apr 28, 2026
4 checks passed
@mihajlo-stojkovic
mihajlo-stojkovic deleted the feat/customizable-tool-render-behavior branch April 28, 2026 07:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants