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:
7
.changeset/fn-7765-grok-cli-chat-crash.md
Normal file
7
.changeset/fn-7765-grok-cli-chat-crash.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user