singularity-forge/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts
Lex Christopherson ef310574da fix: Remove premature pendingTools.delete in webSearchResult handler (#2743)
The webSearchResult branch deleted entries from pendingTools after rendering,
which removed the duplicate-prevention guard. Subsequent streaming tokens
re-iterated content blocks, re-created the serverToolUse component, and
re-rendered the search result — producing 18+ duplicate blocks.

The message_end handler already calls pendingTools.clear(), so the explicit
deletes were unnecessary and harmful.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:03:07 -06:00

341 lines
11 KiB
TypeScript

import { Loader, Spacer, Text } from "@gsd/pi-tui";
import type { InteractiveModeEvent, InteractiveModeStateHost } from "../interactive-mode-state.js";
import { theme } from "../theme/theme.js";
import { AssistantMessageComponent } from "../components/assistant-message.js";
import { ToolExecutionComponent } from "../components/tool-execution.js";
import { appKey } from "../components/keybinding-hints.js";
export async function handleAgentEvent(host: InteractiveModeStateHost & {
init: () => Promise<void>;
getMarkdownThemeWithSettings: () => any;
addMessageToChat: (message: any, options?: any) => void;
formatWebSearchResult: (content: unknown) => string;
getRegisteredToolDefinition: (toolName: string) => any;
checkShutdownRequested: () => Promise<void>;
rebuildChatFromMessages: () => void;
flushCompactionQueue: (options?: { willRetry?: boolean }) => Promise<void>;
showStatus: (message: string) => void;
showError: (message: string) => void;
updatePendingMessagesDisplay: () => void;
updateTerminalTitle: () => void;
updateEditorBorderColor: () => void;
pendingMessagesContainer: { clear: () => void };
}, event: InteractiveModeEvent): Promise<void> {
if (!host.isInitialized) {
await host.init();
}
host.footer.invalidate();
switch (event.type) {
case "session_state_changed":
switch (event.reason) {
case "new_session":
case "switch_session":
case "fork":
host.streamingComponent = undefined;
host.streamingMessage = undefined;
host.pendingTools.clear();
host.pendingMessagesContainer.clear();
host.compactionQueuedMessages = [];
host.rebuildChatFromMessages();
host.updatePendingMessagesDisplay();
host.updateTerminalTitle();
host.updateEditorBorderColor();
host.ui.requestRender();
return;
case "set_session_name":
host.updateTerminalTitle();
host.ui.requestRender();
return;
case "set_model":
case "set_thinking_level":
host.updateEditorBorderColor();
host.ui.requestRender();
return;
default:
host.ui.requestRender();
return;
}
case "agent_start":
if (host.retryEscapeHandler) {
host.defaultEditor.onEscape = host.retryEscapeHandler;
host.retryEscapeHandler = undefined;
}
if (host.retryLoader) {
host.retryLoader.stop();
host.retryLoader = undefined;
}
if (host.loadingAnimation) {
host.loadingAnimation.stop();
}
host.statusContainer.clear();
host.loadingAnimation = new Loader(
host.ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
host.defaultWorkingMessage,
);
host.statusContainer.addChild(host.loadingAnimation);
if (host.pendingWorkingMessage !== undefined) {
if (host.pendingWorkingMessage) {
host.loadingAnimation.setMessage(host.pendingWorkingMessage);
}
host.pendingWorkingMessage = undefined;
}
host.ui.requestRender();
break;
case "message_start":
if (event.message.role === "custom") {
host.addMessageToChat(event.message);
host.ui.requestRender();
} else if (event.message.role === "user") {
host.addMessageToChat(event.message);
host.updatePendingMessagesDisplay();
host.ui.requestRender();
} else if (event.message.role === "assistant") {
host.streamingComponent = new AssistantMessageComponent(
undefined,
host.hideThinkingBlock,
host.getMarkdownThemeWithSettings(),
host.settingsManager.getTimestampFormat(),
);
host.streamingMessage = event.message;
host.chatContainer.addChild(host.streamingComponent);
host.streamingComponent.updateContent(host.streamingMessage);
host.ui.requestRender();
}
break;
case "message_update":
if (host.streamingComponent && event.message.role === "assistant") {
host.streamingMessage = event.message;
host.streamingComponent.updateContent(host.streamingMessage);
for (const content of host.streamingMessage.content) {
if (content.type === "toolCall") {
if (!host.pendingTools.has(content.id)) {
const component = new ToolExecutionComponent(
content.name,
content.arguments,
{ showImages: host.settingsManager.getShowImages() },
host.getRegisteredToolDefinition(content.name),
host.ui,
);
component.setExpanded(host.toolOutputExpanded);
host.chatContainer.addChild(component);
host.pendingTools.set(content.id, component);
} else {
host.pendingTools.get(content.id)?.updateArgs(content.arguments);
}
} else if (content.type === "serverToolUse") {
if (!host.pendingTools.has(content.id)) {
const component = new ToolExecutionComponent(
content.name,
content.input ?? {},
{ showImages: host.settingsManager.getShowImages() },
undefined,
host.ui,
);
component.setExpanded(host.toolOutputExpanded);
host.chatContainer.addChild(component);
host.pendingTools.set(content.id, component);
}
} else if (content.type === "webSearchResult") {
const component = host.pendingTools.get(content.toolUseId);
if (component) {
if (process.env.PI_OFFLINE === "1") {
component.updateResult({
content: [{ type: "text", text: "Web search disabled (offline mode)" }],
isError: false,
});
} else {
const searchContent = content.content;
const isError = searchContent && typeof searchContent === "object" && "type" in (searchContent as any) && (searchContent as any).type === "web_search_tool_result_error";
component.updateResult({
content: [{ type: "text", text: host.formatWebSearchResult(searchContent) }],
isError: !!isError,
});
}
}
}
}
host.ui.requestRender();
}
break;
case "message_end":
if (event.message.role === "user") break;
if (host.streamingComponent && event.message.role === "assistant") {
host.streamingMessage = event.message;
let errorMessage: string | undefined;
if (host.streamingMessage.stopReason === "aborted") {
const retryAttempt = host.session.retryAttempt;
errorMessage = retryAttempt > 0
? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
: "Operation aborted";
host.streamingMessage.errorMessage = errorMessage;
}
host.streamingComponent.updateContent(host.streamingMessage);
if (host.streamingMessage.stopReason === "aborted" || host.streamingMessage.stopReason === "error") {
if (!errorMessage) {
errorMessage = host.streamingMessage.errorMessage || "Error";
}
for (const [, component] of host.pendingTools.entries()) {
component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true });
}
host.pendingTools.clear();
} else {
for (const [, component] of host.pendingTools.entries()) {
component.setArgsComplete();
}
}
host.streamingComponent = undefined;
host.streamingMessage = undefined;
host.footer.invalidate();
}
host.ui.requestRender();
break;
case "tool_execution_start":
if (!host.pendingTools.has(event.toolCallId)) {
const component = new ToolExecutionComponent(
event.toolName,
event.args,
{ showImages: host.settingsManager.getShowImages() },
host.getRegisteredToolDefinition(event.toolName),
host.ui,
);
component.setExpanded(host.toolOutputExpanded);
host.chatContainer.addChild(component);
host.pendingTools.set(event.toolCallId, component);
host.ui.requestRender();
}
break;
case "tool_execution_update": {
const component = host.pendingTools.get(event.toolCallId);
if (component) {
component.updateResult({ ...event.partialResult, isError: false }, true);
host.ui.requestRender();
}
break;
}
case "tool_execution_end": {
const component = host.pendingTools.get(event.toolCallId);
if (component) {
component.updateResult({ ...event.result, isError: event.isError });
host.pendingTools.delete(event.toolCallId);
host.ui.requestRender();
}
break;
}
case "agent_end":
if (host.loadingAnimation) {
host.loadingAnimation.stop();
host.loadingAnimation = undefined;
host.statusContainer.clear();
}
if (host.streamingComponent) {
host.chatContainer.removeChild(host.streamingComponent);
host.streamingComponent = undefined;
host.streamingMessage = undefined;
}
host.pendingTools.clear();
await host.checkShutdownRequested();
host.ui.requestRender();
break;
case "auto_compaction_start":
host.autoCompactionEscapeHandler = host.defaultEditor.onEscape;
host.defaultEditor.onEscape = () => host.session.abortCompaction();
host.statusContainer.clear();
host.autoCompactionLoader = new Loader(
host.ui,
(spinner) => theme.fg("accent", spinner),
(text) => theme.fg("muted", text),
`${event.reason === "overflow" ? "Context overflow detected, " : ""}Auto-compacting... (${appKey(host.keybindings, "interrupt")} to cancel)`,
);
host.statusContainer.addChild(host.autoCompactionLoader);
host.ui.requestRender();
break;
case "auto_compaction_end":
if (host.autoCompactionEscapeHandler) {
host.defaultEditor.onEscape = host.autoCompactionEscapeHandler;
host.autoCompactionEscapeHandler = undefined;
}
if (host.autoCompactionLoader) {
host.autoCompactionLoader.stop();
host.autoCompactionLoader = undefined;
host.statusContainer.clear();
}
if (event.aborted) {
host.showStatus("Auto-compaction cancelled");
} else if (event.result) {
host.chatContainer.clear();
host.rebuildChatFromMessages();
host.addMessageToChat({
role: "compactionSummary",
tokensBefore: event.result.tokensBefore,
summary: event.result.summary,
timestamp: Date.now(),
});
host.footer.invalidate();
} else if (event.errorMessage) {
host.chatContainer.addChild(new Spacer(1));
host.chatContainer.addChild(new Text(theme.fg("error", event.errorMessage), 1, 0));
}
void host.flushCompactionQueue({ willRetry: event.willRetry });
host.ui.requestRender();
break;
case "auto_retry_start":
host.retryEscapeHandler = host.defaultEditor.onEscape;
host.defaultEditor.onEscape = () => host.session.abortRetry();
host.statusContainer.clear();
host.retryLoader = new Loader(
host.ui,
(spinner) => theme.fg("warning", spinner),
(text) => theme.fg("muted", text),
`Retrying (${event.attempt}/${event.maxAttempts}) in ${Math.round(event.delayMs / 1000)}s... (${appKey(host.keybindings, "interrupt")} to cancel)`,
);
host.statusContainer.addChild(host.retryLoader);
host.ui.requestRender();
break;
case "auto_retry_end":
if (host.retryEscapeHandler) {
host.defaultEditor.onEscape = host.retryEscapeHandler;
host.retryEscapeHandler = undefined;
}
if (host.retryLoader) {
host.retryLoader.stop();
host.retryLoader = undefined;
host.statusContainer.clear();
}
if (!event.success) {
host.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
}
host.ui.requestRender();
break;
case "fallback_provider_switch":
host.showStatus(`Switched from ${event.from}${event.to} (${event.reason})`);
host.ui.requestRender();
break;
case "fallback_provider_restored":
host.showStatus(`Restored to ${event.provider}`);
host.ui.requestRender();
break;
case "fallback_chain_exhausted":
host.showError(event.reason);
host.ui.requestRender();
break;
}
}