chat: unify HITL approval UX behind a single paginated card and harden timeline supersede.

This commit is contained in:
CREDO23 2026-05-09 21:44:54 +02:00
parent 89e4953800
commit 2e132513be
25 changed files with 604 additions and 1157 deletions

View file

@ -7,7 +7,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import type { HitlApprovalCard, HitlDecision, InterruptResult } from "../types";
import type { HitlDecision, InterruptResult, PerToolApprovalCard } from "../types";
import { isInterruptResult } from "../types";
import { useHitlDecision } from "../use-hitl-decision";
import { useHitlPhase } from "../use-hitl-phase";
@ -178,7 +178,7 @@ export function isDoomLoopInterrupt(result: unknown): boolean {
* ``isDoomLoopInterrupt(result)`` is true. Caller is responsible for
* the discrimination; this card receives a known ``InterruptResult``.
*/
export const DoomLoopApproval: HitlApprovalCard = ({ toolName, args, result }) => {
export const DoomLoopApproval: PerToolApprovalCard = ({ toolName, args, result }) => {
const { dispatch } = useHitlDecision();
return (
<DoomLoopCardView

View file

@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import type { HitlApprovalCard, HitlDecision, InterruptResult } from "../types";
import type { HitlDecision, InterruptResult, PerToolApprovalCard } from "../types";
import { useHitlDecision } from "../use-hitl-decision";
import { useHitlPhase } from "../use-hitl-phase";
@ -248,7 +248,7 @@ function GenericApprovalCardView({
* guard; this card receives a known ``InterruptResult`` and skips the
* defensive runtime check.
*/
export const GenericHitlApproval: HitlApprovalCard = ({ toolName, args, result }) => {
export const GenericHitlApproval: PerToolApprovalCard = ({ toolName, args, result }) => {
const { dispatch } = useHitlDecision();
return (
<GenericApprovalCardView

View file

@ -0,0 +1,28 @@
"use client";
import { createContext, useContext } from "react";
import type { HitlDecision } from "../types";
/**
* Decisions are keyed by step index (not toolCallId) because the
* resume protocol is positional backend pairs ``decisions[i]`` with
* ``action_requests[i]``. ``stage`` always targets the active step,
* so per-tool bodies stay tcId-agnostic.
*/
export interface HitlApprovalAPI {
total: number;
currentStep: number;
decisions: ReadonlyArray<HitlDecision | undefined>;
stage: (decision: HitlDecision) => void;
next: () => void;
prev: () => void;
goToStep: (i: number) => void;
canAdvance: boolean;
canSubmit: boolean;
}
export const HitlApprovalContext = createContext<HitlApprovalAPI | null>(null);
export function useHitlApproval(): HitlApprovalAPI | null {
return useContext(HitlApprovalContext);
}

View file

@ -0,0 +1,267 @@
"use client";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { type FC, useCallback, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import {
FallbackToolBody,
getToolComponent,
type TimelineToolProps,
} from "@/features/chat-messages/timeline/tool-registry";
import type {
HitlDecision,
InterruptActionRequest,
InterruptResult,
InterruptReviewConfig,
} from "../types";
import { type HitlApprovalAPI, HitlApprovalContext } from "./approval-context";
import type { PendingInterruptState } from "./pending-interrupt-context";
/**
* Narrow the bundle interrupt to the active step so per-tool bodies
* see the same single-action shape they're written against. Mirrors
* any staged decision onto ``__decided__`` (and edited args onto
* ``args``) so revisiting a decided step via Prev shows the past
* choice instead of pristine Approve/Reject buttons.
*/
function sliceForStep(
interruptData: Record<string, unknown>,
action: InterruptActionRequest,
reviewConfig: InterruptReviewConfig | undefined,
stagedDecision: HitlDecision | undefined
): InterruptResult {
const baseAction =
stagedDecision?.type === "edit" && stagedDecision.edited_action
? { ...action, args: stagedDecision.edited_action.args }
: action;
const sliced: InterruptResult = {
...(interruptData as Partial<InterruptResult>),
__interrupt__: true,
action_requests: [baseAction],
review_configs: reviewConfig ? [reviewConfig] : [],
} as InterruptResult;
if (stagedDecision) {
(sliced as unknown as Record<string, unknown>).__decided__ = stagedDecision.type;
}
return sliced;
}
/**
* Single chrome for every HITL approval flow. Branches on
* ``action_requests.length``: 1 per-tool body alone with auto-
* submit on first decision; 2 per-tool body + inline pager +
* Submit-decisions (fires only once every step has a decision).
* Decisions are positional to match the resume protocol.
*/
export const HitlApprovalCard: FC<{
pendingInterrupt: PendingInterruptState;
onSubmit: (decisions: HitlDecision[]) => void;
}> = ({ pendingInterrupt, onSubmit }) => {
const interruptData = pendingInterrupt.interruptData as InterruptResult & Record<string, unknown>;
const actionRequests = (interruptData.action_requests ?? []) as InterruptActionRequest[];
const reviewConfigs = (interruptData.review_configs ?? []) as InterruptReviewConfig[];
const total = actionRequests.length;
const isMulti = total >= 2;
const [currentStep, setCurrentStep] = useState(0);
const [decisions, setDecisions] = useState<(HitlDecision | undefined)[]>(() =>
Array.from({ length: total }, () => undefined)
);
// Reset on a new interrupt-request while still mounted (rapid
// back-to-back resumes), otherwise stale decisions would leak.
const [prevActionsRef, setPrevActionsRef] = useState(actionRequests);
if (prevActionsRef !== actionRequests) {
setPrevActionsRef(actionRequests);
setCurrentStep(0);
setDecisions(Array.from({ length: total }, () => undefined));
}
const submitFromDecisions = useCallback(
(next: (HitlDecision | undefined)[]) => {
if (next.length !== total) return;
if (next.some((d) => d === undefined)) return;
onSubmit(next as HitlDecision[]);
},
[onSubmit, total]
);
const stage = useCallback(
(decision: HitlDecision) => {
// Compute next array outside the setter so the side effect
// (auto-submit / step advance) runs once under StrictMode.
const updated = decisions.slice();
updated[currentStep] = decision;
setDecisions(updated);
if (!isMulti) {
submitFromDecisions(updated);
return;
}
// Skip to the next undecided step rather than +1 so users
// who jumped via Prev don't get pulled back to a decided
// step.
let target = currentStep;
for (let i = currentStep + 1; i < updated.length; i++) {
if (updated[i] === undefined) {
target = i;
break;
}
}
if (target !== currentStep) setCurrentStep(target);
},
[currentStep, decisions, isMulti, submitFromDecisions]
);
const next = useCallback(() => {
setCurrentStep((s) => Math.min(s + 1, Math.max(0, total - 1)));
}, [total]);
const prev = useCallback(() => {
setCurrentStep((s) => Math.max(s - 1, 0));
}, []);
const goToStep = useCallback(
(i: number) => {
if (i < 0 || i >= total) return;
setCurrentStep(i);
},
[total]
);
const submit = useCallback(() => {
submitFromDecisions(decisions);
}, [decisions, submitFromDecisions]);
const stagedCount = useMemo(() => decisions.filter((d) => d !== undefined).length, [decisions]);
const canSubmit = stagedCount === total && total > 0;
const canAdvance = decisions[currentStep] !== undefined;
const api = useMemo<HitlApprovalAPI>(
() => ({
total,
currentStep,
decisions,
stage,
next,
prev,
goToStep,
canAdvance,
canSubmit,
}),
[total, currentStep, decisions, stage, next, prev, goToStep, canAdvance, canSubmit]
);
if (total === 0) return null;
const action = actionRequests[currentStep];
const reviewConfig = reviewConfigs[currentStep];
const stagedDecision = decisions[currentStep];
const sliced = sliceForStep(interruptData, action, reviewConfig, stagedDecision);
const Body = getToolComponent(action.name) ?? FallbackToolBody;
const bodyProps: TimelineToolProps = {
// Per-step key remounts the body on navigation so per-tool
// internal state (useHitlPhase, edit drafts) doesn't bleed
// between steps.
toolCallId: pendingInterrupt.bundleToolCallIds[currentStep] ?? `step-${currentStep}`,
toolName: action.name,
args: (sliced.action_requests[0]?.args ?? {}) as Record<string, unknown>,
argsText: undefined,
result: sliced,
langchainToolCallId: undefined,
status: stagedDecision ? "completed" : "running",
};
return (
<HitlApprovalContext.Provider value={api}>
<div className="space-y-2">
<div key={`approval-step-${currentStep}`}>
<Body {...bodyProps} />
</div>
{isMulti && (
<PagerBar
currentStep={currentStep}
total={total}
stagedCount={stagedCount}
canAdvance={canAdvance}
canSubmit={canSubmit}
actionName={action.name}
onPrev={prev}
onNext={next}
onSubmit={submit}
/>
)}
</div>
</HitlApprovalContext.Provider>
);
};
const PagerBar: FC<{
currentStep: number;
total: number;
stagedCount: number;
canAdvance: boolean;
canSubmit: boolean;
actionName: string;
onPrev: () => void;
onNext: () => void;
onSubmit: () => void;
}> = ({
currentStep,
total,
stagedCount,
canAdvance,
canSubmit,
actionName,
onPrev,
onNext,
onSubmit,
}) => (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-2 py-1.5 text-sm">
<Button
type="button"
size="sm"
variant="outline"
onClick={onPrev}
disabled={currentStep === 0}
aria-label="Previous approval"
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<span className="font-medium tabular-nums">
{currentStep + 1} / {total}
</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
{stagedCount} of {total} decided
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={onNext}
disabled={!canAdvance || currentStep >= total - 1}
aria-label="Next approval"
title={!canAdvance ? "Decide on this action first" : undefined}
>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<span className="ml-2 truncate text-xs text-muted-foreground" title={actionName}>
{getToolDisplayName(actionName)}
</span>
<div className="ml-auto">
<Button
type="button"
size="sm"
onClick={onSubmit}
disabled={!canSubmit}
title={canSubmit ? "Submit decisions" : "Decide every action first"}
>
Submit decisions
</Button>
</div>
</div>
);

View file

@ -0,0 +1,9 @@
export type { HitlApprovalAPI } from "./approval-context";
export { HitlApprovalContext, useHitlApproval } from "./approval-context";
export { HitlApprovalCard } from "./hitl-approval-card";
export {
PendingInterruptProvider,
type PendingInterruptState,
type PendingInterruptValue,
usePendingInterrupt,
} from "./pending-interrupt-context";

View file

@ -0,0 +1,44 @@
"use client";
import { createContext, type ReactNode, useContext } from "react";
import type { HitlDecision } from "../types";
/** Snapshot of one in-flight HITL interrupt; ``null`` when nothing is pending. */
export interface PendingInterruptState {
threadId: number;
assistantMsgId: string;
interruptData: Record<string, unknown>;
bundleToolCallIds: string[];
}
export interface PendingInterruptValue {
pendingInterrupt: PendingInterruptState | null;
onSubmit: (decisions: HitlDecision[]) => void;
}
const PendingInterruptContext = createContext<PendingInterruptValue | null>(null);
/**
* Bridges page-level interrupt state to the Timeline, which is mounted
* by assistant-ui and can't be prop-drilled. Mount once at the chat
* page root.
*/
export function PendingInterruptProvider({
pendingInterrupt,
onSubmit,
children,
}: {
pendingInterrupt: PendingInterruptState | null;
onSubmit: (decisions: HitlDecision[]) => void;
children: ReactNode;
}) {
return (
<PendingInterruptContext.Provider value={{ pendingInterrupt, onSubmit }}>
{children}
</PendingInterruptContext.Provider>
);
}
export function usePendingInterrupt(): PendingInterruptValue | null {
return useContext(PendingInterruptContext);
}

View file

@ -1,157 +0,0 @@
"use client";
import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react";
import type { HitlDecision } from "../types";
export type BundleSubmit = (orderedDecisions: HitlDecision[]) => void;
export interface HitlBundleAPI {
toolCallIds: readonly string[];
currentStep: number;
stagedCount: number;
isInBundle: (toolCallId: string) => boolean;
isCurrentStep: (toolCallId: string) => boolean;
getStaged: (toolCallId: string) => HitlDecision | undefined;
stage: (toolCallId: string, decision: HitlDecision) => void;
goToStep: (i: number) => void;
next: () => void;
prev: () => void;
submit: () => void;
}
const HitlBundleContext = createContext<HitlBundleAPI | null>(null);
const ToolCallIdContext = createContext<string | null>(null);
export function useHitlBundle(): HitlBundleAPI | null {
return useContext(HitlBundleContext);
}
export function useToolCallIdContext(): string | null {
return useContext(ToolCallIdContext);
}
export function ToolCallIdProvider({
toolCallId,
children,
}: {
toolCallId: string;
children: ReactNode;
}) {
return <ToolCallIdContext.Provider value={toolCallId}>{children}</ToolCallIdContext.Provider>;
}
interface HitlBundleProviderProps {
toolCallIds: readonly string[] | null;
onSubmit: BundleSubmit;
children: ReactNode;
}
/**
* Coordinates N pending HITL decisions into ONE ordered submission.
*
* Active only when ``toolCallIds`` has 2+ entries (parallel interrupts);
* single-card interrupts bypass the bundle entirely (``useHitlDecision``
* fires the ``hitl-decision`` window event directly).
*
* Pager UX: ``tool-call-item.tsx`` reads ``isInBundle`` + ``isCurrentStep``
* to render only the current-step card; ``timeline.tsx`` mounts
* ``<PagerChrome />`` once when this Provider is active. Submission is
* user-initiated via the pager's "Submit decisions" button (calls
* ``submit()``); not auto.
*/
export function HitlBundleProvider({ toolCallIds, onSubmit, children }: HitlBundleProviderProps) {
const active = toolCallIds !== null && toolCallIds.length >= 2;
const ids = useMemo(() => (active ? [...toolCallIds] : []), [active, toolCallIds]);
const bundleKey = ids.join("|");
const [prevBundleKey, setPrevBundleKey] = useState(bundleKey);
const [staged, setStaged] = useState<Map<string, HitlDecision>>(() => new Map());
const [currentStep, setCurrentStep] = useState(0);
if (bundleKey !== prevBundleKey) {
setPrevBundleKey(bundleKey);
setStaged(new Map());
setCurrentStep(0);
}
const isInBundle = useCallback((tcId: string) => ids.includes(tcId), [ids]);
const isCurrentStep = useCallback(
(tcId: string) => active === true && ids[currentStep] === tcId,
[active, ids, currentStep]
);
const getStaged = useCallback((tcId: string) => staged.get(tcId), [staged]);
const stage = useCallback(
(tcId: string, decision: HitlDecision) => {
if (!active || !ids.includes(tcId)) return;
setStaged((prev) => {
const next = new Map(prev);
next.set(tcId, decision);
return next;
});
window.dispatchEvent(
new CustomEvent("hitl-stage", { detail: { toolCallId: tcId, decision } })
);
const idx = ids.indexOf(tcId);
if (idx >= 0 && idx < ids.length - 1) {
setCurrentStep(idx + 1);
}
},
[active, ids]
);
const goToStep = useCallback(
(i: number) => {
if (i < 0 || i >= ids.length) return;
setCurrentStep(i);
},
[ids.length]
);
const next = useCallback(() => {
setCurrentStep((s) => Math.min(s + 1, Math.max(0, ids.length - 1)));
}, [ids.length]);
const prev = useCallback(() => {
setCurrentStep((s) => Math.max(s - 1, 0));
}, []);
const submit = useCallback(() => {
if (!active) return;
if (staged.size !== ids.length) return;
const ordered: HitlDecision[] = [];
for (const tcId of ids) {
const d = staged.get(tcId);
if (!d) return;
ordered.push(d);
}
onSubmit(ordered);
}, [active, ids, staged, onSubmit]);
const value = useMemo<HitlBundleAPI | null>(() => {
if (!active) return null;
return {
toolCallIds: ids,
currentStep,
stagedCount: staged.size,
isInBundle,
isCurrentStep,
getStaged,
stage,
goToStep,
next,
prev,
submit,
};
}, [
active,
ids,
currentStep,
staged,
isInBundle,
isCurrentStep,
getStaged,
stage,
goToStep,
next,
prev,
submit,
]);
return <HitlBundleContext.Provider value={value}>{children}</HitlBundleContext.Provider>;
}

View file

@ -1,8 +0,0 @@
export type { BundleSubmit, HitlBundleAPI } from "./bundle-context";
export {
HitlBundleProvider,
ToolCallIdProvider,
useHitlBundle,
useToolCallIdContext,
} from "./bundle-context";
export { PagerChrome } from "./pager-chrome";

View file

@ -1,65 +0,0 @@
"use client";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useHitlBundle } from "./bundle-context";
/**
* Prev/next nav and Submit for the current step of an active HITL bundle.
* Submission is gated on every action_request having a staged decision.
*
* Mounted ONCE by ``timeline.tsx`` when the bundle is active. Does NOT
* wrap individual cards. Reads bundle state via ``useHitlBundle()``;
* renders nothing when no bundle is active.
*/
export function PagerChrome() {
const bundle = useHitlBundle();
if (!bundle) return null;
const total = bundle.toolCallIds.length;
const step = bundle.currentStep;
const allStaged = bundle.stagedCount === total;
return (
<div className="mt-3 flex items-center gap-2 rounded-md border border-border bg-muted/40 p-2 text-sm">
<Button
type="button"
size="sm"
variant="outline"
onClick={bundle.prev}
disabled={step === 0}
aria-label="Previous approval"
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<span className="font-medium tabular-nums">
{step + 1} / {total}
</span>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">
{bundle.stagedCount} of {total} decided
</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={bundle.next}
disabled={step >= total - 1}
aria-label="Next approval"
>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<div className="ml-auto">
<Button
type="button"
size="sm"
onClick={bundle.submit}
disabled={!allStaged}
title={allStaged ? "Submit decisions" : "Decide every action first"}
>
Submit decisions
</Button>
</div>
</div>
);
}

View file

@ -1,13 +1,13 @@
export { DoomLoopApproval, GenericHitlApproval, isDoomLoopInterrupt } from "./approval-cards";
export {
type BundleSubmit,
type HitlBundleAPI,
HitlBundleProvider,
PagerChrome,
ToolCallIdProvider,
useHitlBundle,
useToolCallIdContext,
} from "./bundle";
type HitlApprovalAPI,
HitlApprovalCard,
PendingInterruptProvider,
type PendingInterruptState,
type PendingInterruptValue,
useHitlApproval,
usePendingInterrupt,
} from "./approval";
export { DoomLoopApproval, GenericHitlApproval, isDoomLoopInterrupt } from "./approval-cards";
export {
closeHitlEditPanelAtom,
type ExtraField,
@ -18,13 +18,13 @@ export {
openHitlEditPanelAtom,
} from "./edit-panel";
export type {
HitlApprovalCard,
HitlApprovalCardProps,
HitlDecision,
HitlPhase,
InterruptActionRequest,
InterruptResult,
InterruptReviewConfig,
PerToolApprovalCard,
PerToolApprovalCardProps,
} from "./types";
export { isInterruptResult } from "./types";
export { useHitlDecision } from "./use-hitl-decision";

View file

@ -41,11 +41,19 @@ export interface HitlDecision {
export type HitlPhase = "pending" | "processing" | "complete" | "rejected";
export interface HitlApprovalCardProps {
export interface PerToolApprovalCardProps {
toolName: string;
toolCallId: string;
args: Record<string, unknown>;
result: InterruptResult;
}
export type HitlApprovalCard = (props: HitlApprovalCardProps) => ReactNode;
/**
* Type signature for per-tool fallback approval cards (e.g.
* ``GenericHitlApproval``, ``DoomLoopApproval``) mounted by
* ``FallbackToolBody`` for unregistered HITL tools.
*
* Distinct from ``HitlApprovalCard`` (the high-level multi/single
* chrome) this is the per-tool body that the chrome wraps.
*/
export type PerToolApprovalCard = (props: PerToolApprovalCardProps) => ReactNode;

View file

@ -1,44 +1,31 @@
import { useCallback } from "react";
import { useHitlBundle, useToolCallIdContext } from "./bundle/bundle-context";
import { useHitlApproval } from "./approval/approval-context";
import type { HitlDecision } from "./types";
/**
* Dispatches a HITL decision from inside an approval card.
*
* Behavior:
* - **Bundle active** (N2 parallel interrupts) AND this card's
* ``toolCallId`` is in the bundle: stage the (single) decision
* against this ``toolCallId`` so the bundle can submit one ordered
* N-payload when every card has decided. Multi-decision dispatches
* in this path are a programming error: only ``decisions[0]`` is
* staged; a dev warning fires for the rest.
* - **Otherwise (N=1 or no bundle):** dispatch the ``hitl-decision``
* window event directly with the full ``decisions`` array. The host
* page's listener calls ``runtime.resume`` with the same array.
*
* Cards always call ``dispatch([decision])`` and don't need to know
* which path they're on.
* Per-tool components always call ``dispatch([decision])``. We route
* through ``HitlApprovalContext`` when mounted inside an approval
* card (so multi-approval can stage and pager-navigate), and fall
* back to the ``hitl-decision`` window event for standalone callers.
*/
export function useHitlDecision() {
const bundle = useHitlBundle();
const toolCallId = useToolCallIdContext();
const approval = useHitlApproval();
const dispatch = useCallback(
(decisions: HitlDecision[]) => {
if (bundle && toolCallId && bundle.isInBundle(toolCallId) && decisions.length > 0) {
if (approval && decisions.length > 0) {
if (decisions.length > 1 && process.env.NODE_ENV !== "production") {
console.warn(
"[hitl] dispatch received %d decisions inside an active bundle; only [0] will be staged for %s",
decisions.length,
toolCallId
"[hitl] dispatch received %d decisions inside an approval card; only [0] will be staged",
decisions.length
);
}
bundle.stage(toolCallId, decisions[0]);
approval.stage(decisions[0]);
return;
}
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions } }));
},
[bundle, toolCallId]
[approval]
);
return { dispatch };

View file

@ -1,9 +1,9 @@
import type { ItemStatus, ReasoningItem, TimelineItem, ToolCallItem } from "./types";
/**
* The thinking-step shape produced by the streaming pipeline (see
* ``data-thinking-step`` SSE events). Kept structural here so this
* builder doesn't depend on the legacy ``thinking-steps.tsx`` file.
* Structural shape of the relay's ``data-thinking-step`` payload.
* Declared here (not imported) so the builder stays free of the
* legacy ``thinking-steps.tsx`` dependency.
*/
export interface ThinkingStepInput {
id: string;
@ -13,12 +13,7 @@ export interface ThinkingStepInput {
metadata?: Record<string, unknown>;
}
/**
* The minimum tool-call-part shape we read from message content. We
* accept ``unknown[]`` and structurally narrow per part the assistant-
* ui content type has many shapes, but only ``tool-call`` parts matter
* here.
*/
/** Narrowed tool-call shape; the assistant-ui content type is wider. */
interface ToolCallPart {
type: "tool-call";
toolCallId: string;
@ -43,15 +38,101 @@ function asNonEmptyString(v: unknown): string | undefined {
}
/**
* Derive coarse status for a tool-call from its result shape. Used
* when the tool-call has no joined thinking step (orphan path).
* True iff THIS tool-call is the actual interrupt request (carries an
* ``action_requests[]``), not just a parent ``task`` wrapper that
* inherited the propagated ``__interrupt__`` flag. Pending requests
* are hidden so ``HitlApprovalCard`` owns the pending UX; the
* ``length > 0`` guard keeps parent task wrappers visible so their
* children stay indented under the delegation span.
*/
function isPendingHitlInterrupt(result: unknown): boolean {
if (typeof result !== "object" || result === null) return false;
const r = result as {
__interrupt__?: unknown;
__decided__?: unknown;
action_requests?: unknown;
};
return (
r.__interrupt__ === true &&
r.__decided__ === undefined &&
Array.isArray(r.action_requests) &&
r.action_requests.length > 0
);
}
/**
* Stable interrupt signal across pre/post decision: the resume flow
* spreads the original result and only adds ``__decided__``, so
* ``__interrupt__`` alone is the right key.
*/
function hasInterruptMarker(result: unknown): boolean {
if (typeof result !== "object" || result === null) return false;
return (result as { __interrupt__?: unknown }).__interrupt__ === true;
}
interface ToolCallSlim {
toolName: string;
toolCallId: string;
result?: unknown;
spanId?: string;
}
/**
* During the live-resume window the in-memory message holds BOTH the
* OLD interrupt-frame parts AND the freshly-streamed resume parts in
* a new ``task`` scope. Without this filter we'd render both until
* the next reload (where ``filterSupersededAbortedMessages`` drops
* the OLD row upstream).
*
* - HITL ``__decided__: "reject"`` ``cancelled``
* - Has any result ``completed``
* - No result yet ``running``
*
* The per-tool component picks its own visual state from the result;
* this is only the timeline chrome's coarse signal.
* A tool-call is "interrupt-affected" when it either carries
* ``__interrupt__`` directly or sits in a span that contains one. An
* affected call is superseded iff a later same-name call in a
* different scope exists. The conservative branch (no successor)
* preserves rejects that ended the run with no replacement.
*/
function collectSupersededToolCallIds(content: readonly unknown[]): Set<string> {
const slims: ToolCallSlim[] = [];
for (const part of content) {
if (!isToolCallPart(part)) continue;
slims.push({
toolName: part.toolName,
toolCallId: part.toolCallId,
result: part.result,
spanId: asNonEmptyString(part.metadata?.spanId),
});
}
const interruptedSpans = new Set<string>();
for (const tc of slims) {
if (!hasInterruptMarker(tc.result)) continue;
if (tc.spanId) interruptedSpans.add(tc.spanId);
}
const superseded = new Set<string>();
for (let i = 0; i < slims.length; i++) {
const tc = slims[i];
const inInterruptedSpan = tc.spanId !== undefined && interruptedSpans.has(tc.spanId);
const isDirectInterrupt = hasInterruptMarker(tc.result);
if (!inInterruptedSpan && !isDirectInterrupt) continue;
for (let j = i + 1; j < slims.length; j++) {
// Both-undefined counts as different scopes so standalone
// HITL tools (no delegation) get caught.
const sameSpan = tc.spanId !== undefined && slims[j].spanId === tc.spanId;
if (slims[j].toolName === tc.toolName && !sameSpan) {
superseded.add(tc.toolCallId);
break;
}
}
}
return superseded;
}
/**
* Coarse status for orphan tool-calls (no joined thinking step). The
* per-tool body picks its own visual state from ``result``; this
* only feeds the chrome dot/header.
*/
function deriveToolCallStatus(result: unknown): ItemStatus {
if (!result) return "running";
@ -68,119 +149,30 @@ function mapStepStatus(status: ThinkingStepInput["status"]): ItemStatus {
}
/**
* True when a tool-call's result carries an HITL interrupt. Catches
* both pre-decision (``__interrupt__: true``) and post-decision
* (``__interrupt__: true, __decided__: ``) states the resume
* flow's decision-application spreads the original result and only
* adds ``__decided__``, so ``__interrupt__`` alone is the stable
* signal.
*/
function isInterruptInResult(result: unknown): boolean {
if (typeof result !== "object" || result === null) return false;
return (result as { __interrupt__?: unknown }).__interrupt__ === true;
}
/**
* Build the set of tool-call ids that have been superseded by the
* resume stream's continuation.
*
* The challenge: during the live resume window, the in-memory message
* holds BOTH the rehydrated interrupt-frame parts (the OLD ``task`` +
* its inner ``update_notion_page`` whose result has ``__decided__``)
* AND the freshly-streamed resume parts (a NEW ``task`` + a NEW
* ``update_notion_page`` with the actual success result). We need to
* drop the entire OLD delegation chain so only the NEW one renders.
*
* Two-stage detection:
*
* 1. **Identify "interrupted spans"** any spanId that contains at
* least one tool-call whose ``result.__interrupt__`` is true. This
* captures both the inner decided tool and its outer ``task``
* wrapper (which itself has no result but shares the spanId).
* Without this the wrapper survives as an orphan parent the
* stray "Notion" row we saw post-approve.
*
* 2. **Mark a tool-call as superseded** when (a) it sits in an
* interrupted span OR carries the interrupt marker directly, AND
* (b) a later tool-call with the same ``toolName`` in a DIFFERENT
* span exists. The "different span" guard prevents self-supersession
* within the same delegation episode.
*
* Mirrors the message-level rule in
* ``filterSupersededAbortedMessages`` but at the part level same
* data-shape problem (interrupt frame + resume continuation cohabiting
* one in-memory message) one level down.
*
* Conservative: an interrupted tool-call with NO later same-named
* different-span successor stays (e.g. a reject that ended the run, a
* never-resumed decision).
*/
function collectSupersededToolCallIds(content: readonly unknown[]): Set<string> {
const toolCallParts: ToolCallPart[] = [];
for (const part of content) {
if (isToolCallPart(part)) toolCallParts.push(part);
}
const interruptedSpans = new Set<string>();
for (const part of toolCallParts) {
if (!isInterruptInResult(part.result)) continue;
const sid = asNonEmptyString(part.metadata?.spanId);
if (sid) interruptedSpans.add(sid);
}
const superseded = new Set<string>();
for (let i = 0; i < toolCallParts.length; i++) {
const part = toolCallParts[i];
const sid = asNonEmptyString(part.metadata?.spanId);
const inInterruptedSpan = sid !== undefined && interruptedSpans.has(sid);
const isDirectInterrupt = isInterruptInResult(part.result);
if (!inInterruptedSpan && !isDirectInterrupt) continue;
for (let j = i + 1; j < toolCallParts.length; j++) {
const jsid = asNonEmptyString(toolCallParts[j].metadata?.spanId);
// Both-undefined counts as "different scopes" so standalone
// HITL tools (no delegation, no spanId) get caught. Naive
// ``jsid !== sid`` misses them since ``undefined !==
// undefined`` is false.
const sameSpan = sid !== undefined && jsid === sid;
if (toolCallParts[j].toolName === part.toolName && !sameSpan) {
superseded.add(part.toolCallId);
break;
}
}
}
return superseded;
}
/**
* Build the timeline's flat ``TimelineItem[]`` from thinking steps +
* message content tool-calls.
*
* 1. Index tool-call parts by ``metadata.thinkingStepId`` (O(1) join).
* 2. Walk thinking steps in order. Joined ``ToolCallItem``;
* unjoined ``ReasoningItem``.
* 3. Append unjoined tool-calls as orphan ``ToolCallItem``s (legacy
* history pre-``thinkingStepId``).
*
* Pure: no React, no I/O. ``result`` is forwarded verbatim per-tool
* components own its discrimination. ``isThreadRunning`` lives in
* ``timeline.tsx`` as a runtime override.
* Pure builder: thinking steps + message content ``TimelineItem[]``.
* Joins tool-calls to thinking steps via ``metadata.thinkingStepId``,
* appends unjoined tool-calls as orphans, drops superseded
* interrupt-frame parts and pending HITL requests (those are owned
* by ``HitlApprovalCard``). ``result`` is forwarded verbatim so
* per-tool bodies can discriminate.
*/
export function buildTimeline(
thinkingSteps: readonly ThinkingStepInput[],
content: readonly unknown[] | undefined
): TimelineItem[] {
const toolByStepId = new Map<string, ToolCallPart>();
const supersededStepIds = new Set<string>();
const consumedToolCallIds = new Set<string>();
const supersededToolCallIds = content
? collectSupersededToolCallIds(content)
: new Set<string>();
const superseded = content ? collectSupersededToolCallIds(content) : new Set<string>();
if (content) {
for (const part of content) {
if (!isToolCallPart(part)) continue;
const tid = asNonEmptyString(part.metadata?.thinkingStepId);
if (superseded.has(part.toolCallId)) {
if (tid) supersededStepIds.add(tid);
continue;
}
if (tid) toolByStepId.set(tid, part);
}
}
@ -188,15 +180,14 @@ export function buildTimeline(
const items: TimelineItem[] = [];
for (const step of thinkingSteps) {
// Drop the step alongside its superseded tool-call, otherwise
// it'd render as an orphan reasoning row with the OLD title.
if (supersededStepIds.has(step.id)) continue;
const stepSpanId = asNonEmptyString(step.metadata?.spanId);
const joined = toolByStepId.get(step.id);
// Drop the step entirely when it joins a superseded tool-call:
// the resume stream has emitted a fresh same-named tool-call
// (with its own thinking step) that takes over the row.
// Without this, the timeline shows two "Notion → Update
// Notion page" groups during the live resume window.
if (joined && supersededToolCallIds.has(joined.toolCallId)) {
if (joined && isPendingHitlInterrupt(joined.result)) {
consumedToolCallIds.add(joined.toolCallId);
continue;
}
@ -236,7 +227,8 @@ export function buildTimeline(
for (const part of content) {
if (!isToolCallPart(part)) continue;
if (consumedToolCallIds.has(part.toolCallId)) continue;
if (supersededToolCallIds.has(part.toolCallId)) continue;
if (superseded.has(part.toolCallId)) continue;
if (isPendingHitlInterrupt(part.result)) continue;
const orphan: ToolCallItem = {
kind: "tool-call",
id: part.toolCallId,

View file

@ -2,25 +2,32 @@
import { makeAssistantDataUI, useAuiState } from "@assistant-ui/react";
import { useMemo } from "react";
import { PendingInterruptProvider, usePendingInterrupt } from "@/features/chat-messages/hitl";
import { buildTimeline, type ThinkingStepInput } from "./build-timeline";
import { Timeline } from "./timeline";
const noopSubmit = () => {};
/**
* assistant-ui data UI for the ``thinking-steps`` data-part. Receives
* the relay's step array as ``data``, reads message ``content`` via
* ``useAuiState``, builds the unified ``TimelineItem[]`` once
* (``buildTimeline`` is pure), and renders the ``Timeline``.
* assistant-ui data UI for the ``thinking-steps`` data-part.
*
* ``isMessageStreaming`` is the AND of thread-running + this-message-
* is-last; that flag drives the ``isThreadRunning`` runtime override
* in ``Timeline`` (stale "running" "completed" once the thread
* stops). Mirrors the legacy ``ThinkingStepsDataRenderer`` semantics.
* Re-scopes the global ``PendingInterruptProvider`` per message: the
* approval card only mounts under the assistant message that owns
* the interrupt (otherwise every message in scrollback would render
* its own card).
*/
function TimelineDataRenderer({ data }: { name: string; data: unknown }) {
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
const isLastMessage = useAuiState(({ message }) => message?.isLast ?? false);
const isMessageStreaming = isThreadRunning && isLastMessage;
const content = useAuiState(({ message }) => message?.content);
const messageId = useAuiState(({ message }) => message?.id);
const pendingValue = usePendingInterrupt();
const pendingForThisMessage =
pendingValue?.pendingInterrupt && pendingValue.pendingInterrupt.assistantMsgId === messageId
? pendingValue.pendingInterrupt
: null;
const onSubmit = pendingValue?.onSubmit ?? noopSubmit;
const steps = useMemo<ThinkingStepInput[]>(
() => (data as { steps: ThinkingStepInput[] } | null)?.steps ?? [],
@ -32,21 +39,18 @@ function TimelineDataRenderer({ data }: { name: string; data: unknown }) {
[steps, content]
);
if (items.length === 0) return null;
if (items.length === 0 && !pendingForThisMessage) return null;
return (
<div className="mb-3 -mx-2 leading-normal">
<Timeline items={items} isThreadRunning={isMessageStreaming} />
<PendingInterruptProvider pendingInterrupt={pendingForThisMessage} onSubmit={onSubmit}>
<Timeline items={items} isThreadRunning={isMessageStreaming} />
</PendingInterruptProvider>
</div>
);
}
/**
* Drop-in replacement for the legacy ``ThinkingStepsDataUI``. Same
* registration name (``thinking-steps``) so consumers (assistant-
* message.tsx, public-thread.tsx, free-chat-page.tsx, etc.) just swap
* the import no SSE relay changes, no message format changes.
*/
/** Registers under ``thinking-steps`` so consumers swap the import only. */
export const TimelineDataUI = makeAssistantDataUI({
name: "thinking-steps",
render: TimelineDataRenderer,

View file

@ -1,33 +1,33 @@
import type { TimelineGroup, TimelineItem } from "./types";
/**
* Group consecutive delegated child items under their parent.
* Group delegated child items under their owning ``task`` parent.
*
* The contract: the parent of a span is the FIRST item carrying that
* ``spanId``. Subsequent items with the same ``spanId`` are children.
* Items with no ``spanId`` are their own parent (no children).
* Backend invariant: ``metadata.spanId`` is set only while a ``task``
* tool is open, so every non-task item with ``spanId = X`` shares it
* with the ``task`` that owns the span. We promote that task to the
* group header.
*
* For ``task`` delegations specifically, the ``task`` tool-call IS the
* span owner its ``spanId`` is set on the call itself, and child
* items emitted while the subagent is running carry the same ``spanId``.
* The ``task`` item must therefore become the parent header, NOT a
* child of itself. This is achieved by treating the FIRST occurrence
* of any ``spanId`` as the parent; downstream items with the same
* ``spanId`` are children.
*
* Defensive: if the very first item of a stream is a child of a span
* we haven't seen the parent for yet, it's promoted to a parent so it
* still renders. Real flows always emit the parent ``task`` first.
*
* Pure function. No React, no side effects. Trivially testable.
* The owner-missing branch defends against the live-resume window
* where the OLD ``task`` wrapper can be superseded while its
* children briefly survive without it, grouping would promote
* the first orphan child to parent and visually nest its siblings
* under it.
*/
export function groupItems(items: readonly TimelineItem[]): TimelineGroup[] {
const spanOwners = new Set<string>();
for (const item of items) {
if (item.kind === "tool-call" && item.toolName === "task" && item.spanId) {
spanOwners.add(item.spanId);
}
}
const groups: TimelineGroup[] = [];
const spanParent = new Map<string, TimelineGroup>();
for (const item of items) {
const sid = item.spanId;
if (!sid) {
if (!sid || !spanOwners.has(sid)) {
groups.push({ parent: item, children: [] });
continue;
}

View file

@ -2,49 +2,25 @@
import type { FC } from "react";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { ToolCallIdProvider, useHitlBundle } from "@/features/chat-messages/hitl";
import { resolveItemTitle } from "../subagent-rename";
import { adaptItemToProps, FallbackToolBody, getToolComponent } from "../tool-registry";
import type { ToolCallItem as ToolCallItemModel } from "../types";
import { ItemHeader } from "./item-header";
/**
* Renders a ``kind: "tool-call"`` row: ``ItemHeader`` (title + items)
* plus the resolved tool body underneath.
*
* Tool body is selected from the registry; unknown names fall through
* to ``FallbackToolBody`` (which itself dispatches between HITL
* approval cards and the default visual card based on result shape).
*
* Multi-approval bundle behaviour: when the HITL bundle is active, all
* cards EXCEPT the current step are hidden so the user is paged
* through them one at a time. Hiding is local to this row the header
* and the timeline chrome around it are unaffected (the row collapses
* to its header only). The bundle's ``PagerChrome`` is mounted once
* at the end of the timeline by ``timeline.tsx``.
*
* Every tool body is wrapped in ``ToolCallIdProvider`` so
* ``useHitlDecision`` (called inside HITL approval cards) can read the
* tool-call id from context and stage decisions in the bundle.
* Renders a tool-call row. Pending HITL interrupts are filtered
* upstream in ``buildTimeline`` (owned by ``HitlApprovalCard``); this
* component only sees running / completed / errored / decided rows.
*/
export const ToolCallItem: FC<{ item: ToolCallItemModel }> = ({ item }) => {
const bundle = useHitlBundle();
const hideForBundle =
bundle?.isInBundle(item.toolCallId) === true && !bundle.isCurrentStep(item.toolCallId);
const title = resolveItemTitle(item, getToolDisplayName);
const Body = getToolComponent(item.toolName) ?? FallbackToolBody;
const props = adaptItemToProps(item);
return (
<>
<ItemHeader title={title} status={item.status} items={item.items} itemKey={item.id} />
{!hideForBundle && (
<ToolCallIdProvider toolCallId={item.toolCallId}>
<Body {...props} />
</ToolCallIdProvider>
)}
<Body {...props} />
</>
);
};

View file

@ -4,7 +4,7 @@ import { ChevronRightIcon } from "lucide-react";
import { type FC, useEffect, useMemo, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { PagerChrome, useHitlBundle } from "@/features/chat-messages/hitl";
import { HitlApprovalCard, usePendingInterrupt } from "@/features/chat-messages/hitl";
import { cn } from "@/lib/utils";
import { groupItems } from "./grouping";
import { resolveItemTitle } from "./subagent-rename";
@ -12,10 +12,9 @@ import { TimelineGroupRow } from "./timeline-group-row";
import type { ItemStatus, TimelineItem } from "./types";
/**
* Override coarse status when the thread isn't running anymore: a
* stale "running" must read as "completed" so the chrome stops
* pulsing. Mirrors the legacy ``getEffectiveStatus`` from
* ``thinking-steps.tsx``.
* Force a stale "running" to read as "completed" once the thread
* stops, so the chrome doesn't keep pulsing forever after a stream
* is aborted or disconnected.
*/
function effectiveStatus(status: ItemStatus, isThreadRunning: boolean): ItemStatus {
if (status === "running" && !isThreadRunning) return "completed";
@ -23,54 +22,23 @@ function effectiveStatus(status: ItemStatus, isThreadRunning: boolean): ItemStat
}
/**
* True when a tool-call's result is an HITL interrupt the user has
* NOT decided on yet. The backend marks the step as ``completed``
* (the tool DID complete it returned an interrupt as its result),
* which would normally collapse the timeline. This predicate lets the
* chrome treat "waiting on user" as still-in-progress.
*
* Decided interrupts (``__decided__`` set to "approve"/"reject"/
* "edit") count as completed for chrome purposes the resume stream
* will take it from there.
*/
function isPendingInterrupt(result: unknown): boolean {
if (typeof result !== "object" || result === null) return false;
const r = result as { __interrupt__?: unknown; __decided__?: unknown };
return r.__interrupt__ === true && r.__decided__ === undefined;
}
/**
* The chain-of-thought timeline. The "process" surface in the
* `body | timeline` split owns chrome (collapsible header, tree
* dots/lines, indent, group iteration) and dispatches to per-kind
* items for the actual content.
*
* Rendering responsibilities (kept here, not on items):
* - Outer max-width container.
* - Collapsible header with state-aware label ("Reviewed" /
* "Processing" / current step title) and shimmer.
* - Open/close state derived from ``isThreadRunning`` + completion.
* - Status dot + vertical connector line per group (delegates the
* inner row to ``TimelineGroupRow``).
* - Mounting ``PagerChrome`` once at the bottom when the HITL bundle
* is active (multi-approval coordination see
* ``hitl/bundle/bundle-context.tsx``).
*
* Pure consumption of ``TimelineItem[]`` does NOT call
* ``buildTimeline`` itself. The data-renderer adapter does that and
* passes the items in.
* The "process" surface in the body | timeline split. Pure consumer
* of ``TimelineItem[]`` owns the collapsible chrome and tree
* indent only. Pending HITL interrupts mount ``HitlApprovalCard`` at
* the bottom; the card owns its own decision/pager state.
*/
export const Timeline: FC<{
items: readonly TimelineItem[];
isThreadRunning?: boolean;
}> = ({ items, isThreadRunning = true }) => {
const bundle = useHitlBundle();
const pendingValue = usePendingInterrupt();
const pendingInterrupt = pendingValue?.pendingInterrupt ?? null;
const onSubmit = pendingValue?.onSubmit;
const hasPending = pendingInterrupt !== null;
// Apply the runtime ``isThreadRunning`` override to every item once,
// up-front, so downstream code (grouping, group rows, item headers,
// status dot, all children) sees the corrected coarse status without
// having to thread a callback through. ``buildTimeline`` stays pure;
// the override is purely a render-time concern that lives here.
// Apply the override here so downstream (grouping, headers, dots)
// sees the corrected status without threading a callback. Keeps
// ``buildTimeline`` pure.
const effectiveItems = useMemo<TimelineItem[]>(
() =>
items.map((it) => ({
@ -89,29 +57,20 @@ export const Timeline: FC<{
[inProgressItem]
);
// Detect a tool-call that's parked on an HITL interrupt the user hasn't
// decided yet. Treated as "still in progress" by the chrome so the
// timeline doesn't auto-collapse on the user mid-decision (the LangGraph
// thread paused, but the agent's work is conceptually unfinished).
const pendingInterruptItem = useMemo(
() => effectiveItems.find((it) => it.kind === "tool-call" && isPendingInterrupt(it.result)),
[effectiveItems]
);
const pendingInterruptTitle = useMemo(
() =>
pendingInterruptItem ? resolveItemTitle(pendingInterruptItem, getToolDisplayName) : undefined,
[pendingInterruptItem]
);
const allCompleted = useMemo(
// "Settled" includes cancelled/errored, not just completed —
// rejecting an interrupt leaves items in ``cancelled`` and the
// timeline still needs to auto-collapse.
const allSettled = useMemo(
() =>
effectiveItems.length > 0 &&
!isThreadRunning &&
!pendingInterruptItem &&
effectiveItems.every((it) => it.status === "completed"),
[effectiveItems, isThreadRunning, pendingInterruptItem]
!hasPending &&
effectiveItems.every(
(it) => it.status === "completed" || it.status === "cancelled" || it.status === "error"
),
[effectiveItems, isThreadRunning, hasPending]
);
const isProcessing = (isThreadRunning || !!pendingInterruptItem) && !allCompleted;
const isProcessing = (isThreadRunning || hasPending) && !allSettled;
const [isOpen, setIsOpen] = useState(() => isProcessing);
useEffect(() => {
@ -119,22 +78,19 @@ export const Timeline: FC<{
setIsOpen(true);
return;
}
if (allCompleted) {
if (allSettled) {
setIsOpen(false);
}
}, [allCompleted, isProcessing]);
}, [allSettled, isProcessing]);
const groups = useMemo(() => groupItems(effectiveItems), [effectiveItems]);
if (effectiveItems.length === 0) return null;
if (effectiveItems.length === 0 && !hasPending) return null;
const headerText = (() => {
if (allCompleted) return "Reviewed";
if (allSettled) return "Reviewed";
if (hasPending) return "Awaiting your decision";
if (inProgressTitle) return inProgressTitle;
// Pending HITL: prefer the tool's own name so the user knows WHICH
// approval is gating progress (e.g. "Update Notion page") rather
// than a generic "Awaiting approval" label.
if (pendingInterruptTitle) return pendingInterruptTitle;
if (isProcessing) return "Processing";
return "Reviewed";
})();
@ -168,16 +124,22 @@ export const Timeline: FC<{
>
<div className="overflow-hidden">
<div className="mt-3 pl-1">
{groups.map((group, groupIndex) => (
<TimelineGroupRow
key={group.parent.id}
group={group}
parentStatus={group.parent.status}
showParentLine={groupIndex < groups.length - 1}
/>
))}
{bundle && <PagerChrome />}
{groups.map((group, idx) => {
const showLine = idx < groups.length - 1 || hasPending;
return (
<TimelineGroupRow
key={group.parent.id}
group={group}
parentStatus={group.parent.status}
showParentLine={showLine}
/>
);
})}
{pendingInterrupt && onSubmit && (
<div className="pl-5">
<HitlApprovalCard pendingInterrupt={pendingInterrupt} onSubmit={onSubmit} />
</div>
)}
</div>
</div>
</div>

View file

@ -56,7 +56,7 @@ export interface ReasoningItem extends BaseItem {
export interface ToolCallItem extends BaseItem {
kind: "tool-call";
toolName: string;
/** The actual tool-call ID — used by HITL (bundle membership, ``ToolCallIdProvider``). */
/** The actual tool-call ID — passed to per-tool components (e.g. for the Revert button). */
toolCallId: string;
args: Record<string, unknown>;
argsText?: string;