feat(FN-2152): render collapsed tool call previews in chat

- Emit tool_start/tool_end SSE events from dashboard chat backend and parse them in streaming client helpers
- Track in-flight and completed tool calls in useChat/useQuickChat to preserve tool output summaries alongside assistant messages
- Render collapsed tool call preview blocks in ChatView and QuickChatFAB with dedicated tokenized styles for compact output summaries
- Expand frontend and backend test coverage for SSE tool events, hook state transitions, and collapsed preview rendering behavior
- Add a changeset for @gsxdsm/fusion documenting the new tool-call display behavior
This commit is contained in:
Fusion
2026-04-19 23:47:12 -07:00
committed by gsxdsm
parent eaf99e6279
commit 7b8bbaaa0a
12 changed files with 1021 additions and 84 deletions

View File

@@ -309,6 +309,96 @@ describe("ChatManager.sendMessage", () => {
expect(assistantCall?.[1].content).toBe("Hello world!");
});
it("broadcasts tool_start and tool_end SSE events when agent calls tools", async () => {
const events: Array<{ type: string; data: unknown }> = [];
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => {
events.push(event);
});
let onToolStartCb: ((name: string, args?: Record<string, unknown>) => void) | undefined;
let onToolEndCb: ((name: string, isError: boolean, result?: unknown) => void) | undefined;
__setCreateFnAgent(async (options: any) => {
onToolStartCb = options.onToolStart;
onToolEndCb = options.onToolEnd;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
onToolStartCb?.("read", { path: "/foo.ts" });
onToolEndCb?.("read", false, "file contents");
options.onText?.("Done");
}),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Use read tool");
unsubscribe();
expect(events).toContainEqual({
type: "tool_start",
data: { toolName: "read", args: { path: "/foo.ts" } },
});
expect(events).toContainEqual({
type: "tool_end",
data: { toolName: "read", isError: false, result: "file contents" },
});
});
it("persists tool calls in assistant message metadata", async () => {
let onToolStartCb: ((name: string, args?: Record<string, unknown>) => void) | undefined;
let onToolEndCb: ((name: string, isError: boolean, result?: unknown) => void) | undefined;
__setCreateFnAgent(async (options: any) => {
onToolStartCb = options.onToolStart;
onToolEndCb = options.onToolEnd;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
onToolStartCb?.("read", { path: "foo.ts" });
onToolEndCb?.("read", false, "contents");
options.onText?.("Here you go");
}),
dispose: vi.fn(),
state: {
messages: [{ role: "assistant", content: "Here you go" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Read foo.ts");
const assistantCall = mockChatStore.addMessage.mock.calls.find(
(call) => call[1].role === "assistant",
);
expect(assistantCall).toBeDefined();
expect(assistantCall?.[1]).toEqual(
expect.objectContaining({
metadata: {
toolCalls: [
{
toolName: "read",
args: { path: "foo.ts" },
isError: false,
result: "contents",
},
],
},
}),
);
});
it("creates chat agents with the full coding toolset", async () => {
let createOptions: any;
__setCreateFnAgent(async (options: any) => {

View File

@@ -42,74 +42,6 @@ function createSSEResponse(): {
return { res, chunks };
}
/**
* Parse collected SSE chunks into structured event objects.
* SSE format: "id: N\nevent: event-name\ndata: {...}\n\n"
*/
function parseSSEChunks(chunks: string[]): Array<{ id?: number; event: string; data: string }> {
const events: Array<{ id?: number; event: string; data: string }> = [];
let currentEvent: { id?: number; event: string; data: string } | null = null;
for (const chunk of chunks) {
// SSE events end with \n\n
const eventMatches = chunk.match(/([^\n]*\n)*/g);
const lines = chunk.split("\n").filter((l) => l !== "");
for (const line of lines) {
if (line.startsWith(":")) {
// Comment line - ignore
continue;
}
if (line === "") {
// Empty line marks end of event
if (currentEvent) {
events.push(currentEvent);
currentEvent = null;
}
continue;
}
const colonIndex = line.indexOf(":");
if (colonIndex === -1) continue;
const field = line.slice(0, colonIndex).trim();
const value = line.slice(colonIndex + 1).trim();
if (field === "id") {
if (!currentEvent) currentEvent = { event: "", data: "" };
currentEvent.id = parseInt(value, 10);
} else if (field === "event") {
if (!currentEvent) currentEvent = { event: "", data: "" };
currentEvent.event = value;
} else if (field === "data") {
if (!currentEvent) currentEvent = { event: "", data: "" };
currentEvent.data = value;
}
}
}
// Push last event if not already ended
if (currentEvent) {
events.push(currentEvent);
}
return events;
}
/**
* Extract JSON data from an SSE message chunk.
*/
function extractSSEPayload(sseChunk: string): unknown {
const dataMatch = sseChunk.match(/data: ([\s\S]*?)(?=\n\n|$)/);
if (!dataMatch) return {};
try {
return JSON.parse(dataMatch[1]);
} catch {
return dataMatch[1];
}
}
// ── Mock Setup ──────────────────────────────────────────────────────────────
const mockInit = vi.fn().mockResolvedValue(undefined);
@@ -1070,7 +1002,7 @@ describe("Chat API Routes", () => {
}
// Get the actual handler function from the layer
const routeHandler = handler.route.stack[0].handle;
const routeHandler = handler.route.stack[handler.route.stack.length - 1].handle;
// The handler is wrapped in middleware (rateLimit), so we need to call next
const next = vi.fn();
@@ -1125,7 +1057,7 @@ describe("Chat API Routes", () => {
// Invoke the handler - handler should execute without error
const next = vi.fn();
const routeHandler = handler.route.stack[0].handle;
const routeHandler = handler.route.stack[handler.route.stack.length - 1].handle;
const result = routeHandler(req, res, next);
// If it returns a promise, await it
@@ -1136,6 +1068,55 @@ describe("Chat API Routes", () => {
// Handler executed without throwing
expect(true).toBe(true);
});
it("SSE route passes through tool_start and tool_end events", async () => {
mockGetSession.mockReturnValue(sampleSession);
const chatModule = await import("../chat.js");
vi.mocked(chatModule.checkRateLimit).mockReturnValue(true);
mockSendMessage.mockImplementation(async (sessionId: string) => {
mockChatStreamManager.broadcast(sessionId, {
type: "tool_start",
data: {
toolName: "read",
args: { path: "/foo.ts" },
},
});
mockChatStreamManager.broadcast(sessionId, {
type: "tool_end",
data: {
toolName: "read",
isError: false,
result: "file contents",
},
});
mockChatStreamManager.broadcast(sessionId, {
type: "done",
data: { messageId: "msg-tool" },
});
});
const req = createSSERequest();
const { res, chunks } = createSSEResponse();
req.body = { content: "Read #foo.ts" };
req.params = { id: "chat-abc123" };
req.query = {} as any;
req.headers = {} as any;
req.ip = "127.0.0.1";
req.socket = { remoteAddress: "127.0.0.1" } as any;
await invokeSSEHandler(req, res, store, mockChatStore, mockChatManager);
const output = chunks.join("");
expect(output).toContain("event: tool_start");
expect(output).toContain("event: tool_end");
expect(output).toContain('data: {"toolName":"read","args":{"path":"/foo.ts"}}');
expect(output).toContain('data: {"toolName":"read","isError":false,"result":"file contents"}');
});
});
});