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 d37da5b3db
commit fd3ef35e0d
12 changed files with 1021 additions and 84 deletions

View File

@@ -94,6 +94,7 @@ const defaultChatState = {
isStreaming: false,
streamingText: "",
streamingThinking: "",
streamingToolCalls: [],
selectSession: vi.fn(),
createSession: vi.fn().mockResolvedValue({ id: "session-new", agentId: "__fn_agent__" }),
archiveSession: vi.fn(),
@@ -398,6 +399,143 @@ describe("ChatView", () => {
expect(screen.getByText("Hi there!")).toBeInTheDocument();
});
it("renders tool calls from persisted messages", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "I used a tool",
toolCalls: [
{
toolName: "read",
args: { path: "foo.ts" },
isError: false,
result: "contents",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByText("read")).toBeInTheDocument();
const preview = document.querySelector(".chat-tool-call-preview");
expect(preview).toHaveTextContent("result: contents");
});
it("renders streaming tool calls", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "user", content: "Use tools", createdAt: "2026-04-08T00:00:00.000Z" }],
isStreaming: true,
streamingText: "Working...",
streamingToolCalls: [
{
toolName: "read",
args: { path: "foo.ts" },
isError: false,
status: "running",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const streamingBubble = document.querySelector(".chat-message--streaming");
expect(streamingBubble).toBeInTheDocument();
expect(within(streamingBubble as HTMLElement).getByText("read")).toBeInTheDocument();
const preview = (streamingBubble as HTMLElement).querySelector(".chat-tool-call-preview");
expect(preview).toHaveTextContent("path=foo.ts");
});
it("completed tool calls are collapsed by default", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Done",
toolCalls: [
{
toolName: "read",
isError: false,
result: "contents",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const details = document.querySelector(".chat-tool-call") as HTMLDetailsElement | null;
expect(details).toBeInTheDocument();
expect(details?.open).toBe(false);
});
it("running tool calls show running indicator", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Running",
toolCalls: [
{
toolName: "read",
isError: false,
status: "running",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(document.querySelector(".chat-tool-call--running")).toBeInTheDocument();
});
it("error tool calls show error styling", () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [
{
id: "msg-002",
sessionId: "session-001",
role: "assistant",
content: "Error",
toolCalls: [
{
toolName: "read",
isError: true,
result: "failed",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(document.querySelector(".chat-tool-call--error")).toBeInTheDocument();
});
it("shows resolved agent name in assistant message avatar", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Agent Chat", updatedAt: "2026-04-08T00:00:00.000Z" },

View File

@@ -88,6 +88,8 @@ function createMockStreamResponse() {
const handlers: {
onThinking?: (data: string) => void;
onText?: (data: string) => void;
onToolStart?: (data: { toolName: string; args?: Record<string, unknown> }) => void;
onToolEnd?: (data: { toolName: string; isError: boolean; result?: unknown }) => void;
onDone?: (data: { messageId: string }) => void;
onError?: (data: string) => void;
onConnectionStateChange?: (state: string) => void;
@@ -454,6 +456,8 @@ describe("QuickChatFAB", () => {
expect.objectContaining({
onThinking: expect.any(Function),
onText: expect.any(Function),
onToolStart: expect.any(Function),
onToolEnd: expect.any(Function),
onDone: expect.any(Function),
onError: expect.any(Function),
}),
@@ -611,6 +615,71 @@ describe("QuickChatFAB", () => {
});
});
it("renders tool calls in quick chat messages", async () => {
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => {
handlers.onText?.("Used read tool");
handlers.onToolStart?.({ toolName: "read", args: { path: "foo.ts" } });
handlers.onToolEnd?.({ toolName: "read", isError: false, result: "contents" });
handlers.onDone?.({ messageId: "msg-tool" });
}, 0);
return {
close: vi.fn(),
isConnected: vi.fn(() => true),
};
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Show tools" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
expect(screen.getByText("read")).toBeInTheDocument();
expect(screen.getByText("Tool calls")).toBeInTheDocument();
});
const preview = document.querySelector(".chat-tool-call-preview");
expect(preview).toHaveTextContent("result: contents");
});
it("shows streaming tool calls during generation", async () => {
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
handlers.onText?.("Still working");
handlers.onToolStart?.({ toolName: "read", args: { path: "foo.ts" } });
return {
close: vi.fn(),
isConnected: vi.fn(() => true),
};
});
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
await waitFor(() => {
expect(mockFetchChatSessions).toHaveBeenCalled();
});
const input = await screen.findByTestId("quick-chat-input");
fireEvent.change(input, { target: { value: "Stream tools" } });
fireEvent.click(screen.getByTestId("quick-chat-send"));
await waitFor(() => {
const streamingMessage = screen.getByTestId("quick-chat-streaming-message");
expect(within(streamingMessage).getByText("read")).toBeInTheDocument();
expect(streamingMessage.querySelector(".chat-tool-call--running")).toBeTruthy();
expect(streamingMessage.querySelector(".chat-tool-call-preview")).toHaveTextContent("path=foo.ts");
});
});
it("preserves user message after assistant reply completes", async () => {
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);