docs(FN-823): clarify agent log content is never truncated per-entry

- Clarify in dashboard README that agent log entries are never truncated
- Add maxLength safeguards in store (getAgentLog), routes, useAgentLogs, and useMultiAgentLogs hooks
- Add tests for store, AgentLogViewer component, useAgentLogs hook, useMultiAgentLogs hook, and dashboard routes
- All tests verify that log text and detail fields are preserved in full without per-entry truncation
This commit is contained in:
gsxdsm
2026-04-04 01:41:13 -07:00
parent 3516394e9f
commit c78f49a8db
11 changed files with 280 additions and 2 deletions

View File

@@ -1660,6 +1660,68 @@ Task with acceptance criteria
expect(logs[2].detail).toBe("file not found"); expect(logs[2].detail).toBe("file not found");
expect(logs[2].agent).toBe("reviewer"); expect(logs[2].agent).toBe("reviewer");
}); });
it("preserves long multiline text without truncation", async () => {
const task = await createTestTask();
const longText = [
"## Analysis",
"",
"After reviewing the codebase, I found several issues:",
"",
"1. The first issue is that the function `processData` does not handle",
" edge cases where the input array is empty. This can cause unexpected",
" behavior downstream when consumers expect at least one element.",
"",
"2. The second issue relates to the caching layer. The TTL is set to",
" a very low value (60 seconds) which causes excessive cache misses.",
"",
"```typescript",
"function processData(data: unknown[]): Result {",
" // This is a very long code block that should not be truncated",
" if (!data || data.length === 0) {",
" throw new Error('Data array must not be empty');",
" }",
" return data.map(item => transform(item)).filter(Boolean);",
"}",
"```",
"",
"Line " + "A".repeat(500) + " end of long line",
].join("\n");
// Total length should be well over 1000 characters
expect(longText.length).toBeGreaterThan(1000);
await store.appendAgentLog(task.id, longText, "text");
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(1);
expect(logs[0].text).toBe(longText);
});
it("preserves long detail strings without truncation", async () => {
const task = await createTestTask();
const longDetail = "path/to/a/very/deeply/nested/directory/structure/that/contains/many/segments/".repeat(20)
+ "src/components/features/dashboard/panels/AgentLogViewer.tsx";
// Total length should be well over 500 characters
expect(longDetail.length).toBeGreaterThan(500);
await store.appendAgentLog(task.id, "Read", "tool", longDetail);
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(1);
expect(logs[0].detail).toBe(longDetail);
});
it("preserves both long text and long detail simultaneously", async () => {
const task = await createTestTask();
const longText = "X".repeat(2000);
const longDetail = "Y".repeat(2000);
await store.appendAgentLog(task.id, longText, "tool", longDetail, "executor");
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(1);
expect(logs[0].text).toBe(longText);
expect(logs[0].text.length).toBe(2000);
expect(logs[0].detail).toBe(longDetail);
expect(logs[0].detail!.length).toBe(2000);
});
}); });
describe("task comments", () => { describe("task comments", () => {

View File

@@ -2296,6 +2296,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* Read all historical agent log entries for a task from its agent log file. * Read all historical agent log entries for a task from its agent log file.
* Returns entries in chronological order (oldest first). * Returns entries in chronological order (oldest first).
* *
* Each entry's `text` and `detail` fields are returned in full — there is
* no per-entry truncation at the persistence layer. The 500-entry cap
* (`MAX_LOG_ENTRIES`) in the dashboard hooks is a whole-list limit only.
*
* @param taskId - The task ID (e.g. "KB-001") * @param taskId - The task ID (e.g. "KB-001")
* @returns Array of agent log entries, empty if no log file exists * @returns Array of agent log entries, empty if no log file exists
*/ */

View File

@@ -319,7 +319,7 @@ To add a new color theme:
The dashboard includes several runtime safeguards to stay responsive during long sessions and on larger boards: The dashboard includes several runtime safeguards to stay responsive during long sessions and on larger boards:
- **Agent log cap**: The UI keeps only the most recent **500 agent log entries per task** in memory. Historical log fetches and live SSE appends are both truncated to this window. - **Agent log cap**: The UI keeps only the most recent **500 agent log entries per task** in memory. Historical log fetches and live SSE appends are both capped to this window. **Per-entry content is never truncated** — each entry's `text` and `detail` fields survive in full from persistence (`agent.log` JSONL) through the API (`GET /tasks/:id/logs`), SSE streaming (`/api/tasks/:id/logs/stream`), and rendering in `AgentLogViewer` / `AgentDetailView`. The 500-entry limit is a whole-list in-memory cap only.
- **Memoized task rendering**: `TaskCard`, `Column`, and worktree grouping are memoized so unrelated SSE updates do not force the whole board to repaint. The board also preserves stable per-column task arrays for unchanged columns. - **Memoized task rendering**: `TaskCard`, `Column`, and worktree grouping are memoized so unrelated SSE updates do not force the whole board to repaint. The board also preserves stable per-column task arrays for unchanged columns.
- **Large-column pagination**: Columns with more than **100 tasks** use incremental client-side pagination, rendering **50 tasks initially** and loading **25 more** at a time. This is applied to active non-archived, non-`in-progress` columns to avoid breaking worktree grouping and archived browsing behavior. - **Large-column pagination**: Columns with more than **100 tasks** use incremental client-side pagination, rendering **50 tasks initially** and loading **25 more** at a time. This is applied to active non-archived, non-`in-progress` columns to avoid breaking worktree grouping and archived browsing behavior.
- **Badge update isolation**: Live GitHub PR/issue badge websocket updates are rendered through a dedicated child component so badge freshness is preserved even when task cards are memoized. - **Badge update isolation**: Live GitHub PR/issue badge websocket updates are rendered through a dedicated child component so badge freshness is preserved even when task cards are memoized.

View File

@@ -430,6 +430,71 @@ describe("AgentLogViewer", () => {
}); });
}); });
describe("long content preservation", () => {
it("renders very long text entries without truncation", () => {
const longText = "A".repeat(5000);
const entries = [makeEntry({ text: longText })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const textSpans = container.querySelectorAll(".agent-log-text");
expect(textSpans).toHaveLength(1);
expect(textSpans[0].textContent).toBe(longText);
expect(textSpans[0].textContent!.length).toBe(5000);
});
it("renders very long detail text without truncation", () => {
const longDetail = "B".repeat(5000);
const entries = [makeEntry({ text: "Read", type: "tool", detail: longDetail })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const detail = container.querySelector(".agent-log-tool-detail");
expect(detail).toBeTruthy();
expect(detail!.textContent).toContain(longDetail);
expect(detail!.textContent!.length).toBeGreaterThan(5000); // " — " prefix adds a few chars
});
it("renders multiline text content without truncation", () => {
const multilineText = [
"## Analysis",
"",
"After reviewing the codebase:",
"",
"1. First issue found",
"2. Second issue found",
"",
"```typescript",
"const x = 1;",
"```",
"",
"Line " + "C".repeat(2000) + " end",
].join("\n");
const entries = [makeEntry({ text: multilineText })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const textSpans = container.querySelectorAll(".agent-log-text");
expect(textSpans).toHaveLength(1);
// The markdown-rendered content should still contain the essential parts
expect(textSpans[0].textContent).toContain("Analysis");
expect(textSpans[0].textContent).toContain("First issue found");
expect(textSpans[0].textContent).toContain("const x = 1");
});
it("renders long tool_result detail without truncation", () => {
const longDetail = "D".repeat(5000);
const entries = [makeEntry({ text: "Bash", type: "tool_result", detail: longDetail })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const detail = container.querySelector(".agent-log-tool-detail");
expect(detail).toBeTruthy();
expect(detail!.textContent).toContain(longDetail);
});
it("renders long tool_error detail without truncation", () => {
const longDetail = "E".repeat(5000);
const entries = [makeEntry({ text: "Write", type: "tool_error", detail: longDetail })];
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
const detail = container.querySelector(".agent-log-tool-detail");
expect(detail).toBeTruthy();
expect(detail!.textContent).toContain(longDetail);
});
});
describe("markdown rendering", () => { describe("markdown rendering", () => {
it("renders plain text without markdown correctly", () => { it("renders plain text without markdown correctly", () => {
const entries = [ const entries = [

View File

@@ -191,4 +191,56 @@ describe("useAgentLogs", () => {
expect(mockFetchAgentLogs).not.toHaveBeenCalled(); expect(mockFetchAgentLogs).not.toHaveBeenCalled();
expect(MockEventSource.instances).toHaveLength(0); expect(MockEventSource.instances).toHaveLength(0);
}); });
it("preserves long text and detail in historical log entries without truncation", async () => {
const longText = "A".repeat(5000);
const longDetail = "B".repeat(5000);
mockFetchAgentLogs.mockResolvedValueOnce([
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: longText, type: "text" as const },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-001", text: "Read", type: "tool" as const, detail: longDetail },
]);
const { result } = renderHook(() => useAgentLogs("FN-001", true));
await waitFor(() => {
expect(result.current.entries).toHaveLength(2);
});
expect(result.current.entries[0].text).toBe(longText);
expect(result.current.entries[0].text.length).toBe(5000);
expect(result.current.entries[1].detail).toBe(longDetail);
expect(result.current.entries[1].detail!.length).toBe(5000);
});
it("preserves long text and detail in live SSE entries without truncation", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([]);
const { result } = renderHook(() => useAgentLogs("FN-001", true));
await waitFor(() => {
expect(MockEventSource.instances).toHaveLength(1);
});
const longText = "X".repeat(5000);
const longDetail = "Y".repeat(5000);
const es = MockEventSource.instances[0];
act(() => {
es._emit("agent:log", {
timestamp: "2026-01-01T00:01:00Z",
taskId: "FN-001",
text: longText,
type: "text",
detail: longDetail,
});
});
await waitFor(() => {
expect(result.current.entries).toHaveLength(1);
});
expect(result.current.entries[0].text).toBe(longText);
expect(result.current.entries[0].text.length).toBe(5000);
expect(result.current.entries[0].detail).toBe(longDetail);
expect(result.current.entries[0].detail!.length).toBe(5000);
});
}); });

View File

@@ -460,4 +460,58 @@ describe("useMultiAgentLogs", () => {
expect(result.current["FN-001"].entries[1].text).toBe("task1-new"); expect(result.current["FN-001"].entries[1].text).toBe("task1-new");
expect(result.current["FN-002"].entries[1].text).toBe("task2-new"); expect(result.current["FN-002"].entries[1].text).toBe("task2-new");
}); });
it("preserves long text and detail in historical log entries without truncation", async () => {
const longText = "A".repeat(5000);
const longDetail = "B".repeat(5000);
mockFetchAgentLogs.mockResolvedValue([
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: longText, type: "text" as const },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-001", text: "Read", type: "tool" as const, detail: longDetail },
]);
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
await waitFor(() => {
expect(result.current["FN-001"].entries).toHaveLength(2);
});
expect(result.current["FN-001"].entries[0].text).toBe(longText);
expect(result.current["FN-001"].entries[0].text.length).toBe(5000);
expect(result.current["FN-001"].entries[1].detail).toBe(longDetail);
expect(result.current["FN-001"].entries[1].detail!.length).toBe(5000);
});
it("preserves long text and detail in live SSE entries without truncation", async () => {
mockFetchAgentLogs.mockResolvedValue([]);
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
await waitFor(() => {
expect(MockEventSource.instances.length).toBeGreaterThanOrEqual(1);
});
const es = getConnection("FN-001");
expect(es).toBeDefined();
const longText = "X".repeat(5000);
const longDetail = "Y".repeat(5000);
act(() => {
es!._emit("agent:log", {
timestamp: "2026-01-01T00:01:00Z",
taskId: "FN-001",
text: longText,
type: "text",
detail: longDetail,
});
});
await waitFor(() => {
expect(result.current["FN-001"].entries).toHaveLength(1);
});
expect(result.current["FN-001"].entries[0].text).toBe(longText);
expect(result.current["FN-001"].entries[0].text.length).toBe(5000);
expect(result.current["FN-001"].entries[0].detail).toBe(longDetail);
expect(result.current["FN-001"].entries[0].detail!.length).toBe(5000);
});
}); });

