Skip to content

Commit 39ea541

Browse files
committed
fix(chat): reconcile a steer into the model lane when the turn fails
The append ran on the success path only. A turn that failed after a steer reported it to the hook's newUIMessages but left it pending, so the error event's messages lacked it and the next turn received it one slot late. The catch path reconciles it now, before the partial is considered.
1 parent 4f535ac commit 39ea541

2 files changed

Lines changed: 37 additions & 14 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9248,6 +9248,8 @@ function chatAgent<
92489248

92499249
let erroredNewModelMessages: ModelMessage[] = [];
92509250

9251+
reconcilePendingSteer();
9252+
92519253
if (!responseCommitted) {
92529254
try {
92539255
if (erroredNewUIMessages.length > 0) {

‎packages/trigger-sdk/test/steering-error-path.test.ts‎

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ describe("chat.agent steering on a turn that fails", () => {
9292
const chatId = "steer-error-path";
9393
const toolGate = deferred();
9494
let toolEntered = false;
95-
const events: { newUIMessages: UIMessage[]; finishReason?: string }[] = [];
95+
const events: { newUIMessages: UIMessage[]; messages: unknown[]; finishReason?: string }[] = [];
9696
const promptsSawSteer: boolean[] = [];
9797

9898
const gateTool = tool({
@@ -109,25 +109,36 @@ describe("chat.agent steering on a turn that fails", () => {
109109
const model = new MockLanguageModelV3({
110110
doStream: async ({ prompt }) => {
111111
promptsSawSteer.push(JSON.stringify(prompt).includes("steer-me"));
112-
// Step 1 calls the tool, step 2 fails mid-stream.
113-
return step++ === 0
114-
? {
115-
stream: new ReadableStream({
116-
start(c) {
117-
for (const ch of toolCallChunks("tc-1")) c.enqueue(ch);
118-
c.close();
119-
},
120-
}),
121-
}
122-
: { stream: erroringStream() };
112+
// Step 1 calls the tool, step 2 fails mid-stream, the next turn answers.
113+
const n = step++;
114+
const fromChunks = (chunks: LanguageModelV3StreamPart[]) => ({
115+
stream: new ReadableStream<LanguageModelV3StreamPart>({
116+
start(c) {
117+
for (const ch of chunks) c.enqueue(ch);
118+
c.close();
119+
},
120+
}),
121+
});
122+
if (n === 0) return fromChunks(toolCallChunks("tc-1"));
123+
if (n === 1) return { stream: erroringStream() };
124+
return fromChunks([
125+
{ type: "text-start", id: "t1" },
126+
{ type: "text-delta", id: "t1", delta: "ok" },
127+
{ type: "text-end", id: "t1" },
128+
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: USAGE },
129+
]);
123130
},
124131
});
125132

126133
const agent = chat.agent({
127134
id: "steer-error-path",
128135
pendingMessages: { shouldInject: () => true },
129-
onTurnComplete: async ({ newUIMessages, finishReason }) => {
130-
events.push({ newUIMessages: [...(newUIMessages ?? [])], finishReason });
136+
onTurnComplete: async ({ newUIMessages, messages, finishReason }) => {
137+
events.push({
138+
newUIMessages: [...(newUIMessages ?? [])],
139+
messages: [...messages],
140+
finishReason,
141+
});
131142
},
132143
run: async ({ messages, signal }) =>
133144
streamText({
@@ -160,6 +171,16 @@ describe("chat.agent steering on a turn that fails", () => {
160171
.map((p) => p.text ?? "")
161172
);
162173
expect(texts).toContain("steer-me");
174+
175+
// The model lane the failed turn reports has to carry it too, and so
176+
// does the prompt the next turn actually sends. Reconciling only on the
177+
// success path leaves it pending, so the next turn misses it and it
178+
// lands a slot late at the end of that turn.
179+
expect(JSON.stringify(events[0]!.messages)).toContain("steer-me");
180+
const promptsBefore = promptsSawSteer.length;
181+
await harness.sendMessage(userMessage("m3", "u-3"));
182+
await waitFor(() => promptsSawSteer.length > promptsBefore, "turn 2 prompt built");
183+
expect(promptsSawSteer[promptsBefore]).toBe(true);
163184
} finally {
164185
toolGate.resolve();
165186
await harness.close();

0 commit comments

Comments
 (0)