Skip to content

Commit 0a23814

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(sdk,core,webapp): chat.close() ends a chat session from inside the run
## Summary A chat agent could stop a turn and end its run, but not end the conversation. `chat.close({ reason })` adds the missing level: the session row is closed, further sends are refused with HTTP 409, and the run exits without scheduling a continuation, so a budget cap, a completed goal, or a signed-out user can end the chat instead of hoping nobody sends another message. ```ts onBeforeTurnComplete: async ({ chatId }) => { if (await overBudget(chatId)) { chat.close({ reason: "Monthly budget reached" }); } }, ``` Call it from `run()`, `prepareStep`, or `onBeforeTurnComplete`. The current turn still streams in full; called mid-step it aborts the in-flight `streamText` the same way the stop signal does. ## Design Three gaps closed around the existing `sessions.close()`. The agent gets an in-run API. `chat.close()` records the request, aborts the current step, and at the loop exit writes a terminal `session-closed` record to the response stream and closes the session row. A close decided before the turn ended also rides out on that turn's `turn-complete` record, since every reader ends its stream there. A live run now hears about a close from outside. The close route appends a `trigger: "close"` record to the session's input channel and completes the run's waitpoint, so an idle or suspended agent leaves its loop on the next wake instead of waiting out its idle timeout. It is skipped when the close came from the session's own run, which is already exiting. A close arriving after a failed turn also ends the run now; that path consumed the record as the next turn's payload and kept going. Custom agents get it too. Their loop belongs to the customer, so there is no exit site the SDK controls: the close is performed when `run()` returns, which covers a hand-rolled loop as well as a `createSession` loop the caller breaks out of. The client hears about it too. Appends to a closed session return the documented 409 with a stable `session_closed` code and the close reason, and `TriggerChatTransport` keys on that (or on the terminal record) to expose `sessionStatus(chatId)` plus `sessionClosedReason(chatId)`, then stops sending and reconnecting. Closing stays one-way, and a closed session's transcript stays readable. Decide the close before the turn ends, as the example does. The closed state reaches the browser on that turn's final `turn-complete` record, and `onTurnComplete` runs after that record is written, so a close decided there does not reach a reader that has already finished the turn: the user sees a normal answer and finds out only when their next message is refused. `onBeforeTurnComplete` carries the same fields, including `usage`, one step earlier while the stream is still open. `onTurnComplete` still closes the session, so this is guidance rather than a restriction, and the docs carry the same warning. Known limitation: appending the wake-up record is best effort. If it fails the row is still closed and the run falls back to its idle timeout, which is today's behaviour. Mono-RevId: 5f691fc1095aea62f9f434767b3087a54a262c1d
1 parent 25af651 commit 0a23814

22 files changed

Lines changed: 1481 additions & 59 deletions

.changeset/chat-close-session.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@trigger.dev/sdk": minor
3+
"@trigger.dev/core": patch
4+
---
5+
6+
End a chat conversation from inside the agent with `chat.close({ reason })`. The session row is closed, further sends are refused with HTTP 409, and the run exits without scheduling a continuation, so a budget cap, a completed goal, or a signed-out user can stop the conversation rather than only the current run.
7+
8+
```ts
9+
chat.agent({
10+
id: "budgeted-agent",
11+
run: async ({ messages, signal }) =>
12+
streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }),
13+
onBeforeTurnComplete: async ({ chatId }) => {
14+
if (await overBudget(chatId)) {
15+
chat.close({ reason: "Monthly budget reached" });
16+
}
17+
},
18+
});
19+
```
20+
21+
The current turn still streams in full. Decide the close before the turn ends (`run()`, `prepareStep`, `onBeforeTurnComplete`) so the closed state rides out on that turn's final record and the user sees it as soon as the answer finishes. `TriggerChatTransport` picks the close up from the response stream or from a refused send, exposes it as `transport.sessionStatus(chatId)` plus `transport.sessionClosedReason(chatId)`, and stops sending and reconnecting. Closing a session from outside with `sessions.close()` now also reaches a live run, so an idle or suspended agent exits on its next wake instead of waiting out its idle timeout. Writes to a closed session's named side channels are refused with the same 409.

