Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { mkdtempSync, writeFileSync, rmSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
import {
isCompactionAgent,
findNearestMessageExcludingCompaction,
resolvePromptContextFromSessionMessages,
} from "./compaction-aware-message-resolver"
import {
clearCompactionAgentConfigCheckpoint,
setCompactionAgentConfigCheckpoint,
} from "../../shared/compaction-agent-config-checkpoint"

describe("isCompactionAgent", () => {
describe("#given agent name variations", () => {
Expand Down Expand Up @@ -65,6 +73,7 @@ describe("findNearestMessageExcludingCompaction", () => {

afterEach(() => {
rmSync(tempDir, { force: true, recursive: true })
clearCompactionAgentConfigCheckpoint("ses_checkpoint")
})

describe("#given directory with messages", () => {
Expand Down Expand Up @@ -186,5 +195,65 @@ describe("findNearestMessageExcludingCompaction", () => {
expect(result).not.toBeNull()
expect(result?.agent).toBe("newer")
})

test("merges partial metadata from multiple recent messages", () => {
// given
writeFileSync(
join(tempDir, "003.json"),
JSON.stringify({ model: { providerID: "anthropic", modelID: "claude-opus-4-1" } }),
)
writeFileSync(join(tempDir, "002.json"), JSON.stringify({ agent: "atlas" }))
writeFileSync(join(tempDir, "001.json"), JSON.stringify({ tools: { bash: true } }))

// when
const result = findNearestMessageExcludingCompaction(tempDir)

// then
expect(result).toEqual({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
tools: { bash: true },
})
})

test("fills missing metadata from compaction checkpoint", () => {
// given
setCompactionAgentConfigCheckpoint("ses_checkpoint", {
agent: "sisyphus",
model: { providerID: "openai", modelID: "gpt-5" },
})
writeFileSync(join(tempDir, "001.json"), JSON.stringify({ tools: { bash: true } }))

// when
const result = findNearestMessageExcludingCompaction(tempDir, "ses_checkpoint")

// then
expect(result).toEqual({
agent: "sisyphus",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
})
})
})
})

describe("resolvePromptContextFromSessionMessages", () => {
test("merges partial prompt context from recent SDK messages", () => {
// given
const messages = [
{ info: { agent: "atlas" } },
{ info: { model: { providerID: "anthropic", modelID: "claude-opus-4-1" } } },
{ info: { tools: { bash: true } } },
]

// when
const result = resolvePromptContextFromSessionMessages(messages)

// then
expect(result).toEqual({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-1" },
tools: { bash: true },
})
})
})
134 changes: 114 additions & 20 deletions src/features/background-agent/compaction-aware-message-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import { readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import type { StoredMessage } from "../hook-message-injector"
import { getCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"

type SessionMessage = {
info?: {
agent?: string
model?: {
providerID?: string
modelID?: string
variant?: string
}
providerID?: string
modelID?: string
tools?: StoredMessage["tools"]
}
}

export function isCompactionAgent(agent: string | undefined): boolean {
return agent?.trim().toLowerCase() === "compaction"
Expand All @@ -16,42 +31,121 @@ function hasFullAgentAndModel(message: StoredMessage): boolean {
function hasPartialAgentOrModel(message: StoredMessage): boolean {
const hasAgent = !!message.agent && !isCompactionAgent(message.agent)
const hasModel = !!message.model?.providerID && !!message.model?.modelID
return hasAgent || hasModel
return hasAgent || hasModel || !!message.tools
}

export function findNearestMessageExcludingCompaction(messageDir: string): StoredMessage | null {
function convertSessionMessageToStoredMessage(message: SessionMessage): StoredMessage | null {
const info = message.info
if (!info) {
return null
}

const providerID = info.model?.providerID ?? info.providerID
const modelID = info.model?.modelID ?? info.modelID

return {
...(info.agent ? { agent: info.agent } : {}),
...(providerID && modelID
? {
model: {
providerID,
modelID,
...(info.model?.variant ? { variant: info.model.variant } : {}),
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Opencode Compatibility

The variant property in the OpenCode SDK is located at the root of the message object (info.variant), not within model. Reading info.model?.variant will silently fail to recover the agent variant from OpenCode message responses. Update the SessionMessage type and this mapping logic to extract variant directly from info.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/background-agent/compaction-aware-message-resolver.ts, line 53:

<comment>The `variant` property in the OpenCode SDK is located at the root of the message object (`info.variant`), not within `model`. Reading `info.model?.variant` will silently fail to recover the agent variant from OpenCode message responses. Update the `SessionMessage` type and this mapping logic to extract `variant` directly from `info`.</comment>

<file context>
@@ -16,42 +31,121 @@ function hasFullAgentAndModel(message: StoredMessage): boolean {
+          model: {
+            providerID,
+            modelID,
+            ...(info.model?.variant ? { variant: info.model.variant } : {}),
+          },
+        }
</file context>
Fix with Cubic

},
}
: {}),
...(info.tools ? { tools: info.tools } : {}),
}
}

function mergeStoredMessages(
messages: Array<StoredMessage | null>,
sessionID?: string,
): StoredMessage | null {
const merged: StoredMessage = {}

for (const message of messages) {
if (!message || isCompactionAgent(message.agent)) {
continue
}

if (!merged.agent && message.agent) {
merged.agent = message.agent
}

if (!merged.model?.providerID && message.model?.providerID && message.model.modelID) {
merged.model = {
providerID: message.model.providerID,
modelID: message.model.modelID,
...(message.model.variant ? { variant: message.model.variant } : {}),
}
}

if (!merged.tools && message.tools) {
merged.tools = message.tools
}

if (hasFullAgentAndModel(merged) && merged.tools) {
break
}
}

const checkpoint = sessionID
? getCompactionAgentConfigCheckpoint(sessionID)
: undefined

if (!merged.agent && checkpoint?.agent) {
merged.agent = checkpoint.agent
}

if (!merged.model && checkpoint?.model) {
merged.model = {
providerID: checkpoint.model.providerID,
modelID: checkpoint.model.modelID,
}
}

if (!merged.tools && checkpoint?.tools) {
merged.tools = checkpoint.tools
}

return hasPartialAgentOrModel(merged) ? merged : null
}

export function resolvePromptContextFromSessionMessages(
messages: SessionMessage[],
sessionID?: string,
): StoredMessage | null {
const convertedMessages = messages
.map(convertSessionMessageToStoredMessage)
.reverse()

return mergeStoredMessages(convertedMessages, sessionID)
}

export function findNearestMessageExcludingCompaction(
messageDir: string,
sessionID?: string,
): StoredMessage | null {
try {
const files = readdirSync(messageDir)
.filter((name) => name.endsWith(".json"))
.filter((name: string) => name.endsWith(".json"))
.sort()
.reverse()

for (const file of files) {
try {
const content = readFileSync(join(messageDir, file), "utf-8")
const parsed = JSON.parse(content) as StoredMessage
if (hasFullAgentAndModel(parsed)) {
return parsed
}
} catch {
continue
}
}
const messages: Array<StoredMessage | null> = []
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Synchronously reading and parsing all message files before merging blocks the event loop and degrades performance for long sessions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/features/background-agent/compaction-aware-message-resolver.ts, line 136:

<comment>Synchronously reading and parsing all message files before merging blocks the event loop and degrades performance for long sessions.</comment>

<file context>
@@ -16,42 +31,121 @@ function hasFullAgentAndModel(message: StoredMessage): boolean {
-        continue
-      }
-    }
+    const messages: Array<StoredMessage | null> = []
 
     for (const file of files) {
</file context>
Fix with Cubic


for (const file of files) {
try {
const content = readFileSync(join(messageDir, file), "utf-8")
const parsed = JSON.parse(content) as StoredMessage
if (hasPartialAgentOrModel(parsed)) {
return parsed
}
messages.push(JSON.parse(content) as StoredMessage)
} catch {
continue
}
}

return mergeStoredMessages(messages, sessionID)
} catch {
return null
}

return null
}
35 changes: 20 additions & 15 deletions src/features/background-agent/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ import {
} from "./error-classifier"
import { tryFallbackRetry } from "./fallback-retry-handler"
import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
import { isCompactionAgent, findNearestMessageExcludingCompaction } from "./compaction-aware-message-resolver"
import {
findNearestMessageExcludingCompaction,
resolvePromptContextFromSessionMessages,
} from "./compaction-aware-message-resolver"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import { MESSAGE_STORAGE } from "../hook-message-injector"
import { join } from "node:path"
Expand Down Expand Up @@ -1323,20 +1326,20 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
tools?: Record<string, boolean | "allow" | "deny" | "ask">
}
}>)
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (isCompactionAgent(info?.agent)) {
continue
}
const normalizedTools = isRecord(info?.tools)
? normalizePromptTools(info.tools as Record<string, boolean | "allow" | "deny" | "ask">)
const promptContext = resolvePromptContextFromSessionMessages(
messages,
task.parentSessionID,
)
const normalizedTools = isRecord(promptContext?.tools)
? normalizePromptTools(promptContext.tools)
: undefined

if (promptContext?.agent || promptContext?.model || normalizedTools) {
agent = promptContext?.agent ?? task.parentAgent
model = promptContext?.model?.providerID && promptContext.model.modelID
? { providerID: promptContext.model.providerID, modelID: promptContext.model.modelID }
: undefined
if (info?.agent || info?.model || (info?.modelID && info?.providerID) || normalizedTools) {
agent = info?.agent ?? task.parentAgent
model = info?.model ?? (info?.providerID && info?.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
tools = normalizedTools ?? tools
break
}
tools = normalizedTools ?? tools
}
} catch (error) {
if (isAbortedSessionError(error)) {
Expand All @@ -1346,7 +1349,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
})
}
const messageDir = join(MESSAGE_STORAGE, task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageExcludingCompaction(messageDir) : null
const currentMessage = messageDir
? findNearestMessageExcludingCompaction(messageDir, task.parentSessionID)
: null
agent = currentMessage?.agent ?? task.parentAgent
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
Expand Down
56 changes: 56 additions & 0 deletions src/hooks/compaction-context-injector/compaction-context-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
createSystemDirective,
SystemDirectiveTypes,
} from "../../shared/system-directive"

export const COMPACTION_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)}

When summarizing this session, you MUST include the following sections in your summary:

## 1. User Requests (As-Is)
- List all original user requests exactly as they were stated
- Preserve the user's exact wording and intent

## 2. Final Goal
- What the user ultimately wanted to achieve
- The end result or deliverable expected

## 3. Work Completed
- What has been done so far
- Files created/modified
- Features implemented
- Problems solved

## 4. Remaining Tasks
- What still needs to be done
- Pending items from the original request
- Follow-up tasks identified during the work

## 5. Active Working Context (For Seamless Continuation)
- **Files**: Paths of files currently being edited or frequently referenced
- **Code in Progress**: Key code snippets, function signatures, or data structures under active development
- **External References**: Documentation URLs, library APIs, or external resources being consulted
- **State & Variables**: Important variable names, configuration values, or runtime state relevant to ongoing work

## 6. Explicit Constraints (Verbatim Only)
- Include ONLY constraints explicitly stated by the user or in existing AGENTS.md context
- Quote constraints verbatim (do not paraphrase)
- Do NOT invent, add, or modify constraints
- If no explicit constraints exist, write "None"

## 7. Agent Verification State (Critical for Reviewers)
- **Current Agent**: What agent is running (momus, oracle, etc.)
- **Verification Progress**: Files already verified/validated
- **Pending Verifications**: Files still needing verification
- **Previous Rejections**: If reviewer agent, what was rejected and why
- **Acceptance Status**: Current state of review process

This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity.

## 8. Delegated Agent Sessions
- List ALL background agent tasks spawned during this session
- For each: agent name, category, status, description, and **session_id**
- **RESUME, DON'T RESTART.** Each listed session retains full context. After compaction, use \`session_id\` to continue existing agent sessions instead of spawning new ones. This saves tokens, preserves learned context, and prevents duplicate work.

This context is critical for maintaining continuity after compaction.
`
5 changes: 5 additions & 0 deletions src/hooks/compaction-context-injector/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const HOOK_NAME = "compaction-context-injector"
export const AGENT_RECOVERY_PROMPT = "[restore checkpointed session agent configuration after compaction]"
export const NO_TEXT_TAIL_THRESHOLD = 5
export const RECOVERY_COOLDOWN_MS = 60_000
export const RECENT_COMPACTION_WINDOW_MS = 10 * 60 * 1000
Loading
Loading