feat(FN-1500): suppress per-tool triage stdout spam
- Add tool-specific toolOutput handler in triage agent to filter per-tool output - Add test coverage for tool output suppression behavior - Suppress console spam from read_file, Read, glob, Grep tools during triage runs - Update memory documentation with Vitest expect.any(Number) pitfall
This commit is contained in:
11
.changeset/fn-1500-suppress-triage-tool-stdout.md
Normal file
11
.changeset/fn-1500-suppress-triage-tool-stdout.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
"@gsxdsm/fusion": patch
|
||||
---
|
||||
|
||||
Suppress per-tool triage stdout spam during `fn dashboard` / `fn serve` runtime
|
||||
|
||||
Triage agent tool calls no longer emit per-tool lines like `[triage] FN-XXX tool: read` to stdout. This reduces terminal noise while preserving:
|
||||
- Internal observability via task agent logs (`fn task logs`)
|
||||
- Stuck-task heartbeat tracking via `StuckTaskDetector.recordActivity()`
|
||||
|
||||
This aligns with project memory guidance to keep engine diagnostics high-signal and avoid noisy low-value terminal spam.
|
||||
@@ -19,6 +19,7 @@
|
||||
## Conventions
|
||||
|
||||
- When mocking function types with Vitest for the build (tsc), use `vi.fn().mockResolvedValue(x) as unknown as T` instead of `vi.fn<Parameters<T>, ReturnType<T>>()`. The generic syntax works at runtime but fails during `tsc` build.
|
||||
- `expect.any(Number)` does not work in Vitest matchers — use `expect(mockFn.mock.calls.length).toBeGreaterThanOrEqual(1)` or similar instead.
|
||||
- When mocking `AgentStore` for heartbeat execution tests, track `saveRun` calls in a local `Map<string, AgentHeartbeatRun>` and have `getRunDetail` read from it — this way `completeRun`'s saved state is reflected in the returned run.
|
||||
- When `HeartbeatMonitorOptions` has optional fields (`taskStore?`, `rootDir?`), capture them in local `const` variables after the early-return validation check to avoid `Object is possibly 'undefined'` TypeScript errors in the closure.
|
||||
- For package-scoped single-file test runs, prefer `pnpm --filter <pkg> exec vitest run <file>` over `pnpm --filter <pkg> test -- <file>` when the package test script already hardcodes positional args.
|
||||
|
||||
@@ -1968,3 +1968,128 @@ describe("stuck task detector integration", () => {
|
||||
expect(recordActivity).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tool callback behavior (FN-1500)", () => {
|
||||
it("records activity via stuckTaskDetector on tool callbacks", async () => {
|
||||
const recordActivity = vi.fn();
|
||||
const mockDetector = { trackTask: vi.fn(), untrackTask: vi.fn(), recordActivity } as any;
|
||||
|
||||
const store = createMockStore();
|
||||
const processor = new TriageProcessor(store, "/tmp/root", { stuckTaskDetector: mockDetector });
|
||||
|
||||
// Access the agentLogger via internal agentWork closure
|
||||
// by running specifyTask and intercepting the createKbAgent call
|
||||
let capturedOnAgentTool: ((id: string, name: string) => void) | undefined;
|
||||
mockCreateKbAgent.mockImplementation(async (opts: any) => {
|
||||
// Capture the onToolStart callback that was passed to createKbAgent
|
||||
// This is the onAgentTool from agentLogger
|
||||
if (opts.onToolStart) {
|
||||
capturedOnAgentTool = opts.onToolStart;
|
||||
}
|
||||
return {
|
||||
session: {
|
||||
state: {},
|
||||
sessionManager: {},
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
navigateTree: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const task: Task = { id: "FN-TOOL-001", description: "test tool callbacks", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
|
||||
await processor.specifyTask(task);
|
||||
|
||||
// Simulate tool callbacks
|
||||
if (capturedOnAgentTool) {
|
||||
capturedOnAgentTool("call-1", "read");
|
||||
capturedOnAgentTool("call-2", "write");
|
||||
capturedOnAgentTool("call-3", "bash");
|
||||
}
|
||||
|
||||
// Stuck detector should have recorded activity
|
||||
expect(recordActivity).toHaveBeenCalledWith("FN-TOOL-001");
|
||||
// Activity should have been recorded at least once (for each tool callback)
|
||||
expect(recordActivity.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("agent logger persists tool events via appendAgentLog", async () => {
|
||||
const store = createMockStore();
|
||||
const processor = new TriageProcessor(store, "/tmp/root");
|
||||
|
||||
let capturedOnToolStart: ((name: string, args?: Record<string, unknown>) => void) | undefined;
|
||||
mockCreateKbAgent.mockImplementation(async (opts: any) => {
|
||||
if (opts.onToolStart) {
|
||||
capturedOnToolStart = opts.onToolStart;
|
||||
}
|
||||
return {
|
||||
session: {
|
||||
state: {},
|
||||
sessionManager: {},
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
navigateTree: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const task: Task = { id: "FN-TOOL-002", description: "test tool logging", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
|
||||
await processor.specifyTask(task);
|
||||
|
||||
// Simulate tool call
|
||||
if (capturedOnToolStart) {
|
||||
capturedOnToolStart("read", { path: "test.txt" });
|
||||
}
|
||||
|
||||
// Agent logger should have persisted via appendAgentLog
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-TOOL-002",
|
||||
"read",
|
||||
"tool",
|
||||
"test.txt",
|
||||
"triage",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not emit stdout 'tool:' log pattern during triage (FN-1500)", async () => {
|
||||
// Spy on console.log to verify no tool: spam
|
||||
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
const store = createMockStore();
|
||||
const processor = new TriageProcessor(store, "/tmp/root");
|
||||
|
||||
let capturedOnToolStart: ((name: string, args?: Record<string, unknown>) => void) | undefined;
|
||||
mockCreateKbAgent.mockImplementation(async (opts: any) => {
|
||||
if (opts.onToolStart) {
|
||||
capturedOnToolStart = opts.onToolStart;
|
||||
}
|
||||
return {
|
||||
session: {
|
||||
state: {},
|
||||
sessionManager: {},
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
navigateTree: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const task: Task = { id: "FN-STDOUT-001", description: "test no stdout spam", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
|
||||
await processor.specifyTask(task);
|
||||
|
||||
// Simulate multiple tool calls
|
||||
if (capturedOnToolStart) {
|
||||
capturedOnToolStart("read", { path: "file1.txt" });
|
||||
capturedOnToolStart("edit", { path: "file2.txt" });
|
||||
capturedOnToolStart("bash", { command: "npm test" });
|
||||
}
|
||||
|
||||
// Verify no stdout "tool:" pattern was emitted
|
||||
const toolSpamLogs = (consoleLogSpy.mock.calls as string[][]).filter(
|
||||
(args) => args.some((arg) => typeof arg === "string" && arg.includes("tool:"))
|
||||
);
|
||||
expect(toolSpamLogs).toHaveLength(0);
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -525,9 +525,10 @@ export class TriageProcessor {
|
||||
stuckDetector?.recordActivity(task.id);
|
||||
this.options.onAgentText?.(id, delta);
|
||||
},
|
||||
onAgentTool: (_id, name) => {
|
||||
onAgentTool: (_id, _name) => {
|
||||
stuckDetector?.recordActivity(task.id);
|
||||
triageLog.log(`${task.id} tool: ${name}`);
|
||||
// Tool events are persisted via AgentLogger (tool/tool_result/tool_error)
|
||||
// for fn task logs and agent log history — no stdout spam
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user