Skip to content

Commit 31810b7

Browse files
committed
fix(chat): suppress turns after iterator return
1 parent 5839cc3 commit 31810b7

5 files changed

Lines changed: 71 additions & 5 deletions

File tree

apps/webapp/test/helpers/testChatAgent.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,33 @@ async function expectActiveSessionIteratorError() {
291291
export const testEndAndContinueIteratorGuardCustomAgent = chat.customAgent({
292292
id: "e2e-test-chat-custom-end-and-continue-iterator-guard",
293293
run: async (payload, { signal }) => {
294+
if (payload.continuation) {
295+
const next = await chat.messages.waitWithIdleTimeout({
296+
idleTimeoutInSeconds: 2,
297+
timeout: "1m",
298+
});
299+
if (!next.ok) {
300+
throw next.error;
301+
}
302+
303+
const message = next.output.message as UIMessage | undefined;
304+
const text = message ? firstText(message) : "";
305+
const { waitUntilComplete } = chat.stream.writer({
306+
execute: ({ write }) => {
307+
write({ type: "text-start", id: "guard-continuation-result" });
308+
write({
309+
type: "text-delta",
310+
id: "guard-continuation-result",
311+
delta: `received:${text}`,
312+
});
313+
write({ type: "text-end", id: "guard-continuation-result" });
314+
},
315+
});
316+
await waitUntilComplete();
317+
await chat.writeTurnComplete();
318+
return;
319+
}
320+
294321
const iterator = chat.createSession(payload, { signal })[Symbol.asyncIterator]();
295322
const firstTurn = await iterator.next();
296323
if (firstTurn.done) {
@@ -311,8 +338,8 @@ export const testEndAndContinueIteratorGuardCustomAgent = chat.customAgent({
311338
endAndContinueGuardEvents.push({ chatId: payload.chatId, kind: "guard-held" });
312339

313340
const [nextResult] = await Promise.all([pendingNext, pendingReturn]);
314-
if (nextResult.done) {
315-
throw new Error("Expected the pending next() call to receive the release input");
341+
if (!nextResult.done) {
342+
throw new Error("Expected return() to suppress the pending next() turn");
316343
}
317344
endAndContinueGuardEvents.push({ chatId: payload.chatId, kind: "return-settled" });
318345

apps/webapp/test/session-agent.e2e.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1698,6 +1698,7 @@ describe("session agent e2e (real chat.agent loop)", () => {
16981698
agentFailure = error;
16991699
}
17001700
);
1701+
let continuation: ReturnType<typeof runRealChatAgent> | undefined;
17011702

17021703
try {
17031704
await waitFor(
@@ -1739,7 +1740,34 @@ describe("session agent e2e (real chat.agent loop)", () => {
17391740
select: { currentRunId: true },
17401741
});
17411742
expect(session.currentRunId).not.toBe(initialRun.id);
1743+
1744+
const successor = await server.prisma.taskRun.findFirstOrThrow({
1745+
where: { id: session.currentRunId! },
1746+
select: { friendlyId: true },
1747+
});
1748+
continuation = runRealChatAgent({
1749+
agentId: testEndAndContinueIteratorGuardCustomAgent.id,
1750+
baseUrl,
1751+
addressingKey,
1752+
secretKey: apiKey,
1753+
model: textModel("unused"),
1754+
modelLocal: testChatModelLocal,
1755+
runId: successor.friendlyId,
1756+
continuation: true,
1757+
previousRunId: runId,
1758+
});
1759+
1760+
const { parts } = await collectSessionOut({
1761+
baseUrl,
1762+
addressingKey,
1763+
token: publicAccessToken,
1764+
until: (records) => records.filter(isTurnComplete).length >= 2,
1765+
maxMs: 30_000,
1766+
});
1767+
expect(joinChunks(parts)).toContain("received:release pending next");
1768+
await expect(continuation.done).resolves.toBeUndefined();
17421769
} finally {
1770+
await continuation?.close();
17431771
await agent.close();
17441772
}
17451773
});

docs/ai-chat/custom-agents.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ Without this, a resumed chat silently loses its history: the model sees only the
148148

149149
With `chat.createSession()`, use `chat.requestUpgrade()` and let the iterator exit normally. For an immediate handoff, close the iterator before calling `chat.endAndContinue()`; the method rejects until the iterator and any active `next()` call have settled. In a fully hand-rolled custom agent, call it directly to hand the Session to a fresh run.
150150

151+
Close the iterator between reads. If `return()` races a `next()` that is already waiting for input, it waits for that read to settle before releasing the handoff guard. Input dispatched while the iterator is closing is not yielded as a turn and remains available to the continuation unless you write another turn-complete boundary.
152+
151153
Call it between turns, after detaching the old run's input listeners. If the old run completed its current turn, persist its state and write the turn-complete boundary before the handoff:
152154

153155
```ts

docs/ai-chat/patterns/version-upgrades.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ This upgrades on **every** deploy, not just breaking changes. Good for fast-movi
155155

156156
Use `chat.requestUpgrade()` with `chat.agent()`. With `chat.createSession()`, call `chat.requestUpgrade()`, then advance the iterator once more so it can exit normally. For an immediate handoff, close the iterator before calling `chat.endAndContinue()`. In a fully hand-rolled `chat.customAgent()` task, detach input listeners, persist the completed turn, write its boundary, then call `chat.endAndContinue()` and return immediately:
157157

158+
Close a `chat.createSession()` iterator between reads. If `return()` races a `next()` that is already waiting for input, it waits for that read to settle before the handoff can continue. Input dispatched while the iterator is closing is not yielded as a turn and remains available to the continuation unless you write another turn-complete boundary.
159+
158160
```ts
159161
// Detach any chat.messages.on() subscriptions you created.
160162
stop.cleanup();

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8717,9 +8717,11 @@ function requestUpgrade(): void {
87178717
* This is the low-level handoff for a fully hand-rolled
87188718
* `chat.customAgent()` loop. This method rejects while a
87198719
* `chat.createSession()` iterator is active. Close the iterator before calling
8720-
* it. Call only between turns and after detaching input listeners for the old
8721-
* run. If the old run completed its current turn, persist its state and call
8722-
* {@link chatWriteTurnComplete} before handing off.
8720+
* it. If `return()` races an active `next()`, it waits for that read to settle
8721+
* before releasing the handoff guard. Call only between turns and after
8722+
* detaching input listeners for the old run. If the old run completed its
8723+
* current turn, persist its state and call {@link chatWriteTurnComplete} before
8724+
* handing off.
87238725
* Do not write a new turn boundary after input that the continuation run should
87248726
* process has been dispatched: the boundary acknowledges that input.
87258727
*
@@ -9656,6 +9658,11 @@ function trackActiveChatSessionIterator(
96569658
} catch {
96579659
// The inner next() already ended cleanly; cleanup remains best-effort.
96589660
}
9661+
} else if (closing) {
9662+
// return() won the race. Do not expose a turn the caller has already
9663+
// abandoned; without a new turn-complete boundary its input remains
9664+
// replayable by the continuation run.
9665+
return { done: true as const, value: undefined };
96599666
}
96609667
return result;
96619668
},

0 commit comments

Comments
 (0)