Skip to content

Commit f202ccc

Browse files
committed
fix(webapp): keep session .out writable when S2 access tokens are skipped
Self-hosted deployments that run S2 with REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS and no configured access token got an empty token back from the session channel initialize call. The SDK's session-stream writer treats an empty access token as missing S2 credentials and never opens the writer, so a chat.agent turn's assistant output was silently dropped: .out stayed empty with no error surfaced. The initialize response now hands back a non-empty placeholder token when access tokens are skipped (S2 ignores it in that mode, but the client requires a token to be present). The session-agent e2e runs the skip-access-tokens path with no token so the regression stays covered.
1 parent 243289c commit f202ccc

3 files changed

Lines changed: 33 additions & 5 deletions

File tree

apps/webapp/app/services/realtime/s2realtimeStreams.server.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,15 @@ export type S2RealtimeStreamsOptions = {
7474
const S2_TOKEN_OPS = ["append", "create-stream", "trim"] as const;
7575
const S2_TOKEN_OPS_FINGERPRINT = [...S2_TOKEN_OPS].sort().join(",");
7676

77+
/**
78+
* Placeholder handed back as the S2 access token when `skipAccessTokens` is set
79+
* and no token is configured (self-hosted s2-lite ignores the token entirely).
80+
* The SDK's session-stream writer rejects an empty access token as "no S2
81+
* credentials" and never opens the writer, so the token must be non-empty even
82+
* when it is semantically unused.
83+
*/
84+
const SKIP_ACCESS_TOKENS_SENTINEL = "s2-skip-access-tokens";
85+
7786
type S2IssueAccessTokenResponse = { access_token: string };
7887
type S2AppendInput = { records: { body: string }[] };
7988
type S2AppendAck = {
@@ -168,7 +177,7 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
168177
relativeName: string
169178
): Promise<{ responseHeaders?: Record<string, string> }> {
170179
const accessToken = this.skipAccessTokens
171-
? this.token
180+
? this.token || SKIP_ACCESS_TOKENS_SENTINEL
172181
: await this.getS2AccessToken(randomUUID());
173182

174183
return {

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,24 @@ afterAll(async () => {
6666
await server?.stop();
6767
}, 120_000);
6868

69+
/**
70+
* A synchronous mock model stream: enqueues every chunk in one tick and closes.
71+
* `simulateReadableStream`'s default 0ms delay still yields a `setTimeout(0)`
72+
* macrotask between chunks, which a loaded CI event loop can starve so the
73+
* later chunks never emit and the turn produces an empty `.out`. Emitting
74+
* synchronously keeps the mock deterministic across environments.
75+
*/
76+
function simStream(chunks: readonly unknown[]): ReadableStream<any> {
77+
return new ReadableStream({
78+
start(controller) {
79+
for (const chunk of chunks) {
80+
controller.enqueue(chunk);
81+
}
82+
controller.close();
83+
},
84+
});
85+
}
86+
6987
function textModel(text: string) {
7088
const chunks = [
7189
{ type: "text-start", id: "t1" },
@@ -81,7 +99,7 @@ function textModel(text: string) {
8199
},
82100
];
83101
return new MockLanguageModelV3({
84-
doStream: async () => ({ stream: simulateReadableStream({ chunks: chunks as never }) }),
102+
doStream: async () => ({ stream: simStream(chunks) }),
85103
});
86104
}
87105

@@ -139,7 +157,7 @@ function echoModel() {
139157
},
140158
},
141159
];
142-
return { stream: simulateReadableStream({ chunks: chunks as never }) };
160+
return { stream: simStream(chunks) };
143161
},
144162
});
145163
}
@@ -239,7 +257,7 @@ function toolCallThenText(opts: {
239257
let idx = 0;
240258
return new MockLanguageModelV3({
241259
doStream: async () => ({
242-
stream: simulateReadableStream({ chunks: (idx++ === 0 ? call1 : call2) as never }),
260+
stream: simStream(idx++ === 0 ? call1 : call2),
243261
}),
244262
});
245263
}
@@ -264,7 +282,7 @@ function sequenceModel(texts: string[]) {
264282
{ type: "text-end", id: "t1" },
265283
FINISH_STOP,
266284
];
267-
return { stream: simulateReadableStream({ chunks: chunks as never }) };
285+
return { stream: simStream(chunks) };
268286
},
269287
});
270288
}

internal-packages/testcontainers/src/webapp.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ export async function startSessionStreamTestServer(): Promise<SessionStreamTestS
312312
{ host: rc.getHost(), port: rc.getPort() },
313313
{
314314
extraEnv: {
315+
REALTIME_STREAMS_S2_ACCESS_TOKEN: "",
315316
REALTIME_STREAMS_S2_BASIN: s2.basin,
316317
REALTIME_STREAMS_S2_ENDPOINT: `${s2.endpoint}/v1`,
317318
REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS: "true",

0 commit comments

Comments
 (0)