feat(HAI-091): add agent log persistence to triage and merger agents

- Add agent log persistence to triage agent with log capture and storage
- Add agent log persistence to merger agent with log capture and storage
- Add unit tests for triage agent log persistence
- Add unit tests for merger agent log persistence
This commit is contained in:
Dustin Byrne
2026-03-26 01:17:07 -04:00
parent fc57042522
commit a8a7fd711b
4 changed files with 283 additions and 5 deletions

View File

@@ -45,6 +45,7 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
updateTask: vi.fn().mockResolvedValue(baseTask), updateTask: vi.fn().mockResolvedValue(baseTask),
moveTask: vi.fn().mockResolvedValue(baseTask), moveTask: vi.fn().mockResolvedValue(baseTask),
logEntry: vi.fn().mockResolvedValue(undefined), logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }), getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
emit: vi.fn(), emit: vi.fn(),
on: vi.fn(), on: vi.fn(),
@@ -183,3 +184,92 @@ describe("aiMergeTask — conditional worktree cleanup", () => {
expect(result.merged).toBe(true); expect(result.merged).toBe(true);
}); });
}); });
describe("aiMergeTask — agent log persistence", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
});
it("logs text deltas to store.appendAgentLog", async () => {
let capturedOnText: ((delta: string) => void) | undefined;
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedOnText = opts.onText;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
capturedOnText?.("Hello ");
capturedOnText?.("merge");
}),
dispose: vi.fn(),
},
} as any;
});
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
[{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "HAI-050");
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-050", "Hello merge", "text");
});
it("logs tool invocations to store.appendAgentLog", async () => {
let capturedOnToolStart: ((name: string, args: any) => void) | undefined;
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedOnToolStart = opts.onToolStart;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
capturedOnToolStart?.("Bash", { command: "git status" });
}),
dispose: vi.fn(),
},
} as any;
});
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
[{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "HAI-050");
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-050", "Bash", "tool");
});
it("still fires onAgentText callback alongside logging", async () => {
const onAgentText = vi.fn();
let capturedOnText: ((delta: string) => void) | undefined;
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedOnText = opts.onText;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
capturedOnText?.("hi");
}),
dispose: vi.fn(),
},
} as any;
});
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
[{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task],
);
await aiMergeTask(store, "/tmp/root", "HAI-050", { onAgentText });
expect(onAgentText).toHaveBeenCalledWith("hi");
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-050", "hi", "text");
});
});

View File

@@ -185,12 +185,49 @@ export async function aiMergeTask(
`[merger] ${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`, `[merger] ${taskId}: ${hasConflicts ? "resolving conflicts + " : ""}writing commit message`,
); );
// ── Agent log buffering ──────────────────────────────────────────
let textBuffer = "";
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const FLUSH_INTERVAL_MS = 500;
const FLUSH_SIZE_BYTES = 1024;
const flushTextBuffer = async () => {
if (textBuffer.length === 0) return;
const chunk = textBuffer;
textBuffer = "";
try {
await store.appendAgentLog(taskId, chunk, "text");
} catch { /* best-effort persistence */ }
};
const scheduleFlush = () => {
if (flushTimer) return;
flushTimer = setTimeout(async () => {
flushTimer = null;
await flushTextBuffer();
}, FLUSH_INTERVAL_MS);
};
const { session } = await createHaiAgent({ const { session } = await createHaiAgent({
cwd: rootDir, cwd: rootDir,
systemPrompt: MERGE_SYSTEM_PROMPT, systemPrompt: MERGE_SYSTEM_PROMPT,
tools: "coding", tools: "coding",
onText: (delta) => options.onAgentText?.(delta), onText: (delta) => {
onToolStart: (name, _args) => options.onAgentTool?.(name), options.onAgentText?.(delta);
textBuffer += delta;
if (textBuffer.length >= FLUSH_SIZE_BYTES) {
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
flushTextBuffer();
} else {
scheduleFlush();
}
},
onToolStart: (name, _args) => {
options.onAgentTool?.(name);
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
flushTextBuffer();
store.appendAgentLog(taskId, name, "tool").catch(() => {});
},
}); });
try { try {
@@ -221,6 +258,8 @@ export async function aiMergeTask(
} catch { /* */ } } catch { /* */ }
throw new Error(`AI merge failed for ${taskId}: ${err.message}`); throw new Error(`AI merge failed for ${taskId}: ${err.message}`);
} finally { } finally {
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
await flushTextBuffer();
session.dispose(); session.dispose();
} }

View File

