Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2ac486e
feat(sdk,core): close resumed chat streams promptly when caught up
ericallam Jul 23, 2026
7667d1e
fix(webapp): stop the dev server sending duplicate CORS headers
ericallam Jul 23, 2026
4e0ad53
fix(core): end caught-up tracking on all terminal stream paths
ericallam Jul 23, 2026
5655a2a
fix(core,sdk): settle resumed chat streams correctly on caught-up
ericallam Jul 24, 2026
6deadd9
docs(ai-chat): document tail-on-ping and the client-side caught-up close
ericallam Jul 24, 2026
b4500cc
test(webapp): full-stack session-stream e2e over s2-lite
ericallam Jul 24, 2026
d56458f
test(webapp): browser-level cross-origin session-stream e2e
ericallam Jul 24, 2026
8774359
test(webapp): make the browser session-stream leg CI-robust
ericallam Jul 24, 2026
c417803
test(webapp): cover more session-stream scenarios
ericallam Jul 24, 2026
328faff
test(webapp): full-stack chat.agent e2e with the real turn loop
ericallam Jul 24, 2026
11f0e0f
test(webapp): agent e2e legs for validate rejection, action, and stop
ericallam Jul 24, 2026
1bfc26a
test(webapp): agent e2e legs for tools, HITL, and snapshot restore
ericallam Jul 24, 2026
e4ea85c
test(webapp): agent e2e legs for regenerate, tool approval, and sessi…
ericallam Jul 24, 2026
79b6676
test(core,webapp): in-process waitpoint backend for run-lifecycle e2e
ericallam Jul 24, 2026
78929db
test(webapp): suspend/resume, upgrade deferral, and multi-hop continu…
ericallam Jul 24, 2026
21c4bf4
test(core,webapp): suspend-hook, OOM-retry, and idle-timeout e2e legs
ericallam Jul 24, 2026
4b75bd5
fix(sdk): recover the in-flight message on a preloaded chat.agent retry
ericallam Jul 24, 2026
373c421
fix(core): stop reconnecting a session stream after the consumer cancels
ericallam Jul 24, 2026
4cfe780
ci(e2e-webapp): pre-pull the MinIO image for the session-stream e2e
ericallam Jul 24, 2026
f6e4a19
test(webapp): make the session-stream e2e reliable in CI
ericallam Jul 25, 2026
863dd36
test(core,webapp): terminate in-process agent e2e runs reliably
ericallam Jul 25, 2026
9964f0d
test(webapp): drain the session .out collector across reconnects
ericallam Jul 25, 2026
8e2329d
fix(webapp): keep session .out writable when S2 access tokens are ski…
ericallam Jul 25, 2026
a6e1caf
fix(sdk): request the caught-up settle on AgentChat.reconnect()
ericallam Jul 29, 2026
ff17e0e
refactor(core,sdk): drop the client-side caught-up close, rely on the…
ericallam Jul 31, 2026
a37396c
fix(core): fire the session-stream onComplete callback at most once
ericallam Jul 31, 2026
5a9b176
docs(sdk): note AgentChat.reconnect resumes a stream, not a fresh turn
ericallam Jul 31, 2026
b1c40b3
refactor(webapp): make S2 account and basin hosts env-configurable wi…
ericallam Jul 31, 2026
b72a247
fix(core): notify stream completion on connection end, not only on co…
ericallam Jul 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chat-agent-preload-oom-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---

Fix a preloaded `chat.agent` run dropping an in-flight message when it retries after an out-of-memory error. The message being processed when the run hit the OOM is now recovered and re-run on the retry, instead of being skipped while the run waited for a new message.
7 changes: 7 additions & 0 deletions .changeset/chat-session-caught-up-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@trigger.dev/core": patch
"@trigger.dev/sdk": patch
"trigger.dev": patch
---

`AgentChat.reconnect()` now settles promptly when reconnecting to an idle chat instead of holding the connection open for the full long-poll window. Also upgrades the S2 streamstore client to 0.25 and moves realtime streams to S2's current hosts.
7 changes: 6 additions & 1 deletion .github/workflows/e2e-webapp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
e2eTests:
name: "🧪 E2E Tests: Webapp"
runs-on: warp-ubuntu-latest-x64-16x
timeout-minutes: 20
timeout-minutes: 30
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
steps:
Expand Down Expand Up @@ -80,6 +80,8 @@ jobs:
docker pull postgres:14
docker pull redis:7.2
docker pull testcontainers/ryuk:0.11.0
docker pull ghcr.io/s2-streamstore/s2:0.40.0@sha256:b26249e2ede0949755f5af8028185dc2bcfc3aa2db21eb9610543d144eb6ee9d
docker pull minio/minio:latest
echo "Image pre-pull complete"