View File

@@ -4,6 +4,14 @@ import { fetchAgentLogs } from "../api";
export const MAX_LOG_ENTRIES = 500; export const MAX_LOG_ENTRIES = 500;
/**
* Cap the total number of log entries to `MAX_LOG_ENTRIES`.
*
* This is a **whole-list cap** — it limits how many entries are kept
* in memory, not the content of any individual entry. Per-entry `text`
* and `detail` fields are never truncated anywhere in the pipeline
* (persistence → API → SSE → hook → rendering).
*/
function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] { function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
return entries.length > MAX_LOG_ENTRIES return entries.length > MAX_LOG_ENTRIES
? entries.slice(-MAX_LOG_ENTRIES) ? entries.slice(-MAX_LOG_ENTRIES)

View File

@@ -4,6 +4,14 @@ import { fetchAgentLogs } from "../api";
export const MAX_LOG_ENTRIES = 500; export const MAX_LOG_ENTRIES = 500;
/**
* Cap the total number of log entries to `MAX_LOG_ENTRIES`.
*
* This is a **whole-list cap** — it limits how many entries are kept
* in memory, not the content of any individual entry. Per-entry `text`
* and `detail` fields are never truncated anywhere in the pipeline
* (persistence → API → SSE → hook → rendering).
*/
function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] { function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
return entries.length > MAX_LOG_ENTRIES return entries.length > MAX_LOG_ENTRIES
? entries.slice(-MAX_LOG_ENTRIES) ? entries.slice(-MAX_LOG_ENTRIES)

View File

@@ -1603,6 +1603,25 @@ describe("Attachment routes", () => {
expect(res.status).toBe(500); expect(res.status).toBe(500);
expect(res.body.error).toBe("disk error"); expect(res.body.error).toBe("disk error");
}); });
it("GET /tasks/:id/logs — preserves long text and detail without truncation", async () => {
const longText = "A".repeat(5000);
const longDetail = "B".repeat(5000);
const fakeLogs = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "KB-001", text: longText, type: "text" },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "KB-001", text: "Read", type: "tool", detail: longDetail },
];
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue(fakeLogs);
const res = await GET(buildApp(), "/api/tasks/KB-001/logs");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
expect(res.body[0].text).toBe(longText);
expect(res.body[0].text.length).toBe(5000);
expect(res.body[1].detail).toBe(longDetail);
expect(res.body[1].detail.length).toBe(5000);
});
}); });
// --- Models route tests --- // --- Models route tests ---

