From 363916926d774c7ca47cadb92080b0fac5e286ea Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 16:17:37 -0700 Subject: [PATCH] FN-7995: always persist tool_error detail for Activity feed diagnosis Always persist bounded tool_error detail so the task Activity feed can surface underlying failure messages even when verbose tool-output persistence is off. - Keep tool args and successful tool_result detail opt-in via persistAgentToolOutput - Always include bounded tool_error detail in agent-log JSONL rows - Document diagnostic retention in types, agent-logger, and storage docs - Cover Activity reveal behavior and logger persistence with unit tests - Add patch changeset for operator-facing Activity error detail fix Files changed: .changeset/fn-7995-tool-error-detail.md | 7 ++++ docs/storage.md | 1 + packages/core/src/agent-log-constants.ts | 4 +++ packages/core/src/types.ts | 10 ++++-- .../app/components/__tests__/TaskChatTab.test.tsx | 42 ++++++++++++++++++++++ packages/engine/src/__tests__/agent-logger.test.ts | 41 ++++++++++++++++++--- packages/engine/src/agent-logger.ts | 9 ++--- 7 files changed, 104 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-7995 Fusion-Task-Lineage: 0fa063df-58b1-4991-a0d9-e8a77181d32a Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7995-tool-error-detail.md | 7 ++++ docs/storage.md | 1 + packages/core/src/agent-log-constants.ts | 4 ++ packages/core/src/types.ts | 10 ++++- .../components/__tests__/TaskChatTab.test.tsx | 42 +++++++++++++++++++ .../engine/src/__tests__/agent-logger.test.ts | 41 ++++++++++++++++-- packages/engine/src/agent-logger.ts | 9 ++-- 7 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 .changeset/fn-7995-tool-error-detail.md diff --git a/.changeset/fn-7995-tool-error-detail.md b/.changeset/fn-7995-tool-error-detail.md new file mode 100644 index 0000000000..69f8dfe3c2 --- /dev/null +++ b/.changeset/fn-7995-tool-error-detail.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Show the underlying error message for failed tool calls in the task Activity feed. +category: fix +dev: tool_error agent-log entries now always persist bounded `detail` regardless of `persistAgentToolOutput`; TaskChatTab renders it in an expandable "Error" block. diff --git a/docs/storage.md b/docs/storage.md index 73f458db4e..992e784629 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -30,6 +30,7 @@ See the [2026-07-14 PostgreSQL runtime cutover review](./postgres-migration-revi ### Agent log storage + soft-delete visibility (FN-5143 / FN-5911) - Agent logs are stored outside PostgreSQL. Each task appends newline-delimited JSON records to `/.fusion/tasks/{ID}/agent-log.jsonl`. +- Tool arguments and successful `tool_result` detail remain opt-in through `persistAgentToolOutput`; failed `tool_error` detail always persists as bounded diagnostic signal so task Activity transcripts can reveal the underlying failure. - Agent-log JSONL rows may include optional numeric timing metadata: `timeToFirstTokenMs` on the first visible model-output row for a request, and `durationMs` on tool/request completion rows such as `tool_result` or `tool_error`. These fields are additive, non-sensitive millisecond values; legacy rows may omit them and readers must continue to treat omission as normal. - `TaskStore.deleteTask` keeps that JSONL file on disk for forensics, but all live read APIs (`getAgentLogs*`, `getAgentLogCount`) gate on task liveness and return zero entries once `deletedAt` is set. - Archived-task snapshot behavior (`taskToArchiveEntry` / `archiveTask`) embeds a capped agent-log snapshot sourced from JSONL. diff --git a/packages/core/src/agent-log-constants.ts b/packages/core/src/agent-log-constants.ts index cce4bd0ad4..414b1df215 100644 --- a/packages/core/src/agent-log-constants.ts +++ b/packages/core/src/agent-log-constants.ts @@ -10,6 +10,10 @@ export const AGENT_LOG_TOOL_TYPES = new Set([ "tool_error", ]); +/* + * FNXC:AgentLogging 2026-07-15-16:05: + * FN-7995 makes failed tool_error detail persist even when verbose tool output is disabled. Keep its shared storage bound identical to other tool details so diagnostic stacks remain safe for JSONL and dashboard reads. + */ export function truncateAgentLogDetail( detail: string | null | undefined, type: AgentLogEntry["type"], diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e6360c77be..657b37364a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -527,8 +527,14 @@ export interface AgentLogEntry { text: string; /** The kind of entry — streamed text delta, standalone engine status message, tool invocation marker, thinking block, tool result, or tool error. */ type: AgentLogType; - /** For tool entries: human-readable summary of tool args (e.g. file path, command). - * For tool_result/tool_error: summary of the result or error message. */ + /** + * For `tool`: human-readable argument summary (for example a file path or command). + * `tool` and successful `tool_result` detail are persisted only when `persistAgentToolOutput` is enabled; + * failed `tool_error` detail is always persisted as bounded diagnostic signal. + * + * FNXC:AgentLogging 2026-07-15-16:05: FN-7995 requires failed tool-call errors to remain available + * to task transcript renderers even when verbose successful tool output is disabled. + */ detail?: string; /** Which agent produced this entry. Absent in logs written before this field was added. */ agent?: AgentRole; diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index f2c53f148a..551bdfaa71 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -951,6 +951,48 @@ describe("TaskChatTab", () => { expect(screen.getByLabelText("Tool invocation timestamp")).toBeVisible(); }); + it.each([ + ["desktop", false], + ["mobile", true], + ])("keeps an error detail revealable in the collapsed tool group on %s", async (_viewport, matchesMobile) => { + const user = userEvent.setup(); + mockMatchMedia(matchesMobile); + mockLogs([ + makeEntry({ agent: "executor", type: "tool", text: "edit" }), + makeEntry({ agent: "executor", type: "tool_error", text: "edit", detail: "replacement text did not match" }), + ]); + + render(); + + const toolGroup = screen.getByTestId("task-chat-tool-group"); + expect(toolGroup).not.toHaveAttribute("open"); + expect(within(toolGroup).getByText("1 error")).toBeVisible(); + expect(screen.getByText("replacement text did not match")).not.toBeVisible(); + + await user.click(within(toolGroup).getByText("1 tool call")); + + const detailBlock = document.querySelector(".task-chat-tool-detail-block"); + expect(detailBlock).toBeTruthy(); + expect(within(detailBlock as HTMLElement).getByText("Error")).toBeVisible(); + expect(within(detailBlock as HTMLElement).getByText("replacement text did not match")).toBeVisible(); + }); + + it("does not render an empty Error detail block when a tool_error has no detail", async () => { + const user = userEvent.setup(); + mockLogs([ + makeEntry({ agent: "executor", type: "tool", text: "Write" }), + makeEntry({ agent: "executor", type: "tool_error", text: "Write", detail: undefined }), + ]); + + render(); + + await user.click(screen.getByText("1 tool call")); + + expect(screen.getByText("Tool call → error")).toBeVisible(); + expect(screen.queryByText("Error")).not.toBeInTheDocument(); + expect(document.querySelector(".task-chat-tool-detail-block")).toBeNull(); + }); + it("renders a single tool entry as one collapsed group and tolerates missing detail", () => { mockLogs([ makeEntry({ agent: "executor", type: "tool", text: "bash", detail: undefined }), diff --git a/packages/engine/src/__tests__/agent-logger.test.ts b/packages/engine/src/__tests__/agent-logger.test.ts index 0474b77545..4e6aa75354 100644 --- a/packages/engine/src/__tests__/agent-logger.test.ts +++ b/packages/engine/src/__tests__/agent-logger.test.ts @@ -145,7 +145,7 @@ describe("AgentLogger", () => { expect(calls[1]).toEqual(["FN-003", "Bash", "tool", undefined, undefined]); }); - it("omits tool detail by default when persistAgentToolOutput is unset", async () => { + it("omits tool and successful result detail by default when persistAgentToolOutput is unset", async () => { const store = createMockStore(); const logger = new AgentLogger({ store, taskId: "FN-004" }); @@ -156,7 +156,7 @@ describe("AgentLogger", () => { expect(store.appendAgentLog).toHaveBeenNthCalledWith(1, "FN-004", "Read", "tool", undefined, undefined); expect(store.appendAgentLog).toHaveBeenNthCalledWith(2, "FN-004", "Read", "tool_result", undefined, undefined, { durationMs: 0, timeToFirstTokenMs: undefined }); - expect(store.appendAgentLog).toHaveBeenNthCalledWith(3, "FN-004", "Read", "tool_error", undefined, undefined); + expect(store.appendAgentLog).toHaveBeenNthCalledWith(3, "FN-004", "Read", "tool_error", "err", undefined); }); it("logs tool detail using summarizeToolArgs when explicitly enabled", async () => { @@ -169,7 +169,7 @@ describe("AgentLogger", () => { expect(store.appendAgentLog).toHaveBeenCalledWith("FN-004A", "Read", "tool", "src/index.ts", undefined); }); - it("omits tool detail when persistAgentToolOutput is disabled", async () => { + it("persists tool_error detail while persistAgentToolOutput is disabled", async () => { const store = createMockStore(); const logger = new AgentLogger({ store, @@ -184,7 +184,40 @@ describe("AgentLogger", () => { expect(store.appendAgentLog).toHaveBeenNthCalledWith(1, "FN-004B", "Read", "tool", undefined, undefined); expect(store.appendAgentLog).toHaveBeenNthCalledWith(2, "FN-004B", "Read", "tool_result", undefined, undefined, { durationMs: 0, timeToFirstTokenMs: undefined }); - expect(store.appendAgentLog).toHaveBeenNthCalledWith(3, "FN-004B", "Read", "tool_error", undefined, undefined); + expect(store.appendAgentLog).toHaveBeenNthCalledWith(3, "FN-004B", "Read", "tool_error", "err", undefined); + }); + + it("persists bounded error detail for edit and Bash with default tool-output persistence", async () => { + const store = createMockStore(); + const logger = new AgentLogger({ store, taskId: "FN-7995" }); + const oversizedError = `Bash failed: ${"x".repeat(5_000)}`; + + logger.onToolStart("edit", { path: "src/file.ts" }); + logger.onToolEnd("edit", true, "edit failed: replacement text did not match"); + logger.onToolStart("Bash", { command: "pnpm test" }); + logger.onToolEnd("Bash", true, oversizedError); + await logger.flush(); + + const calls = (store.appendAgentLog as ReturnType).mock.calls; + expect(calls[0]).toEqual(["FN-7995", "edit", "tool", undefined, undefined]); + expect(calls[1]?.slice(0, 5)).toEqual(["FN-7995", "edit", "tool_error", "edit failed: replacement text did not match", undefined]); + expect(calls[2]).toEqual(["FN-7995", "Bash", "tool", undefined, undefined]); + expect(calls[3]?.[2]).toBe("tool_error"); + expect(calls[3]?.[3]).toContain("Bash failed:"); + expect(calls[3]?.[3]).toContain("[tool output truncated to keep dashboard log views responsive]"); + expect(calls[3]?.[3].length).toBeLessThan(5_000); + }); + + it("omits absent error detail and preserves an empty error result without crashing", async () => { + const store = createMockStore(); + const logger = new AgentLogger({ store, taskId: "FN-7995-EMPTY" }); + + logger.onToolEnd("Write", true); + logger.onToolEnd("unknown_tool", true, ""); + await logger.flush(); + + expect(store.appendAgentLog).toHaveBeenNthCalledWith(1, "FN-7995-EMPTY", "Write", "tool_error", undefined, undefined); + expect(store.appendAgentLog).toHaveBeenNthCalledWith(2, "FN-7995-EMPTY", "unknown_tool", "tool_error", "", undefined); }); it("logs tool with undefined detail for unknown args", async () => { diff --git a/packages/engine/src/agent-logger.ts b/packages/engine/src/agent-logger.ts index 5930fbd1bd..c9089905bd 100644 --- a/packages/engine/src/agent-logger.ts +++ b/packages/engine/src/agent-logger.ts @@ -145,7 +145,7 @@ export function summarizeToolArgs(name: string, args?: Record): * When both are provided, both sinks receive every entry. */ export interface AgentLoggerOptions { - /** When true, persist `detail` payloads for tool entries; default false preserves rows without verbose payloads. */ + /** When true, persist `detail` payloads for `tool` and successful `tool_result` entries; failed `tool_error` details always persist. */ persistAgentToolOutput?: boolean; /** When true, persist `thinking` rows. Default: false (skip thinking persistence). */ persistAgentThinkingLog?: boolean; @@ -253,8 +253,8 @@ export class AgentLogger { this.flushSizeBytes = options.flushSizeBytes ?? FLUSH_SIZE_BYTES; this.flushIntervalMs = options.flushIntervalMs ?? FLUSH_INTERVAL_MS; /* - FNXC:AgentLogs 2026-06-23-00:00: - Direct logger construction must match global settings: verbose tool payload persistence is default-off and only explicit persistAgentToolOutput: true saves tool entry detail. Tool/tool_result/tool_error rows still persist so timelines and usage telemetry remain intact. + FNXC:AgentLogging 2026-07-15-16:00: + Verbose tool arguments and successful result payloads remain default-off unless persistAgentToolOutput is enabled. Failed tool_error detail is bounded diagnostic signal, so it must persist regardless of that setting for Activity-feed diagnosis (FN-7995). */ this.persistAgentToolOutput = options.persistAgentToolOutput === true; this.persistAgentThinkingLog = options.persistAgentThinkingLog === true; @@ -444,7 +444,8 @@ export class AgentLogger { timing?: Pick, ): void { const isToolEntry = type === "tool" || type === "tool_result" || type === "tool_error"; - const includeDetail = !isToolEntry || this.persistAgentToolOutput; + // FNXC:AgentLogging 2026-07-15-16:00: Failed tool detail is diagnostic signal, unlike verbose arguments/success output, and must survive default-off tool-output persistence for FN-7995 Activity diagnosis. + const includeDetail = !isToolEntry || type === "tool_error" || this.persistAgentToolOutput; const entry: AgentLogEntry = { timestamp: new Date().toISOString(), taskId: this.taskId,