fix: reconcile non-native tool-call streamed <function=...> XML delta (#16220)

This commit is contained in:
Ondra Pelech
2026-08-19 10:44:39 +00:00
committed by GitHub
parent 3865628375
commit 5f11a4eec6
2 changed files with 61 additions and 5 deletions
@@ -742,6 +742,41 @@ describe("handleEventForUI", () => {
expect(result).toEqual([mockMessageEvent, delta, action]);
});
// Non-native tool call: the delta is `thought` + raw `<function=...>` XML, a
// superset of the thought, so only the marker signal reconciles it.
it("clears the delta when streamed text is the thought plus an unstripped <function=...> block", () => {
const thought = "Coding and executing";
const delta = makeStreamingDelta(
"delta-1",
`${thought}<function=terminal>\n<parameter=command>echo hi</parameter>\n<parameter=security_risk>LOW</parameter>\n</function>`,
);
const action = makeThoughtAction("intermediate-1", thought);
const result = handleEventForUI(action, [mockMessageEvent, delta]);
expect(result).toEqual([mockMessageEvent, action]);
});
// The planning and main sockets share this store, so the marker signal must
// not let one agent's action strip the other's live delta.
it("leaves a marker-bearing delta from the other agent untouched", () => {
const delta = {
...makeStreamingDelta(
"delta-1",
`Planning<function=terminal>\n<parameter=command>echo hi</parameter>\n</function>`,
),
isFromPlanningAgent: true,
};
const action = makeThoughtAction(
"intermediate-1",
"Coding and executing",
);
const result = handleEventForUI(action, [mockMessageEvent, delta]);
expect(result).toEqual([mockMessageEvent, delta, action]);
});
it("does not reconcile a ThinkAction (its thought renders separately)", () => {
const thought = "A reasoning step.";
const delta = makeStreamingDelta("delta-1", thought);
+26 -5
View File
@@ -117,8 +117,18 @@ const getTrailingDeltas = (
return deltas;
};
const getTrailingContentDeltas = (uiEvents: OpenHandsEvent[]) =>
getTrailingDeltas(uiEvents, (event) => (event.content?.length ?? 0) > 0);
// Sender-scoped for the same reason as `getTrailingReasoningDeltas` (#1656):
// a main-agent action must not strip the planning agent's live content.
const getTrailingContentDeltas = (
uiEvents: OpenHandsEvent[],
finalEvent: OpenHandsEvent,
) =>
getTrailingDeltas(
uiEvents,
(event) =>
(event.content?.length ?? 0) > 0 &&
isSameStreamingSender(finalEvent, event),
);
// Sender-scoped: the main and planning sockets share this event store, so a
// main-agent action must not strip the planning agent's live reasoning (#1656).
@@ -187,6 +197,12 @@ const matchStreamedSegments = (
return findTextSegmentsInOrder(targetText, searchSegments);
};
// A `<function=` marker means the delta still holds the raw prompted-tool-call
// XML the SDK strips only after the response completes, so the streamed text is
// a superset of the action's `thought` that `matchStreamedSegments` can't match.
const hasUnstrippedFunctionCallMarker = (segments: string[]): boolean =>
segments.some((segment) => segment.includes("<function="));
// Whether the finalized event renders its own reasoning: an ActionEvent via
// reasoning_content/thinking_blocks, an agent MessageEvent via an inline
// <think> block in its content. Decides if a replaced delta's reasoning must
@@ -257,7 +273,7 @@ const supersedeStreamedThoughtWithAction = (
return null;
}
const contentDeltas = getTrailingContentDeltas(uiEvents);
const contentDeltas = getTrailingContentDeltas(uiEvents, action);
if (contentDeltas.length === 0) {
return null;
}
@@ -266,8 +282,13 @@ const supersedeStreamedThoughtWithAction = (
({ event }) => event.content ?? "",
);
// Only strip when the streamed text is the action's rendered thought.
if (!matchStreamedSegments(thoughtText, streamingSegments).matched) {
// Strip on a thought match, or on an unstripped `<function=...>` marker whose
// streamed text is a superset of `thought` that the match can't reconcile.
const matchedThought = matchStreamedSegments(
thoughtText,
streamingSegments,
).matched;
if (!matchedThought && !hasUnstrippedFunctionCallMarker(streamingSegments)) {
return null;
}