Skip to content

Commit ccf96a8

Browse files
committed
fix(sdk): channel stream editor tail, abort cleanup, and error egress
Three fixes to chat.agent channel egress: The debounced stream editor now re-arms after an edit it skipped because a previous edit was still in flight, so text buffered during that window still reaches the channel instead of stalling until the next delta. The stream editor is stopped when the reply stream is aborted or cancelled, not just on normal completion, so a late timer can no longer edit the channel message after the turn has ended. A turn that throws now edits the placeholder to show the error, so a channel user sees the failure instead of a message stuck on the loading placeholder.
1 parent 2b97fbb commit ccf96a8

2 files changed

Lines changed: 211 additions & 18 deletions

File tree

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

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5057,13 +5057,23 @@ function makeChannelStreamEditor<TEvent>(
50575057
) {
50585058
const outbound = connector.outbound ?? defaultChannelOutbound;
50595059
let latest = "";
5060+
let lastSent = "";
50605061
let timer: ReturnType<typeof setTimeout> | undefined;
50615062
let inFlight = false;
50625063
let stopped = false;
50635064

5065+
const arm = () => {
5066+
if (stopped || timer) return;
5067+
timer = setTimeout(() => {
5068+
timer = undefined;
5069+
void edit();
5070+
}, CHANNEL_STREAM_EDIT_INTERVAL_MS);
5071+
};
5072+
50645073
const edit = async () => {
50655074
if (stopped || inFlight) return;
50665075
const text = latest;
5076+
if (text === lastSent) return;
50675077
const message = outbound({
50685078
text,
50695079
message: { id: ackRef, role: "assistant", parts: [{ type: "text", text }] } as UIMessage,
@@ -5080,10 +5090,12 @@ function makeChannelStreamEditor<TEvent>(
50805090
mode: "stream",
50815091
final: false,
50825092
});
5093+
lastSent = text;
50835094
} catch (error) {
50845095
logger.warn("chat.agent: channel stream edit failed", { error });
50855096
} finally {
50865097
inFlight = false;
5098+
if (!stopped && latest !== lastSent) arm();
50875099
}
50885100
};
50895101

@@ -5093,11 +5105,7 @@ function makeChannelStreamEditor<TEvent>(
50935105
const c = chunk as { type?: string; delta?: unknown };
50945106
if (c?.type === "text-delta" && typeof c.delta === "string") {
50955107
latest += c.delta;
5096-
if (!timer)
5097-
timer = setTimeout(() => {
5098-
timer = undefined;
5099-
void edit();
5100-
}, CHANNEL_STREAM_EDIT_INTERVAL_MS);
5108+
arm();
51015109
}
51025110
},
51035111
stop() {
@@ -5110,6 +5118,39 @@ function makeChannelStreamEditor<TEvent>(
51105118
};
51115119
}
51125120

5121+
type ChannelStreamEditor = { observe(chunk: unknown): void; stop(): void };
5122+
5123+
/**
5124+
* Wrap the reply stream so it debounce-edits the channel message as it streams.
5125+
* Both `flush` (the stream completed) and `cancel` (the stream was aborted or
5126+
* cancelled downstream) stop the editor, so a pending debounce timer can never
5127+
* fire an edit after the turn ended, onto a message the next turn already owns.
5128+
*/
5129+
function makeChannelStreamTap(editor: ChannelStreamEditor): TransformStream<unknown, unknown> {
5130+
const transformer: {
5131+
transform(chunk: unknown, controller: TransformStreamDefaultController<unknown>): void;
5132+
flush(): void;
5133+
cancel?: (reason?: unknown) => void;
5134+
} = {
5135+
transform(chunk, controller) {
5136+
editor.observe(chunk);
5137+
controller.enqueue(chunk);
5138+
},
5139+
flush() {
5140+
editor.stop();
5141+
},
5142+
cancel() {
5143+
editor.stop();
5144+
},
5145+
};
5146+
return new TransformStream<unknown, unknown>(transformer);
5147+
}
5148+
5149+
export {
5150+
makeChannelStreamEditor as __makeChannelStreamEditorForTests,
5151+
makeChannelStreamTap as __makeChannelStreamTapForTests,
5152+
};
5153+
51135154
// The `action` type onAction receives: the actionSchema output (when set) unioned with the action
51145155
// envelopes of any listed chat.event descriptors. ChatEventActions<[]> is `never`, so it collapses
51155156
// cleanly when no events are listed; `unknown` is kept only when neither source is present.
@@ -6925,6 +6966,8 @@ function chatAgent<
69256966
let channelConn: AnyChannelConnector | undefined;
69266967
let channelWorkingReaction: string | undefined;
69276968
let channelWireEvent: { event: unknown; deliveryId: string } | undefined;
6969+
let channelAckRef: string | undefined;
6970+
let channelStreamEditor: ChannelStreamEditor | undefined;
69286971
let droppedStaleInteraction = false;
69296972
try {
69306973
// Extract turn-level context before entering the span. Slim
@@ -6941,7 +6984,6 @@ function chatAgent<
69416984
void _hsm;
69426985
channelWireEvent = wireChannelEvent;
69436986
let effectiveIncomingMessage = incomingMessage;
6944-
let channelAckRef: string | undefined;
69456987
if (wireChannelEvent) {
69466988
channelConn = channels?.find((c) => c.id === wireChannelEvent.connectorId);
69476989
if (channelConn) {
@@ -7799,21 +7841,13 @@ function chatAgent<
77997841
channelAckRef &&
78007842
uiStream instanceof ReadableStream
78017843
) {
7802-
const editor = makeChannelStreamEditor(
7844+
channelStreamEditor = makeChannelStreamEditor(
78037845
channelConn,
78047846
wireChannelEvent,
78057847
channelAckRef
78067848
);
78077849
streamForPipe = uiStream.pipeThrough(
7808-
new TransformStream({
7809-
transform(chunk, controller) {
7810-
editor.observe(chunk);
7811-
controller.enqueue(chunk);
7812-
},
7813-
flush() {
7814-
editor.stop();
7815-
},
7816-
})
7850+
makeChannelStreamTap(channelStreamEditor)
78177851
);
78187852
}
78197853
await pipeChat(tapUIMessageChunks(streamForPipe, turnBufferedChunks), {
@@ -7833,6 +7867,7 @@ function chatAgent<
78337867
}
78347868
} finally {
78357869
msgSub.off();
7870+
channelStreamEditor?.stop();
78367871
}
78377872

78387873
// Wait for onFinish to fire — on abort this may resolve slightly
@@ -8593,6 +8628,37 @@ function chatAgent<
85938628
}
85948629
}
85958630

8631+
if (channelWireEvent && channelConn?.send && channelAckRef) {
8632+
const channelErrorText =
8633+
turnError instanceof Error ? turnError.message : "An unexpected error occurred";
8634+
const errorMessage = (channelConn.outbound ?? defaultChannelOutbound)({
8635+
text: channelErrorText,
8636+
message: {
8637+
id: channelAckRef,
8638+
role: "assistant",
8639+
parts: [{ type: "text", text: channelErrorText }],
8640+
} as UIMessage,
8641+
final: true,
8642+
stopped: false,
8643+
error: turnError,
8644+
});
8645+
if (errorMessage) {
8646+
try {
8647+
await channelConn.send(errorMessage, {
8648+
event: channelWireEvent.event,
8649+
deliveryId: channelWireEvent.deliveryId,
8650+
previousRef: channelAckRef,
8651+
mode: channelConn.delivery,
8652+
final: true,
8653+
});
8654+
} catch (egressError) {
8655+
logger.warn("chat.agent: channel error egress send failed", {
8656+
error: egressError,
8657+
});
8658+
}
8659+
}
8660+
}
8661+
85968662
let errorTurnCompleteResult:
85978663
| Awaited<ReturnType<typeof writeTurnCompleteChunk>>
85988664
| undefined;

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

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { mockChatAgent, recordingChannelConnector } from "../src/v3/test/index.js";
22

3-
import { describe, expect, it } from "vitest";
4-
import { chat } from "../src/v3/ai.js";
3+
import { describe, expect, it, vi } from "vitest";
4+
import {
5+
chat,
6+
__makeChannelStreamEditorForTests,
7+
__makeChannelStreamTapForTests,
8+
} from "../src/v3/ai.js";
59
import { simulateReadableStream, streamText } from "ai";
610
import { MockLanguageModelV3 } from "ai/test";
711
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
@@ -147,4 +151,127 @@ describe("chat.agent channels", () => {
147151
await harness.close();
148152
}
149153
});
154+
155+
it("edits the placeholder to the error when a channel turn throws", async () => {
156+
const channel = recordingChannelConnector();
157+
158+
const agent = chat.agent({
159+
id: "chatChannels.error-egress",
160+
channels: [channel],
161+
run: async () => {
162+
throw new Error("turn exploded");
163+
},
164+
});
165+
166+
const harness = mockChatAgent(agent, { chatId: "chan-5" });
167+
try {
168+
await harness.sendChannelEvent({ event: { text: "cause an error", threadId: "chan-5" } });
169+
170+
expect(channel.acks).toHaveLength(1);
171+
const finalSend = channel.sent[channel.sent.length - 1]!;
172+
expect(finalSend.ctx.final).toBe(true);
173+
expect(finalSend.ctx.previousRef).toBe(channel.acks[0]!.ref);
174+
expect(finalSend.message.text).toBe("turn exploded");
175+
} finally {
176+
await harness.close();
177+
}
178+
});
179+
});
180+
181+
describe("makeChannelStreamEditor", () => {
182+
function streamConnector(send: (message: { text: string }) => Promise<{ ref?: string }>) {
183+
return { delivery: "stream" as const, send } as never;
184+
}
185+
186+
it("re-arms the debounce timer so text buffered during an in-flight edit still lands", async () => {
187+
vi.useFakeTimers();
188+
try {
189+
const sends: string[] = [];
190+
let releaseFirst!: () => void;
191+
const firstGate = new Promise<void>((resolve) => {
192+
releaseFirst = resolve;
193+
});
194+
let call = 0;
195+
const editor = __makeChannelStreamEditorForTests(
196+
streamConnector(async (message) => {
197+
sends.push(message.text);
198+
call += 1;
199+
if (call === 1) await firstGate;
200+
return { ref: "r1" };
201+
}),
202+
{ event: {}, deliveryId: "d1" },
203+
"r1"
204+
);
205+
206+
editor.observe({ type: "text-delta", delta: "a" });
207+
await vi.advanceTimersByTimeAsync(1000);
208+
expect(sends).toEqual(["a"]);
209+
210+
editor.observe({ type: "text-delta", delta: "b" });
211+
await vi.advanceTimersByTimeAsync(1000);
212+
expect(sends).toEqual(["a"]);
213+
214+
releaseFirst();
215+
await vi.advanceTimersByTimeAsync(0);
216+
await vi.advanceTimersByTimeAsync(1000);
217+
expect(sends).toEqual(["a", "ab"]);
218+
} finally {
219+
vi.useRealTimers();
220+
}
221+
});
222+
223+
it("does not fire a pending edit after stop()", async () => {
224+
vi.useFakeTimers();
225+
try {
226+
const sends: string[] = [];
227+
const editor = __makeChannelStreamEditorForTests(
228+
streamConnector(async (message) => {
229+
sends.push(message.text);
230+
return { ref: "r1" };
231+
}),
232+
{ event: {}, deliveryId: "d1" },
233+
"r1"
234+
);
235+
236+
editor.observe({ type: "text-delta", delta: "a" });
237+
editor.stop();
238+
await vi.advanceTimersByTimeAsync(1000);
239+
expect(sends).toEqual([]);
240+
} finally {
241+
vi.useRealTimers();
242+
}
243+
});
244+
});
245+
246+
describe("makeChannelStreamTap", () => {
247+
it("stops the editor when the stream completes (flush)", async () => {
248+
let stops = 0;
249+
const tap = __makeChannelStreamTapForTests({ observe: () => {}, stop: () => (stops += 1) });
250+
const source = new ReadableStream({
251+
start(controller) {
252+
controller.enqueue({ type: "text-delta", delta: "x" });
253+
controller.close();
254+
},
255+
});
256+
const reader = source.pipeThrough(tap).getReader();
257+
let done = false;
258+
while (!done) {
259+
done = (await reader.read()).done;
260+
}
261+
expect(stops).toBeGreaterThan(0);
262+
});
263+
264+
it("stops the editor when the stream is cancelled mid-flight (abort)", async () => {
265+
let stops = 0;
266+
const tap = __makeChannelStreamTapForTests({ observe: () => {}, stop: () => (stops += 1) });
267+
const source = new ReadableStream({
268+
start(controller) {
269+
controller.enqueue({ type: "text-delta", delta: "x" });
270+
},
271+
});
272+
const reader = source.pipeThrough(tap).getReader();
273+
await reader.read();
274+
await reader.cancel("aborted");
275+
expect(stops).toBeGreaterThan(0);
276+
});
150277
});

0 commit comments

Comments
 (0)