- name: 📥 Download deps
Expand All @@ -91,6 +93,9 @@ jobs:
- name: 🏗️ Build Webapp
run: pnpm run build --filter webapp

- name: 🎭 Install Playwright Chromium
run: cd apps/webapp && pnpm exec playwright install chromium

- name: 🧪 Run Webapp E2E Tests
run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.config.ts --reporter=default
env:
Expand Down
2 changes: 2 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2121,6 +2121,8 @@ const EnvironmentSchema = z
REALTIME_STREAMS_S2_BASIN: z.string().optional(),
REALTIME_STREAMS_S2_ACCESS_TOKEN: z.string().optional(),
REALTIME_STREAMS_S2_ENDPOINT: z.string().optional(),
REALTIME_STREAMS_S2_ACCOUNT_URL: z.string().default("https://a.s2.dev/v1"),
REALTIME_STREAMS_S2_BASIN_URL: z.string().default("https://{basin}.b.s2.dev/v1"),
REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS: z.enum(["true", "false"]).default("false"),
REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS: z.coerce
.number()
Expand Down
21 changes: 18 additions & 3 deletions apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export type S2RealtimeStreamsOptions = {

// Custom endpoint for s2-lite (self-hosted)
endpoint?: string; // e.g., "http://localhost:4566/v1"
/** Account-level API base for account/basin ops. Defaults to S2 cloud. */
accountUrl?: string;
/** Per-basin API base, with a `{basin}` placeholder. Defaults to S2 cloud. */
basinUrl?: string;

// Skip access token issuance (s2-lite doesn't support /access-tokens)
skipAccessTokens?: boolean;
Expand Down Expand Up @@ -74,6 +78,15 @@ export type S2RealtimeStreamsOptions = {
const S2_TOKEN_OPS = ["append", "create-stream", "trim"] as const;
const S2_TOKEN_OPS_FINGERPRINT = [...S2_TOKEN_OPS].sort().join(",");

/**
* Placeholder handed back as the S2 access token when `skipAccessTokens` is set
* and no token is configured (self-hosted s2-lite ignores the token entirely).
* The SDK's session-stream writer rejects an empty access token as "no S2
* credentials" and never opens the writer, so the token must be non-empty even
* when it is semantically unused.
*/
const SKIP_ACCESS_TOKENS_SENTINEL = "s2-skip-access-tokens";

type S2IssueAccessTokenResponse = { access_token: string };
type S2AppendInput = { records: { body: string }[] };
type S2AppendAck = {
Expand Down Expand Up @@ -107,8 +120,10 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {

constructor(opts: S2RealtimeStreamsOptions) {
this.basin = opts.basin;
this.baseUrl = opts.endpoint ?? `https://${this.basin}.b.aws.s2.dev/v1`;
this.accountUrl = opts.endpoint ?? `https://aws.s2.dev/v1`;
this.baseUrl =
opts.endpoint ??
(opts.basinUrl ?? `https://{basin}.b.s2.dev/v1`).replace("{basin}", this.basin);
this.accountUrl = opts.endpoint ?? opts.accountUrl ?? `https://a.s2.dev/v1`;
Comment on lines +123 to +126

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 New S2 host env vars only steer the server half of the stream path

REALTIME_STREAMS_S2_ACCOUNT_URL / REALTIME_STREAMS_S2_BASIN_URL are consumed only by the webapp (apps/webapp/app/services/realtime/s2realtimeStreams.server.ts:123-126 and streamBasinProvisioner.server.ts:188,225). The task-side writer gets only X-S2-Basin, X-S2-Access-Token and (when set) X-S2-Endpoint from #initializeStreamByName, so it resolves hosts from the @s2-dev/streamstore built-in defaults (packages/core/src/v3/realtimeStreams/streamsWriterV2.ts:70-77). Consequence: an operator who overrides REALTIME_STREAMS_S2_BASIN_URL/ACCOUNT_URL without also setting REALTIME_STREAMS_S2_ENDPOINT gets a split brain — the server appends/reads on the overridden host while deployed tasks write to the library default. If the intent of these vars is "be able to follow an S2 host migration by config alone", the basin URL likely needs to be forwarded to the client (e.g. as a header the writer honours), otherwise they are effectively webapp-only knobs and the client still requires an SDK upgrade.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

this.endpoint = opts.endpoint;
this.token = opts.accessToken;
this.streamPrefix = opts.streamPrefix ?? "";
Expand Down Expand Up @@ -168,7 +183,7 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
relativeName: string
): Promise<{ responseHeaders?: Record<string, string> }> {
const accessToken = this.skipAccessTokens
? this.token
? this.token || SKIP_ACCESS_TOKENS_SENTINEL
: await this.getS2AccessToken(randomUUID());

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ type CreateBasinOptions = {
};

async function s2CreateBasin(name: string, opts: CreateBasinOptions): Promise<void> {
const url = `https://aws.s2.dev/v1/basins`;
const url = `${env.REALTIME_STREAMS_S2_ACCOUNT_URL}/basins`;
const body = {
basin: name,
config: {
Expand Down Expand Up @@ -222,7 +222,7 @@ type ReconfigureBasinOptions = {
};

async function s2ReconfigureBasin(name: string, opts: ReconfigureBasinOptions): Promise<void> {
const url = `https://aws.s2.dev/v1/basins/${encodeURIComponent(name)}`;
const url = `${env.REALTIME_STREAMS_S2_ACCOUNT_URL}/basins/${encodeURIComponent(name)}`;
const body = {
default_stream_config: {
retention_policy: { age: parseDuration(opts.retentionPolicy) },
Expand Down
2 changes: 2 additions & 0 deletions apps/webapp/app/services/realtime/v1StreamsGlobal.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export function getRealtimeStreamInstance(
basin: resolvedBasin,
accessToken: env.REALTIME_STREAMS_S2_ACCESS_TOKEN ?? "",
endpoint: env.REALTIME_STREAMS_S2_ENDPOINT,
accountUrl: env.REALTIME_STREAMS_S2_ACCOUNT_URL,
basinUrl: env.REALTIME_STREAMS_S2_BASIN_URL,
skipAccessTokens: env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true",
streamPrefix: streamPrefixFor(environment, resolvedBasin),
logLevel: env.REALTIME_STREAMS_S2_LOG_LEVEL,
Expand Down
3 changes: 2 additions & 1 deletion apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
"@remix-run/react": "2.17.5",
"@remix-run/router": "^1.23.3",
"@remix-run/server-runtime": "2.17.5",
"@s2-dev/streamstore": "^0.22.10",
"@s2-dev/streamstore": "^0.25.0",
"@sentry/remix": "9.46.0",
"@slack/web-api": "7.16.0",
"@socket.io/redis-adapter": "^8.3.0",
Expand Down Expand Up @@ -222,6 +222,7 @@
"@internal/clickhouse": "workspace:*",
"@internal/replication": "workspace:*",
"@internal/testcontainers": "workspace:*",
"@playwright/test": "^1.36.2",
Comment thread
ericallam marked this conversation as resolved.
"@remix-run/dev": "2.17.5",
"@remix-run/testing": "^2.17.5",
"@sentry/cli": "2.50.2",
Expand Down
197 changes: 197 additions & 0 deletions apps/webapp/test/helpers/agentHarness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { apiClientManager, resourceCatalog } from "@trigger.dev/core/v3";
import type { LocalsKey } from "@trigger.dev/core/v3";
import type { LanguageModel } from "ai";
import {
installSessionWaitpointBackend,
runInMockTaskContext,
StandardSessionStreamManager,
} from "@trigger.dev/core/v3/test";

export type RunRealChatAgentOptions = {
agentId: string;
baseUrl: string;
addressingKey: string;
/**
* The environment secret key. The agent writes `.out` and reads `.in` as the
* backend (PRIVATE auth) — the `.out` channel rejects client session tokens.
*/
secretKey: string;
model: LanguageModel;
modelLocal: LocalsKey<LanguageModel>;
runId?: string;
/**
* Boot as a continuation of a previous run for the same session. Gates the
* snapshot + `.out`/`.in` replay boot path, so the agent restores prior
* history instead of treating the chat as brand new.
*/
continuation?: boolean;
previousRunId?: string;
/**
* Idle window (seconds) before the turn loop falls through from the SSE
* once() to the suspending `session.in.wait()`. Set this low to force the
* suspend/resume path in a test.
*/
idleTimeoutInSeconds?: number;
/**
* `ctx.attempt.number`. A value greater than 1 makes the boot treat the run
* as a retry (`couldHavePriorState`), restoring from the snapshot + `.in`
* replay. Used to model an OOM retry re-dispatch.
*/
attemptNumber?: number;
};

export type RunningAgent = {
done: Promise<void>;
close: () => Promise<void>;
};

/**
* Run the real `chat.agent` turn loop in-process, wired to a running webapp:
* `apiClientManager` + a real `StandardSessionStreamManager` point the agent's
* `.in`/`.out` at the webapp's Session streams (real S2 + SSE), the model is
* injected via locals (so it survives without serialization), and turns are
* driven by appending to `.in` over HTTP. Callers keep each message inside the
* idle window and `close()` promptly so the run-engine suspend path is never
* reached.
*/
export function runRealChatAgent(opts: RunRealChatAgentOptions): RunningAgent {
apiClientManager.setGlobalAPIClientConfiguration({
baseURL: opts.baseUrl,
accessToken: opts.secretKey,
});
const apiClient = apiClientManager.clientOrThrow();
const manager = new StandardSessionStreamManager(apiClient, opts.baseUrl);
const { backend, runtimeManager, restore } = installSessionWaitpointBackend(apiClient);

const taskEntry = resourceCatalog.getTask(opts.agentId);
if (!taskEntry) {
restore();
throw new Error(`runRealChatAgent: agent "${opts.agentId}" is not registered`);
}
const runFn = taskEntry.fns.run as (
payload: unknown,
params: { ctx: unknown; signal: AbortSignal }
) => Promise<unknown>;

const runSignal = new AbortController();
runSignal.signal.addEventListener("abort", () => {
try {
backend.disable();
} catch {}
});
const runId = opts.runId ?? `run_${opts.addressingKey}`;

const idle =
opts.idleTimeoutInSeconds !== undefined
? { idleTimeoutInSeconds: opts.idleTimeoutInSeconds }
: {};

const done = (
runInMockTaskContext(
async (drivers) => {
drivers.locals.set(opts.modelLocal, opts.model);
const payload = opts.continuation
? {
chatId: opts.addressingKey,
continuation: true,
metadata: {},
...idle,
...(opts.previousRunId ? { previousRunId: opts.previousRunId } : {}),
}
: { chatId: opts.addressingKey, trigger: "preload", metadata: {}, ...idle };
await runFn(payload, { ctx: drivers.ctx, signal: runSignal.signal });
},
{
ctx: {
run: { id: runId },
...(opts.attemptNumber !== undefined ? { attempt: { number: opts.attemptNumber } } : {}),
},
sessionStreamManager: manager,
runtimeManager,
}
) as Promise<void>
).finally(restore);

return {
done,
close: async () => {
try {
await fetch(
`${opts.baseUrl}/realtime/v1/sessions/${encodeURIComponent(opts.addressingKey)}/in/append`,
{
method: "POST",
headers: {
Authorization: `Bearer ${opts.secretKey}`,
"Content-Type": "application/json",
"X-Part-Id": "close",
},
body: JSON.stringify({
kind: "message",
payload: { chatId: opts.addressingKey, trigger: "close" },
}),
}
);
} catch {}
runSignal.abort();
await done.catch(() => {});
},
};
}

export type ChatAgentSessionOptions = Omit<
RunRealChatAgentOptions,
"runId" | "continuation" | "previousRunId"
>;

export type ChatAgentSession = {
/** How many runs the session has spawned so far (1 fresh + N continuations). */
runCount: () => number;
/** Close the currently-active run and stop spawning continuations. */
close: () => Promise<void>;
};

/**
* A session-scoped orchestrator that stands in for the run-engine's run
* lifecycle: it starts a run, and whenever that run exits on its own
* (`chat.endRun()` / `chat.requestUpgrade()`), spawns the next run as a
* continuation (new run id, `continuation: true`, `previousRunId` threaded)
* for the same session. That mirrors the server triggering a fresh run on the
* next append after the previous run went terminal, and lets each continuation
* restore prior history from the persisted snapshot. Runs never overlap: the
* next spawn is chained on the previous run's `done` (after its manager
* teardown), so the process-global managers are never installed twice at once.
*/
export function runChatAgentSession(opts: ChatAgentSessionOptions): ChatAgentSession {
let closed = false;
let index = 0;
let current: RunningAgent | undefined;
let previousRunId: string | undefined;

const spawn = () => {
index += 1;
const runId = `run_${opts.addressingKey}_${index}`;
current = runRealChatAgent({
...opts,
runId,
continuation: index > 1,
previousRunId,
});
previousRunId = runId;
const settle = () => {
if (!closed) {
spawn();
}
};
current.done.then(settle, settle);
};

spawn();

return {
runCount: () => index,
close: async () => {
closed = true;
await current?.close();
},
};
Comment thread
ericallam marked this conversation as resolved.
}
Loading
Loading