Skip to content

Commit b729865

Browse files
committed
fix(chat): guard handoff during managed sessions
1 parent adf37ab commit b729865

3 files changed

Lines changed: 114 additions & 6 deletions

File tree

docs/ai-chat/custom-agents.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ Without this, a resumed chat silently loses its history: the model sees only the
146146

147147
### Rotating to a new deployment
148148

149-
With `chat.createSession()`, use `chat.requestUpgrade()` to leave the current run after the turn. In a fully hand-rolled custom agent, use `chat.endAndContinue()` to immediately hand the Session to a fresh run.
149+
With `chat.createSession()`, use `chat.requestUpgrade()` to leave the current run after the turn. `chat.endAndContinue()` rejects while a `chat.createSession()` iterator is active. In a fully hand-rolled custom agent, use it to immediately hand the Session to a fresh run.
150150

151151
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:
152152

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

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2540,6 +2540,8 @@ const chatOnCompactedKey =
25402540
const chatAgentRunContextKey = locals.create<TaskRunContext>("chat.agentRunContext");
25412541
/** @internal Marks the root run created by `chat.customAgent()`. */
25422542
const chatCustomAgentRunKey = locals.create<boolean>("chat.customAgentRun");
2543+
/** @internal Number of active `chat.createSession()` iterators in this run. */
2544+
const chatActiveSessionIteratorsKey = locals.create<number>("chat.createSession.activeIterators");
25432545
const chatPrepareMessagesKey =
25442546
locals.create<(event: PrepareMessagesEvent<unknown>) => ModelMessage[] | Promise<ModelMessage[]>>(
25452547
"chat.prepareMessages"
@@ -8714,9 +8716,10 @@ function requestUpgrade(): void {
87148716
*
87158717
* This is the low-level handoff for a fully hand-rolled
87168718
* `chat.customAgent()` loop. (Use {@link requestUpgrade} with
8717-
* `chat.createSession()` instead.) Call only between turns and after detaching
8718-
* input listeners for the old run. If the old run completed its current turn,
8719-
* persist its state and call {@link chatWriteTurnComplete} before handing off.
8719+
* `chat.createSession()` instead; this method rejects while its iterator is
8720+
* active.) Call only between turns and after detaching input listeners for the
8721+
* old run. If the old run completed its current turn, persist its state and
8722+
* call {@link chatWriteTurnComplete} before handing off.
87208723
* Do not write a new turn boundary after input that the continuation run should
87218724
* process has been dispatched: the boundary acknowledges that input.
87228725
*
@@ -8742,6 +8745,11 @@ async function endAndContinue(): Promise<void> {
87428745
"chat.endAndContinue() can only be called from inside a chat.customAgent() run"
87438746
);
87448747
}
8748+
if ((locals.get(chatActiveSessionIteratorsKey) ?? 0) > 0) {
8749+
throw new Error(
8750+
"chat.endAndContinue() cannot be called while a chat.createSession() iterator is active. Use chat.requestUpgrade() instead."
8751+
);
8752+
}
87458753

87468754
await performEndAndContinue();
87478755
}
@@ -9570,6 +9578,47 @@ export type ChatTurn = {
95709578
| undefined;
95719579
};
95729580