View File

@@ -2042,7 +2042,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
} }
}); });
// Get historical agent logs for a task // Get historical agent logs for a task.
// Per-entry text and detail fields are returned in full — no truncation.
// The 500-entry cap (MAX_LOG_ENTRIES) is a client-side whole-list limit.
router.get("/tasks/:id/logs", async (req, res) => { router.get("/tasks/:id/logs", async (req, res) => {
try { try {
const scopedStore = await getScopedStore(req); const scopedStore = await getScopedStore(req);

View File

@@ -146,6 +146,10 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
// directly. Using getOrCreateProjectStore here would attach the listener // directly. Using getOrCreateProjectStore here would attach the listener
// to a different EventEmitter instance that the executor never writes to, // to a different EventEmitter instance that the executor never writes to,
// breaking real-time log streaming. // breaking real-time log streaming.
//
// Per-entry text and detail fields are serialized in full — there is no
// SSE-level truncation. The 500-entry cap is applied client-side in the
// React hooks (useAgentLogs / useMultiAgentLogs).
const onAgentLog = (entry: { taskId: string; text: string; type: string; timestamp: string }) => { const onAgentLog = (entry: { taskId: string; text: string; type: string; timestamp: string }) => {
if (entry.taskId !== taskId) return; if (entry.taskId !== taskId) return;
res.write(`event: agent:log\ndata: ${JSON.stringify(entry)}\n\n`); res.write(`event: agent:log\ndata: ${JSON.stringify(entry)}\n\n`);