diff --git a/src/features/background-agent/task-history.test.ts b/src/features/background-agent/task-history.test.ts index 0afb426b7..adfc95911 100644 --- a/src/features/background-agent/task-history.test.ts +++ b/src/features/background-agent/task-history.test.ts @@ -166,5 +166,35 @@ describe("TaskHistory", () => { expect(result).not.toContain("\n\n") expect(result).toContain("Line1 Line2 Line3") }) + + it("bounds compaction output when many task descriptions are long", () => { + //#given + const history = new TaskHistory() + const longDescription = "Inspect the same lengthy failure context. ".repeat(200) + + //#when + for (let i = 0; i < 100; i++) { + history.record("parent-1", { + id: `t${i}`, + agent: "explore", + description: `${longDescription} task ${i}`, + status: "completed", + category: "quick", + sessionID: `ses_child_${i}`, + }) + } + const result = history.formatForCompaction("parent-1") + + //#then + expect(result).not.toBeNull() + if (result === null) { + throw new Error("Expected compaction history") + } + expect(result.length).toBeLessThanOrEqual(6_000) + expect(result).toContain("older delegated sessions omitted") + expect(result).toContain("[truncated]") + expect(result).toContain("`t99`") + expect(result).not.toContain("`t0`") + }) }) }) diff --git a/src/features/background-agent/task-history.ts b/src/features/background-agent/task-history.ts index 14d1cdeb6..a40d67992 100644 --- a/src/features/background-agent/task-history.ts +++ b/src/features/background-agent/task-history.ts @@ -1,6 +1,9 @@ import type { BackgroundTaskStatus } from "./types" const MAX_ENTRIES_PER_PARENT = 100 +const MAX_COMPACTION_ENTRIES = 20 +const MAX_COMPACTION_DESCRIPTION_CHARS = 240 +const MAX_COMPACTION_TOTAL_CHARS = 6_000 export interface TaskHistoryEntry { id: string @@ -62,18 +65,80 @@ export class TaskHistory { 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("") - }) + const recent = list.slice(-MAX_COMPACTION_ENTRIES) + const olderOmittedCount = list.length - recent.length + const lines: string[] = [] + + if (olderOmittedCount > 0) { + lines.push(`- ${olderOmittedCount} older delegated sessions omitted from compaction summary.`) + } + + let budgetOmittedCount = 0 + for (let i = recent.length - 1; i >= 0; i--) { + const entry = recent[i] + if (!entry) continue + + const line = formatCompactionEntry(entry) + if (!appendWithinBudget(lines, line, MAX_COMPACTION_TOTAL_CHARS)) { + budgetOmittedCount = i + 1 + break + } + } + + if (budgetOmittedCount > 0) { + appendBudgetSummary(lines, budgetOmittedCount) + } return lines.join("\n") } } + +function formatCompactionEntry(entry: TaskHistoryEntry): string { + const description = compactInline(entry.description, MAX_COMPACTION_DESCRIPTION_CHARS) + const parts = [ + `- **${compactInline(entry.agent, 80)}**`, + entry.category ? `[${compactInline(entry.category, 60)}]` : "", + `(${entry.status})`, + ` task_id: \`${compactInline(entry.id, 120)}\``, + description ? `: ${description}` : "", + entry.sessionID ? ` | session: \`${compactInline(entry.sessionID, 120)}\`` : "", + ] + return parts.filter((part) => part.length > 0).join("") +} + +function compactInline(value: string, maxChars: number): string { + const normalized = value.replace(/[\n\r]+/g, " ").replace(/\s+/g, " ").replace(/`/g, "'").trim() + if (normalized.length <= maxChars) { + return normalized + } + + const suffix = "... [truncated]" + const keepChars = Math.max(0, maxChars - suffix.length) + return `${normalized.slice(0, keepChars).trimEnd()}${suffix}` +} + +function appendWithinBudget(lines: string[], line: string, maxChars: number): boolean { + const currentLength = joinedLength(lines) + const separatorLength = lines.length === 0 ? 0 : 1 + if (currentLength + separatorLength + line.length > maxChars) { + return false + } + + lines.push(line) + return true +} + +function appendBudgetSummary(lines: string[], omittedCount: number): void { + const summary = `- ${omittedCount} delegated sessions omitted to stay within compaction budget.` + if (appendWithinBudget(lines, summary, MAX_COMPACTION_TOTAL_CHARS)) { + return + } + + while (lines.length > 0 && !appendWithinBudget(lines, summary, MAX_COMPACTION_TOTAL_CHARS)) { + lines.pop() + } +} + +function joinedLength(lines: readonly string[]): number { + return lines.reduce((total, line, index) => total + line.length + (index === 0 ? 0 : 1), 0) +} diff --git a/src/features/boulder-state/storage.test.ts b/src/features/boulder-state/storage.test.ts index 55d66b379..9cbf6c1a6 100644 --- a/src/features/boulder-state/storage.test.ts +++ b/src/features/boulder-state/storage.test.ts @@ -20,7 +20,6 @@ import { getPlanProgress, getPlanName, createBoulderState, - findPrometheusPlans, getTaskSessionState, resolveBoulderPlanPath, resolveBoulderPlanPathForWork, @@ -74,7 +73,7 @@ describe("boulder-state", () => { expect(writeSucceeded).toBe(true) expect(roundTripState?.active_plan).toBe(legacyRawState.active_plan) expect(roundTripState?.started_at).toBe(legacyRawState.started_at) - expect(roundTripState?.session_ids).toEqual(legacyRawState.session_ids) + expect(roundTripState?.session_ids).toEqual(["opencode:legacy-session"]) expect(roundTripState?.plan_name).toBe(legacyRawState.plan_name) }) @@ -172,7 +171,7 @@ describe("boulder-state", () => { const result = readBoulderState(TEST_DIR) // then - expect(result?.session_origins).toEqual({ "session-1": "direct" }) + expect(result?.session_origins).toEqual({ "opencode:session-1": "direct" }) }) test("should keep missing origins empty when multiple sessions are tracked", () => { @@ -207,7 +206,7 @@ describe("boulder-state", () => { // then expect(result).not.toBeNull() expect(result?.active_plan).toBe("/path/to/plan.md") - expect(result?.session_ids).toEqual(["session-1", "session-2"]) + expect(result?.session_ids).toEqual(["opencode:session-1", "opencode:session-2"]) expect(result?.plan_name).toBe("my-plan") }) @@ -267,7 +266,7 @@ describe("boulder-state", () => { // then expect(result).not.toBeNull() - expect(result?.session_ids).toEqual(["session-1", "session-2"]) + expect(result?.session_ids).toEqual(["opencode:session-1", "opencode:session-2"]) }) test("should not duplicate existing session id", () => { @@ -285,7 +284,7 @@ describe("boulder-state", () => { const result = readBoulderState(TEST_DIR) // then - expect(result?.session_ids).toEqual(["session-1"]) + expect(result?.session_ids).toEqual(["opencode:session-1"]) }) test("should return null when no state exists", () => { @@ -310,7 +309,7 @@ describe("boulder-state", () => { //#then - should not crash and should contain the new session expect(result).not.toBeNull() - expect(result!.session_ids).toContain("ses-new") + expect(result!.session_ids).toContain("opencode:ses-new") }) test("should persist appended session origin when provided", () => { @@ -328,8 +327,8 @@ describe("boulder-state", () => { // then expect(result?.session_origins).toEqual({ - "session-1": "direct", - "session-2": "appended", + "opencode:session-1": "direct", + "opencode:session-2": "appended", }) }) }) @@ -387,7 +386,7 @@ describe("boulder-state", () => { // then expect(result).not.toBeNull() - expect(result?.session_id).toBe("ses_task_123") + expect(result?.session_id).toBe("opencode:ses_task_123") expect(result?.task_title).toBe("Implement auth flow") expect(result?.agent).toBe("sisyphus-junior") expect(result?.category).toBe("deep") @@ -422,7 +421,7 @@ describe("boulder-state", () => { const result = getTaskSessionState(TEST_DIR, "todo:1") // then - expect(result?.session_id).toBe("ses_new") + expect(result?.session_id).toBe("opencode:ses_new") }) }) @@ -542,7 +541,7 @@ describe("boulder-state", () => { // then expect(updated).not.toBeNull() const taskSession = updated?.works?.[workId]?.task_sessions?.["todo:1"] - expect(taskSession?.session_id).toBe("task-session-b") + expect(taskSession?.session_id).toBe("opencode:task-session-b") expect(taskSession?.started_at).toBe("2026-01-01T00:00:00.000Z") }) }) @@ -993,7 +992,7 @@ describe("boulder-state", () => { // then expect(state.active_plan).toBe(planPath) - expect(state.session_ids).toEqual([sessionId]) + expect(state.session_ids).toEqual(["opencode:ses-abc123"]) expect(state.plan_name).toBe("auth-refactor") expect(state.started_at).toBeDefined() }) @@ -1010,7 +1009,7 @@ describe("boulder-state", () => { //#then - state should include the agent field expect(state.agent).toBe("atlas") expect(state.active_plan).toBe(planPath) - expect(state.session_ids).toEqual([sessionId]) + expect(state.session_ids).toEqual(["opencode:ses-xyz789"]) expect(state.plan_name).toBe("feature") }) @@ -1023,7 +1022,7 @@ describe("boulder-state", () => { const state = createBoulderState(planPath, sessionId) // then - expect(state.session_origins).toEqual({ [sessionId]: "direct" }) + expect(state.session_origins).toEqual({ "opencode:ses-origin": "direct" }) }) test("should allow agent to be undefined", () => { @@ -1080,4 +1079,79 @@ describe("boulder-state", () => { expect(resolvedPath).toBe(planPath) }) }) + + describe("platform-prefixed session ids", () => { + test("#given a fresh state with raw session id #when read back #then opencode prefix is stored", () => { + // given + const planPath = join(TEST_DIR, ".omo", "plans", "raw-session.md") + + // when + const state = createBoulderState(planPath, "raw-sess", "atlas", undefined) + writeBoulderState(TEST_DIR, state) + const readBack = readBoulderState(TEST_DIR) + + // then + expect(readBack?.session_ids).toEqual(["opencode:raw-sess"]) + }) + + test("#given a fresh state with codex session id #when read back #then codex prefix is preserved", () => { + // given + const planPath = join(TEST_DIR, ".omo", "plans", "codex-session.md") + + // when + const state = createBoulderState(planPath, "codex:raw-sess", "atlas", undefined) + writeBoulderState(TEST_DIR, state) + const readBack = readBoulderState(TEST_DIR) + + // then + expect(readBack?.session_ids).toEqual(["codex:raw-sess"]) + }) + + test("#given a legacy boulder file with bare session id #when read #then opencode prefix is migrated", () => { + // given + const boulderFile = join(OMO_DIR, "boulder.json") + writeFileSync(boulderFile, JSON.stringify({ + active_plan: "/path/to/legacy.md", + started_at: "2026-01-01T00:00:00Z", + session_ids: ["legacy-bare-id"], + plan_name: "legacy", + })) + + // when + const state = readBoulderState(TEST_DIR) + + // then + expect(state?.session_ids).toEqual(["opencode:legacy-bare-id"]) + }) + + test("#given existing prefixed session #when appending raw session #then appended id receives opencode prefix", () => { + // given + writeBoulderState(TEST_DIR, { + active_plan: "/path/to/plan.md", + started_at: "2026-01-01T00:00:00Z", + session_ids: ["opencode:first"], + plan_name: "plan", + }) + + // when + appendSessionId(TEST_DIR, "another-raw") + const state = readBoulderState(TEST_DIR) + + // then + expect(state?.session_ids).toEqual(["opencode:first", "opencode:another-raw"]) + }) + + test("#given stored work with prefixed session #when looking up by raw id #then matching work is returned", () => { + // given + const planPath = join(TEST_DIR, ".omo", "plans", "lookup.md") + const state = createBoulderState(planPath, "opencode:raw-id", "atlas", undefined) + writeBoulderState(TEST_DIR, state) + + // when + const work = getWorkForSession(TEST_DIR, "raw-id") + + // then + expect(work?.session_ids).toEqual(["opencode:raw-id"]) + }) + }) }) diff --git a/src/features/boulder-state/storage.ts b/src/features/boulder-state/storage.ts index c99e05098..647e23919 100644 --- a/src/features/boulder-state/storage.ts +++ b/src/features/boulder-state/storage.ts @@ -18,6 +18,7 @@ export { getWorkByPlanName, getWorkForSession, getWorkResumeOptions, + normalizeSessionId, readBoulderState, resolveBoulderPlanPath, resolveBoulderPlanPathForWork,