From 059016ef3c4025773190bb11b838ecc17c34df0c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 00:22:59 -0700 Subject: [PATCH] FN-7765: fix dashboard chat crash when plugin CLI sessions omit session.state Fix a "Response failed" crash for plugin CLI runtime chats (grok/droid/cursor) whose sessions expose top-level `messages` and stream via `onText` without a pi-shaped `session.state`. - Read messages/errorMessage null-safely from `session.state`, falling back to top-level `session.messages` when state is absent, in both the room responder and streaming response extraction paths - Keep `state.errorMessage` optional so successful streams from state-less sessions no longer throw TypeErrors, while pi/openclaw/hermes provider errors still surface correctly - Add regression tests covering state-less plugin CLI sessions in chat-manager.test.ts - Add changeset for the fix Files changed: .changeset/fn-7765-grok-cli-chat-crash.md | 7 ++ packages/dashboard/src/__tests__/chat-manager.test.ts | 90 ++++++++++++++++++++++ packages/dashboard/src/chat.ts | 35 ++++++--- 3 files changed, 121 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-7765 Fusion-Task-Lineage: 80e33a97-e971-4aec-a4cf-29d97c5c5e62 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7765-grok-cli-chat-crash.md | 7 ++ .../src/__tests__/chat-manager.test.ts | 90 +++++++++++++++++++ packages/dashboard/src/chat.ts | 35 +++++--- 3 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 .changeset/fn-7765-grok-cli-chat-crash.md diff --git a/.changeset/fn-7765-grok-cli-chat-crash.md b/.changeset/fn-7765-grok-cli-chat-crash.md new file mode 100644 index 0000000000..cfdd18c9b9 --- /dev/null +++ b/.changeset/fn-7765-grok-cli-chat-crash.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Grok CLI chat failing instantly with a "Response failed" error. +category: fix +dev: ChatManager.sendMessage (packages/dashboard/src/chat.ts) now null-safely reads session.state.errorMessage/messages and falls back to the session's top-level messages + accumulated onText stream, so plugin-backed CLI runtime sessions (grok/droid/cursor) that expose no pi-shaped `state` render their reply instead of throwing "Cannot read properties of undefined (reading 'errorMessage')". pi/openclaw/hermes state.errorMessage failure bubbles are unchanged. Same fix applied to the room-responder session.state.messages read. diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 954f3ae625..d209d7d5a0 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -547,6 +547,96 @@ describe("ChatManager.sendMessage", () => { expect(assistantCall?.[1].content).toBe("Hello world!"); }); + it("persists streamed replies and broadcasts done for no-state plugin runtime sessions", async () => { + const events: Array<{ type: string; data: any }> = []; + const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => { + events.push(event); + }); + mockChatStore.addMessage.mockImplementation((_sessionId, input) => ({ + id: input.role === "assistant" ? "assistant-msg" : "user-msg", + sessionId: "chat-001", + role: input.role, + content: input.content, + thinkingOutput: input.thinkingOutput, + metadata: input.metadata, + createdAt: "2026-07-10T00:00:00.000Z", + })); + + __setCreateFnAgent(async (options: any) => ({ + session: { + prompt: vi.fn().mockImplementation(async () => { + options.onText?.("Grok CLI streamed reply"); + }), + dispose: vi.fn(), + messages: [], + }, + })); + + const chatManager = createChatManager(); + await expect(chatManager.sendMessage("chat-001", "Hello Grok")).resolves.toBeUndefined(); + unsubscribe(); + + const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant"); + expect(assistantCalls).toHaveLength(1); + expect(assistantCalls[0]?.[1].content).toBe("Grok CLI streamed reply"); + expect(events).toContainEqual(expect.objectContaining({ + type: "done", + data: expect.objectContaining({ + message: expect.objectContaining({ content: "Grok CLI streamed reply" }), + }), + })); + expect(events).not.toContainEqual(expect.objectContaining({ type: "error" })); + expect(assistantCalls[0]?.[1].content).not.toContain("Response failed"); + }); + + it("does not crash when a no-state plugin runtime streams no text", async () => { + const events: Array<{ type: string; data: any }> = []; + const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => { + events.push(event); + }); + mockChatStore.addMessage.mockImplementation((_sessionId, input) => ({ + id: input.role === "assistant" ? "assistant-msg" : "user-msg", + sessionId: "chat-001", + role: input.role, + content: input.content, + createdAt: "2026-07-10T00:00:00.000Z", + })); + + __setCreateFnAgent(async () => ({ + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + messages: [], + }, + })); + + const chatManager = createChatManager(); + await expect(chatManager.sendMessage("chat-001", "Hello Grok")).resolves.toBeUndefined(); + unsubscribe(); + + const assistantCalls = mockChatStore.addMessage.mock.calls.filter((call) => call[1].role === "assistant"); + expect(assistantCalls).toHaveLength(1); + expect(assistantCalls[0]?.[1].content).toBe(""); + expect(events).toContainEqual(expect.objectContaining({ type: "done" })); + expect(events).not.toContainEqual(expect.objectContaining({ type: "error" })); + }); + + it("falls back to top-level messages for no-state plugin runtime sessions", async () => { + __setCreateFnAgent(async () => ({ + session: { + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + messages: [{ role: "assistant", content: "Top-level Grok message" }], + }, + })); + + const chatManager = createChatManager(); + await expect(chatManager.sendMessage("chat-001", "Hello Grok")).resolves.toBeUndefined(); + + const assistantCall = mockChatStore.addMessage.mock.calls.find((call) => call[1].role === "assistant"); + expect(assistantCall?.[1].content).toBe("Top-level Grok message"); + }); + // U11 / R12 drift guard: the chat lane must expose workflow discovery, // mutation, settings, selection, and trait vocabulary to the agent when a // scoped task store is available. diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index 220f208c6e..259b53ff71 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -1781,7 +1781,13 @@ export class ChatManager { ); type AgentMessage = { role?: string; type?: string; content?: string | Array<{ type?: string; text?: string }> }; - const messages = (resolvedSession.session.state.messages as AgentMessage[]) ?? []; + /* + * FNXC:Chat 2026-07-10-00:00: + * Plugin CLI runtime sessions (grok/droid/cursor) expose top-level `messages` and stream via `onText` without a pi-shaped `state`, so room responders must read messages null-safely while preserving pi/openclaw state-backed sessions. + */ + const roomSessionState = resolvedSession.session.state as { messages?: AgentMessage[]; errorMessage?: string } | undefined; + const roomTopLevelMessages = (resolvedSession.session as { messages?: AgentMessage[] }).messages; + const messages = roomSessionState?.messages ?? roomTopLevelMessages ?? []; const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant" || message.type === "assistant"); let content = ""; if (typeof lastAssistant?.content === "string") { @@ -1792,7 +1798,7 @@ export class ChatManager { .join(""); } - const stateError = (resolvedSession.session.state as { errorMessage?: string } | undefined)?.errorMessage; + const stateError = roomSessionState?.errorMessage; if (stateError?.trim()) { throw new Error(stateError.trim()); } @@ -2369,10 +2375,16 @@ export class ChatManager { return; } - // Some runtimes (e.g. plugin-backed Codex/openclaw) signal provider failures - // by setting session.state.errorMessage rather than throwing. Surface that - // as an error event instead of persisting a blank assistant reply. - const sessionErrorMessage = (agentResult.session.state as { errorMessage?: unknown }).errorMessage; + interface AgentMessage { + role: string; + content?: string | Array<{ type: string; text: string }>; + } + /* + * FNXC:Chat 2026-07-10-00:00: + * Plugin CLI runtime sessions (grok/droid/cursor) expose top-level `messages` and stream via `onText` without a pi-shaped `state`; keep `state.errorMessage` optional so successful streams do not become TypeErrors, while pi/openclaw/hermes provider errors still surface when set. + */ + const agentSessionState = agentResult.session.state as { errorMessage?: unknown; messages?: AgentMessage[] } | undefined; + const sessionErrorMessage = agentSessionState?.errorMessage; if (typeof sessionErrorMessage === "string" && sessionErrorMessage.trim().length > 0 && !accumulatedText && !accumulatedThinking && toolCallsAccum.length === 0) { const failureInfo = addModelContextToFailureInfo( @@ -2391,11 +2403,12 @@ export class ChatManager { // Extract response text from agent state let responseText = ""; - interface AgentMessage { - role: string; - content?: string | Array<{ type: string; text: string }>; - } - const lastMessage = (agentResult.session.state.messages as AgentMessage[]) + /* + * FNXC:Chat 2026-07-10-00:00: + * Plugin CLI runtimes can omit `state` entirely; use streamed text first, then fall back through state-backed messages and top-level session messages so no-state sessions persist successful replies instead of crashing during extraction. + */ + const agentMessages = agentSessionState?.messages ?? (agentResult.session as { messages?: AgentMessage[] }).messages ?? []; + const lastMessage = agentMessages .filter((m: AgentMessage) => m.role === "assistant") .pop();