From ca583e93050cf331c4ca28b8d9566ba48ac0a8bc Mon Sep 17 00:00:00 2001 From: Hamza Agar Date: Wed, 29 Jul 2026 00:47:23 +0300 Subject: [PATCH] fix(genui): swallow the closing stream_done event instead of materializing it A connector's terminating `stream_done` event (mekik PROTOCOL.md 4.1) is stream lifecycle, not content. ChatEngine treated it like any other chunk, so a text-only stream got a phantom empty genui bubble opened for an event that has nowhere to live, and a genui stream got the closing event appended to its chunk list. _handleGenUIChunk now finishes the stream and returns early. Also makes AIChunkEvent.payload optional, matching schemas/genui/ai-chunk.schema.json, which already lists only type/name/id as required. Signal-only events such as stream_done carry no payload. Co-Authored-By: Claude Opus 5 (1M context) --- docs/genui/streaming.md | 2 +- packages/core/src/application/ChatEngine.ts | 7 +++++ .../application/__tests__/ChatEngine.test.ts | 28 +++++++++++++++++++ packages/core/src/domain/entities/GenUI.ts | 4 +-- website/docs/genui/streaming.md | 2 +- 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/docs/genui/streaming.md b/docs/genui/streaming.md index 80cbaa3..e0c7881 100644 --- a/docs/genui/streaming.md +++ b/docs/genui/streaming.md @@ -8,7 +8,7 @@ A connector advertises GenUI support by implementing `onGenUIChunk(callback)`. T type AIChunk = | { type: "text"; content: string; id: number; } | { type: "ui"; component: string; props: Record; id: number; } - | { type: "event"; name: string; payload: unknown; id: number; for?: number; }; + | { type: "event"; name: string; payload?: unknown; id: number; for?: number; }; ``` Schema: [`schemas/genui/ai-chunk.schema.json`](../../schemas/genui/ai-chunk.schema.json). diff --git a/packages/core/src/application/ChatEngine.ts b/packages/core/src/application/ChatEngine.ts index eb297cb..f692ceb 100644 --- a/packages/core/src/application/ChatEngine.ts +++ b/packages/core/src/application/ChatEngine.ts @@ -262,6 +262,13 @@ export class ChatEngine { } private _handleGenUIChunk(streamId: string, chunk: AIChunk, done: boolean): void { + // The closing `stream_done` event (mekik PROTOCOL.md §4.1) is stream + // lifecycle, not content — materializing it would add a phantom empty + // message (a text-only stream has no genui host for it to live in). + if (chunk.type === "event" && chunk.name === "stream_done") { + if (done) this._finishStream(streamId); + return; + } const firstChunk = !this._streamHosts.has(streamId); // Text deltas render as a normal, growing bot bubble (default-text-message); diff --git a/packages/core/src/application/__tests__/ChatEngine.test.ts b/packages/core/src/application/__tests__/ChatEngine.test.ts index fb13766..7b9cdc3 100644 --- a/packages/core/src/application/__tests__/ChatEngine.test.ts +++ b/packages/core/src/application/__tests__/ChatEngine.test.ts @@ -646,6 +646,34 @@ describe("ChatEngine", () => { expect(data.chunks[1].type).toBe("event"); }); + it("does not open a phantom bubble for the closing stream_done event of a text-only stream", async () => { + const connector = createMockConnector(); + const engine = new ChatEngine(connector); + await engine.init(); + connector.simulateGenUIChunk("s-done", { type: "text", content: "Hi", id: 1 }, false); + connector.simulateGenUIChunk("s-done", { type: "event", name: "stream_done", id: 2 }, true); + const msgs = messageStore.getState().messages; + expect(msgs).toHaveLength(1); + expect(msgs[0].data.text).toBe("Hi"); + expect(msgs[0].data.streaming).toBe(false); + }); + + it("does not append the closing stream_done event into a genui message", async () => { + const handler = vi.fn(); + EventBus.on("genui_stream_completed", handler); + const connector = createMockConnector(); + const engine = new ChatEngine(connector); + await engine.init(); + connector.simulateGenUIChunk("s-done2", { type: "ui", component: "card", props: {}, id: 1 }, false); + connector.simulateGenUIChunk("s-done2", { type: "event", name: "stream_done", id: 2 }, true); + const msgs = messageStore.getState().messages; + expect(msgs).toHaveLength(1); + const data = msgs[0].data as { chunks: AIChunk[]; streamingComplete: boolean }; + expect(data.chunks).toHaveLength(1); + expect(data.streamingComplete).toBe(true); + expect(handler).toHaveBeenCalledWith({ streamId: "s-done2" }); + }); + // ── receiveComponentEvent ────────────────────────────────────────── it("routes component events to connector by stream id", async () => { diff --git a/packages/core/src/domain/entities/GenUI.ts b/packages/core/src/domain/entities/GenUI.ts index 9b4947d..2a33d69 100644 --- a/packages/core/src/domain/entities/GenUI.ts +++ b/packages/core/src/domain/entities/GenUI.ts @@ -32,8 +32,8 @@ export interface AIChunkEvent { type: "event"; /** Event name (e.g. "form_success"). */ name: string; - /** Arbitrary payload delivered to listeners. */ - payload: unknown; + /** Arbitrary payload delivered to listeners. Omitted for signal-only events. */ + payload?: unknown; /** Unique id of this chunk (for deduplication). */ id: number; /** diff --git a/website/docs/genui/streaming.md b/website/docs/genui/streaming.md index ccc5549..56e15da 100644 --- a/website/docs/genui/streaming.md +++ b/website/docs/genui/streaming.md @@ -14,7 +14,7 @@ A connector advertises GenUI support by implementing `onGenUIChunk(callback)`. T type AIChunk = | { type: "text"; content: string; id: number; } | { type: "ui"; component: string; props: Record; id: number; } - | { type: "event"; name: string; payload: unknown; id: number; for?: number; }; + | { type: "event"; name: string; payload?: unknown; id: number; for?: number; }; ``` Schema: [`schemas/genui/ai-chunk.schema.json`](https://github.com/AimTune/chativa/blob/main/schemas/genui/ai-chunk.schema.json).