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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 00:22:59 -07:00
parent 587bdfd394
commit 059016ef3c
3 changed files with 121 additions and 11 deletions

View File

@@ -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.

View File

@@ -547,6 +547,96 @@ describe("ChatManager.sendMessage", () => {
expect(assistantCall?.[1].content).toBe("Hello world!"); 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, // U11 / R12 drift guard: the chat lane must expose workflow discovery,
// mutation, settings, selection, and trait vocabulary to the agent when a // mutation, settings, selection, and trait vocabulary to the agent when a
// scoped task store is available. // scoped task store is available.

View File

@@ -1781,7 +1781,13 @@ export class ChatManager {
); );
type AgentMessage = { role?: string; type?: string; content?: string | Array<{ type?: string; text?: string }> }; 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"); const lastAssistant = [...messages].reverse().find((message) => message.role === "assistant" || message.type === "assistant");
let content = ""; let content = "";
if (typeof lastAssistant?.content === "string") { if (typeof lastAssistant?.content === "string") {
@@ -1792,7 +1798,7 @@ export class ChatManager {
.join(""); .join("");
} }
const stateError = (resolvedSession.session.state as { errorMessage?: string } | undefined)?.errorMessage; const stateError = roomSessionState?.errorMessage;
if (stateError?.trim()) { if (stateError?.trim()) {
throw new Error(stateError.trim()); throw new Error(stateError.trim());
} }
@@ -2369,10 +2375,16 @@ export class ChatManager {
return; return;
} }
// Some runtimes (e.g. plugin-backed Codex/openclaw) signal provider failures interface AgentMessage {
// by setting session.state.errorMessage rather than throwing. Surface that role: string;
// as an error event instead of persisting a blank assistant reply. content?: string | Array<{ type: string; text: string }>;
const sessionErrorMessage = (agentResult.session.state as { errorMessage?: unknown }).errorMessage; }
/*
* 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 if (typeof sessionErrorMessage === "string" && sessionErrorMessage.trim().length > 0
&& !accumulatedText && !accumulatedThinking && toolCallsAccum.length === 0) { && !accumulatedText && !accumulatedThinking && toolCallsAccum.length === 0) {
const failureInfo = addModelContextToFailureInfo( const failureInfo = addModelContextToFailureInfo(
@@ -2391,11 +2403,12 @@ export class ChatManager {
// Extract response text from agent state // Extract response text from agent state
let responseText = ""; let responseText = "";
interface AgentMessage { /*
role: string; * FNXC:Chat 2026-07-10-00:00:
content?: string | Array<{ type: string; text: string }>; * 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 lastMessage = (agentResult.session.state.messages as AgentMessage[]) const agentMessages = agentSessionState?.messages ?? (agentResult.session as { messages?: AgentMessage[] }).messages ?? [];
const lastMessage = agentMessages
.filter((m: AgentMessage) => m.role === "assistant") .filter((m: AgentMessage) => m.role === "assistant")
.pop(); .pop();