Skip to content

Commit 471d17d

Browse files
committed
fix(chat,sdk): attach the input router per run, not per chat
A worker process is reused across runs, and the executor tears the channel subscription down at the end of each one. The router was cached per chat, so a second run of the same chat in the same process skipped the attach and then had no subscription feeding it: no messages arrived, and the conversation hung with no error raised. Caching it per run instead means every run attaches its own subscription, while a nested `chat.createSession` inside one run still shares the router. Reported by review, reproduced first as a failing test that drives two runs through one process with the executor's teardown in between.
1 parent 4693292 commit 471d17d

2 files changed

Lines changed: 75 additions & 9 deletions

File tree

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

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1871,17 +1871,18 @@ const CHAT_ROUTE_STOP = "stop";
18711871
const CHAT_ROUTE_HANDOVER = "handover";
18721872

18731873
/**
1874-
* The `.in` router for the chat this worker is serving.
1875-
*
1876-
* One slot rather than a map: a worker process serves one chat, and the facades
1877-
* have to reach the same router the boot attached without depending on a locals
1878-
* scope being active. Tagged with its chat so a nested `chat.createSession` for
1879-
* the same chat reuses the attached router while a different chat gets a fresh
1880-
* one.
1874+
* The `.in` router for the run this worker is currently serving.
1875+
*
1876+
* One slot rather than a map, because the facades have to reach the same router
1877+
* the boot attached without depending on a locals scope being active. Tagged
1878+
* with the run as well as the chat: a warm process is reused across runs and the
1879+
* executor tears the channel subscription down at the end of each one, so
1880+
* reusing a router across runs would leave the new run with no input at all. A
1881+
* nested `chat.createSession` within the same run still shares it.
18811882
* @internal
18821883
*/
18831884
let currentChatInputRouter:
1884-
| { chatId: string; router: SessionChannelRouter; attached: boolean }
1885+
| { chatId: string; runId: string | undefined; router: SessionChannelRouter; attached: boolean }
18851886
| undefined;
18861887

18871888
/**
@@ -2021,13 +2022,18 @@ async function installChatInputRouter(
20212022

20222023
function chatInputRouterEntry(chatId: string): {
20232024
chatId: string;
2025+
runId: string | undefined;
20242026
router: SessionChannelRouter;
20252027
attached: boolean;
20262028
} {
2027-
if (currentChatInputRouter?.chatId === chatId) return currentChatInputRouter;
2029+
const runId = taskContext.ctx?.run.id;
2030+
if (currentChatInputRouter?.chatId === chatId && currentChatInputRouter.runId === runId) {
2031+
return currentChatInputRouter;
2032+
}
20282033

20292034
currentChatInputRouter = {
20302035
chatId,
2036+
runId,
20312037
router: new SessionChannelRouter(CHAT_INPUT_ROUTES, {
20322038
onDrop: (record, reason) => {
20332039
if (reason === "unroutable" || reason === "malformed") {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import "../src/v3/test/index.js";
2+
3+
import { resourceCatalog, sessionStreams } from "@trigger.dev/core/v3";
4+
import { runInMockTaskContext } from "@trigger.dev/core/v3/test";
5+
import { describe, expect, it } from "vitest";
6+
import { chat, type ChatTaskWirePayload } from "../src/v3/ai.js";
7+
8+
function userPayload(chatId: string, id: string): ChatTaskWirePayload {
9+
return {
10+
chatId,
11+
trigger: "submit-message",
12+
message: { id, role: "user", parts: [{ type: "text", text: id }] },
13+
};
14+
}
15+
16+
/**
17+
* A worker process is reused across runs. The end of every run tears down the
18+
* channel subscription via `sessionStreams.clearHandlers()`, so a second run of
19+
* the same chat in the same process has to attach a fresh one. Anything cached
20+
* across runs that skips the attach leaves the new run with no input at all, and
21+
* the conversation hangs with no error raised.
22+
*/
23+
describe("chat input across runs in one warm process", () => {
24+
it("delivers messages to a second run of the same chat", async () => {
25+
const chatId = "warm-reuse";
26+
const seen: string[] = [];
27+
28+
const agent = chat.customAgent({
29+
id: "chat-warm-process-reuse",
30+
run: async () => {
31+
const record = await chat.messages.next({ timeoutInSeconds: 2 });
32+
const part = record?.payload.message?.parts?.[0];
33+
if (part && part.type === "text") seen.push(part.text);
34+
},
35+
});
36+
const run = resourceCatalog.getTask(agent.id)?.fns.run;
37+
if (!run) throw new Error("custom agent was not registered");
38+
39+
for (const attempt of ["first", "second"]) {
40+
await runInMockTaskContext(
41+
async (drivers) => {
42+
const runPromise = run(
43+
{ chatId, trigger: "submit-message" },
44+
{ ctx: drivers.ctx, signal: new AbortController().signal }
45+
);
46+
await drivers.sessions.in.send(
47+
chatId,
48+
{ kind: "message", payload: userPayload(chatId, attempt) },
49+
"in"
50+
);
51+
await runPromise;
52+
},
53+
{ ctx: { run: { id: `run_${attempt}` } } }
54+
);
55+
sessionStreams.clearHandlers();
56+
}
57+
58+
expect(seen).toEqual(["first", "second"]);
59+
});
60+
});

0 commit comments

Comments
 (0)