fix(gateway): streamed chat text stalls mid-answer until the next agent event (#119566)

* fix(gateway): flush trailing chat deltas

Keep the fixed-deadline wake-up on the existing chat run record so terminal, abort, and shutdown cleanup cancel it at the lifecycle owner. Normalize voice runs onto their unique per-turn ID instead of compensating for a stale client alias downstream.

Fixes #119557

Co-authored-by: Serghei <43180231+xyrolle@users.noreply.github.com>

* test(gateway): drop obsolete delta length assertions

Remove stale fixtures and assertions for the write-only chat delta length field deleted by the owner-boundary repair.

Co-authored-by: Serghei <43180231+xyrolle@users.noreply.github.com>

* fix(gateway): preserve chat run state type contract

Keep the optional delta length field and its cleanup semantics in the exported ChatRunState closure without restoring runtime writes.

Co-authored-by: Serghei <43180231+xyrolle@users.noreply.github.com>

* refactor(gateway): hide chat delta timer from SDK shape

Keep the trailing wake physically on each run record while exposing it only through internal state-module accessors, preserving the public ChatRunState closure.

Co-authored-by: Serghei <43180231+xyrolle@users.noreply.github.com>

* refactor(gateway): narrow chat delta timer access

Use one Gateway-internal record accessor so the run-owned timer remains outside the public ChatRunState shape without extra state maps.

Co-authored-by: Serghei <43180231+xyrolle@users.noreply.github.com>

* refactor(gateway): keep chat timer casts private

Keep the run-owned timer invisible to the generated Plugin SDK closure by using module-private casts in each owning Gateway module.

Co-authored-by: Serghei <43180231+xyrolle@users.noreply.github.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Serghei
2026-08-09 14:53:16 +03:00
committed by GitHub
parent f83ba2b326
commit d7054d4daf
7 changed files with 305 additions and 79 deletions
-1
View File
@@ -17,7 +17,6 @@ describe("createChatRunState", () => {
planSnapshot: { steps: [{ step: "Inspect", status: "in_progress" }] },
bufferUpdatedAt: 1,
deltaSentAt: 2,
deltaLastBroadcastLen: 9,
deltaLastBroadcastText: "projected",
agentText: { assistant: { lastSentAt: 3 } },
abortMarker: createChatAbortMarker(4),
+27
View File
@@ -104,6 +104,11 @@ type ChatRunToolRecipientState = {
finalizedAt?: number;
};
type PendingChatDeltaFlush = {
timer: NodeJS.Timeout;
flush: () => void;
};
type ChatRunRecord = {
registrations?: ChatRunEntry[];
rawBuffer?: string;
@@ -126,6 +131,11 @@ type ChatRunRecord = {
toolRecipient?: ChatRunToolRecipientState;
};
type InternalChatRunRecord = ChatRunRecord & {
/** Fixed-deadline trailing wake-up owned by this run's buffered state. */
pendingDeltaFlush?: PendingChatDeltaFlush;
};
type ChatRunRecordStore = {
runs: Map<string, ChatRunRecord>;
getOrCreate: (runId: string) => ChatRunRecord;
@@ -153,6 +163,19 @@ function createChatRunRecordStore(): ChatRunRecordStore {
return { runs, getOrCreate, releaseIfEmpty };
}
function internalChatRunRecord(record: ChatRunRecord): InternalChatRunRecord {
return record;
}
function clearPendingChatDeltaFlush(record: ChatRunRecord): void {
const internal = internalChatRunRecord(record);
if (!internal.pendingDeltaFlush) {
return;
}
clearTimeout(internal.pendingDeltaFlush.timer);
delete internal.pendingDeltaFlush;
}
export type ChatRunRegistry = {
add: (sessionId: string, entry: ChatRunRegistration) => void;
peek: (sessionId: string) => ChatRunEntry | undefined;
@@ -274,11 +297,15 @@ export function createChatRunState(): ChatRunState {
delete record.deltaSentAt;
delete record.deltaLastBroadcastLen;
delete record.deltaLastBroadcastText;
clearPendingChatDeltaFlush(record);
delete record.agentText;
store.releaseIfEmpty(runId);
};
const clear = () => {
for (const record of store.runs.values()) {
clearPendingChatDeltaFlush(record);
}
store.runs.clear();
};
@@ -239,6 +239,13 @@ describe("agent event handler", () => {
return broadcast.mock.calls.filter(([event]) => event === "chat");
}
function chatDeltaTexts(broadcast: ReturnType<typeof vi.fn>) {
return chatBroadcastCalls(broadcast)
.map(([, payload]) => payload as { state?: string; deltaText?: string })
.filter((payload) => payload.state === "delta")
.map((payload) => payload.deltaText);
}
function agentBroadcastCalls(broadcast: ReturnType<typeof vi.fn>) {
return broadcast.mock.calls.filter(([event]) => event === "agent");
}
@@ -1440,6 +1447,73 @@ describe("agent event handler", () => {
nowSpy.mockRestore();
});
it("delivers a throttled delta when the window expires without another event", () => {
vi.useFakeTimers();
let now = 12_000;
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
const { broadcast, chatRunState, handler } = createHarness();
registerNamedChatRun(chatRunState, "trailing");
emitAgentEvent(handler, "run-trailing", "assistant", { text: "Hello" });
now = 12_020;
emitAgentEvent(handler, "run-trailing", "assistant", { text: "Hello world" });
expect(chatDeltaTexts(broadcast)).toEqual(["Hello"]);
now = 12_150;
vi.advanceTimersByTime(130);
expect(chatDeltaTexts(broadcast)).toEqual(["Hello", " world"]);
expect(vi.getTimerCount()).toBe(0);
nowSpy.mockRestore();
});
it("cancels the trailing delta before the terminal frame", () => {
vi.useFakeTimers();
let now = 13_000;
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
const { broadcast, chatRunState, handler } = createHarness();
registerNamedChatRun(chatRunState, "terminal-trailing");
emitAgentEvent(handler, "run-terminal-trailing", "assistant", { text: "Hello" });
now = 13_020;
emitAgentEvent(handler, "run-terminal-trailing", "assistant", { text: "Hello world" });
expect(vi.getTimerCount()).toBe(1);
emitLifecycleEnd(handler, "run-terminal-trailing");
expect(vi.getTimerCount()).toBe(0);
const statesAtTerminal = chatBroadcastCalls(broadcast).map(
([, payload]) => (payload as { state?: string }).state,
);
expect(statesAtTerminal).toEqual(["delta", "delta", "final"]);
now = 13_500;
vi.advanceTimersByTime(1_000);
expect(
chatBroadcastCalls(broadcast).map(([, payload]) => (payload as { state?: string }).state),
).toEqual(statesAtTerminal);
nowSpy.mockRestore();
});
it("cancels trailing deltas when gateway chat state is cleared", () => {
vi.useFakeTimers();
let now = 14_000;
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
const { broadcast, chatRunState, handler } = createHarness();
registerNamedChatRun(chatRunState, "shutdown-trailing");
emitAgentEvent(handler, "run-shutdown-trailing", "assistant", { text: "Hello" });
now = 14_020;
emitAgentEvent(handler, "run-shutdown-trailing", "assistant", { text: "Hello world" });
expect(vi.getTimerCount()).toBe(1);
chatRunState.clear();
expect(vi.getTimerCount()).toBe(0);
now = 14_500;
vi.advanceTimersByTime(1_000);
expect(chatDeltaTexts(broadcast)).toEqual(["Hello"]);
nowSpy.mockRestore();
});
it("does not emit a delta when a repeated assistant snapshot is unchanged", () => {
let now = 11_250;
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
+118 -70
View File
@@ -221,6 +221,7 @@ function normalizeHeartbeatChatFinalText(params: {
* do not finalize a run before fallback or retry reuses the same runId.
*/
const AGENT_LIFECYCLE_ERROR_RETRY_GRACE_MS = 15_000;
const CHAT_DELTA_THROTTLE_MS = 150;
export type ChatEventBroadcast = GatewayBroadcastFn;
@@ -359,6 +360,16 @@ type AgentEventHandler = ((event: AgentEventPayload) => void) & {
dispose: () => void;
};
type InternalChatRunRecord = ReturnType<ChatRunState["getOrCreate"]> & {
pendingDeltaFlush?: { timer: NodeJS.Timeout; flush: () => void };
};
function internalChatRunRecord(
record: ReturnType<ChatRunState["getOrCreate"]>,
): InternalChatRunRecord {
return record;
}
function roundedChatSendTimingMs(value: number): number {
return Math.max(0, Math.round(value * 1000) / 1000);
}
@@ -421,6 +432,16 @@ export function createAgentEventHandler({
const agentTextThrottleStreams = ["assistant", "thinking"] as const;
const cancelPendingChatDeltaFlush = (clientRunId: string) => {
const record = chatRunState.runs.get(clientRunId);
const pending = record ? internalChatRunRecord(record).pendingDeltaFlush : undefined;
if (!pending || !record) {
return;
}
clearTimeout(pending.timer);
delete internalChatRunRecord(record).pendingDeltaFlush;
};
const clearBufferedChatState = (clientRunId: string) => {
chatRunState.clearRun(clientRunId);
};
@@ -887,6 +908,89 @@ export function createAgentEventHandler({
pendingTerminalLifecycleErrors.set(evt.runId, { timer, event: evt, opts });
};
const broadcastChatDelta = (
sessionKey: string,
agentId: string | undefined,
clientRunId: string,
sourceRunId: string,
seq: number,
text: string,
opts?: { controlUiVisible?: boolean; firstAssistantTimingEntry?: ChatRunEntry },
) => {
cancelPendingChatDeltaFlush(clientRunId);
const run = chatRunState.getOrCreate(clientRunId);
const broadcastDelta = resolveBroadcastDelta({
text,
previousBroadcastText: run.deltaLastBroadcastText,
});
if (!broadcastDelta) {
return;
}
const now = Date.now();
run.deltaSentAt = now;
run.deltaLastBroadcastText = text;
const spawnedBy = resolveSpawnedBy(sessionKey);
const payload = {
runId: clientRunId,
sessionKey,
...(agentId ? { agentId } : {}),
...(spawnedBy && { spawnedBy }),
seq,
state: "delta" as const,
deltaText: broadcastDelta.deltaText,
...(broadcastDelta.replace ? { replace: true as const } : {}),
message: {
role: "assistant",
content: [{ type: "text", text }],
timestamp: now,
},
};
emitFirstAssistantChatSendTiming(
opts?.firstAssistantTimingEntry ?? chatRunState.registry.peek(sourceRunId),
);
sendChatPayload(sessionKey, payload, {
agentId,
controlUiVisible: opts?.controlUiVisible ?? true,
dropIfSlow: true,
});
};
const scheduleChatDeltaFlush = (
sessionKey: string,
agentId: string | undefined,
clientRunId: string,
sourceRunId: string,
seq: number,
delayMs: number,
controlUiVisible: boolean | undefined,
) => {
const run = internalChatRunRecord(chatRunState.getOrCreate(clientRunId));
const flush = () => {
const projected = chatRunState.resolveBuffer(clientRunId);
if (projected.suppress || shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) {
return;
}
broadcastChatDelta(sessionKey, agentId, clientRunId, sourceRunId, seq, projected.text, {
controlUiVisible,
});
};
const existing = run.pendingDeltaFlush;
if (existing) {
existing.flush = flush;
return;
}
const timer = setSafeTimeout(() => {
const pending = run.pendingDeltaFlush;
if (!pending || pending.timer !== timer) {
return;
}
cancelPendingChatDeltaFlush(clientRunId);
pending.flush();
}, delayMs);
timer.unref?.();
run.pendingDeltaFlush = { timer, flush };
};
const emitChatDelta = (
sessionKey: string,
agentId: string | undefined,
@@ -910,8 +1014,17 @@ export function createAgentEventHandler({
const now = Date.now();
run.rawBuffer = mergedRawText;
run.bufferUpdatedAt = now;
const last = run.deltaSentAt ?? 0;
if (now - last < 150) {
const waitedMs = now - (run.deltaSentAt ?? 0);
if (waitedMs < CHAT_DELTA_THROTTLE_MS) {
scheduleChatDeltaFlush(
sessionKey,
agentId,
clientRunId,
sourceRunId,
seq,
CHAT_DELTA_THROTTLE_MS - waitedMs,
opts?.controlUiVisible,
);
return;
}
const projected = chatRunState.resolveBuffer(clientRunId);
@@ -919,38 +1032,7 @@ export function createAgentEventHandler({
if (projected.suppress || shouldHideHeartbeatChatOutput(clientRunId, sourceRunId)) {
return;
}
const broadcastDelta = resolveBroadcastDelta({
text: mergedText,
previousBroadcastText: run.deltaLastBroadcastText,
});
if (!broadcastDelta) {
return;
}
run.deltaSentAt = now;
run.deltaLastBroadcastLen = mergedText.length;
run.deltaLastBroadcastText = mergedText;
const spawnedBy = resolveSpawnedBy(sessionKey);
const payload = {
runId: clientRunId,
sessionKey,
...(agentId ? { agentId } : {}),
...(spawnedBy && { spawnedBy }),
seq,
state: "delta" as const,
deltaText: broadcastDelta.deltaText,
...(broadcastDelta.replace ? { replace: true as const } : {}),
message: {
role: "assistant",
content: [{ type: "text", text: mergedText }],
timestamp: now,
},
};
emitFirstAssistantChatSendTiming(chatRunState.registry.peek(sourceRunId));
sendChatPayload(sessionKey, payload, {
agentId,
controlUiVisible: opts?.controlUiVisible ?? true,
dropIfSlow: true,
});
broadcastChatDelta(sessionKey, agentId, clientRunId, sourceRunId, seq, mergedText, opts);
};
const resolveBufferedChatTextState = (
@@ -981,6 +1063,7 @@ export function createAgentEventHandler({
seq: number,
opts?: { controlUiVisible?: boolean; firstAssistantTimingEntry?: ChatRunEntry },
) => {
cancelPendingChatDeltaFlush(clientRunId);
const { text, shouldSuppressSilent } = resolveBufferedChatTextState(clientRunId, sourceRunId, {
suppressLeadFragments: true,
});
@@ -992,42 +1075,7 @@ export function createAgentEventHandler({
return;
}
const now = Date.now();
const run = chatRunState.getOrCreate(clientRunId);
const delta = resolveBroadcastDelta({
text,
previousBroadcastText: run.deltaLastBroadcastText,
});
if (!delta) {
return;
}
const spawnedBy = resolveSpawnedBy(sessionKey);
const flushPayload = {
runId: clientRunId,
sessionKey,
...(agentId ? { agentId } : {}),
...(spawnedBy && { spawnedBy }),
seq,
state: "delta" as const,
deltaText: delta.deltaText,
...(delta.replace ? { replace: true as const } : {}),
message: {
role: "assistant",
content: [{ type: "text", text }],
timestamp: now,
},
};
emitFirstAssistantChatSendTiming(
opts?.firstAssistantTimingEntry ?? chatRunState.registry.peek(sourceRunId),
);
sendChatPayload(sessionKey, flushPayload, {
agentId,
controlUiVisible: opts?.controlUiVisible ?? true,
dropIfSlow: true,
});
run.deltaLastBroadcastLen = text.length;
run.deltaLastBroadcastText = text;
run.deltaSentAt = now;
broadcastChatDelta(sessionKey, agentId, clientRunId, sourceRunId, seq, text, opts);
};
const sendChatPayload = (
+1 -2
View File
@@ -1324,8 +1324,7 @@ describe("voice transcript events", () => {
const [runId, runMetadata] = mockCall(addChatRun) ?? [];
expect(runId).toBe(optsRecord.runId);
const clientRunId = (runMetadata as { clientRunId?: unknown } | undefined)?.clientRunId;
expect(typeof clientRunId).toBe("string");
expect(clientRunId).toMatch(/^voice-/);
expect(clientRunId).toBe(runId);
});
it("does not block agent dispatch when session-store touch fails", async () => {
+3 -3
View File
@@ -607,11 +607,11 @@ export const handleNodeEvent = async (
isConnectionCurrent: opts?.isConnectionCurrent,
});
// Ensure chat UI clients refresh when this run completes (even though it wasn't started via chat.send).
// This maps agent bus events (keyed by per-turn runId) to chat events (keyed by clientRunId).
// Voice now has a unique per-turn run id, so it is also the stable
// client identity for chat streaming and abort lifecycle ownership.
ctx.addChatRun(runId, {
sessionKey: canonicalKey,
clientRunId: `voice-${randomUUID()}`,
clientRunId: runId,
});
},
});
@@ -10,7 +10,7 @@ import {
const TEST_TIMEOUT_MS = 120_000;
const REQUEST_TIMEOUT_MS = 20_000;
const STREAM_INTERVAL_MS = 225;
const STREAM_INTERVAL_MS = 20;
const MODEL_REF = "mock-openai/gpt-5.6-luna";
const SESSION_KEY = "agent:qa:qa:session-streaming";
const IDEMPOTENCY_KEY = "qa-session-streaming";
@@ -27,6 +27,12 @@ type GatewayEvent = {
event: string;
payload?: unknown;
};
type ChatEventPayload = {
runId?: string;
sessionKey?: string;
state?: string;
deltaText?: string;
};
type AgentEvent = {
runId?: string;
sessionKey?: string;
@@ -41,6 +47,14 @@ type AgentEvent = {
const cleanups: Array<() => Promise<void>> = [];
function createDeferred() {
let resolve = () => {};
const promise = new Promise<void>((settle) => {
resolve = settle;
});
return { promise, resolve };
}
afterEach(async () => {
const errors: unknown[] = [];
for (const cleanup of cleanups.splice(0).toReversed()) {
@@ -62,7 +76,11 @@ function writeEvent(response: ServerResponse, event: unknown): void {
response.write(`data: ${JSON.stringify(event)}\n\n`);
}
async function writeStreamingResponse(response: ServerResponse): Promise<void> {
async function writeStreamingResponse(
response: ServerResponse,
deltasSent: ReturnType<typeof createDeferred>,
terminalRelease: ReturnType<typeof createDeferred>,
): Promise<void> {
const message = {
type: "message",
id: "qa-session-streaming-message",
@@ -91,6 +109,8 @@ async function writeStreamingResponse(response: ServerResponse): Promise<void> {
});
await delay(STREAM_INTERVAL_MS);
}
deltasSent.resolve();
await terminalRelease.promise;
writeEvent(response, {
type: "response.output_text.done",
item_id: message.id,
@@ -114,6 +134,13 @@ async function writeStreamingResponse(response: ServerResponse): Promise<void> {
async function startStreamingProvider() {
const providerRequests: Array<Record<string, unknown>> = [];
const transportRequests: string[] = [];
const deltasSent = createDeferred();
const terminalRelease = createDeferred();
let terminalReleased = false;
const releaseTerminal = () => {
terminalReleased = true;
terminalRelease.resolve();
};
const server = createServer((request, response) => {
void (async () => {
if (request.method === "GET" && request.url === "/v1/models") {
@@ -133,7 +160,7 @@ async function startStreamingProvider() {
providerRequests.push(
JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>,
);
await writeStreamingResponse(response);
await writeStreamingResponse(response, deltasSent, terminalRelease);
return;
}
transportRequests.push(`${request.method ?? "UNKNOWN"} ${request.url ?? ""}`);
@@ -153,9 +180,13 @@ async function startStreamingProvider() {
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
deltasSent: deltasSent.promise,
isTerminalReleased: () => terminalReleased,
releaseTerminal,
providerRequests,
transportRequests,
stop: async () => {
releaseTerminal();
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
@@ -216,6 +247,12 @@ function asAgentEvent(event: GatewayEvent): AgentEvent | undefined {
: undefined;
}
function asChatEvent(event: GatewayEvent): ChatEventPayload | undefined {
return event.event === "chat" && event.payload && typeof event.payload === "object"
? (event.payload as ChatEventPayload)
: undefined;
}
function messageRole(message: unknown): string | undefined {
const role = message && typeof message === "object" ? (message as { role?: unknown }).role : null;
return typeof role === "string" ? role : undefined;
@@ -275,6 +312,7 @@ describe("agent session streaming", () => {
const gatewayEvents: GatewayEvent[] = [];
const client = await connectOperator(gateway, gatewayEvents);
cleanups.push(() => client.stopAndWait({ timeoutMs: 1_000 }));
await client.request("sessions.messages.subscribe", { key: SESSION_KEY });
const accepted = await client.request<AgentResult>("agent", {
sessionKey: SESSION_KEY,
message: REQUEST_MESSAGE,
@@ -286,6 +324,21 @@ describe("agent session streaming", () => {
runId: IDEMPOTENCY_KEY,
});
await provider.deltasSent;
await vi.waitFor(
() => {
expect(provider.isTerminalReleased()).toBe(false);
const streamedChatText = gatewayEvents
.map(asChatEvent)
.filter((event) => event?.runId === IDEMPOTENCY_KEY && event.state === "delta")
.map((event) => event?.deltaText ?? "")
.join("");
expect(streamedChatText).toBe(TERMINAL_TEXT);
},
{ interval: 20, timeout: REQUEST_TIMEOUT_MS },
);
provider.releaseTerminal();
const terminal = await client.request<AgentResult>(
"agent.wait",
{ runId: IDEMPOTENCY_KEY, timeoutMs: 30_000 },
@@ -345,6 +398,32 @@ describe("agent session streaming", () => {
});
expect(terminalEvents[0]?.seq).toBeGreaterThan(deltaSeqs.at(-1) ?? 0);
await vi.waitFor(
() => {
const runChatEvents = gatewayEvents
.map(asChatEvent)
.filter((event) => event?.runId === IDEMPOTENCY_KEY);
expect(runChatEvents.filter((event) => event?.state === "final")).toHaveLength(1);
},
{ interval: 20, timeout: REQUEST_TIMEOUT_MS },
);
const runChatEventsAtTerminal = gatewayEvents
.map(asChatEvent)
.filter((event) => event?.runId === IDEMPOTENCY_KEY);
const finalIndex = runChatEventsAtTerminal.findIndex((event) => event?.state === "final");
expect(finalIndex).toBeGreaterThan(-1);
expect(
runChatEventsAtTerminal.slice(finalIndex + 1).some((event) => event?.state === "delta"),
).toBe(false);
await delay(250);
expect(
gatewayEvents
.map(asChatEvent)
.filter((event) => event?.runId === IDEMPOTENCY_KEY)
.slice(finalIndex + 1)
.some((event) => event?.state === "delta"),
).toBe(false);
const streamedText = assistantDeltas.map((event) => event.data?.delta ?? "").join("");
expect(streamedText).toBe(TERMINAL_TEXT);