9581+
function trackActiveChatSessionIterator(
9582+
iterator: AsyncIterator<ChatTurn>
9583+
): AsyncIterator<ChatTurn> {
9584+
locals.set(chatActiveSessionIteratorsKey, (locals.get(chatActiveSessionIteratorsKey) ?? 0) + 1);
9585+
let active = true;
9586+
9587+
function finish() {
9588+
if (!active) return;
9589+
active = false;
9590+
const remaining = Math.max((locals.get(chatActiveSessionIteratorsKey) ?? 1) - 1, 0);
9591+
locals.set(chatActiveSessionIteratorsKey, remaining);
9592+
}
9593+
9594+
return {
9595+
async next() {
9596+
try {
9597+
const result = await iterator.next();
9598+
if (result.done) finish();
9599+
return result;
9600+
} catch (error) {
9601+
try {
9602+
await iterator.return?.();
9603+
} catch {
9604+
// Preserve the original iterator error after best-effort cleanup.
9605+
}
9606+
finish();
9607+
throw error;
9608+
}
9609+
},
9610+
async return() {
9611+
try {
9612+
return iterator.return
9613+
? await iterator.return()
9614+
: { done: true as const, value: undefined };
9615+
} finally {
9616+
finish();
9617+
}
9618+
},
9619+
};
9620+
}
9621+
95739622
/**
95749623
* Create a chat session that yields turns as an async iterator.
95759624
*
@@ -9641,7 +9690,7 @@ function createChatSession(
96419690
// top of next() in case user code threw without complete()/done().
96429691
let activeMsgSub: { off: () => void } | undefined;
96439692

9644-
return {
9693+
const iterator: AsyncIterator<ChatTurn> = {
96459694
async next(): Promise<IteratorResult<ChatTurn>> {
96469695
activeMsgSub?.off();
96479696
activeMsgSub = undefined;
@@ -10087,6 +10136,8 @@ function createChatSession(
1008710136
return { done: true, value: undefined };
1008810137
},
1008910138
};
10139+
10140+
return trackActiveChatSessionIterator(iterator);
1009010141
},
1009110142
};
1009210143
}
Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { describe, expect, it } from "vitest";
1+
import "../src/v3/test/index.js";
2+
3+
import { apiClientManager, resourceCatalog } from "@trigger.dev/core/v3";
4+
import { runInMockTaskContext } from "@trigger.dev/core/v3/test";
5+
import { describe, expect, it, vi } from "vitest";
26
import { chat } from "../src/v3/ai.js";
37

48
describe("chat.endAndContinue", () => {
@@ -7,4 +11,57 @@ describe("chat.endAndContinue", () => {
711
"chat.endAndContinue() can only be called from inside a chat.customAgent() run"
812
);
913
});
14+
15+
it("rejects while a createSession iterator is active and allows handoff after return", async () => {
16+
const chatId = "end-and-continue-active-session";
17+
const endAndContinueSession = vi.fn().mockResolvedValue({});
18+
const clientSpy = vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
19+
readSessionStreamRecords: async () => ({ records: [] }),
20+
endAndContinueSession,
21+
} as never);
22+
23+
const agent = chat.customAgent({
24+
id: "end-and-continue-active-session-agent",
25+
run: async (payload, { signal }) => {
26+
const iterator = chat.createSession(payload, { signal })[Symbol.asyncIterator]();
27+
const firstTurn = await iterator.next();
28+
expect(firstTurn.done).toBe(false);
29+
30+
await expect(chat.endAndContinue()).rejects.toThrow(
31+
"chat.endAndContinue() cannot be called while a chat.createSession() iterator is active. Use chat.requestUpgrade() instead."
32+
);
33+
expect(endAndContinueSession).not.toHaveBeenCalled();
34+
35+
await iterator.return?.();
36+
await chat.endAndContinue();
37+
},
38+
});
39+
const run = resourceCatalog.getTask(agent.id)?.fns.run;
40+
if (!run) throw new Error("custom agent was not registered");
41+
42+
try {
43+
await runInMockTaskContext((drivers) =>
44+
run(
45+
{
46+
chatId,
47+
trigger: "submit-message",
48+
message: {
49+
id: "user-1",
50+
role: "user",
51+
parts: [{ type: "text", text: "hello" }],
52+
},
53+
},
54+
{ ctx: drivers.ctx, signal: new AbortController().signal }
55+
)
56+
);
57+
58+
expect(endAndContinueSession).toHaveBeenCalledOnce();
59+
expect(endAndContinueSession).toHaveBeenCalledWith(chatId, {
60+
callingRunId: "run_test",
61+
reason: "upgrade",
62+
});
63+
} finally {
64+
clientSpy.mockRestore();
65+
}
66+
});
1067
});

0 commit comments

Comments
 (0)