/** * tool-result-helpers.ts — Shared helpers for formatting tool results, * notifications, and agent status used by index.ts tools. */ import type { AgentRecord, NotificationDetails } from "./types.js"; import { formatTokens, } from "./ui/agent-format.js"; import type { AgentActivity, AgentDetails } from "./ui/agent-ui-types.js"; import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js"; /** Tool execute return value for a text response. */ export function textResult(msg: string, details?: AgentDetails) { return { content: [{ type: "text" as const, text: msg }], details: details as any }; } /** Format an agent's lifetime token total, or "" when zero. */ export function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string { const t = getLifetimeTotal(o.lifetimeUsage); return t > 0 ? formatTokens(t) : ""; } /** * Create an AgentActivity state and spawn callbacks for tracking tool usage. * Used by both foreground and background paths to avoid duplication. */ export function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) { const state: AgentActivity = { activeTools: new Map(), toolUses: 0, turnCount: 1, maxTurns, responseText: "", session: undefined, lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 }, lastSeenMs: Date.now(), }; const callbacks = { onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => { if (activity.type === "start") { state.activeTools.set(`${activity.toolName}_${Date.now()}`, activity.toolName); } else { for (const [key, name] of state.activeTools) { if (name === activity.toolName) { state.activeTools.delete(key); break; } } state.toolUses++; } state.lastSeenMs = Date.now(); onStreamUpdate?.(); }, onTextDelta: (_delta: string, fullText: string) => { state.responseText = fullText; state.lastSeenMs = Date.now(); onStreamUpdate?.(); }, onTurnEnd: (turnCount: number) => { state.turnCount = turnCount; state.lastSeenMs = Date.now(); onStreamUpdate?.(); }, onSessionCreated: (session: any) => { state.session = session; }, onAssistantUsage: (usage: { input: number; output: number; cacheWrite: number }) => { addUsage(state.lifetimeUsage, usage); onStreamUpdate?.(); }, }; return { state, callbacks }; } /** Human-readable status label for agent completion. */ export function getStatusLabel(status: string, error?: string): string { switch (status) { case "error": return `Error: ${error ?? "unknown"}`; case "aborted": return "Aborted (max turns exceeded)"; case "steered": return "Wrapped up (turn limit)"; case "stopped": return "Stopped"; default: return "Done"; } } /** Parenthetical status note for completed agent result text. */ export function getStatusNote(status: string): string { switch (status) { case "aborted": return " (aborted — max turns exceeded, output may be incomplete)"; case "steered": return " (wrapped up — reached turn limit)"; case "stopped": return " (stopped by user)"; default: return ""; } } /** Escape XML special characters to prevent injection in structured notifications. */ export function escapeXml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } /** Format a structured task notification matching Claude Code's XML. */ export function formatTaskNotification(record: AgentRecord, resultMaxLen: number): string { const status = getStatusLabel(record.status, record.error); const durationMs = record.completedAt ? record.completedAt - (record.startedAt ?? 0) : 0; const totalTokens = getLifetimeTotal(record.lifetimeUsage); const contextPercent = getSessionContextPercent(record.session); const ctxXml = contextPercent === null ? "" : `${Math.round(contextPercent)}`; const compactXml = record.compactionCount ? `${record.compactionCount}` : ""; const resultPreview = record.result ? record.result.length > resultMaxLen ? `${record.result.slice(0, resultMaxLen)}\n...(truncated, use get_subagent_result for full output)` : record.result : "No output."; return [ ``, `${record.id}`, record.toolCallId ? `${escapeXml(record.toolCallId)}` : null, record.outputFile ? `${escapeXml(record.outputFile)}` : null, `${escapeXml(status)}`, `Agent "${escapeXml(record.description)}" ${record.status}`, `${escapeXml(resultPreview)}`, `${totalTokens}${record.toolUses}${ctxXml}${compactXml}${durationMs}`, ``, ].filter(Boolean).join('\n'); } /** Build AgentDetails from a base + record-specific fields. */ export function buildDetails( base: Pick, record: Pick, activity?: AgentActivity, overrides?: Partial, ): AgentDetails { return { ...base, toolUses: record.toolUses, tokens: formatLifetimeTokens(record), turnCount: activity?.turnCount, maxTurns: activity?.maxTurns, durationMs: (record.completedAt ?? Date.now()) - (record.startedAt ?? 0), status: record.status as AgentDetails["status"], agentId: record.id, error: record.error, validated: record.validated, ...overrides, }; } /** Build notification details for the custom message renderer. */ export function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails { const totalTokens = getLifetimeTotal(record.lifetimeUsage); return { id: record.id, description: record.description, status: record.status, toolUses: record.toolUses, turnCount: activity?.turnCount ?? 0, maxTurns: activity?.maxTurns, totalTokens, durationMs: record.completedAt ? record.completedAt - (record.startedAt ?? 0) : 0, outputFile: record.outputFile, error: record.error, validated: record.validated, resultPreview: record.result ? record.result.length > resultMaxLen ? `${record.result.slice(0, resultMaxLen)}…` : record.result : "No output.", }; }