Merge upstream main at 20260906T105913Z batch boundary

Change-source: pulse-maintainer

# Conflicts:
#	frontend-modern/browser-verification.json
This commit is contained in:
pulse-triage[bot]
2026-09-06 12:02:03 +01:00
8 changed files with 334 additions and 85 deletions
@@ -62,7 +62,7 @@ reproduction evidence, not a representative customer success rate.
| Step | Work | Acceptance | Current state |
|---|---|---|---|
| 1. Product contract and baseline | Map the current loop and sources of judgment. Record telemetry populations and gaps. | Every identified decision has an owner. Activity is not labelled usefulness. | Complete for this redesign scope. Contract, ownership decisions and baseline limits are recorded. |
| 2. Shared evidence | Preserve canonical risk reasons and SMART counters, source/time semantics and history across tools/turns. | Regression tests preserve unknown versus zero and all canonical evidence. Real responses can inspect the same facts as the product. | Implemented and qualified for the named shared-evidence defects. Canonical disk detail, risk and cadence pass real data-path proof. Affected package and concurrency checks pass. Integrated CI later exposed remaining query and allocation regressions. The final bounded query-reuse correction passes complete selected exact-base worker comparisons and full metrics/database and focused race checks. Final landing CI remains open. Real-model interpretation failures remain tracked in step 5. |
| 2. Shared evidence | Preserve canonical risk reasons and SMART counters, source/time semantics and history across tools/turns. | Regression tests preserve unknown versus zero and all canonical evidence. Real responses can inspect the same facts as the product. | Implemented and qualified for the named shared-evidence defects. Canonical disk detail, risk and cadence pass real data-path proof. Affected package and concurrency checks pass. Integrated CI later exposed remaining query and allocation regressions. The final bounded query-reuse correction passes complete selected exact-base worker comparisons and full metrics/database and focused race checks. Final landing CI passed and PRs #1928 and #1929 merged. Real-model interpretation failures remain tracked in step 5. |
| 3. Diagnostic orchestration | Correct proposal-as-proof. Audit triage budgets, unmatched-signal evaluation, assessment completion and investigation cutoffs. | No code-written causal conclusion. No quality inferred from tool, flag or finding counts. Each retained pass has an objective reason. Safety boundaries and incomplete outcomes remain explicit. | Proposal promotion and capture inference were removed in c5d2f56dda. Commit 668af3fe6b removes investigation success-call floors, checkpoint instructions and generic call-count wrap-up rules. The detection slice removes contextless follow-up passes, flag/report-count policy and first-finding completion modes. Full chat and AI suites, focused API and conversation race tests pass. Real-model/action outcome qualification remains open. |
| 4. Issue through verified outcome | Follow existing issue/investigation/action records into Assistant, approval, execution and independent readback. | Accepted proposal is visibly distinct from execution and verification. Rejected or unsupported actions do not become success. Uncertainty can survive an action proposal. | Existing foundation, full journey qualification pending. |
| 5. Ground-truth qualification and landing | Extend existing qualification tooling only where necessary. Exercise healthy/unhealthy, dependency, missing-access, storage/backup and approved/rejected action cases. Inspect the final browser journey at desktop and narrow widths. | Record exact source/model/permissions, evidence, decisions, faults/misses, latency and verification. Fix in-scope failures, pass appropriate proofs and land scoped commits. | Pending. |
@@ -1328,3 +1328,83 @@ diagnostic-record commit `ba69933da352` passed its two-file staged hook, all
163 tests in 127.527s with unchanged hashes. The current runtime change still
requires its final staged hook and exact-head remote CI. Overall diagnostic and
action-outcome qualification remains open.
## Healthy and dependency Assistant qualification, 2026-09-06
Two ordinary read-only Assistant requests used the configured
`claude-subscription:claude-opus-5` route with explicit `autonomous_mode=false`.
These are single-case observations, not an autonomous Patrol pass or an estimate
of customer success, false-alarm or missed-problem rates. The earlier policy
refusal remains in force. No paid-provider request was made.
The unchanged Docker dependency manifest ran through the existing `DockerLab`
on the monitored Tower host. Only run-owned Alpine containers and their private
network were created. The existing production container was untouched. Independent
Docker observations and Pulse resource convergence established healthy client and
dependency, then a stopped dependency and running-but-unhealthy client. Fault
injection used a deliberate stop with a five-second grace period. Its exit 137
therefore does not establish OOM.
| Case | Observed result | Limit |
|---|---|---|
| Healthy client | 82.835s, seven tool calls, no failed tools. Assistant correctly recommended no action. Independent observations before and after the request retained both healthy containers and unchanged identities. | The answer incorrectly inferred no contribution to or effect from host storage pressure from empty mounts and zero sampled writes. The primary decision passes this case, but the full explanation does not. |
| Stopped dependency | 204.384s, sixteen tool calls, three failed reads. Assistant identified the stopped sibling and treated the dependency explanation as a hypothesis. It preserved the command-access limit and did not claim exit 137 proved OOM. | It excluded storage causality from zero sampled I/O, overstated what `OOMKilled=false` establishes, recommended restarting the client without first establishing that need, and claimed continued failure after dependency recovery must be a client-healthcheck fault. Those claims exceed the observations. This is partial diagnostic evidence, not a qualification pass. |
The fault case also received two `app-container not found` responses from config
reads despite successful canonical get responses. That tool capability/error
contract remains an in-scope follow-up. No post-answer Docker fault observation or
explicit recovery phase was recorded before the fixture's 25-minute deadline.
Deadline cleanup completed at 08:16:40.820Z, removed both run-owned containers and
their network, then passed a second no-op cleanup with unchanged original
inventory. The helper exited on its deadline. This proves cleanup, not a completed
recovery or action-outcome journey. Neither Assistant request mutated the lab.
Private evidence root:
`/Volumes/Development/pulse/tmp/patrol-assistant-lab-readiness/runs/asst-20260906075136356-380d4d/`.
It contains independent `oracle/` receipts, healthy/fault Pulse convergence,
complete SSE streams, persisted sessions, requests, screenshots and before/after
source/binary bindings. Both requests used unchanged runtime binary SHA256
`ebd0c2c74e4dbee330284a12a99137cfcecaecfe1ec2545c25432f06183a10c1`.
HTTP windows were 07:53:58.742Z to 07:55:21.577Z and 07:58:04.234Z to
08:01:28.618Z. Displayed token counts are not complete context or billable-cost
measurements. Browser interception blocked unrelated non-GET requests, including
route checks, producing an artificial selected-route warning. It did not test
route recovery or retry the autonomous refusal.
### Tool evidence identity correction
The captured browser responses exposed a separate reproducible evidence defect.
Concurrent starts/progress used name fallback even when invocation IDs differed.
Completing one same-name action could remove another invocation's approval card.
The shared reducer now treats supplied IDs as authoritative through start,
progress, cancellation, completion and approval cleanup. Older ID-less name
matching remains the existing compatibility path.
A second reproduction showed deep Solid store reconciliation mutating objects
shared by `toolCalls` and `streamEvents`. Removing a workflow row could change the
first query's input and output into a later alerts call. Message rendering now
keys rows by message ID and reads the immutable message through an accessor.
It keeps DOM stability without copying or mutating the evidence graph. Regression
proof includes the actual status-row-removal trigger, concurrent same-name calls,
sibling approval retention and cancellation. The affected test files pass all
167 tests, including existing message mount-stability checks.
Browser matrix: `/patrol` at 1440x1000, 900x1000 and 390x1000. Captured-response
replay checks all seven healthy and sixteen dependency tool inputs and outputs
against their exact terminal SSE records. It also exercises hover/focus, keyboard
expansion/collapse, output scrolling and restored persisted sessions. A controlled
stream fixture exercises concurrent pending tools, repeated starts, progress,
out-of-order completion, independent approval cards, cancellation and failed
completion. These are renderer/identity checks, not model or action qualification.
Private replay and state receipts live under
`/Volumes/Development/pulse/tmp/patrol-assistant-lab-readiness/identity-*`.
PR #1928 merged its earlier scope at `6d2d188867430f653e1bf9ada634fd2b90440786`.
The later diagnosis record and route-label performance correction merged through
PR #1929 at `cf98358c0eb46987a82def5776fa41db5f54210a`. Its backend, frontend,
benchmarks, governance, CodeQL and eight Core E2E shards passed. The identity
correction above is a separate scoped change and requires its own landing checks.
The redesign remains open for reliable interpretation, the config-read contract,
storage/backup, approved/rejected action outcomes and supported autonomous Patrol
qualification. Wider customer readiness still requires independent Pro environments.
File diff suppressed because one or more lines are too long
@@ -2414,6 +2414,12 @@ deriving an older display status from `workflowStatusHistory`.
normalized tool name when an older server omits IDs, upsert one pending tool
row and collapse stale duplicate pending rows instead of replaying several
near-identical steps in the transcript.
A supplied invocation ID is authoritative. Distinct IDs must not merge by
tool name during start, progress, cancellation, completion or approval
cleanup. Completing one invocation must leave its sibling approvals intact.
Message rendering keys rows by message ID and reads immutable transcript
values. It must not deeply reconcile shared tool objects: removing a status
row must never mutate evidence retained in another event or in toolCalls.
`frontend-modern/src/api/aiChat.ts` owns the Assistant predicate: token
content and hidden reasoning may continue to opt out of those checkpoints so
answer streaming remains fast, while session, workflow, model-switch, tool,
+18 -11
View File
@@ -1,33 +1,40 @@
{
"version": 1,
"base_sha": "9761e5e91457a2d007542ff61074db9ddcc427e7",
"verified_at": "2026-09-06T09:19:36.187652Z",
"base_sha": "9b4fa52d84e6196108e961ccba2b317df497268b",
"verified_at": "2026-09-06T10:15:18.709765Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts"
"frontend-modern/src/components/AI/Chat/ChatMessages.tsx",
"frontend-modern/src/components/AI/Chat/hooks/useChat.ts"
],
"content_sha256": {
"frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts": "be919497714d749fcbe9a48a325aa82282b7afa8d6866d84eb1cc09ab52c14a6"
"frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "9672f7608d1e3a531c73cba20fd4a78752316783212afd0c292ddfd11d2bf371",
"frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "0b56b7a56e35d51ca96f0e126dd493b3164aa9e0ad4d8ae24bcf3af7a574b97c"
},
"routes": [
"/qualification (real Destinations state hook and delivery-log card, scripted APIs; not full DestinationsTab)"
"/patrol"
],
"viewports": [
{
"width": 1440,
"height": 900
"height": 1000
},
{
"width": 900,
"height": 1000
},
{
"width": 390,
"height": 900
"height": 1000
}
],
"states": [
"Four cases preserve current attempt and held-event rows after stale success/failure; two cases retain disabled refresh while latest read is pending and show unavailable after latest failure. Held-event pending does not block attempt refresh."
"Captured healthy and dependency Assistant responses replayed without provider calls. All 7 and 16 terminal tool records retain exact inputs, outputs and distinct rows. Success, failed NO_AGENT and unsupported config results inspected.",
"Controlled stream: concurrent same-name calls, repeated starts, progress, two separate approval cards, sibling completion, canceled call and failed completion. No real action was attempted.",
"Restored persisted healthy and dependency sessions retain original prompts and evidence. Artificial route warning reflects blocked non-GET readiness checks, not route qualification."
],
"interactions": [
"Registered mount read, fixture Configuration Retry invoking real handleRetry, scripted out-of-order attempt and held-event completion. Six Chromium cases passed via pulse-heavy-run -- node scripts/check-delivery-log-ordering.mjs.",
"Inspected 390-error.png and 1440-pending-unavailable.png: current attempt and held rows visible on narrow layout; unavailable warning visible at desktop. Synthetic component fixture only, no backend or independent-recipient qualification.",
"Evidence: queue/staging/20260906T091009Z-web-product/evidence/browser.log and screenshots under /var/lib/pulse-maintainer."
"Hover, focus, Enter/Space expand and collapse, scroll expanded output to its end, Escape, reload and select the original persisted session. Tool details and wrapping inspected at desktop, intermediate and narrow widths.",
"Private evidence: /Volumes/Development/pulse/tmp/patrol-assistant-lab-readiness/identity-verified-healthy, identity-verified-fault and identity-states. Baseline reproductions retained in identity-before-fault and failed focused regressions."
]
}
@@ -1,5 +1,4 @@
import { Component, Show, For, createEffect, createMemo, createSignal } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import ArrowDownIcon from 'lucide-solid/icons/arrow-down';
import { Button } from '@/components/shared/Button';
import { MessageItem } from './MessageItem';
@@ -66,22 +65,13 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
let containerRef: HTMLDivElement | undefined;
const [isPinnedToBottom, setIsPinnedToBottom] = createSignal(true);
// useChat hands us a fresh, immutably-rebuilt message array on every stream
// event (each content chunk, workflow-status change, tool update spreads a new
// message object). Rendering that array directly through <For>, which keys by
// object reference, tears down and recreates the whole MessageItem on every
// event — the visible flashing / rows popping in and out and the transcript
// jumping up and down during a turn.
//
// Reconcile the incoming array into a keyed store mirror so each message keeps
// a stable identity across updates (matched by id). MessageItem already reads
// every field through `() => props.message.x` accessors, so once it stops
// re-mounting, only the genuinely changed text/rows update in place. This keeps
// the streaming transcript stable the way OpenCode's timeline is.
const [mirroredMessages, setMirroredMessages] = createStore<ChatMessage[]>([]);
createEffect(() => {
setMirroredMessages(reconcile(props.messages, { key: 'id', merge: false }));
});
// Key only the message rows. Deep store reconciliation mutates objects shared
// by toolCalls and streamEvents when a status row is removed, corrupting the
// source transcript. Read each immutable message through its stable ID instead.
const messagesById = createMemo(
() => new Map(props.messages.map((message) => [message.id, message])),
);
const messageIds = createMemo(() => [...messagesById().keys()]);
// Regenerate re-runs the LAST turn via session undo, so only the final
// assistant answer qualifies, and only once it has settled with nothing
@@ -342,54 +332,58 @@ export const ChatMessages: Component<ChatMessagesProps> = (props) => {
</Show>
{/* Messages */}
<For each={mirroredMessages}>
{(message) => {
const queuedMeta = createMemo(() => queuedFollowUpMetaByMessageId().get(message.id));
<For each={messageIds()}>
{(messageId) => {
const queuedMeta = createMemo(() => queuedFollowUpMetaByMessageId().get(messageId));
return (
<MessageItem
message={message}
onApprove={(approval) => props.onApprove(message.id, approval)}
onSkip={(toolId) => props.onSkip(message.id, toolId)}
onAnswerQuestion={(question, answers) =>
props.onAnswerQuestion(message.id, question, answers)
}
onSkipQuestion={(questionId) => props.onSkipQuestion(message.id, questionId)}
onRetry={props.onRetry}
onRegenerate={
message.id === regenerableMessageId()
? () => props.onRegenerate?.(message.id)
: undefined
}
onEditPrompt={
message.id === editablePromptMessageId()
? () => props.onEditPrompt?.(message.id)
: undefined
}
onChangeModel={props.onChangeModel}
getModelRouteLabel={props.getModelRouteLabel}
modelRouteAlternative={props.getModelRouteAlternative?.(message)}
onUseModelRoute={props.onUseModelRoute}
queuedPosition={queuedMeta()?.position}
queuedCount={queuedMeta()?.count}
queuedPaused={queuedMeta()?.paused}
queuedSteering={queuedMeta()?.steering}
onEditQueued={
queuedMeta() && !queuedMeta()?.steering && props.onEditQueuedFollowUp
? () => {
const meta = queuedMeta();
if (meta) props.onEditQueuedFollowUp?.(meta.id);
}
: undefined
}
onCancelQueued={
queuedMeta() && !queuedMeta()?.steering && props.onCancelQueuedFollowUp
? () => {
const meta = queuedMeta();
if (meta) props.onCancelQueuedFollowUp?.(meta.id);
}
: undefined
}
/>
<Show when={messagesById().get(messageId)}>
{(message) => (
<MessageItem
message={message()}
onApprove={(approval) => props.onApprove(messageId, approval)}
onSkip={(toolId) => props.onSkip(messageId, toolId)}
onAnswerQuestion={(question, answers) =>
props.onAnswerQuestion(messageId, question, answers)
}
onSkipQuestion={(questionId) => props.onSkipQuestion(messageId, questionId)}
onRetry={props.onRetry}
onRegenerate={
messageId === regenerableMessageId()
? () => props.onRegenerate?.(messageId)
: undefined
}
onEditPrompt={
messageId === editablePromptMessageId()
? () => props.onEditPrompt?.(messageId)
: undefined
}
onChangeModel={props.onChangeModel}
getModelRouteLabel={props.getModelRouteLabel}
modelRouteAlternative={props.getModelRouteAlternative?.(message())}
onUseModelRoute={props.onUseModelRoute}
queuedPosition={queuedMeta()?.position}
queuedCount={queuedMeta()?.count}
queuedPaused={queuedMeta()?.paused}
queuedSteering={queuedMeta()?.steering}
onEditQueued={
queuedMeta() && !queuedMeta()?.steering && props.onEditQueuedFollowUp
? () => {
const meta = queuedMeta();
if (meta) props.onEditQueuedFollowUp?.(meta.id);
}
: undefined
}
onCancelQueued={
queuedMeta() && !queuedMeta()?.steering && props.onCancelQueuedFollowUp
? () => {
const meta = queuedMeta();
if (meta) props.onCancelQueuedFollowUp?.(meta.id);
}
: undefined
}
/>
)}
</Show>
);
}}
</For>
@@ -115,6 +115,79 @@ function setScrollMetrics(
}
describe('ChatMessages', () => {
it('does not mutate shared tool evidence when completed status rows are removed', () => {
const a = { name: 'pulse_query', input: 'client', output: 'client evidence', success: true };
const b = { name: 'pulse_alerts', input: 'alerts', output: 'alert evidence', success: true };
const pendingA = { id: 'a', name: a.name, input: a.input };
const pendingB = { id: 'b', name: b.name, input: b.input };
const [messages, setMessages] = createSignal<ChatMessage[]>([
makeMessage({
role: 'assistant',
toolCalls: [],
streamEvents: [
{
type: 'workflow_status',
workflowStatus: { phase: 'provider_start', message: 'Starting' },
},
{ type: 'pending_tool', toolId: 'a', pendingTool: pendingA },
{ type: 'pending_tool', toolId: 'b', pendingTool: pendingB },
],
pendingTools: [pendingA, pendingB],
}),
]);
render(() => <ChatMessages messages={messages()} {...makeHandlers()} />);
setMessages([
makeMessage({
role: 'assistant',
toolCalls: [a],
streamEvents: [
{
type: 'workflow_status',
workflowStatus: { phase: 'provider_start', message: 'Starting' },
},
{ type: 'tool', toolId: 'a', tool: a },
{ type: 'pending_tool', toolId: 'b', pendingTool: pendingB },
],
pendingTools: [pendingB],
}),
]);
setMessages([
makeMessage({
role: 'assistant',
toolCalls: [a, b],
streamEvents: [
{
type: 'workflow_status',
workflowStatus: { phase: 'provider_start', message: 'Starting' },
},
{ type: 'tool', toolId: 'a', tool: a },
{ type: 'tool', toolId: 'b', tool: b },
],
pendingTools: [],
}),
]);
setMessages([
makeMessage({
role: 'assistant',
toolCalls: [a, b],
streamEvents: [
{ type: 'tool', toolId: 'a', tool: a },
{ type: 'tool', toolId: 'b', tool: b },
],
pendingTools: [],
}),
]);
expect(a).toEqual({
name: 'pulse_query',
input: 'client',
output: 'client evidence',
success: true,
});
expect(
capturedMessageItemProps[0].message.streamEvents?.map((event) => event.tool?.output),
).toEqual(['client evidence', 'alert evidence']);
});
describe('empty transcript', () => {
it('keeps the transcript blank when there are no messages or resume actions', () => {
const { container } = render(() => <ChatMessages messages={[]} {...makeHandlers()} />);
@@ -2219,6 +2219,96 @@ describe('useChat', () => {
dispose();
});
it('preserves distinct invocation IDs across concurrent same-name tool updates', async () => {
const { getFireEvent } = setupWithEventCapture();
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' }));
await chat.sendMessage('hi');
const fire = getFireEvent();
const inputA = '{"action":"search","query":"client"}';
const inputB = '{"action":"search","query":"Tower"}';
const assistant = () => chat.messages().find((m) => m.role === 'assistant')!;
fire({ type: 'tool_start', data: { id: 'a', name: 'pulse_query', input: inputA } });
fire({ type: 'tool_start', data: { id: 'b', name: 'pulse_query', input: inputB } });
expect(assistant().pendingTools?.map((tool) => [tool.id, tool.input])).toEqual([
['a', inputA],
['b', inputB],
]);
fire({
type: 'tool_progress',
data: { id: 'a', name: 'pulse_query', message: 'Reading client' },
});
fire({ type: 'tool_start', data: { id: 'a', name: 'pulse_query', input: inputA } });
expect(assistant().pendingTools?.map((tool) => tool.id)).toEqual(['a', 'b']);
expect(assistant().pendingTools?.[1].progress).toBeUndefined();
fire({
type: 'tool_end',
data: { id: 'a', name: 'pulse_query', output: 'client evidence', success: true },
});
expect(
assistant()
.streamEvents?.filter((event) => event.type === 'tool' || event.type === 'pending_tool')
.map((event) => [
event.type,
event.toolId,
event.tool?.input || event.pendingTool?.input,
]),
).toEqual([
['tool', 'a', inputA],
['pending_tool', 'b', inputB],
]);
fire({
type: 'tool_end',
data: { id: 'b', name: 'pulse_query', output: 'host unavailable', success: false },
});
expect(assistant().pendingTools).toEqual([]);
expect(assistant().toolCalls?.map((tool) => [tool.input, tool.output, tool.success])).toEqual(
[
[inputA, 'client evidence', true],
[inputB, 'host unavailable', false],
],
);
dispose();
});
it('does not cancel or approve a sibling invocation with the same name', async () => {
const { getFireEvent } = setupWithEventCapture();
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' }));
await chat.sendMessage('hi');
const fire = getFireEvent();
const assistant = () => chat.messages().find((m) => m.role === 'assistant')!;
for (const id of ['a', 'b']) {
fire({
type: 'tool_start',
data: { id, name: 'pulse_control', input: JSON.stringify({ resource_id: id }) },
});
fire({
type: 'approval_needed',
data: {
tool_id: id,
tool_name: 'pulse_control',
approval_id: `approval-${id}`,
command: id,
},
});
}
fire({ type: 'tool_end', data: { id: 'a', name: 'pulse_control', output: 'completed a' } });
expect(assistant().pendingApprovals?.map((approval) => approval.toolId)).toEqual(['b']);
expect(
assistant()
.streamEvents?.filter((event) => event.type === 'approval')
.map((event) => event.approval?.toolId),
).toEqual(['b']);
fire({ type: 'tool_start', data: { id: 'c', name: 'pulse_control', input: '{}' } });
fire({ type: 'tool_cancel', data: { id: 'c', name: 'pulse_control', reason: 'Skipped' } });
expect(assistant().pendingTools?.map((tool) => tool.id)).toEqual(['b']);
expect(
assistant()
.streamEvents?.filter((event) => event.type === 'tool_cancel')
.map((event) => event.toolId),
).toEqual(['c']);
dispose();
});
it('processes tool_start events', async () => {
const { getFireEvent } = setupWithEventCapture();
const { value: chat, dispose } = withRoot(() => useChat({ sessionId: 's' }));
@@ -821,8 +821,8 @@ export function useChat(options: UseChatOptions = {}) {
const matchesTool = (tool?: PendingTool, toolId?: string) => {
if (!tool) return false;
if (data.id && toolId === data.id) return true;
if (data.id && tool.id === data.id) return true;
// A supplied invocation ID must never fall back to a sibling tool name.
if (data.id) return toolId === data.id || tool.id === data.id;
return normalizedName !== '' && normalizeChatToolName(tool.name) === normalizedName;
};
@@ -903,8 +903,8 @@ export function useChat(options: UseChatOptions = {}) {
const matchesTool = (tool?: PendingTool, toolId?: string) => {
if (!tool) return false;
if (data.id && toolId === data.id) return true;
if (data.id && tool.id === data.id) return true;
// A supplied invocation ID must never fall back to a sibling tool name.
if (data.id) return toolId === data.id || tool.id === data.id;
return normalizedName !== '' && normalizeChatToolName(tool.name) === normalizedName;
};
@@ -1519,8 +1519,8 @@ export function useChat(options: UseChatOptions = {}) {
const normalizedName = normalizeChatToolName(data.name || '');
const matchesTool = (tool?: PendingTool, toolId?: string) => {
if (!tool) return false;
if (data.id && toolId === data.id) return true;
if (data.id && tool.id === data.id) return true;
// A supplied invocation ID must never fall back to a sibling tool name.
if (data.id) return toolId === data.id || tool.id === data.id;
return normalizedName !== '' && normalizeChatToolName(tool.name) === normalizedName;
};
const pendingTools = msg.pendingTools || [];
@@ -1588,8 +1588,7 @@ export function useChat(options: UseChatOptions = {}) {
data.input && data.input.trim() ? data.input : resolvedPendingTool?.input || '{}';
const completedRawInput = data.raw_input ?? resolvedPendingTool?.rawInput;
const matchesCompletedTool = (toolId?: string, toolName?: string) => {
if (data.id && toolId === data.id) return true;
if (completedToolId && toolId === completedToolId) return true;
if (completedToolId) return toolId === completedToolId;
return (
normalizedEndName !== '' &&
normalizeChatToolName(toolName || '') === normalizedEndName