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:
@@ -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) => {
|
||||
|
||||
@@ -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"}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user