Skip to content

Commit 0e54bef

Browse files
committed
fix(sdk): keep hydrated output-denied terminal in mergeIncomingIntoHydrated
1 parent 84c1434 commit 0e54bef

2 files changed

Lines changed: 101 additions & 5 deletions

File tree

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2174,10 +2174,17 @@ function mergeIncomingIntoHydrated<TMsg extends UIMessage>(
21742174
if (typeof toolCallId !== "string" || toolCallId.length === 0) return part;
21752175
const incomingPart = incomingAdvancedByCallId.get(toolCallId);
21762176
if (!incomingPart) return part;
2177-
// Hydrated already carries a resolved state for this call — treat
2178-
// it as authoritative and ignore the wire copy. Repeat sends of the
2179-
// same answer (replay, retry) are no-ops.
2180-
if (isResolvedToolState(part.state)) return part;
2177+
// Terminal hydrated states (`output-available`, `output-error`,
2178+
// `output-denied`) are authoritative — never regressed by a stale
2179+
// wire arrival (replay, retry, out-of-order). `output-denied`
2180+
// matters here because the wire's `approval-responded` could
2181+
// otherwise overwrite a hydrated denial back to a non-terminal
2182+
// state.
2183+
if (isResolvedToolState(part.state) || part.state === "output-denied") {
2184+
return part;
2185+
}
2186+
// Same state on both sides — no progression to apply.
2187+
if (part.state === incomingPart.state) return part;
21812188
mutated = true;
21822189
if (incomingPart.state === "output-available") {
21832190
return {

packages/trigger-sdk/test/mockChatAgent.test.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,92 @@ describe("mockChatAgent", () => {
469469
}
470470
});
471471

472+
it("does not downgrade a hydrated `output-denied` when a stale `approval-responded` arrives", async () => {
473+
// Terminal hydrated states (`output-available` / `output-error` /
474+
// `output-denied`) are authoritative. A wire copy that re-ships a
475+
// prior `approval-responded` (replay, retry, out-of-order arrival)
476+
// must NOT regress the terminal denial back to a pre-resolution
477+
// state — the agent would then re-run the tool.
478+
const { z } = await import("zod");
479+
const { tool } = await import("ai");
480+
const deleteTool = tool({
481+
description: "Delete.",
482+
inputSchema: z.object({ resource: z.string() }),
483+
});
484+
485+
const TC = "tc_denied_no_regress";
486+
const HEAD_ID = "a-head-denied";
487+
488+
const dbAssistant = {
489+
id: HEAD_ID,
490+
role: "assistant" as const,
491+
parts: [
492+
{
493+
type: "tool-delete" as const,
494+
toolCallId: TC,
495+
state: "output-denied" as const,
496+
input: { resource: "/critical/data" },
497+
approval: { id: "appr_1", approved: false, reason: "no" },
498+
},
499+
],
500+
};
501+
502+
const model = new MockLanguageModelV3({
503+
doStream: async () => ({ stream: textStream("ok") }),
504+
});
505+
506+
let mergedToolPart: any;
507+
const agent = chat.agent({
508+
id: "mockChatAgent.denied-no-regress",
509+
hydrateMessages: async () => [dbAssistant as any],
510+
onTurnComplete: async ({ uiMessages }) => {
511+
const head = uiMessages.find((m: any) => m.id === HEAD_ID);
512+
mergedToolPart = (head?.parts ?? []).find(
513+
(p: any) => p?.toolCallId === TC
514+
);
515+
},
516+
run: async ({ messages, signal }) => {
517+
return streamText({
518+
model,
519+
messages,
520+
tools: { delete: deleteTool },
521+
abortSignal: signal,
522+
});
523+
},
524+
});
525+
526+
const harness = mockChatAgent(agent, { chatId: "test-denied-no-regress" });
527+
try {
528+
// Stale wire arrival — `approval-responded` for a tool that
529+
// hydrated already shows as `output-denied`.
530+
const stale = {
531+
id: HEAD_ID,
532+
role: "assistant" as const,
533+
parts: [
534+
{
535+
type: "tool-delete" as const,
536+
toolCallId: TC,
537+
state: "approval-responded" as const,
538+
approval: { id: "appr_1", approved: true },
539+
},
540+
],
541+
};
542+
await harness.sendMessage(stale as any);
543+
await new Promise((r) => setTimeout(r, 50));
544+
545+
// The hydrated terminal state survives the merge.
546+
expect(mergedToolPart?.state).toBe("output-denied");
547+
expect(mergedToolPart?.approval).toEqual({
548+
id: "appr_1",
549+
approved: false,
550+
reason: "no",
551+
});
552+
expect(mergedToolPart?.input).toEqual({ resource: "/critical/data" });
553+
} finally {
554+
await harness.close();
555+
}
556+
});
557+
472558
it("onValidateMessages sees the slim wire on HITL continuations — fresh-user filter still works", async () => {
473559
// Slim wire is the wire shape on HITL `addToolOutput` continuations.
474560
// Existing customers calling `validateUIMessages` from `ai` on the
@@ -546,7 +632,10 @@ describe("mockChatAgent", () => {
546632
if (userMessages.length > 0) {
547633
await validateUIMessages({
548634
messages: userMessages,
549-
tools: { askUser },
635+
// `validateUIMessages` is stricter than `streamText` about
636+
// tool input/output variance — cast to satisfy the wider
637+
// `Tool<unknown, unknown>` it expects.
638+
tools: { askUser } as any,
550639
});
551640
}
552641
return messages;

0 commit comments

Comments
 (0)