@@ -30,6 +30,7 @@ function createMockStore(tasks: any[] = []) {
}), }),
updateTask: vi.fn().mockResolvedValue({}), updateTask: vi.fn().mockResolvedValue({}),
moveTask: vi.fn().mockResolvedValue({}), moveTask: vi.fn().mockResolvedValue({}),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({ getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2, maxConcurrent: 2,
maxWorktrees: 4, maxWorktrees: 4,
@@ -420,3 +421,113 @@ describe("TriageProcessor deleted task handling", () => {
expect(mockedCreateHaiAgent).toHaveBeenCalled(); expect(mockedCreateHaiAgent).toHaveBeenCalled();
}); });
}); });
describe("TriageProcessor agent log persistence", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("logs text deltas to store.appendAgentLog", async () => {
const store = createMockStore();
let capturedOnText: ((delta: string) => void) | undefined;
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedOnText = opts.onText;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// Simulate text deltas from the agent
capturedOnText?.("Hello ");
capturedOnText?.("world");
}),
dispose: vi.fn(),
},
} as any;
});
const triage = new TriageProcessor(store, "/tmp/test", {});
await triage.specifyTask({
id: "HAI-001",
title: "Test",
description: "Test",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Text buffer is flushed in finally block
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-001", "Hello world", "text");
});
it("logs tool invocations to store.appendAgentLog", async () => {
const store = createMockStore();
let capturedOnToolStart: ((name: string, args: any) => void) | undefined;
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedOnToolStart = opts.onToolStart;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
capturedOnToolStart?.("Read", { path: "foo.ts" });
}),
dispose: vi.fn(),
},
} as any;
});
const triage = new TriageProcessor(store, "/tmp/test", {});
await triage.specifyTask({
id: "HAI-001",
title: "Test",
description: "Test",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-001", "Read", "tool");
});
it("still fires onAgentText callback alongside logging", async () => {
const store = createMockStore();
const onAgentText = vi.fn();
let capturedOnText: ((delta: string) => void) | undefined;
mockedCreateHaiAgent.mockImplementation(async (opts: any) => {
capturedOnText = opts.onText;
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
capturedOnText?.("hi");
}),
dispose: vi.fn(),
},
} as any;
});
const triage = new TriageProcessor(store, "/tmp/test", { onAgentText });
await triage.specifyTask({
id: "HAI-001",
title: "Test",
description: "Test",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect(onAgentText).toHaveBeenCalledWith("HAI-001", "hi");
expect(store.appendAgentLog).toHaveBeenCalledWith("HAI-001", "hi", "text");
});
});

View File

@@ -251,14 +251,50 @@ export class TriageProcessor {
const promptPath = `.hai/tasks/${task.id}/PROMPT.md`; const promptPath = `.hai/tasks/${task.id}/PROMPT.md`;
const agentWork = async () => { const agentWork = async () => {
// ── Agent log buffering ──────────────────────────────────────────
let textBuffer = "";
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const FLUSH_INTERVAL_MS = 500;
const FLUSH_SIZE_BYTES = 1024;
const flushTextBuffer = async () => {
if (textBuffer.length === 0) return;
const chunk = textBuffer;
textBuffer = "";
try {
await this.store.appendAgentLog(task.id, chunk, "text");
} catch { /* best-effort persistence */ }
};
const scheduleFlush = () => {
if (flushTimer) return;
flushTimer = setTimeout(async () => {
flushTimer = null;
await flushTextBuffer();
}, FLUSH_INTERVAL_MS);
};
const { session } = await createHaiAgent({ const { session } = await createHaiAgent({
cwd: this.rootDir, cwd: this.rootDir,
systemPrompt: TRIAGE_SYSTEM_PROMPT, systemPrompt: TRIAGE_SYSTEM_PROMPT,
tools: "coding", tools: "coding",
customTools: this.createTriageTools(), customTools: this.createTriageTools(),
onText: (delta) => this.options.onAgentText?.(task.id, delta), onText: (delta) => {
onToolStart: (name, _args) => this.options.onAgentText?.(task.id, delta);
console.log(`[triage] ${task.id} tool: ${name}`), textBuffer += delta;
if (textBuffer.length >= FLUSH_SIZE_BYTES) {
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
flushTextBuffer();
} else {
scheduleFlush();
}
},
onToolStart: (name, _args) => {
console.log(`[triage] ${task.id} tool: ${name}`);
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
flushTextBuffer();
this.store.appendAgentLog(task.id, name, "tool").catch(() => {});
},
}); });
try { try {
@@ -290,6 +326,8 @@ export class TriageProcessor {
this.options.onSpecifyComplete?.(task); this.options.onSpecifyComplete?.(task);
} }
} finally { } finally {
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
await flushTextBuffer();
session.dispose(); session.dispose();
} }
}; };