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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 16:17:37 -07:00
parent 3d658059cb
commit 363916926d
7 changed files with 104 additions and 10 deletions

View File

@@ -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.

View File

@@ -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 `<rootDir>/.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.

View File

@@ -10,6 +10,10 @@ export const AGENT_LOG_TOOL_TYPES = new Set<AgentLogEntry["type"]>([
"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"],

View File

@@ -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;

View File

@@ -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(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
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(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
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 }),

View File

@@ -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<typeof vi.fn>).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 () => {

View File

@@ -145,7 +145,7 @@ export function summarizeToolArgs(name: string, args?: Record<string, unknown>):
* 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<AgentLogEntry, "durationMs" | "timeToFirstTokenMs">,
): 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,