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:
7
.changeset/fn-7995-tool-error-detail.md
Normal file
7
.changeset/fn-7995-tool-error-detail.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user