diff --git a/src/features/background-agent/task-history.test.ts b/src/features/background-agent/task-history.test.ts new file mode 100644 index 000000000..0afb426b7 --- /dev/null +++ b/src/features/background-agent/task-history.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "bun:test" +import { TaskHistory } from "./task-history" + +describe("TaskHistory", () => { + describe("record", () => { + it("stores an entry for a parent session", () => { + //#given + const history = new TaskHistory() + + //#when + history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "pending" }) + + //#then + const entries = history.getByParentSession("parent-1") + expect(entries).toHaveLength(1) + expect(entries[0].id).toBe("t1") + expect(entries[0].agent).toBe("explore") + expect(entries[0].status).toBe("pending") + }) + + it("ignores undefined parentSessionID", () => { + //#given + const history = new TaskHistory() + + //#when + history.record(undefined, { id: "t1", agent: "explore", description: "Find auth", status: "pending" }) + + //#then + expect(history.getByParentSession("undefined")).toHaveLength(0) + }) + + it("upserts without clobbering undefined fields", () => { + //#given + const history = new TaskHistory() + history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "pending", category: "quick" }) + + //#when + history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "running" }) + + //#then + const entries = history.getByParentSession("parent-1") + expect(entries).toHaveLength(1) + expect(entries[0].status).toBe("running") + expect(entries[0].category).toBe("quick") + }) + + it("caps entries at MAX_ENTRIES_PER_PARENT (100)", () => { + //#given + const history = new TaskHistory() + + //#when + for (let i = 0; i < 105; i++) { + history.record("parent-1", { id: `t${i}`, agent: "explore", description: `Task ${i}`, status: "completed" }) + } + + //#then + const entries = history.getByParentSession("parent-1") + expect(entries).toHaveLength(100) + expect(entries[0].id).toBe("t5") + expect(entries[99].id).toBe("t104") + }) + }) + + describe("getByParentSession", () => { + it("returns defensive copies", () => { + //#given + const history = new TaskHistory() + history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "pending" }) + + //#when + const entries = history.getByParentSession("parent-1") + entries[0].status = "completed" + + //#then + const fresh = history.getByParentSession("parent-1") + expect(fresh[0].status).toBe("pending") + }) + + it("returns empty array for unknown parent", () => { + //#given + const history = new TaskHistory() + + //#when + const entries = history.getByParentSession("nonexistent") + + //#then + expect(entries).toHaveLength(0) + }) + }) + + describe("clearSession", () => { + it("removes all entries for a parent session", () => { + //#given + const history = new TaskHistory() + history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "pending" }) + history.record("parent-2", { id: "t2", agent: "oracle", description: "Review", status: "running" }) + + //#when + history.clearSession("parent-1") + + //#then + expect(history.getByParentSession("parent-1")).toHaveLength(0) + expect(history.getByParentSession("parent-2")).toHaveLength(1) + }) + }) + + describe("formatForCompaction", () => { + it("returns null when no entries exist", () => { + //#given + const history = new TaskHistory() + + //#when + const result = history.formatForCompaction("nonexistent") + + //#then + expect(result).toBeNull() + }) + + it("formats entries with agent, status, and description", () => { + //#given + const history = new TaskHistory() + history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth patterns", status: "completed" }) + + //#when + const result = history.formatForCompaction("parent-1") + + //#then + expect(result).toContain("**explore**") + expect(result).toContain("(completed)") + expect(result).toContain("Find auth patterns") + }) + + it("includes category when present", () => { + //#given + const history = new TaskHistory() + history.record("parent-1", { id: "t1", agent: "explore", description: "Find auth", status: "running", category: "quick" }) + + //#when + const result = history.formatForCompaction("parent-1") + + //#then + expect(result).toContain("[quick]") + }) + + it("includes session_id when present", () => { + //#given + const history = new TaskHistory() + history.record("parent-1", { id: "t1", sessionID: "ses_abc123", agent: "oracle", description: "Review arch", status: "completed" }) + + //#when + const result = history.formatForCompaction("parent-1") + + //#then + expect(result).toContain("`ses_abc123`") + }) + + it("sanitizes newlines in description", () => { + //#given + const history = new TaskHistory() + history.record("parent-1", { id: "t1", agent: "explore", description: "Line1\nLine2\rLine3", status: "pending" }) + + //#when + const result = history.formatForCompaction("parent-1") + + //#then + expect(result).not.toContain("\n\n") + expect(result).toContain("Line1 Line2 Line3") + }) + }) +}) diff --git a/src/features/background-agent/task-history.ts b/src/features/background-agent/task-history.ts new file mode 100644 index 000000000..333340c62 --- /dev/null +++ b/src/features/background-agent/task-history.ts @@ -0,0 +1,75 @@ +import type { BackgroundTaskStatus } from "./types" + +const MAX_ENTRIES_PER_PARENT = 100 + +export interface TaskHistoryEntry { + id: string + sessionID?: string + agent: string + description: string + status: BackgroundTaskStatus + category?: string + startedAt?: Date + completedAt?: Date +} + +export class TaskHistory { + private entries: Map = new Map() + + record(parentSessionID: string | undefined, entry: TaskHistoryEntry): void { + if (!parentSessionID) return + + const list = this.entries.get(parentSessionID) ?? [] + const existing = list.findIndex((e) => e.id === entry.id) + + if (existing !== -1) { + const current = list[existing] + list[existing] = { + ...current, + ...(entry.sessionID !== undefined ? { sessionID: entry.sessionID } : {}), + ...(entry.agent !== undefined ? { agent: entry.agent } : {}), + ...(entry.description !== undefined ? { description: entry.description } : {}), + ...(entry.status !== undefined ? { status: entry.status } : {}), + ...(entry.category !== undefined ? { category: entry.category } : {}), + ...(entry.startedAt !== undefined ? { startedAt: entry.startedAt } : {}), + ...(entry.completedAt !== undefined ? { completedAt: entry.completedAt } : {}), + } + } else { + if (list.length >= MAX_ENTRIES_PER_PARENT) { + list.shift() + } + list.push({ ...entry }) + } + + this.entries.set(parentSessionID, list) + } + + getByParentSession(parentSessionID: string): TaskHistoryEntry[] { + const list = this.entries.get(parentSessionID) + if (!list) return [] + return list.map((e) => ({ ...e })) + } + + clearSession(parentSessionID: string): void { + this.entries.delete(parentSessionID) + } + + formatForCompaction(parentSessionID: string): string | null { + const list = this.getByParentSession(parentSessionID) + if (list.length === 0) return null + + const lines = list.map((e) => { + const desc = e.description.replace(/[\n\r]+/g, " ").trim() + const parts = [ + `- **${e.agent}**`, + e.category ? `[${e.category}]` : null, + `(${e.status})`, + `: ${desc}`, + e.sessionID ? ` | session: \`${e.sessionID}\`` : null, + ] + return parts.filter(Boolean).join("") + }) + + return lines.join("\n") + } +}