LibreChat/client/src/utils/toolCallPhase.ts
Danny Avila df294fa474
🧩 refactor: Resolve Tool-Card State Once (#14934)
* 🧩 refactor: Resolve Tool-Card State Once (AI-1810)

Each tool card derived its state several times over — the visible label
from one expression, the `aria-live` announcement from another, the
icon and shimmer from a third, and since #14906 the follow-scroll from
a fourth. Nothing tied them together; they agreed only because each was
written to agree. Thirteen of the seventeen review findings on #14873
were instances of one derivation being updated and another left behind,
and #14892 added more.

`resolveToolCallPhase` is now the single source: one function encoding
the precedence rules, each of which a specific review finding
established, returning `running | completed | cancelled | failed`.
Everything the card shows reads that value.

`ProgressText` takes `phase` in place of the `error` + `errorSuffix`
pair, which encoded three terminal states in two booleans — `error`
meant cancelled, a present `errorSuffix` meant failed — and made every
consumer reconstruct the distinction. That shape is precisely what let
a duration render beside "failed" (Codex round 1 on #14892).

Two things fell out once the state had one home, both dead code rather
than deletions of behaviour:

- `progress` left `ProgressText` entirely; the phase already carries
  everything it was used to decide.
- The `useProgress` mask went with it. Passing 1 in still matters — it
  stops the 200ms interval — but masking the output no longer does,
  because the phase treats an explicit close as terminal outright. The
  "both halves are load-bearing" subtlety is now one half.

Scope: the nine cards that render the shared `ProgressText`. The three
with bespoke layouts (`WebSearch`, `SubagentCall`, `OpenAIImageGen`)
still resolve their own state and are the natural follow-up — they can
adopt the resolver without adopting the component.

Refactor-only. 4891/4891 client tests pass unchanged, including the
suites that encode the cancelled/failed precedence in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🐛 fix: Infer Cancellation From Reported Progress, Not The Animation

`useProgress` holds below 1 for ~200ms after a call reports completion:
it emits the previous value, then `0.99`, then `1` on a timeout. The
resolver read that animated value for its cancellation inference, so a
successful call whose submission ended inside that window rendered —
and announced — as "Cancelled".

The input is now split. `reportedProgress` is what the stream said and
drives the inference; `displayProgress` is the animated value and drives
`running` vs `completed`, so the label and shimmer still follow the
animation rather than snapping.

This restores `ToolCall` and `RetrievalCall`, whose previous predicates
used `initialProgress` and were immune, and additionally fixes
`useToolCallState`, which inferred from `rawProgress` and therefore
carried the bug already — every card the hook backs was exposed to it
before this PR.

Three tests cover the window: a reported-complete call mid-settle is
`running`, a genuinely unfinished one is still `cancelled`, and the card
settles to `completed` without a cancelled frame in between.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🧹 chore: Drop Unused Phase Predicates; Correct A Stale Comment

`isFailedPhase` and `isRunningPhase` had no callers — every consumer
compares the phase directly, which reads better than a wrapper. An
unused abstraction is the thing this PR argues against, so it should not
ship one.

The comment above the hook's resolver call still described "the raw
progress the legacy heuristic was written against", which stopped being
true when the input split into reported and display progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-17 12:05:17 -04:00

88 lines
3.6 KiB
TypeScript

import type { PartMetadata } from 'librechat-data-provider';
/**
* The settled state of one tool call, as every part of its card should read
* it: the visible label, the `aria-live` announcement, the icon, the shimmer,
* and whether a duration is worth showing.
*
* Before this existed each card derived those independently — the label from
* one expression, the announcement from another, the animation from a third —
* and a change to one kept missing the others. Thirteen of the seventeen
* review findings on #14873 were instances of that drift, and #14892 added a
* fourteenth. One value, read everywhere, is what stops it: two presentations
* of the same card can no longer disagree about what happened.
*/
export type ToolCallPhase = 'running' | 'completed' | 'cancelled' | 'failed';
export interface ToolCallPhaseInput {
/**
* The run's own terminal verdict from `on_run_step_closed`. Absent on parts
* saved before the event existed and on endpoints that never emit it, which
* is what the heuristic below is for.
*/
runStepStatus?: PartMetadata['runStepStatus'];
/**
* The animated value from `useProgress` — what the card is showing right
* now. Drives `running` vs `completed` so the label and shimmer follow the
* animation rather than snapping.
*/
displayProgress: number;
/**
* The progress the stream actually reported, before display animation.
* Kept separate because `useProgress` holds below 1 for ~200ms after a call
* reports completion (it emits `0.99`, then `1` on a timeout), and the
* cancellation inference must not read that lag as an unfinished call.
*/
reportedProgress: number;
/** Whether the whole message is still streaming — a message-level fact. */
isSubmitting: boolean;
/**
* Whether this call's own result reads as a failure: parsed error output,
* or a card-specific signal such as a backgrounded task settling as `error`.
*/
hasError: boolean;
}
/**
* Resolve a tool call's phase from every signal that bears on it.
*
* The precedence rules encoded here were each established by a specific
* review finding, and each is load-bearing:
*
* - **An explicit status is authoritative and never gated on output parsing.**
* Reading the result text can otherwise demote a step the run reported as
* stopped back into an in-flight state.
* - **Explicit cancellation outranks a failure-shaped result.** A step the
* user stopped is cancelled even if its partial output parses as an error.
* - **Under the legacy heuristic the opposite holds: failure outranks
* cancellation**, because that inference reads "not submitting and not
* finished" as a stop, which a genuine failure also satisfies. Applying the
* explicit-close precedence there relabelled real failures as user stops.
* - **A closed step is never `running`**, whatever progress says.
* - **Cancellation is inferred from reported progress, never from the
* animation.** `useProgress` lags a completed call by ~200ms; reading that
* lag would label — and announce — a successful call as cancelled whenever
* submission ended inside the window.
*/
export function resolveToolCallPhase({
runStepStatus,
displayProgress,
reportedProgress,
isSubmitting,
hasError,
}: ToolCallPhaseInput): ToolCallPhase {
if (runStepStatus != null) {
if (runStepStatus === 'cancelled') {
return 'cancelled';
}
return runStepStatus === 'failed' || hasError ? 'failed' : 'completed';
}
if (hasError) {
return 'failed';
}
if (!isSubmitting && reportedProgress < 1) {
return 'cancelled';
}
return displayProgress < 1 ? 'running' : 'completed';
}