From d520bc4cb6b335076f9093c8e1c7493e4d68a90c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 11:03:35 +0100 Subject: [PATCH] fix(gateway): flush initial openai chat stream chunk --- CHANGELOG.md | 1 + src/gateway/openai-http.test.ts | 79 +++++++++++++++++++++++++++++++++ src/gateway/openai-http.ts | 3 ++ 3 files changed, 83 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c78d18f83f2..66a11bce406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/OpenAI-compatible: send the assistant role SSE chunk as soon as streaming chat-completion headers are accepted, so cold agent setup cannot leave `/v1/chat/completions` clients with a bodyless 200 response until their idle timeout fires. - TUI/sessions: bound the session picker to recent rows and use exact lookup-style refreshes for the active session, so dusty stores no longer make TUI hydrate weeks-old transcripts before becoming responsive. Thanks @vincentkoc. - Doctor/gateway: report recent supervisor restart handoffs in `openclaw doctor --deep`, using the installed service environment when available so service-managed clean exits are visible in guided diagnostics. Thanks @shakkernerd. - Gateway/status: show recent supervisor restart handoffs in `openclaw gateway status --deep`, including JSON details, so clean service-managed restarts are reported as restart handoffs instead of opaque stopped-service diagnostics. Thanks @shakkernerd. diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 51896e8c266..5545e6c573f 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -1028,6 +1028,85 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { } }); + it( + "sends an initial SSE chunk before a streaming agent run settles", + { timeout: 15_000 }, + async () => { + const port = enabledPort; + let serverAbortSignal: AbortSignal | undefined; + + agentCommand.mockClear(); + agentCommand.mockImplementationOnce( + (opts: unknown) => + new Promise((resolve) => { + const signal = (opts as { abortSignal?: AbortSignal } | undefined)?.abortSignal; + serverAbortSignal = signal; + if (signal?.aborted) { + resolve(undefined); + return; + } + signal?.addEventListener("abort", () => resolve(undefined), { once: true }); + }), + ); + + let settled = false; + const firstChunk = new Promise((resolve, reject) => { + const clientReq = http.request( + { + hostname: "127.0.0.1", + port, + path: "/v1/chat/completions", + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer secret", + }, + }, + (res) => { + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"] ?? "").toContain("text/event-stream"); + res.setEncoding("utf8"); + res.once("data", (chunk) => { + settled = true; + resolve(String(chunk)); + clientReq.destroy(); + }); + }, + ); + clientReq.on("error", (err) => { + if (!settled) { + reject(err); + } + }); + clientReq.setTimeout(2_000, () => { + if (!settled) { + settled = true; + clientReq.destroy(new Error("timed out waiting for first SSE chunk")); + } + }); + clientReq.end( + JSON.stringify({ + stream: true, + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }), + ); + }); + + await expect(firstChunk).resolves.toContain('"role":"assistant"'); + await vi.waitFor(() => { + expect(agentCommand).toHaveBeenCalledTimes(1); + }); + + await vi.waitFor( + () => { + expect(serverAbortSignal?.aborted).toBe(true); + }, + { timeout: 5_000, interval: 50 }, + ); + }, + ); + it("includes usage in final stream chunk when stream_options.include_usage=true", async () => { const port = enabledPort; agentCommand.mockClear(); diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 0f4b502de5f..cce77af0025 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -732,6 +732,9 @@ export async function handleOpenAiHttpRequest( unsubscribe(); }); + wroteRole = true; + writeAssistantRoleChunk(res, { runId, model }); + void (async () => { try { const result = await agentCommandFromIngress(commandInput, defaultRuntime, deps);