feat(chat): unify QuickChat backend path and consolidate render toggle

Backend
- chat.ts now routes both regular chat and QuickChat through
  createResolvedAgentSession instead of branching to createFnAgent for
  the no-runtime-hint case. This removes the divergent path where
  pi-ai's cleanupSessionResources(sessionId) could tear down resources
  the next generation depends on.
- sendMessage's finally only disposes the agent if it still owns the
  activeGenerations slot. A newer generation that has pre-empted us
  cleans up its own agent in its own finally — disposing here would
  yank the underlying CLI process out from under it.
- __setCreateFnAgent test helper now mirrors its mock into the
  createResolvedAgentSession slot so existing test setups still work
  after the unification.

Frontend
- Extract createChatStreamHandlers (RAF coalescing, accumulators,
  tool-call dedup, fallback handling) — useChat and useQuickChat were
  duplicating ~85 LOC each. Both now compose the shared factory.
- Move shared chat types into chatTypes.ts. The hooks re-export them
  for backward compatibility with existing consumers.
- Removed per-message Markdown/plain-text eye toggles. A single
  thread-level toggle in the chat header now flips every assistant
  bubble (including the streaming one) between rendered Markdown and
  plain text.
- Model-only chats hide the per-message agent identity row entirely;
  the model name is already in the thread header.

Tests
- Updated ChatView tests to reflect the new render-toggle contract
  (single header toggle drives all bubbles) and the model-only avatar
  suppression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-05 11:04:48 -07:00
parent 02eb1c5b30
commit 32c19af7d3
9 changed files with 491 additions and 373 deletions

View File

@@ -0,0 +1,9 @@
---
"@runfusion/fusion": patch
---
Fix Quick Chat backend divergence and consolidate the chat render-mode toggle.
- Backend: Quick Chat and regular chat now go through a single agent-creation path (`createResolvedAgentSession`), eliminating the `createFnAgent` branch where pi-ai's `cleanupSessionResources(sessionId)` could tear down resources still in use by a newer generation. The `sendMessage` `finally` only disposes the agent if it still owns the `activeGenerations` slot, so a pre-empted generation no longer rips state out from under its successor.
- Frontend: extracted the SSE streaming-handler factory shared between `useChat` and `useQuickChat` (RAF coalescing, accumulators, tool-call dedup, fallback handling) into `createChatStreamHandlers`. Both hooks now compose it instead of duplicating ~85 LOC each.
- UX: removed per-message Markdown/plain-text eye toggles. A single thread-level toggle now lives in the chat header and flips every assistant bubble (including the streaming one) between rendered Markdown and plain text. Model-only chats also drop their per-message agent-identity row — the model is shown once in the thread header.

View File

