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!");
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user