feat(background-agent): add TaskHistory class for persistent task tracking

In-memory tracker that survives BackgroundManager's cleanup cycles.
Records agent delegations with defensive copies, MAX 100 cap per parent,
undefined-safe upsert, and newline-sanitized formatForCompaction output.
This commit is contained in:
YeonGyu-Kim
2026-02-13 17:40:12 +09:00
parent a7b56a0391
commit a413e57676
2 changed files with 245 additions and 0 deletions

View File

@@ -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")
})
})
})

View File

@@ -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<string, TaskHistoryEntry[]> = 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")
}
}