@@ -215,10 +215,40 @@
} }
.chat-thread-header-new-chat { .chat-thread-header-new-chat {
margin-left: auto;
flex-shrink: 0; flex-shrink: 0;
} }
/* Single thread-wide markdown / plain-text toggle, anchored to the right of
* the header next to "New Chat". Replaces the per-message eye toggle that
* used to live inside every assistant bubble. */
.chat-thread-header-render-toggle {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(var(--space-md) * 3);
height: calc(var(--space-md) * 3);
min-width: calc(var(--space-md) * 3);
min-height: calc(var(--space-md) * 3);
padding: 0;
border: none;
border-radius: 4px;
color: var(--text-muted);
background: transparent;
cursor: pointer;
outline: none;
transition: background var(--transition-fast), color var(--transition-fast);
}
.chat-thread-header-render-toggle:hover {
background: var(--bg-hover, var(--bg-secondary));
color: var(--text);
}
.chat-thread-header-render-toggle--plain {
color: var(--text);
}
/* Messages */ /* Messages */
.chat-messages { .chat-messages {
flex: 1; flex: 1;
@@ -262,6 +292,15 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
/* Model-only chats hide the per-message agent identity. The toggle still
* needs a place to live, so collapse the avatar row to just the toggle on
* the right edge instead of a full identity strip. */
.chat-message-avatar.chat-message-avatar--toolbar-only {
margin-bottom: 0;
min-height: 0;
justify-content: flex-end;
}
.chat-message-render-toggle { .chat-message-render-toggle {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@@ -541,13 +541,24 @@ function NewChatDialog({ projectId, onClose, onCreate }: NewChatDialogProps) {
interface ChatMessageItemProps { interface ChatMessageItemProps {
message: ChatMessageInfo; message: ChatMessageInfo;
/**
* When true, render assistant message content as plain text instead of
* Markdown. The per-message eye toggle has been removed in favor of a
* single thread-level toggle in the chat header, so this is a global
* mirror of that header state.
*/
forcePlain: boolean; forcePlain: boolean;
agentName: string; agentName: string;
/**
* Hide the per-message agent identity (icon + name + model tag) on
* assistant bubbles. In model-only chats the agent identity *is* the
* active model and it's already shown in the thread header.
*/
hideAssistantIdentity: boolean;
showAssistantModelTag: boolean; showAssistantModelTag: boolean;
activeModelTag: string | null; activeModelTag: string | null;
activeSessionId: string | null; activeSessionId: string | null;
mentionAgentsByName: Map<string, Agent>; mentionAgentsByName: Map<string, Agent>;
onToggleRender: (id: string) => void;
} }
// Renders a single chat message bubble. Memoized so the streaming bubble's // Renders a single chat message bubble. Memoized so the streaming bubble's
@@ -557,11 +568,11 @@ const ChatMessageItem = memo(function ChatMessageItem({
message, message,
forcePlain, forcePlain,
agentName, agentName,
hideAssistantIdentity,
showAssistantModelTag, showAssistantModelTag,
activeModelTag, activeModelTag,
activeSessionId, activeSessionId,
mentionAgentsByName, mentionAgentsByName,
onToggleRender,
}: ChatMessageItemProps) { }: ChatMessageItemProps) {
const isAssistantMessage = message.role === "assistant"; const isAssistantMessage = message.role === "assistant";
@@ -659,20 +670,11 @@ const ChatMessageItem = memo(function ChatMessageItem({
className={`chat-message chat-message--${message.role}`} className={`chat-message chat-message--${message.role}`}
data-testid={`chat-message-${message.id}`} data-testid={`chat-message-${message.id}`}
> >
{isAssistantMessage && ( {isAssistantMessage && !hideAssistantIdentity && (
<div className="chat-message-avatar"> <div className="chat-message-avatar">
<Bot size={14} /> <Bot size={14} />
<span>{agentName}</span> <span>{agentName}</span>
{showAssistantModelTag && activeModelTag && <span className="chat-model-tag">{activeModelTag}</span>} {showAssistantModelTag && activeModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
<button
type="button"
className={`chat-message-render-toggle${forcePlain ? " chat-message-render-toggle--plain" : ""}`}
data-testid="chat-message-render-toggle"
aria-label={forcePlain ? "Show rendered markdown" : "Show plain text"}
onClick={() => onToggleRender(message.id)}
>
{forcePlain ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div> </div>
)} )}
{isAssistantMessage {isAssistantMessage
@@ -730,7 +732,11 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const [mentionPopupVisible, setMentionPopupVisible] = useState(false); const [mentionPopupVisible, setMentionPopupVisible] = useState(false);
const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0); const [mentionHighlightIndex, setMentionHighlightIndex] = useState(0);
const [mentionStartPos, setMentionStartPos] = useState(-1); const [mentionStartPos, setMentionStartPos] = useState(-1);
const [plainTextMessageIds, setPlainTextMessageIds] = useState<Set<string>>(() => new Set()); // Single thread-wide toggle: when true, all assistant content (including the
// streaming bubble) renders as plain text instead of Markdown. Replaces the
// earlier per-message toggle so the chat header owns this control instead
// of every reply having its own button.
const [showAllAsPlain, setShowAllAsPlain] = useState(false);
// Attachment state mirrors QuickEntryBox: pending files selected before send. // Attachment state mirrors QuickEntryBox: pending files selected before send.
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]); const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@@ -1479,22 +1485,25 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
? (activeModelTag ?? "Fusion") ? (activeModelTag ?? "Fusion")
: (activeSession?.agentId?.slice(0, 30) ?? "Fusion")); : (activeSession?.agentId?.slice(0, 30) ?? "Fusion"));
const showAssistantModelTag = Boolean(activeModelTag && activeModelTag !== agentName); // The model tag is already visible in the thread header — repeating it on
// every assistant message is noise. Keep it suppressed for regular chat
// (real agent name is the identity); QuickChat already collapses the tag
// because its `agentName` IS the model tag, so the per-message slot was
// always empty there too.
const showAssistantModelTag = false;
// In model-only chats (no real agent picked) the agent identity *is* the
// model name, which is already in the thread header. Repeating it on every
// assistant bubble is noise. Hide the per-message identity row entirely;
// the render-mode toggle still appears in a slim toolbar.
const hideAssistantIdentity = activeSession?.agentId === FN_AGENT_ID;
const pendingPreview = pendingMessage.length > 50 const pendingPreview = pendingMessage.length > 50
? `${pendingMessage.slice(0, 50)}` ? `${pendingMessage.slice(0, 50)}`
: pendingMessage; : pendingMessage;
const toggleMessageRenderMode = useCallback((messageId: string) => { const toggleAllAsPlain = useCallback(() => {
setPlainTextMessageIds((current) => { setShowAllAsPlain((value) => !value);
const next = new Set(current);
if (next.has(messageId)) {
next.delete(messageId);
} else {
next.add(messageId);
}
return next;
});
}, []); }, []);
const renderAssistantContent = useCallback( const renderAssistantContent = useCallback(
@@ -1677,6 +1686,17 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
<Bot size={16} /> <Bot size={16} />
<span className="chat-thread-header-title">{threadHeaderTitle}</span> <span className="chat-thread-header-title">{threadHeaderTitle}</span>
{showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>} {showThreadHeaderModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
{hasThreadInView && (
<button
type="button"
className={`chat-thread-header-render-toggle${showAllAsPlain ? " chat-thread-header-render-toggle--plain" : ""}`}
data-testid="chat-thread-render-toggle"
aria-label={showAllAsPlain ? "Show all messages as rendered Markdown" : "Show all messages as plain text"}
onClick={toggleAllAsPlain}
>
{showAllAsPlain ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
)}
{!isMobile && ( {!isMobile && (
<button <button
className="btn btn-sm btn-primary chat-thread-header-new-chat" className="btn btn-sm btn-primary chat-thread-header-new-chat"
@@ -1699,32 +1719,25 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
<ChatMessageItem <ChatMessageItem
key={message.id} key={message.id}
message={message} message={message}
forcePlain={plainTextMessageIds.has(message.id)} forcePlain={showAllAsPlain}
agentName={agentName} agentName={agentName}
hideAssistantIdentity={hideAssistantIdentity}
showAssistantModelTag={showAssistantModelTag} showAssistantModelTag={showAssistantModelTag}
activeModelTag={activeModelTag} activeModelTag={activeModelTag}
activeSessionId={activeSession?.id ?? null} activeSessionId={activeSession?.id ?? null}
mentionAgentsByName={mentionAgentsByName} mentionAgentsByName={mentionAgentsByName}
onToggleRender={toggleMessageRenderMode}
/> />
))} ))}
<div className="chat-message chat-message--assistant chat-message--streaming"> <div className="chat-message chat-message--assistant chat-message--streaming">
{!hideAssistantIdentity && (
<div className="chat-message-avatar"> <div className="chat-message-avatar">
<Bot size={14} /> <Bot size={14} />
<span>{agentName}</span> <span>{agentName}</span>
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>} {showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
<button
type="button"
className={`chat-message-render-toggle${plainTextMessageIds.has("__streaming__") ? " chat-message-render-toggle--plain" : ""}`}
data-testid="chat-message-render-toggle"
aria-label={plainTextMessageIds.has("__streaming__") ? "Show rendered markdown" : "Show plain text"}
onClick={() => toggleMessageRenderMode("__streaming__")}
>
{plainTextMessageIds.has("__streaming__") ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div> </div>
)}
{streamingText ? ( {streamingText ? (
renderAssistantContent(streamingText, plainTextMessageIds.has("__streaming__")) renderAssistantContent(streamingText, showAllAsPlain)
) : ( ) : (
<div className="chat-message-content chat-message-content--waiting"> <div className="chat-message-content chat-message-content--waiting">
{streamingThinking ? "Thinking…" : "Connecting…"} {streamingThinking ? "Thinking…" : "Connecting…"}
@@ -1758,13 +1771,13 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
<ChatMessageItem <ChatMessageItem
key={message.id} key={message.id}
message={message} message={message}
forcePlain={plainTextMessageIds.has(message.id)} forcePlain={showAllAsPlain}
agentName={agentName} agentName={agentName}
hideAssistantIdentity={hideAssistantIdentity}
showAssistantModelTag={showAssistantModelTag} showAssistantModelTag={showAssistantModelTag}
activeModelTag={activeModelTag} activeModelTag={activeModelTag}
activeSessionId={activeSession?.id ?? null} activeSessionId={activeSession?.id ?? null}
mentionAgentsByName={mentionAgentsByName} mentionAgentsByName={mentionAgentsByName}
onToggleRender={toggleMessageRenderMode}
/> />
))} ))}
</> </>

View File

@@ -445,7 +445,7 @@ describe("ChatView", () => {
expect(screen.queryByTestId("chat-render-mode-plain")).not.toBeInTheDocument(); expect(screen.queryByTestId("chat-render-mode-plain")).not.toBeInTheDocument();
}); });
it("renders per-message eye toggles for assistant bubbles on desktop and isolates toggles by message", async () => { it("thread-header toggle flips every assistant bubble between rendered Markdown and plain text", async () => {
setupMockChat({ setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [ messages: [
@@ -458,24 +458,27 @@ describe("ChatView", () => {
const firstBubble = screen.getByTestId("chat-message-msg-001"); const firstBubble = screen.getByTestId("chat-message-msg-001");
const secondBubble = screen.getByTestId("chat-message-msg-002"); const secondBubble = screen.getByTestId("chat-message-msg-002");
const [firstToggle, secondToggle] = screen.getAllByTestId("chat-message-render-toggle"); const headerToggle = screen.getByTestId("chat-thread-render-toggle");
expect(firstToggle).toBeInTheDocument(); // Per-message toggles were intentionally removed; only the single
expect(secondToggle).toBeInTheDocument(); // thread-level toggle should exist.
expect(screen.queryAllByTestId("chat-message-render-toggle")).toHaveLength(0);
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument(); expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument(); expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
await userEvent.click(firstToggle); await userEvent.click(headerToggle);
expect(within(firstBubble).getByText(/\*\*First\*\* item/)).toBeInTheDocument(); expect(within(firstBubble).getByText(/\*\*First\*\* item/)).toBeInTheDocument();
expect(within(firstBubble).queryByText("First", { selector: "strong" })).toBeNull(); expect(within(firstBubble).queryByText("First", { selector: "strong" })).toBeNull();
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument(); expect(within(secondBubble).getByText(/\*\*Second\*\* item/)).toBeInTheDocument();
expect(within(secondBubble).queryByText("Second", { selector: "strong" })).toBeNull();
await userEvent.click(firstToggle); await userEvent.click(headerToggle);
expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument(); expect(within(firstBubble).getByText("First", { selector: "strong" })).toBeInTheDocument();
expect(within(secondBubble).getByText("Second", { selector: "strong" })).toBeInTheDocument();
}); });
it("uses a dedicated streaming toggle sentinel without affecting persisted assistant messages", async () => { it("thread-header toggle also drives the streaming bubble", async () => {
setupMockChat({ setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "**Persisted**", createdAt: "2026-04-08T00:00:00.000Z" }], messages: [{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "**Persisted**", createdAt: "2026-04-08T00:00:00.000Z" }],
@@ -487,19 +490,19 @@ describe("ChatView", () => {
const persistedBubble = screen.getByTestId("chat-message-msg-001"); const persistedBubble = screen.getByTestId("chat-message-msg-001");
const streamingBubble = document.querySelector(".chat-message--streaming") as HTMLElement; const streamingBubble = document.querySelector(".chat-message--streaming") as HTMLElement;
const [persistedToggle, streamingToggle] = screen.getAllByTestId("chat-message-render-toggle"); const headerToggle = screen.getByTestId("chat-thread-render-toggle");
expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument(); expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument();
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument(); expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
await userEvent.click(streamingToggle); await userEvent.click(headerToggle);
expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument(); expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
await userEvent.click(persistedToggle);
expect(within(persistedBubble).getByText(/\*\*Persisted\*\*/)).toBeInTheDocument(); expect(within(persistedBubble).getByText(/\*\*Persisted\*\*/)).toBeInTheDocument();
expect(within(streamingBubble).getByText(/\*\*Live\*\* stream/)).toBeInTheDocument();
await userEvent.click(headerToggle);
expect(within(streamingBubble).getByText("Live", { selector: "strong" })).toBeInTheDocument();
expect(within(persistedBubble).getByText("Persisted", { selector: "strong" })).toBeInTheDocument();
}); });
it("renders tool calls from persisted messages", () => { it("renders tool calls from persisted messages", () => {
@@ -869,7 +872,10 @@ describe("ChatView", () => {
expect(within(avatar!).queryByText("Fusion")).not.toBeInTheDocument(); expect(within(avatar!).queryByText("Fusion")).not.toBeInTheDocument();
}); });
it("shows Fusion in assistant message avatar for fn agent sessions", () => { it("hides per-message assistant identity for fn agent (model-only) sessions", () => {
// Model-only chats use the active model as their identity, which is
// already shown in the thread header. We deliberately suppress the
// per-message avatar to avoid repeating it on every reply.
setupMockChat({ setupMockChat({
activeSession: { id: "session-001", agentId: "__fn_agent__", status: "active", title: "Fusion Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" }, activeSession: { id: "session-001", agentId: "__fn_agent__", status: "active", title: "Fusion Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
messages: [ messages: [
@@ -879,12 +885,11 @@ describe("ChatView", () => {
render(<ChatView projectId="proj-123" addToast={vi.fn()} />); render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null; const messageBubble = screen.getByTestId("chat-message-msg-001");
expect(avatar).toBeInTheDocument(); expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull();
expect(within(avatar!).getByText("Fusion")).toBeInTheDocument();
}); });
it("shows formatted model name in assistant message avatar for fn agent sessions", async () => { it("hides per-message assistant identity for fn agent (model-only) sessions even when a model is configured", async () => {
setupMockChat({ setupMockChat({
activeSession: { activeSession: {
id: "session-001", id: "session-001",
@@ -902,14 +907,12 @@ describe("ChatView", () => {
render(<ChatView projectId="proj-123" addToast={vi.fn()} />); render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null; const messageBubble = screen.getByTestId("chat-message-msg-001");
expect(avatar).toBeInTheDocument(); expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull();
// The model name still appears once in the thread header.
await waitFor(() => { await waitFor(() => {
expect(within(avatar!).getByText("Claude Sonnet 4.5")).toBeInTheDocument(); expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
}); });
expect(within(avatar!).queryByText("Fusion")).not.toBeInTheDocument();
expect(avatar?.querySelector(".chat-model-tag")).toBeNull();
}); });
it("shows resolved agent name in streaming assistant avatar", async () => { it("shows resolved agent name in streaming assistant avatar", async () => {
@@ -1858,7 +1861,11 @@ describe("ChatView", () => {
expect(modelTag).not.toBeInTheDocument(); expect(modelTag).not.toBeInTheDocument();
}); });
it("shows model tag in message avatar when non-fn session has model", () => { it("does not repeat the model tag in per-message avatars for non-fn sessions", () => {
// Per-message model tags were intentionally removed — the model is shown
// once in the thread header. The avatar should still render with the
// agent name (no agent identity collapse for real agents) but no model
// tag inside it.
setupMockChat({ setupMockChat({
activeSession: { activeSession: {
id: "session-001", id: "session-001",
@@ -1876,12 +1883,13 @@ describe("ChatView", () => {
render(<ChatView projectId="proj-123" addToast={vi.fn()} />); render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null; const messageBubble = screen.getByTestId("chat-message-msg-001");
const avatar = messageBubble.querySelector(".chat-message-avatar") as HTMLElement | null;
expect(avatar).toBeInTheDocument(); expect(avatar).toBeInTheDocument();
expect(avatar?.querySelector(".chat-model-tag")?.textContent).toContain("GPT"); expect(avatar?.querySelector(".chat-model-tag")).toBeNull();
}); });
it("does not show duplicate model tag in message avatar for fn agent sessions", () => { it("hides per-message identity entirely for fn agent (model-only) sessions even when model is set", () => {
setupMockChat({ setupMockChat({
activeSession: { activeSession: {
id: "session-001", id: "session-001",
@@ -1899,10 +1907,8 @@ describe("ChatView", () => {
render(<ChatView projectId="proj-123" addToast={vi.fn()} />); render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const avatar = document.querySelector(".chat-message-avatar") as HTMLElement | null; const messageBubble = screen.getByTestId("chat-message-msg-001");
expect(avatar).toBeInTheDocument(); expect(messageBubble.querySelector(".chat-message-avatar")).toBeNull();
expect(within(avatar!).getByText("GPT-4o")).toBeInTheDocument();
expect(avatar?.querySelector(".chat-model-tag")).toBeNull();
}); });
}); });

View File

@@ -0,0 +1,40 @@
/**
* Shared chat type definitions used by both `useChat` (full chat panel) and
* `useQuickChat` (FAB) plus the `createChatStreamHandlers` factory they
* compose. Keeping the types here lets the streaming-handler factory live in
* its own file without re-importing from one of the hooks (which would create
* an awkward parent→sibling dependency cycle).
*/
export interface ToolCallInfo {
toolName: string;
args?: Record<string, unknown>;
isError: boolean;
result?: unknown;
status: "running" | "completed";
}
export interface FallbackInfo {
primaryModel: string;
fallbackModel: string;
triggerPoint: "session-creation" | "prompt-time";
}
export interface ChatMessageInfo {
id: string;
sessionId: string;
role: "user" | "assistant" | "system";
content: string;
thinkingOutput?: string | null;
toolCalls?: ToolCallInfo[];
fallbackInfo?: FallbackInfo;
attachments?: Array<{
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
createdAt: string;
}>;
createdAt: string;
}

View File

@@ -0,0 +1,212 @@
import type { ChatMessage } from "@fusion/core";
import type { Dispatch, RefObject, SetStateAction } from "react";
import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
/**
* Inputs for the chat streaming-handler factory.
*
* The shared factory owns the per-stream accumulator state (text, thinking,
* tool calls, fallback info), the requestAnimationFrame coalescing of state
* updates, and the SSE event → state-setter wiring. Caller-specific behaviour
* for the terminal events (`onDone`, `onError`) and the optional
* `onFallbackSession` model-swap is provided through callbacks so that
* `useChat` and `useQuickChat` can plug in their own session-management
* semantics without re-implementing the streaming machinery.
*/
export interface CreateChatStreamHandlersOptions {
/** Active session id — used by `onFallbackSession` for parent-side updates. */
sessionId: string;
/** Optimistic temp id of the user message added before the stream started. */
tempUserMessageId: string;
/**
* The latest text/thinking/tool-call snapshots that are committed to React
* state. We pass setters (not values) so the factory can flush per-frame
* without rerunning the parent's effects.
*/
setStreamingText: Dispatch<SetStateAction<string>>;
setStreamingThinking: Dispatch<SetStateAction<string>>;
setStreamingToolCalls: Dispatch<SetStateAction<ToolCallInfo[]>>;
/**
* Caller-side `cancelStreamingFlushes` ref slot. The factory writes its own
* cancel function here so `stopStreaming` (in either parent hook) can call
* it to abort pending RAF flushes regardless of which sendMessage owns them.
*/
cancelStreamingFlushesRef: RefObject<(() => void) | null>;
/** Optional toast helper, used to surface fallback-model warnings + errors. */
addToast?: (message: string, level: "error" | "warning" | "success") => void;
/** Caller-supplied terminal handlers — bind in their own state setters. */
onDone: (data: {
messageId: string;
message?: ChatMessage;
accumulated: {
text: string;
thinking: string;
toolCalls: ToolCallInfo[];
fallbackInfo?: FallbackInfo;
};
}) => void;
onError: (data: string, tempUserMessageId: string) => void;
/**
* Fallback-model side effect for the parent (e.g. updating the session list
* or the active session's model fields). The factory still emits the toast.
*/
onFallbackSession?: (data: FallbackInfo, sessionId: string) => void;
}
export interface ChatStreamHandlers {
onThinking: (delta: string) => void;
onText: (delta: string) => void;
onToolStart: (data: { toolName: string; args?: Record<string, unknown> }) => void;
onToolEnd: (data: { toolName: string; isError: boolean; result?: unknown }) => void;
onFallback: (data: FallbackInfo) => void;
onDone: (data: { messageId: string; message?: ChatMessage }) => void;
onError: (data: string) => void;
}
export interface CreateChatStreamHandlersResult {
handlers: ChatStreamHandlers;
/** Cancel any pending RAF flushes for this stream. Idempotent. */
cancelFlushes: () => void;
}
/**
* Build the SSE handler bundle that `streamChatResponse` consumes. This is the
* portion of the chat send/stream flow that was identical between `useChat`
* and `useQuickChat`; extracting it keeps both hooks in sync when we tweak
* coalescing, tool-call dedup, fallback toasts, etc. The terminal events
* (`onDone`/`onError`) and parent-side fallback bookkeeping stay caller-owned
* because each hook handles message persistence and error recovery
* differently.
*
* The factory writes its `cancelFlushes` into `cancelStreamingFlushesRef.current`
* so the parent's `stopStreaming` can drain pending RAF callbacks before
* clearing transient streaming state — preventing a flushed delta from
* flashing back into the UI after a stop.
*/
export function createChatStreamHandlers(
options: CreateChatStreamHandlersOptions,
): CreateChatStreamHandlersResult {
const {
sessionId,
tempUserMessageId,
setStreamingText,
setStreamingThinking,
setStreamingToolCalls,
cancelStreamingFlushesRef,
addToast,
onDone,
onError,
onFallbackSession,
} = options;
let capturedText = "";
let capturedThinking = "";
let capturedToolCalls: ToolCallInfo[] = [];
let capturedFallbackInfo: FallbackInfo | undefined;
// Coalesce per-token state updates to one render per animation frame.
// ReactMarkdown re-parses the entire growing string on every render and
// every prior message also re-renders, so unthrottled setState here pegs
// the main thread on long replies.
let textRaf: number | null = null;
let thinkingRaf: number | null = null;
const flushText = (): void => {
textRaf = null;
setStreamingText(capturedText);
};
const flushThinking = (): void => {
thinkingRaf = null;
setStreamingThinking(capturedThinking);
};
const cancelFlushes = (): void => {
if (textRaf !== null) {
cancelAnimationFrame(textRaf);
textRaf = null;
}
if (thinkingRaf !== null) {
cancelAnimationFrame(thinkingRaf);
thinkingRaf = null;
}
};
cancelStreamingFlushesRef.current = cancelFlushes;
const handlers: ChatStreamHandlers = {
onThinking: (delta: string) => {
capturedThinking += delta;
if (thinkingRaf === null) {
thinkingRaf = requestAnimationFrame(flushThinking);
}
},
onText: (delta: string) => {
capturedText += delta;
if (textRaf === null) {
textRaf = requestAnimationFrame(flushText);
}
},
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);
},
onFallback: (data: FallbackInfo) => {
capturedFallbackInfo = data;
onFallbackSession?.(data, sessionId);
addToast?.(`Primary model unavailable. Switched to fallback ${data.fallbackModel}.`, "warning");
},
onDone: (data: { messageId: string; message?: ChatMessage }) => {
cancelFlushes();
onDone({
messageId: data.messageId,
message: data.message,
accumulated: {
text: capturedText,
thinking: capturedThinking,
toolCalls: capturedToolCalls,
fallbackInfo: capturedFallbackInfo,
},
});
},
onError: (data: string) => {
cancelFlushes();
onError(data, tempUserMessageId);
},
};
return { handlers, cancelFlushes };
}
export type { ChatMessageInfo };

View File

@@ -30,38 +30,11 @@ export interface ChatSessionInfo {
isGenerating?: boolean; isGenerating?: boolean;
} }
export interface ToolCallInfo { // Re-export shared chat types so existing consumers (`import { ChatMessageInfo } from "../hooks/useChat"`)
toolName: string; // keep working — single source of truth lives in chatTypes.ts.
args?: Record<string, unknown>; export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
isError: boolean; import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
result?: unknown; import { createChatStreamHandlers } from "./createChatStreamHandlers";
status: "running" | "completed";
}
export interface FallbackInfo {
primaryModel: string;
fallbackModel: string;
triggerPoint: "session-creation" | "prompt-time";
}
export interface ChatMessageInfo {
id: string;
sessionId: string;
role: "user" | "assistant" | "system";
content: string;
thinkingOutput?: string | null;
toolCalls?: ToolCallInfo[];
fallbackInfo?: FallbackInfo;
attachments?: Array<{
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
createdAt: string;
}>;
createdAt: string;
}
export interface UseChatReturn { export interface UseChatReturn {
// Session state // Session state
@@ -547,127 +520,37 @@ export function useChat(
setStreamingToolCalls([]); setStreamingToolCalls([]);
setIsStreaming(true); setIsStreaming(true);
// Accumulate streaming text and tool calls in local variables const { handlers } = createChatStreamHandlers({
let capturedText = ""; sessionId: activeSession.id,
let capturedThinking = ""; tempUserMessageId: tempId,
let capturedToolCalls: ToolCallInfo[] = []; setStreamingText,
let capturedFallbackInfo: FallbackInfo | undefined; setStreamingThinking,
setStreamingToolCalls,
// Coalesce per-token state updates to one render per animation frame. cancelStreamingFlushesRef,
// ReactMarkdown re-parses the entire growing string on every render and addToast,
// every prior message also re-renders, so unthrottled updates pin the onFallbackSession: (data, sessionId) => {
// main thread for long replies.
let textRaf: number | null = null;
let thinkingRaf: number | null = null;
const flushText = () => {
textRaf = null;
setStreamingText(capturedText);
};
const flushThinking = () => {
thinkingRaf = null;
setStreamingThinking(capturedThinking);
};
const cancelStreamingFlushes = () => {
if (textRaf !== null) {
cancelAnimationFrame(textRaf);
textRaf = null;
}
if (thinkingRaf !== null) {
cancelAnimationFrame(thinkingRaf);
thinkingRaf = null;
}
};
cancelStreamingFlushesRef.current = cancelStreamingFlushes;
const textHandlers = {
onThinking: (data: string) => {
capturedThinking += data;
if (thinkingRaf === null) {
thinkingRaf = requestAnimationFrame(flushThinking);
}
},
onText: (data: string) => {
capturedText += data;
if (textRaf === null) {
textRaf = requestAnimationFrame(flushText);
}
},
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);
},
onFallback: (data: FallbackInfo) => {
capturedFallbackInfo = data;
const nextModel = parseModelDescriptor(data.fallbackModel); const nextModel = parseModelDescriptor(data.fallbackModel);
setSessions((prev) => prev.map((session) => setSessions((prev) => prev.map((session) =>
session.id === activeSession.id session.id === sessionId ? { ...session, ...nextModel } : session,
? {
...session,
...nextModel,
}
: session,
)); ));
setActiveSession((prev) => prev && prev.id === activeSession.id setActiveSession((prev) => prev && prev.id === sessionId ? { ...prev, ...nextModel } : prev);
? {
...prev,
...nextModel,
}
: prev);
addToast?.(`Primary model unavailable. Switched to fallback ${data.fallbackModel}.`, "warning");
}, },
onDone: (data: { messageId: string; message?: ChatMessage }) => { onDone: ({ messageId, message: finalMessage, accumulated }) => {
cancelStreamingFlushes();
const finalMessage = data.message;
const assistantMessage: ChatMessageInfo = finalMessage const assistantMessage: ChatMessageInfo = finalMessage
? mapChatMessageToInfo(finalMessage) ? mapChatMessageToInfo(finalMessage)
: { : {
id: data.messageId || `msg-${Date.now()}`, id: messageId || `msg-${Date.now()}`,
sessionId: activeSession.id, sessionId: activeSession.id,
role: "assistant", role: "assistant",
content: capturedText, content: accumulated.text,
thinkingOutput: capturedThinking, thinkingOutput: accumulated.thinking,
toolCalls: capturedToolCalls.length > 0 ? capturedToolCalls : undefined, toolCalls: accumulated.toolCalls.length > 0 ? accumulated.toolCalls : undefined,
fallbackInfo: capturedFallbackInfo, fallbackInfo: accumulated.fallbackInfo,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
// Track this message ID so SSE handler skips it if event arrives first // Track this message ID so the SSE chatMessageAdded handler skips it
// if the broadcast event arrives before our optimistic add settles.
streamingMessageIdsRef.current.add(assistantMessage.id); streamingMessageIdsRef.current.add(assistantMessage.id);
// Preserve user message and add assistant message // Preserve user message and add assistant message
@@ -693,9 +576,8 @@ export function useChat(
sendMessage(queuedMessage); sendMessage(queuedMessage);
} }
}, },
onError: (data: string) => { onError: (data, tempUserMessageId) => {
cancelStreamingFlushes(); setMessages((prev) => prev.filter((m) => m.id !== tempUserMessageId));
setMessages((prev) => prev.filter((m) => m.id !== tempId));
setStreamingText(""); setStreamingText("");
setStreamingThinking(""); setStreamingThinking("");
setStreamingToolCalls([]); setStreamingToolCalls([]);
@@ -713,9 +595,9 @@ export function useChat(
} }
} }
}, },
}; });
streamRef.current = streamChatResponse(activeSession.id, content, textHandlers, attachments, projectId); streamRef.current = streamChatResponse(activeSession.id, content, handlers, attachments, projectId);
}, },
[activeSession, isStreaming, projectId, refreshSessions, addToast], [activeSession, isStreaming, projectId, refreshSessions, addToast],
); );

View File

@@ -12,30 +12,14 @@ import {
export const FN_AGENT_ID = "__fn_agent__"; export const FN_AGENT_ID = "__fn_agent__";
export interface ToolCallInfo { // Re-export shared chat types so existing consumers keep working — single
toolName: string; // source of truth lives in chatTypes.ts and is shared with useChat.
args?: Record<string, unknown>; // Note: useQuickChat's previous local `ChatMessageInfo` lacked the
isError: boolean; // `attachments` field; the shared type adds it (a strict superset), which is
result?: unknown; // safe for callers that ignore it.
status: "running" | "completed"; export type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
} import type { ChatMessageInfo, FallbackInfo, ToolCallInfo } from "./chatTypes";
import { createChatStreamHandlers } from "./createChatStreamHandlers";
export interface FallbackInfo {
primaryModel: string;
fallbackModel: string;
triggerPoint: "session-creation" | "prompt-time";
}
export interface ChatMessageInfo {
id: string;
sessionId: string;
role: "user" | "assistant" | "system";
content: string;
thinkingOutput?: string | null;
toolCalls?: ToolCallInfo[];
fallbackInfo?: FallbackInfo;
createdAt: string;
}
interface ModelSelection { interface ModelSelection {
modelProvider?: string; modelProvider?: string;
@@ -559,121 +543,32 @@ export function useQuickChat(
setStreamingToolCalls([]); setStreamingToolCalls([]);
setIsStreaming(true); setIsStreaming(true);
// Accumulate streaming text and tool calls in local variables const { handlers } = createChatStreamHandlers({
let capturedText = ""; sessionId: activeSession.id,
let capturedThinking = ""; tempUserMessageId: tempId,
let capturedToolCalls: ToolCallInfo[] = []; setStreamingText,
let capturedFallbackInfo: FallbackInfo | undefined; setStreamingThinking,
setStreamingToolCalls,
// Coalesce per-token state updates to one render per animation frame — cancelStreamingFlushesRef,
// unthrottled setStreamingText pegs the main thread on long replies. addToast,
let textRaf: number | null = null; onFallbackSession: (data, sessionId) => {
let thinkingRaf: number | null = null;
const flushText = () => {
textRaf = null;
setStreamingText(capturedText);
};
const flushThinking = () => {
thinkingRaf = null;
setStreamingThinking(capturedThinking);
};
const cancelStreamingFlushes = () => {
if (textRaf !== null) {
cancelAnimationFrame(textRaf);
textRaf = null;
}
if (thinkingRaf !== null) {
cancelAnimationFrame(thinkingRaf);
thinkingRaf = null;
}
};
cancelStreamingFlushesRef.current = cancelStreamingFlushes;
const textHandlers = {
onThinking: (data: string) => {
capturedThinking += data;
if (thinkingRaf === null) {
thinkingRaf = requestAnimationFrame(flushThinking);
}
},
onText: (data: string) => {
capturedText += data;
if (textRaf === null) {
textRaf = requestAnimationFrame(flushText);
}
},
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);
},
onFallback: (data: FallbackInfo) => {
capturedFallbackInfo = data;
const nextModel = parseModelDescriptor(data.fallbackModel); const nextModel = parseModelDescriptor(data.fallbackModel);
setSessions((prev) => prev.map((session) => setSessions((prev) => prev.map((session) =>
session.id === activeSession.id session.id === sessionId ? { ...session, ...nextModel } : session,
? {
...session,
...nextModel,
}
: session,
)); ));
setActiveSession((prev) => prev && prev.id === activeSession.id setActiveSession((prev) => prev && prev.id === sessionId ? { ...prev, ...nextModel } : prev);
? {
...prev,
...nextModel,
}
: prev);
addToast?.(`Primary model unavailable. Switched to fallback ${data.fallbackModel}.`, "warning");
}, },
onDone: (data: { messageId: string; message?: ChatMessage }) => { onDone: ({ messageId, message: finalMessage, accumulated }) => {
cancelStreamingFlushes();
const finalMessage = data.message;
const assistantMessage: ChatMessageInfo = finalMessage const assistantMessage: ChatMessageInfo = finalMessage
? mapChatMessageToInfo(finalMessage) ? mapChatMessageToInfo(finalMessage)
: { : {
id: data.messageId || `msg-${Date.now()}`, id: messageId || `msg-${Date.now()}`,
sessionId: activeSession.id, sessionId: activeSession.id,
role: "assistant", role: "assistant",
content: capturedText, content: accumulated.text,
thinkingOutput: capturedThinking || undefined, thinkingOutput: accumulated.thinking || undefined,
toolCalls: capturedToolCalls.length > 0 ? capturedToolCalls : undefined, toolCalls: accumulated.toolCalls.length > 0 ? accumulated.toolCalls : undefined,
fallbackInfo: capturedFallbackInfo, fallbackInfo: accumulated.fallbackInfo,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
@@ -695,8 +590,7 @@ export function useQuickChat(
void sendMessage(queuedMessage); void sendMessage(queuedMessage);
} }
}, },
onError: (data: string) => { onError: (data) => {
cancelStreamingFlushes();
setStreamingText(""); setStreamingText("");
setStreamingThinking(""); setStreamingThinking("");
setStreamingToolCalls([]); setStreamingToolCalls([]);
@@ -718,9 +612,9 @@ export function useQuickChat(
void reloadMessages(); void reloadMessages();
}, },
}; });
streamRef.current = streamChatResponse(activeSession.id, content, textHandlers, attachments, projectId); streamRef.current = streamChatResponse(activeSession.id, content, handlers, attachments, projectId);
}); });
// Preserve rejection semantics for awaiters while preventing unhandled rejection noise // Preserve rejection semantics for awaiters while preventing unhandled rejection noise

View File

@@ -1013,17 +1013,21 @@ export class ChatManager {
}, },
}; };
// Single agent-creation path for both regular chat and QuickChat. When
// the chat is bound to an agent that declares a runtime hint we pass it
// through; when there's no agent (e.g. QuickChat's model-only mode) or
// no hint, `createResolvedAgentSession` falls back to the default
// runtime via `resolveRuntime`. This avoids the previous divergence
// where QuickChat went through `createFnAgent` and hit pi-ai's shared
// `cleanupSessionResources(sessionId)` tear-down across overlapping
// sessions opened from the same CLI session file.
const agentRuntimeHint = agent ? extractRuntimeHint(agent.runtimeConfig) : undefined; const agentRuntimeHint = agent ? extractRuntimeHint(agent.runtimeConfig) : undefined;
if (agentRuntimeHint) {
agentResult = await createResolvedAgentSession({ agentResult = await createResolvedAgentSession({
sessionPurpose: "executor", sessionPurpose: "executor",
runtimeHint: agentRuntimeHint, ...(agentRuntimeHint ? { runtimeHint: agentRuntimeHint } : {}),
pluginRunner: this.pluginRunner, pluginRunner: this.pluginRunner,
...sessionOptions, ...sessionOptions,
}); });
} else {
agentResult = await createFnAgent(sessionOptions);
}
this.activeGenerations.set(sessionId, { abortController, agentResult, generationId }); this.activeGenerations.set(sessionId, { abortController, agentResult, generationId });
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
@@ -1143,16 +1147,29 @@ export class ChatManager {
data: errorMessage, data: errorMessage,
}, broadcastOptions); }, broadcastOptions);
} finally { } finally {
// Only clear the active-generation slot if it still belongs to us. If a newer // Only clear the active-generation slot if it still belongs to us. If a
// sendMessage pre-empted us via beginGeneration, the slot now holds that newer // newer sendMessage pre-empted us via beginGeneration, the slot now holds
// generation's controller and must not be deleted by our cleanup. // that newer generation's controller and must not be deleted by us.
const current = this.activeGenerations.get(sessionId); const current = this.activeGenerations.get(sessionId);
if (current?.generationId === generationId) { const stillOwnsSlot = current?.generationId === generationId;
if (stillOwnsSlot) {
this.activeGenerations.delete(sessionId); this.activeGenerations.delete(sessionId);
} }
// Always dispose agent session // Dispose the agent session — but ONLY when we still own the slot.
if (agentResult) { //
// pi-ai's `cleanupSessionResources(sessionId)` fires globally-registered
// cleanup callbacks keyed by sessionId, and two agents opened from the
// same CLI session file share that sessionId. If a newer generation has
// taken over for the same chat session, disposing this (older) agent
// tears down resources the newer agent is actively using — the model
// produces no output and the next turn looks like a silent failure.
//
// The newer generation will dispose its own agent in its own finally.
// The older agent's resources are largely garbage-collectible without
// an explicit dispose; the small leak per pre-empted generation is
// worth avoiding the cross-generation tear-down.
if (stillOwnsSlot && agentResult) {
try { try {
agentResult.session.dispose?.(); agentResult.session.dispose?.();
} catch (err) { } catch (err) {
@@ -1209,6 +1226,12 @@ export class ChatManager {
*/ */
export function __setCreateFnAgent(mock: typeof createFnAgent): void { export function __setCreateFnAgent(mock: typeof createFnAgent): void {
createFnAgent = mock; createFnAgent = mock;
// chat.ts now routes both regular chat and QuickChat through
// `createResolvedAgentSession`, which would normally bypass this mock and
// hit the real engine. Mirror the same fake into the resolved-session slot
// so existing test setups that only call `__setCreateFnAgent` continue to
// work.
createResolvedAgentSession = (async (options: any) => mock(options)) as typeof createResolvedAgentSession;
} }
/** /**