apps/webapp/app/routes/api.v1.sessions.$session.close.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import { json } from "@remix-run/server-runtime";
2+
import { tryCatch } from "@trigger.dev/core/utils";
23
import { CloseSessionRequestBody, type RetrieveSessionResponseBody } from "@trigger.dev/core/v3";
4+
import type { Session } from "@trigger.dev/database";
35
import { z } from "zod";
46
import { $replica, prisma } from "~/db.server";
7+
import { logger } from "~/services/logger.server";
8+
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
9+
import { appendServerSessionRecord } from "~/services/realtime/sessionChannelAppend.server";
510
import {
11+
canonicalSessionAddressingKey,
612
resolveSessionByIdOrExternalId,
713
serializeSessionWithFriendlyRunId,
814
} from "~/services/realtime/sessions.server";
915
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
16+
import { runStore } from "~/v3/runStore.server";
1017

1118
const ParamsSchema = z.object({
1219
session: z.string(),
@@ -62,8 +69,83 @@ const { action, loader } = createActionApiRoute(
6269

6370
const updated = await prisma.session.findFirst({ where: { id: existing.id } });
6471
if (!updated) return json({ error: "Session not found" }, { status: 404 });
72+
73+
// Best effort in full: the row is closed and that is what the caller asked
74+
// for. A failure resolving the calling run or building the stream client
75+
// must not turn a successful close into a 500.
76+
const [notifyError] = await tryCatch(
77+
notifyLiveRunOfClose({
78+
session: updated,
79+
environment: authentication.environment,
80+
paramSession: params.session,
81+
callingRunId: body.callingRunId,
82+
reason: body.reason,
83+
})
84+
);
85+
if (notifyError) {
86+
logger.error("Failed to notify the live run of a session close", {
87+
sessionId: updated.id,
88+
error: notifyError,
89+
});
90+
}
91+
6592
return json<RetrieveSessionResponseBody>(await serializeSessionWithFriendlyRunId(updated));
6693
}
6794
);
6895

96+
/**
97+
* Put a `trigger: "close"` record on the session's `.in` channel so a live run
98+
* leaves its loop now instead of sitting until its idle timeout. Appending also
99+
* completes the run's `.in` waitpoint, which is what wakes a suspended run.
100+
*
101+
* Skipped when the close came from the session's own run (`chat.close()`), which
102+
* is already exiting and would never consume the record. Best effort throughout:
103+
* the row is closed either way, and further appends are refused.
104+
*/
105+
async function notifyLiveRunOfClose({
106+
session,
107+
environment,
108+
paramSession,
109+
callingRunId,
110+
reason,
111+
}: {
112+
session: Session;
113+
environment: AuthenticatedEnvironment;
114+
paramSession: string;
115+
callingRunId?: string;
116+
reason?: string;
117+
}): Promise<void> {
118+
if (!session.currentRunId) {
119+
return;
120+
}
121+
122+
if (callingRunId) {
123+
const callingRun = await runStore.findRun(
124+
{ friendlyId: callingRunId, runtimeEnvironmentId: environment.id },
125+
{ select: { id: true } },
126+
$replica
127+
);
128+
if (callingRun?.id === session.currentRunId) {
129+
return;
130+
}
131+
}
132+
133+
const record = JSON.stringify({
134+
kind: "message",
135+
payload: {
136+
chatId: session.externalId ?? session.friendlyId,
137+
trigger: "close",
138+
...(reason ? { closedReason: reason } : {}),
139+
},
140+
});
141+
142+
await appendServerSessionRecord({
143+
environment,
144+
session,
145+
addressingKey: canonicalSessionAddressingKey(session, paramSession),
146+
io: "in",
147+
part: record,
148+
});
149+
}
150+
69151
export { action, loader };

apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts

Lines changed: 19 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,10 @@ import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.s
1313
import { stripClientWebhookActionSource } from "~/services/realtime/sanitizeSessionInput.server";
1414
import {
1515
claimSessionStreamPart,
16-
drainSessionStreamWaitpoints,
1716
releaseSessionStreamPart,
1817
} from "~/services/sessionStreamWaitpointCache.server";
18+
import { completeSessionStreamWaitpoints } from "~/services/realtime/sessionChannelAppend.server";
1919
import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
20-
import { engine } from "~/v3/runEngine.server";
2120
import { ServiceValidationError } from "~/v3/services/common.server";
2221

2322
const ParamsSchema = z.object({
@@ -80,11 +79,22 @@ const { action, loader } = createActionApiRoute(
8079
}
8180

8281
if (session.closedAt) {
83-
return json({ ok: false, error: "Cannot append to a closed session" }, { status: 400 });
82+
return json(
83+
{
84+
ok: false,
85+
error: "Cannot append to a closed session",
86+
code: "session_closed",
87+
closedReason: session.closedReason,
88+
},
89+
{ status: 409 }
90+
);
8491
}
8592

8693
if (session.expiresAt && session.expiresAt.getTime() < Date.now()) {
87-
return json({ ok: false, error: "Cannot append to an expired session" }, { status: 400 });
94+
return json(
95+
{ ok: false, error: "Cannot append to an expired session", code: "session_expired" },
96+
{ status: 400 }
97+
);
8898
}
8999

90100
// `.out` is the agent→client channel. Only PRIVATE (secret key) auth —
@@ -195,45 +205,12 @@ const { action, loader } = createActionApiRoute(
195205
}
196206
}
197207

198-
// Fire any run-scoped waitpoints registered against this channel. Best
199-
// effort — a failure here must not fail the append (the record is
200-
// durable in S2; the SSE tail will still deliver it). Waitpoints are
201-
// keyed on the canonical addressing key the agent registered with via
202-
// `sessions.open(...).in.wait()`, so writers and readers converge
203-
// regardless of which URL form they used.
204-
const [drainError, waitpointIds] = await tryCatch(
205-
drainSessionStreamWaitpoints(authentication.environment.id, addressingKey, params.io)
208+
await completeSessionStreamWaitpoints(
209+
authentication.environment.id,
210+
addressingKey,
211+
params.io,
212+
part
206213
);
207-
if (drainError) {
208-
logger.error("Failed to drain session stream waitpoints", {
209-
addressingKey,
210-
io: params.io,
211-
error: drainError,
212-
});
213-
} else if (waitpointIds && waitpointIds.length > 0) {
214-
await Promise.all(
215-
waitpointIds.map(async (waitpointId) => {
216-
const [completeError] = await tryCatch(
217-
engine.completeWaitpoint({
218-
id: waitpointId,
219-
output: {
220-
value: part,
221-
type: "application/json",
222-
isError: false,
223-
},
224-
})
225-
);
226-
if (completeError) {
227-
logger.error("Failed to complete session stream waitpoint", {
228-
addressingKey,
229-
io: params.io,
230-
waitpointId,
231-
error: completeError,
232-
});
233-
}
234-
})
235-
);
236-
}
237214

238215
// `seq` lets the client correlate this send to the turn that consumes it.
239216
return json({ ok: true, seq: appendSeq }, { status: 200 });

apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,15 @@ const { action, loader } = createActionApiRoute(
5555
}
5656

5757
if (session.closedAt) {
58-
return json({ ok: false, error: "Cannot append to a closed session" }, { status: 400 });
58+
return json(
59+
{
60+
ok: false,
61+
error: "Cannot append to a closed session",
62+
code: "session_closed",
63+
closedReason: session.closedReason,
64+
},
65+
{ status: 409 }
66+
);
5967
}
6068

6169
if (session.expiresAt && session.expiresAt.getTime() < Date.now()) {

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.sessions.$session.$io.append.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,15 @@ export async function action({ request, params }: ActionFunctionArgs) {
6262
}
6363

6464
if (session.closedAt) {
65-
return json({ ok: false, error: "Cannot append to a closed session" }, { status: 400 });
65+
return json(
66+
{
67+
ok: false,
68+
error: "Cannot append to a closed session",
69+
code: "session_closed",
70+
closedReason: session.closedReason,
71+
},
72+
{ status: 409 }
73+
);
6674
}
6775

6876
if (session.expiresAt && session.expiresAt.getTime() < Date.now()) {
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { tryCatch } from "@trigger.dev/core/utils";
2+
import { nanoid } from "nanoid";
3+
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
4+
import { logger } from "~/services/logger.server";
5+
import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
6+
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
7+
import { drainSessionStreamWaitpoints } from "~/services/sessionStreamWaitpointCache.server";
8+
import { engine } from "~/v3/runEngine.server";
9+
10+
type SessionChannelIO = "out" | "in";
11+
12+
/**
13+
* Fire any run-scoped waitpoints registered against a session channel, so a
14+
* suspended run wakes on the record that just landed. Best effort: a failure
15+
* here must not fail the append, because the record is durable in S2 and the
16+
* SSE tail still delivers it. Waitpoints are keyed on the canonical addressing
17+
* key the agent registered with via `sessions.open(...).in.wait()`, so writers
18+
* and readers converge regardless of which URL form they used.
19+
*/
20+
export async function completeSessionStreamWaitpoints(
21+
environmentId: string,
22+
addressingKey: string,
23+
io: SessionChannelIO,
24+
part: string
25+
): Promise<void> {
26+
const [drainError, waitpointIds] = await tryCatch(
27+
drainSessionStreamWaitpoints(environmentId, addressingKey, io)
28+
);
29+
30+
if (drainError) {
31+
logger.error("Failed to drain session stream waitpoints", {
32+
addressingKey,
33+
io,
34+
error: drainError,
35+
});
36+
return;
37+
}
38+
39+
if (!waitpointIds || waitpointIds.length === 0) {
40+
return;
41+
}
42+
43+
await Promise.all(
44+
waitpointIds.map(async (waitpointId) => {
45+
const [completeError] = await tryCatch(
46+
engine.completeWaitpoint({
47+
id: waitpointId,
48+
output: {
49+
value: part,
50+
type: "application/json",
51+
isError: false,
52+
},
53+
})
54+
);
55+
if (completeError) {
56+
logger.error("Failed to complete session stream waitpoint", {
57+
addressingKey,
58+
io,
59+
waitpointId,
60+
error: completeError,
61+
});
62+
}
63+
})
64+
);
65+
}
66+
67+
/**
68+
* Append one server-originated record to a session channel and wake anything
69+
* waiting on it. Unlike the client-facing append route there is no idempotency
70+
* claim: the server is the only writer of these records and the caller has
71+
* already made its own state change conditional.
72+
*
73+
* Returns false when the environment isn't on the S2 backend (session channels
74+
* don't exist there) or the append failed. Callers treat that as best effort.
75+
*/
76+
export async function appendServerSessionRecord({
77+
environment,
78+
session,
79+
addressingKey,
80+
io,
81+
part,
82+
}: {
83+
environment: AuthenticatedEnvironment;
84+
session: { id: string; streamBasinName: string | null };
85+
addressingKey: string;
86+
io: SessionChannelIO;
87+
part: string;
88+
}): Promise<boolean> {
89+
const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session });
90+
91+
if (!(realtimeStream instanceof S2RealtimeStreams)) {
92+
return false;
93+
}
94+
95+
const [appendError] = await tryCatch(
96+
realtimeStream.appendPartToSessionStream(part, nanoid(7), addressingKey, io)
97+
);
98+
99+
if (appendError) {
100+
logger.error("Failed to append server session record", {
101+
sessionId: session.id,
102+
addressingKey,
103+
io,
104+
error: appendError,
105+
});
106+
return false;
107+
}
108+
109+
await completeSessionStreamWaitpoints(environment.id, addressingKey, io, part);
110+
return true;
111+
}

0 commit comments

Comments
 (0)