diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index 456d9a537c0..c28b327c325 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -94,12 +94,18 @@ describe("handleChatEvent", () => { expect(state.chatMessages).toEqual([]); }); - it("processes final from own run and clears state", () => { + it("persists streamed text when final event carries no message", () => { + const existingMessage = { + role: "user", + content: [{ type: "text", text: "Hi" }], + timestamp: 1, + }; const state = createState({ sessionKey: "main", chatRunId: "run-1", - chatStream: "Reply", + chatStream: "Here is my reply", chatStreamStartedAt: 100, + chatMessages: [existingMessage], }); const payload: ChatEventPayload = { runId: "run-1", @@ -110,6 +116,69 @@ describe("handleChatEvent", () => { expect(state.chatRunId).toBe(null); expect(state.chatStream).toBe(null); expect(state.chatStreamStartedAt).toBe(null); + expect(state.chatMessages).toHaveLength(2); + expect(state.chatMessages[0]).toEqual(existingMessage); + expect(state.chatMessages[1]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "Here is my reply" }], + }); + }); + + it("does not persist empty or whitespace-only stream on final", () => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-1", + chatStream: " ", + chatStreamStartedAt: 100, + }); + const payload: ChatEventPayload = { + runId: "run-1", + sessionKey: "main", + state: "final", + }; + expect(handleChatEvent(state, payload)).toBe("final"); + expect(state.chatRunId).toBe(null); + expect(state.chatStream).toBe(null); + expect(state.chatMessages).toEqual([]); + }); + + it("does not persist null stream on final with no message", () => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-1", + chatStream: null, + chatStreamStartedAt: 100, + }); + const payload: ChatEventPayload = { + runId: "run-1", + sessionKey: "main", + state: "final", + }; + expect(handleChatEvent(state, payload)).toBe("final"); + expect(state.chatMessages).toEqual([]); + }); + + it("prefers final payload message over streamed text", () => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-1", + chatStream: "Streamed partial", + chatStreamStartedAt: 100, + }); + const finalMsg = { + role: "assistant", + content: [{ type: "text", text: "Complete reply" }], + timestamp: 101, + }; + const payload: ChatEventPayload = { + runId: "run-1", + sessionKey: "main", + state: "final", + message: finalMsg, + }; + expect(handleChatEvent(state, payload)).toBe("final"); + expect(state.chatMessages).toEqual([finalMsg]); + expect(state.chatStream).toBe(null); }); it("appends final payload message from own run before clearing stream state", () => { diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 5305bde0f65..74d93bdecce 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -251,6 +251,15 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) { const finalMessage = normalizeFinalAssistantMessage(payload.message); if (finalMessage) { state.chatMessages = [...state.chatMessages, finalMessage]; + } else if (state.chatStream?.trim()) { + state.chatMessages = [ + ...state.chatMessages, + { + role: "assistant", + content: [{ type: "text", text: state.chatStream }], + timestamp: Date.now(), + }, + ]; } state.chatStream = null; state.chatRunId = null; diff --git a/vitest.config.ts b/vitest.config.ts index 8b158848930..424fa3e8427 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ "ui/src/ui/views/agents-utils.test.ts", "ui/src/ui/views/usage-render-details.test.ts", "ui/src/ui/controllers/agents.test.ts", + "ui/src/ui/controllers/chat.test.ts", ], setupFiles: ["test/setup.ts"], exclude: [