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:
5
.changeset/add-chat-tool-call-display.md
Normal file
5
.changeset/add-chat-tool-call-display.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gsxdsm/fusion": minor
|
||||
---
|
||||
|
||||
Show AI tool calls in agent chat and quick chat with collapsible summary of arguments and output.
|
||||
@@ -5739,6 +5739,8 @@ export function streamChatResponse(
|
||||
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: StreamConnectionState) => void;
|
||||
@@ -5773,6 +5775,20 @@ export function streamChatResponse(
|
||||
handlers.onText?.(rawData);
|
||||
}
|
||||
break;
|
||||
case "tool_start":
|
||||
try {
|
||||
handlers.onToolStart?.(JSON.parse(rawData));
|
||||
} catch {
|
||||
// skip malformed event
|
||||
}
|
||||
break;
|
||||
case "tool_end":
|
||||
try {
|
||||
handlers.onToolEnd?.(JSON.parse(rawData));
|
||||
} catch {
|
||||
// skip malformed event
|
||||
}
|
||||
break;
|
||||
case "done":
|
||||
try {
|
||||
handlers.onDone?.(JSON.parse(rawData));
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
ChevronLeft,
|
||||
Bot,
|
||||
Square,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useChat } from "../hooks/useChat";
|
||||
import { useChat, type ToolCallInfo } from "../hooks/useChat";
|
||||
import { useViewportMode } from "./Header";
|
||||
import { fetchAgents, fetchDiscoveredSkills, fetchModels } from "../api";
|
||||
import type { Agent } from "@fusion/core";
|
||||
@@ -108,6 +109,111 @@ function formatModelTag(provider?: string | null, modelId?: string | null): stri
|
||||
return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted;
|
||||
}
|
||||
|
||||
function truncateValue(value: string, maxLength: number): string {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
|
||||
}
|
||||
|
||||
function formatToolArgsSummary(args?: Record<string, unknown>): string | null {
|
||||
if (!args) return null;
|
||||
|
||||
const entries = Object.entries(args);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return entries
|
||||
.map(([key, value]) => {
|
||||
let stringValue = "";
|
||||
if (typeof value === "string") {
|
||||
stringValue = value;
|
||||
} else {
|
||||
try {
|
||||
stringValue = JSON.stringify(value);
|
||||
} catch {
|
||||
stringValue = String(value);
|
||||
}
|
||||
}
|
||||
return `${key}=${truncateValue(stringValue, 50)}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatToolResultSummary(result: unknown): string | null {
|
||||
if (result === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof result === "string") {
|
||||
return truncateValue(result, 200);
|
||||
}
|
||||
|
||||
try {
|
||||
return truncateValue(JSON.stringify(result), 200);
|
||||
} catch {
|
||||
return truncateValue(String(result), 200);
|
||||
}
|
||||
}
|
||||
|
||||
function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
|
||||
if (!toolCalls || toolCalls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-tool-calls" data-testid="chat-tool-calls">
|
||||
<div className="chat-tool-calls-header">
|
||||
<Wrench size={12} aria-hidden="true" />
|
||||
<span>Tool calls</span>
|
||||
</div>
|
||||
{toolCalls.map((toolCall, index) => {
|
||||
const isRunning = toolCall.status === "running";
|
||||
const isError = toolCall.status === "completed" && toolCall.isError;
|
||||
const argsSummary = formatToolArgsSummary(toolCall.args);
|
||||
const resultSummary = formatToolResultSummary(toolCall.result);
|
||||
const summaryPreview = isRunning
|
||||
? argsSummary
|
||||
: resultSummary
|
||||
? `result: ${resultSummary}`
|
||||
: argsSummary
|
||||
? `args: ${argsSummary}`
|
||||
: null;
|
||||
const statusLabel = isRunning ? "running" : isError ? "error" : "completed";
|
||||
|
||||
return (
|
||||
<details
|
||||
key={`${toolCall.toolName}-${index}`}
|
||||
className={`chat-tool-call${isRunning ? " chat-tool-call--running" : ""}${isError ? " chat-tool-call--error" : ""}`}
|
||||
open={isRunning}
|
||||
>
|
||||
<summary>
|
||||
<span className="chat-tool-call-status-dot" aria-hidden="true" />
|
||||
<span className="chat-tool-call-name">{toolCall.toolName}</span>
|
||||
{summaryPreview && (
|
||||
<span className="chat-tool-call-preview" title={summaryPreview}>
|
||||
{summaryPreview}
|
||||
</span>
|
||||
)}
|
||||
<span className="chat-tool-call-status-text">{statusLabel}</span>
|
||||
</summary>
|
||||
<div className="chat-tool-call-content">
|
||||
{argsSummary && (
|
||||
<div className="chat-tool-call-row">
|
||||
<span className="chat-tool-call-label">args</span>
|
||||
<span className="chat-tool-call-value">{argsSummary}</span>
|
||||
</div>
|
||||
)}
|
||||
{resultSummary && (
|
||||
<div className={`chat-tool-call-row${isError ? " chat-tool-call-row--error" : ""}`}>
|
||||
<span className="chat-tool-call-label">result</span>
|
||||
<span className="chat-tool-call-value">{resultSummary}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant agent ID for the built-in fn agent.
|
||||
* The chat system always uses createFnAgent with CHAT_SYSTEM_PROMPT regardless
|
||||
@@ -316,6 +422,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
isStreaming,
|
||||
streamingText,
|
||||
streamingThinking,
|
||||
streamingToolCalls,
|
||||
selectSession,
|
||||
createSession,
|
||||
archiveSession,
|
||||
@@ -1056,6 +1163,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-message-content">{renderMessageContent(message.content)}</div>
|
||||
{renderToolCalls(message.toolCalls)}
|
||||
{message.thinkingOutput && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>Thinking</summary>
|
||||
@@ -1079,6 +1187,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
{streamingThinking ? "Thinking…" : "Connecting…"}
|
||||
</div>
|
||||
)}
|
||||
{renderToolCalls(streamingToolCalls)}
|
||||
{streamingThinking && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>Thinking</summary>
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { MessageSquare, Send, Square, X } from "lucide-react";
|
||||
import { MessageSquare, Send, Square, Wrench, X } from "lucide-react";
|
||||
import { fetchModels, type Agent, type ModelInfo } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||||
import { FN_AGENT_ID, useQuickChat, type ChatMessageInfo } from "../hooks/useQuickChat";
|
||||
import { FN_AGENT_ID, useQuickChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useQuickChat";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { FileMentionPopup } from "./FileMentionPopup";
|
||||
import { useFileMention } from "../hooks/useFileMention";
|
||||
@@ -98,6 +98,111 @@ function formatModelTagName(modelInfo: ModelInfo | null, parsedSelection: Parsed
|
||||
.trim();
|
||||
}
|
||||
|
||||
function truncateValue(value: string, maxLength: number): string {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
|
||||
}
|
||||
|
||||
function formatToolArgsSummary(args?: Record<string, unknown>): string | null {
|
||||
if (!args) return null;
|
||||
|
||||
const entries = Object.entries(args);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return entries
|
||||
.map(([key, value]) => {
|
||||
let stringValue = "";
|
||||
if (typeof value === "string") {
|
||||
stringValue = value;
|
||||
} else {
|
||||
try {
|
||||
stringValue = JSON.stringify(value);
|
||||
} catch {
|
||||
stringValue = String(value);
|
||||
}
|
||||
}
|
||||
return `${key}=${truncateValue(stringValue, 50)}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatToolResultSummary(result: unknown): string | null {
|
||||
if (result === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof result === "string") {
|
||||
return truncateValue(result, 200);
|
||||
}
|
||||
|
||||
try {
|
||||
return truncateValue(JSON.stringify(result), 200);
|
||||
} catch {
|
||||
return truncateValue(String(result), 200);
|
||||
}
|
||||
}
|
||||
|
||||
function renderToolCalls(toolCalls?: ToolCallInfo[], compact = false): ReactNode {
|
||||
if (!toolCalls || toolCalls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`chat-tool-calls${compact ? " chat-tool-calls--compact" : ""}`} data-testid="chat-tool-calls">
|
||||
<div className="chat-tool-calls-header">
|
||||
<Wrench size={12} aria-hidden="true" />
|
||||
<span>Tool calls</span>
|
||||
</div>
|
||||
{toolCalls.map((toolCall, index) => {
|
||||
const isRunning = toolCall.status === "running";
|
||||
const isError = toolCall.status === "completed" && toolCall.isError;
|
||||
const argsSummary = formatToolArgsSummary(toolCall.args);
|
||||
const resultSummary = formatToolResultSummary(toolCall.result);
|
||||
const summaryPreview = isRunning
|
||||
? argsSummary
|
||||
: resultSummary
|
||||
? `result: ${resultSummary}`
|
||||
: argsSummary
|
||||
? `args: ${argsSummary}`
|
||||
: null;
|
||||
const statusLabel = isRunning ? "running" : isError ? "error" : "completed";
|
||||
|
||||
return (
|
||||
<details
|
||||
key={`${toolCall.toolName}-${index}`}
|
||||
className={`chat-tool-call${isRunning ? " chat-tool-call--running" : ""}${isError ? " chat-tool-call--error" : ""}`}
|
||||
open={isRunning}
|
||||
>
|
||||
<summary>
|
||||
<span className="chat-tool-call-status-dot" aria-hidden="true" />
|
||||
<span className="chat-tool-call-name">{toolCall.toolName}</span>
|
||||
{summaryPreview && (
|
||||
<span className="chat-tool-call-preview" title={summaryPreview}>
|
||||
{summaryPreview}
|
||||
</span>
|
||||
)}
|
||||
<span className="chat-tool-call-status-text">{statusLabel}</span>
|
||||
</summary>
|
||||
<div className="chat-tool-call-content">
|
||||
{argsSummary && (
|
||||
<div className="chat-tool-call-row">
|
||||
<span className="chat-tool-call-label">args</span>
|
||||
<span className="chat-tool-call-value">{argsSummary}</span>
|
||||
</div>
|
||||
)}
|
||||
{resultSummary && (
|
||||
<div className={`chat-tool-call-row${isError ? " chat-tool-call-row--error" : ""}`}>
|
||||
<span className="chat-tool-call-label">result</span>
|
||||
<span className="chat-tool-call-value">{resultSummary}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getMentionTriggerMatch(
|
||||
value: string,
|
||||
cursorPos: number,
|
||||
@@ -374,6 +479,7 @@ export function QuickChatFAB({
|
||||
isStreaming,
|
||||
streamingText,
|
||||
streamingThinking,
|
||||
streamingToolCalls,
|
||||
sessionsLoading,
|
||||
messagesLoading,
|
||||
sendMessage,
|
||||
@@ -973,6 +1079,7 @@ export function QuickChatFAB({
|
||||
data-testid={`quick-chat-message-${message.id}`}
|
||||
>
|
||||
<p>{renderMessageContent(message.content)}</p>
|
||||
{renderToolCalls(message.toolCalls, true)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -989,6 +1096,7 @@ export function QuickChatFAB({
|
||||
{streamingThinking ? "Thinking…" : "Connecting…"}
|
||||
</p>
|
||||
)}
|
||||
{renderToolCalls(streamingToolCalls, true)}
|
||||
{streamingThinking && (
|
||||
<details className="chat-message-thinking" data-testid="quick-chat-streaming-thinking">
|
||||
<summary>Thinking</summary>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" />);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "../api";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { getScopedItem, setScopedItem, removeScopedItem } from "../utils/projectStorage";
|
||||
import type { Agent } from "@fusion/core";
|
||||
import type { Agent, ChatMessage } from "@fusion/core";
|
||||
|
||||
const ACTIVE_SESSION_STORAGE_KEY = "kb-chat-active-session";
|
||||
|
||||
@@ -29,12 +29,21 @@ export interface ChatSessionInfo {
|
||||
lastMessageAt?: string;
|
||||
}
|
||||
|
||||
export interface ToolCallInfo {
|
||||
toolName: string;
|
||||
args?: Record<string, unknown>;
|
||||
isError: boolean;
|
||||
result?: unknown;
|
||||
status: "running" | "completed";
|
||||
}
|
||||
|
||||
export interface ChatMessageInfo {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
thinkingOutput?: string | null;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -50,6 +59,7 @@ export interface UseChatReturn {
|
||||
isStreaming: boolean;
|
||||
streamingText: string;
|
||||
streamingThinking: string;
|
||||
streamingToolCalls: ToolCallInfo[];
|
||||
pendingMessage: string;
|
||||
|
||||
// Session operations
|
||||
@@ -79,6 +89,51 @@ export interface UseChatReturn {
|
||||
agentsMap: Map<string, Agent>;
|
||||
}
|
||||
|
||||
function extractCompletedToolCalls(metadata: Record<string, unknown> | null | undefined): ToolCallInfo[] | undefined {
|
||||
const rawToolCalls = metadata?.toolCalls;
|
||||
if (!Array.isArray(rawToolCalls)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = rawToolCalls
|
||||
.map((toolCall): ToolCallInfo | null => {
|
||||
if (!toolCall || typeof toolCall !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = toolCall as Record<string, unknown>;
|
||||
const toolName = typeof record.toolName === "string" ? record.toolName : "";
|
||||
if (!toolName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const args = record.args;
|
||||
|
||||
return {
|
||||
toolName,
|
||||
...(args && typeof args === "object" ? { args: args as Record<string, unknown> } : {}),
|
||||
isError: Boolean(record.isError),
|
||||
result: record.result,
|
||||
status: "completed" as const,
|
||||
};
|
||||
})
|
||||
.filter((toolCall): toolCall is ToolCallInfo => toolCall !== null);
|
||||
|
||||
return parsed.length > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo {
|
||||
return {
|
||||
id: message.id,
|
||||
sessionId: message.sessionId,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
thinkingOutput: message.thinkingOutput,
|
||||
toolCalls: extractCompletedToolCalls(message.metadata),
|
||||
createdAt: message.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function useChat(projectId?: string): UseChatReturn {
|
||||
// Session state
|
||||
const [sessions, setSessions] = useState<ChatSessionInfo[]>([]);
|
||||
@@ -91,6 +146,7 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingText, setStreamingText] = useState("");
|
||||
const [streamingThinking, setStreamingThinking] = useState("");
|
||||
const [streamingToolCalls, setStreamingToolCalls] = useState<ToolCallInfo[]>([]);
|
||||
const [pendingMessage, setPendingMessage] = useState("");
|
||||
|
||||
// Search/filter
|
||||
@@ -196,11 +252,12 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const data = await fetchChatMessages(sessionId, { limit: 50, ...opts }, projectId);
|
||||
const mappedMessages = data.messages.map(mapChatMessageToInfo);
|
||||
if (opts?.offset && opts.offset > 0) {
|
||||
// Prepend older messages
|
||||
setMessages((prev) => [...data.messages, ...prev]);
|
||||
setMessages((prev) => [...mappedMessages, ...prev]);
|
||||
} else {
|
||||
setMessages(data.messages);
|
||||
setMessages(mappedMessages);
|
||||
}
|
||||
setHasMoreMessages(data.messages.length >= 50);
|
||||
} catch {
|
||||
@@ -228,6 +285,7 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
// Reset streaming state
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
setHasMoreMessages(true);
|
||||
|
||||
@@ -275,6 +333,7 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
setMessages([]);
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
setHasMoreMessages(true);
|
||||
|
||||
@@ -339,6 +398,7 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
setIsStreaming(false);
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
}, [activeSession, projectId]);
|
||||
|
||||
const clearPendingMessage = useCallback(() => {
|
||||
@@ -379,11 +439,13 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
// Clear streaming state
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(true);
|
||||
|
||||
// Accumulate streaming text in local variables
|
||||
// Accumulate streaming text and tool calls in local variables
|
||||
let capturedText = "";
|
||||
let capturedThinking = "";
|
||||
let capturedToolCalls: ToolCallInfo[] = [];
|
||||
|
||||
const textHandlers = {
|
||||
onThinking: (data: string) => {
|
||||
@@ -394,6 +456,46 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
capturedText += data;
|
||||
setStreamingText(capturedText);
|
||||
},
|
||||
onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => {
|
||||
capturedToolCalls = [
|
||||
...capturedToolCalls,
|
||||
{
|
||||
toolName: data.toolName,
|
||||
args: data.args,
|
||||
isError: false,
|
||||
status: "running",
|
||||
},
|
||||
];
|
||||
setStreamingToolCalls(capturedToolCalls);
|
||||
},
|
||||
onToolEnd: (data: { toolName: string; isError: boolean; result?: unknown }) => {
|
||||
const nextToolCalls = [...capturedToolCalls];
|
||||
for (let i = nextToolCalls.length - 1; i >= 0; i--) {
|
||||
const candidate = nextToolCalls[i];
|
||||
if (candidate?.toolName === data.toolName && candidate.status === "running") {
|
||||
nextToolCalls[i] = {
|
||||
...candidate,
|
||||
status: "completed",
|
||||
isError: data.isError,
|
||||
result: data.result,
|
||||
};
|
||||
capturedToolCalls = nextToolCalls;
|
||||
setStreamingToolCalls(nextToolCalls);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
capturedToolCalls = [
|
||||
...nextToolCalls,
|
||||
{
|
||||
toolName: data.toolName,
|
||||
isError: data.isError,
|
||||
result: data.result,
|
||||
status: "completed",
|
||||
},
|
||||
];
|
||||
setStreamingToolCalls(capturedToolCalls);
|
||||
},
|
||||
onDone: (data: { messageId: string }) => {
|
||||
const assistantMessage: ChatMessageInfo = {
|
||||
id: data.messageId || `msg-${Date.now()}`,
|
||||
@@ -401,6 +503,7 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
role: "assistant",
|
||||
content: capturedText,
|
||||
thinkingOutput: capturedThinking,
|
||||
toolCalls: capturedToolCalls.length > 0 ? capturedToolCalls : undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -412,6 +515,7 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
streamRef.current = null;
|
||||
|
||||
@@ -433,6 +537,7 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
setMessages((prev) => prev.filter((m) => m.id !== tempId));
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
streamRef.current = null;
|
||||
console.error("[useChat] Stream error:", data);
|
||||
@@ -506,7 +611,8 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
|
||||
const handleChatMessageAdded = (e: MessageEvent) => {
|
||||
if (isStale()) return;
|
||||
const message: ChatMessageInfo = JSON.parse(e.data);
|
||||
const rawMessage = JSON.parse(e.data) as ChatMessage;
|
||||
const message = mapChatMessageToInfo(rawMessage);
|
||||
|
||||
// Skip if this message was already added via streaming completion
|
||||
// (SSE event may arrive before streaming state clears)
|
||||
@@ -564,6 +670,7 @@ export function useChat(projectId?: string): UseChatReturn {
|
||||
isStreaming,
|
||||
streamingText,
|
||||
streamingThinking,
|
||||
streamingToolCalls,
|
||||
pendingMessage,
|
||||
selectSession,
|
||||
createSession,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ChatSession } from "@fusion/core";
|
||||
import type { ChatMessage, ChatSession } from "@fusion/core";
|
||||
import {
|
||||
fetchChatSessions,
|
||||
createChatSession,
|
||||
@@ -10,12 +10,21 @@ import {
|
||||
|
||||
export const FN_AGENT_ID = "__fn_agent__";
|
||||
|
||||
export interface ToolCallInfo {
|
||||
toolName: string;
|
||||
args?: Record<string, unknown>;
|
||||
isError: boolean;
|
||||
result?: unknown;
|
||||
status: "running" | "completed";
|
||||
}
|
||||
|
||||
export interface ChatMessageInfo {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
thinkingOutput?: string | null;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -41,6 +50,7 @@ export interface UseQuickChatReturn {
|
||||
isStreaming: boolean;
|
||||
streamingText: string;
|
||||
streamingThinking: string;
|
||||
streamingToolCalls: ToolCallInfo[];
|
||||
pendingMessage: string;
|
||||
|
||||
// Operations
|
||||
@@ -104,6 +114,51 @@ function findMatchingSession(sessions: ChatSession[], target: SessionTarget): Ch
|
||||
return candidateSessions.find((session) => !session.modelProvider && !session.modelId) ?? candidateSessions[0];
|
||||
}
|
||||
|
||||
function extractCompletedToolCalls(metadata: Record<string, unknown> | null | undefined): ToolCallInfo[] | undefined {
|
||||
const rawToolCalls = metadata?.toolCalls;
|
||||
if (!Array.isArray(rawToolCalls)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = rawToolCalls
|
||||
.map((toolCall): ToolCallInfo | null => {
|
||||
if (!toolCall || typeof toolCall !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = toolCall as Record<string, unknown>;
|
||||
const toolName = typeof record.toolName === "string" ? record.toolName : "";
|
||||
if (!toolName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const args = record.args;
|
||||
|
||||
return {
|
||||
toolName,
|
||||
...(args && typeof args === "object" ? { args: args as Record<string, unknown> } : {}),
|
||||
isError: Boolean(record.isError),
|
||||
result: record.result,
|
||||
status: "completed" as const,
|
||||
};
|
||||
})
|
||||
.filter((toolCall): toolCall is ToolCallInfo => toolCall !== null);
|
||||
|
||||
return parsed.length > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function mapChatMessageToInfo(message: ChatMessage): ChatMessageInfo {
|
||||
return {
|
||||
id: message.id,
|
||||
sessionId: message.sessionId,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
thinkingOutput: message.thinkingOutput,
|
||||
toolCalls: extractCompletedToolCalls(message.metadata),
|
||||
createdAt: message.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for the QuickChatFAB component.
|
||||
* Provides chat session management and SSE streaming for real-time AI responses.
|
||||
@@ -122,6 +177,7 @@ export function useQuickChat(
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingText, setStreamingText] = useState("");
|
||||
const [streamingThinking, setStreamingThinking] = useState("");
|
||||
const [streamingToolCalls, setStreamingToolCalls] = useState<ToolCallInfo[]>([]);
|
||||
const [pendingMessage, setPendingMessage] = useState("");
|
||||
|
||||
// Stream connection ref for cleanup
|
||||
@@ -183,7 +239,7 @@ export function useQuickChat(
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const data = await fetchChatMessages(activeSession.id, { limit: 50 }, projectId);
|
||||
setMessages(data.messages);
|
||||
setMessages(data.messages.map(mapChatMessageToInfo));
|
||||
} catch (err) {
|
||||
console.error("[useQuickChat] Failed to load messages:", err);
|
||||
} finally {
|
||||
@@ -206,7 +262,7 @@ export function useQuickChat(
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const data = await fetchChatMessages(activeSession.id, { limit: 50 }, projectId);
|
||||
setMessages(data.messages);
|
||||
setMessages(data.messages.map(mapChatMessageToInfo));
|
||||
} catch (err) {
|
||||
console.error("[useQuickChat] Failed to reload messages:", err);
|
||||
} finally {
|
||||
@@ -231,6 +287,7 @@ export function useQuickChat(
|
||||
// Reset streaming state
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
|
||||
if (targetSessionKey === currentSessionKeyRef.current && activeSession) {
|
||||
@@ -271,6 +328,7 @@ export function useQuickChat(
|
||||
setIsStreaming(false);
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
}, [activeSession, projectId]);
|
||||
|
||||
const clearPendingMessage = useCallback(() => {
|
||||
@@ -311,11 +369,13 @@ export function useQuickChat(
|
||||
// Clear streaming state
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(true);
|
||||
|
||||
// Accumulate streaming text in local variables
|
||||
// Accumulate streaming text and tool calls in local variables
|
||||
let capturedText = "";
|
||||
let capturedThinking = "";
|
||||
let capturedToolCalls: ToolCallInfo[] = [];
|
||||
|
||||
const textHandlers = {
|
||||
onThinking: (data: string) => {
|
||||
@@ -326,6 +386,46 @@ export function useQuickChat(
|
||||
capturedText += data;
|
||||
setStreamingText(capturedText);
|
||||
},
|
||||
onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => {
|
||||
capturedToolCalls = [
|
||||
...capturedToolCalls,
|
||||
{
|
||||
toolName: data.toolName,
|
||||
args: data.args,
|
||||
isError: false,
|
||||
status: "running",
|
||||
},
|
||||
];
|
||||
setStreamingToolCalls(capturedToolCalls);
|
||||
},
|
||||
onToolEnd: (data: { toolName: string; isError: boolean; result?: unknown }) => {
|
||||
const nextToolCalls = [...capturedToolCalls];
|
||||
for (let i = nextToolCalls.length - 1; i >= 0; i--) {
|
||||
const candidate = nextToolCalls[i];
|
||||
if (candidate?.toolName === data.toolName && candidate.status === "running") {
|
||||
nextToolCalls[i] = {
|
||||
...candidate,
|
||||
status: "completed",
|
||||
isError: data.isError,
|
||||
result: data.result,
|
||||
};
|
||||
capturedToolCalls = nextToolCalls;
|
||||
setStreamingToolCalls(nextToolCalls);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
capturedToolCalls = [
|
||||
...nextToolCalls,
|
||||
{
|
||||
toolName: data.toolName,
|
||||
isError: data.isError,
|
||||
result: data.result,
|
||||
status: "completed",
|
||||
},
|
||||
];
|
||||
setStreamingToolCalls(capturedToolCalls);
|
||||
},
|
||||
onDone: (data: { messageId: string }) => {
|
||||
const assistantMessage: ChatMessageInfo = {
|
||||
id: data.messageId || `msg-${Date.now()}`,
|
||||
@@ -333,6 +433,7 @@ export function useQuickChat(
|
||||
role: "assistant",
|
||||
content: capturedText,
|
||||
thinkingOutput: capturedThinking || undefined,
|
||||
toolCalls: capturedToolCalls.length > 0 ? capturedToolCalls : undefined,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -341,6 +442,7 @@ export function useQuickChat(
|
||||
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
streamRef.current = null;
|
||||
|
||||
@@ -354,6 +456,7 @@ export function useQuickChat(
|
||||
onError: (data: string) => {
|
||||
setStreamingText("");
|
||||
setStreamingThinking("");
|
||||
setStreamingToolCalls([]);
|
||||
setIsStreaming(false);
|
||||
streamRef.current = null;
|
||||
console.error("[useQuickChat] Stream error:", data);
|
||||
@@ -395,6 +498,7 @@ export function useQuickChat(
|
||||
isStreaming,
|
||||
streamingText,
|
||||
streamingThinking,
|
||||
streamingToolCalls,
|
||||
pendingMessage,
|
||||
sendMessage,
|
||||
stopStreaming,
|
||||
@@ -411,6 +515,7 @@ export function useQuickChat(
|
||||
isStreaming,
|
||||
streamingText,
|
||||
streamingThinking,
|
||||
streamingToolCalls,
|
||||
pendingMessage,
|
||||
sendMessage,
|
||||
stopStreaming,
|
||||
|
||||
@@ -31945,6 +31945,151 @@ html .column.drag-over * {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* === Chat Tool Calls === */
|
||||
.chat-tool-calls {
|
||||
margin-top: var(--space-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.chat-tool-calls-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--space-md);
|
||||
}
|
||||
|
||||
.chat-tool-call {
|
||||
border: var(--btn-border-width, 1px) solid color-mix(in srgb, var(--border) 85%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--surface) 35%, transparent);
|
||||
}
|
||||
|
||||
.chat-tool-call summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--space-md);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-tool-call summary::marker {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.chat-tool-call summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-tool-call summary:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.chat-tool-call-status-dot {
|
||||
width: calc((var(--space-xs) + var(--space-sm)) / 2);
|
||||
height: calc((var(--space-xs) + var(--space-sm)) / 2);
|
||||
border-radius: 50%;
|
||||
background: var(--color-success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-tool-call-name {
|
||||
font-family: var(--font-mono, monospace);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-tool-call-preview {
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.chat-tool-call-status-text {
|
||||
margin-left: auto;
|
||||
font-size: 0.6875rem;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.chat-tool-call-content {
|
||||
margin: var(--space-xs) var(--space-sm) var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
font-family: var(--font-mono, monospace);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.chat-tool-call-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--space-xs);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.chat-tool-call-label {
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.chat-tool-call-value {
|
||||
color: var(--text);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chat-tool-call--running .chat-tool-call-status-dot {
|
||||
background: var(--color-info);
|
||||
animation: tool-call-pulse var(--transition-slow) infinite;
|
||||
}
|
||||
|
||||
.chat-tool-call--error summary {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.chat-tool-call--error .chat-tool-call-status-dot {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.chat-tool-call-row--error {
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.chat-tool-calls--compact .chat-tool-calls-header,
|
||||
.chat-tool-calls--compact .chat-tool-call summary,
|
||||
.chat-tool-calls--compact .chat-tool-call-content,
|
||||
.chat-tool-calls--compact .chat-tool-call-preview,
|
||||
.chat-tool-calls--compact .chat-tool-call-value {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
@keyframes tool-call-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Streaming indicator */
|
||||
.chat-message--streaming {
|
||||
align-self: flex-start;
|
||||
@@ -37640,4 +37785,25 @@ html .column.drag-over * {
|
||||
.dev-server-meta-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.chat-tool-calls-header,
|
||||
.chat-tool-call summary,
|
||||
.chat-tool-call-content,
|
||||
.chat-tool-call-preview,
|
||||
.chat-tool-call-value {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.chat-tool-call summary {
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.chat-tool-call-content {
|
||||
margin: var(--space-xs);
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.chat-tool-call-status-text {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -86,6 +86,8 @@ const MAX_REFERENCED_FILE_SIZE = 50 * 1024;
|
||||
export type ChatStreamEvent =
|
||||
| { type: "thinking"; data: string }
|
||||
| { type: "text"; data: string }
|
||||
| { type: "tool_start"; data: { toolName: string; args?: Record<string, unknown> } }
|
||||
| { type: "tool_end"; data: { toolName: string; isError: boolean; result?: unknown } }
|
||||
| { type: "done"; data: { messageId: string } }
|
||||
| { type: "error"; data: string };
|
||||
|
||||
@@ -518,6 +520,14 @@ export class ChatManager {
|
||||
let agentResult: AgentResult | undefined;
|
||||
let accumulatedThinking = "";
|
||||
let accumulatedText = "";
|
||||
type ToolCallRecord = {
|
||||
toolName: string;
|
||||
args?: Record<string, unknown>;
|
||||
isError: boolean;
|
||||
result?: unknown;
|
||||
};
|
||||
const toolCallsAccum: ToolCallRecord[] = [];
|
||||
const pendingToolStarts = new Map<string, Array<{ toolName: string; args?: Record<string, unknown> }>>();
|
||||
|
||||
try {
|
||||
// Validate session exists
|
||||
@@ -670,6 +680,35 @@ export class ChatManager {
|
||||
data: delta,
|
||||
});
|
||||
},
|
||||
onToolStart: (name: string, args?: Record<string, unknown>) => {
|
||||
const pendingForTool = pendingToolStarts.get(name) ?? [];
|
||||
pendingForTool.push({ toolName: name, args });
|
||||
pendingToolStarts.set(name, pendingForTool);
|
||||
|
||||
chatStreamManager.broadcast(sessionId, {
|
||||
type: "tool_start",
|
||||
data: { toolName: name, args },
|
||||
});
|
||||
},
|
||||
onToolEnd: (name: string, isError: boolean, result?: unknown) => {
|
||||
const pendingForTool = pendingToolStarts.get(name);
|
||||
const pendingStart = pendingForTool?.pop();
|
||||
if (pendingForTool && pendingForTool.length === 0) {
|
||||
pendingToolStarts.delete(name);
|
||||
}
|
||||
|
||||
toolCallsAccum.push({
|
||||
toolName: name,
|
||||
args: pendingStart?.args,
|
||||
isError,
|
||||
result,
|
||||
});
|
||||
|
||||
chatStreamManager.broadcast(sessionId, {
|
||||
type: "tool_end",
|
||||
data: { toolName: name, isError, result },
|
||||
});
|
||||
},
|
||||
});
|
||||
this.activeGenerations.set(sessionId, { abortController, agentResult });
|
||||
|
||||
@@ -714,6 +753,7 @@ export class ChatManager {
|
||||
role: "assistant",
|
||||
content: finalResponseText,
|
||||
thinkingOutput: accumulatedThinking || undefined,
|
||||
metadata: toolCallsAccum.length > 0 ? { toolCalls: toolCallsAccum } : undefined,
|
||||
});
|
||||
|
||||
// Broadcast done event
|
||||
@@ -733,13 +773,16 @@ export class ChatManager {
|
||||
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
|
||||
console.error(`[chat] Error in sendMessage for session ${sessionId}:`, err);
|
||||
|
||||
if (accumulatedText || accumulatedThinking) {
|
||||
if (accumulatedText || accumulatedThinking || toolCallsAccum.length > 0) {
|
||||
try {
|
||||
this.chatStore.addMessage(sessionId, {
|
||||
role: "assistant",
|
||||
content: accumulatedText || "(response interrupted before text generation)",
|
||||
thinkingOutput: accumulatedThinking || undefined,
|
||||
metadata: { interrupted: true },
|
||||
metadata: {
|
||||
interrupted: true,
|
||||
...(toolCallsAccum.length > 0 ? { toolCalls: toolCallsAccum } : {}),
|
||||
},
|
||||
});
|
||||
} catch (persistErr) {
|
||||
console.error(`[chat] Failed to persist partial response for session ${sessionId}:`, persistErr);
|
||||
|
||||
Reference in New Issue
Block a user