Skip to content
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 @@ -1316,20 +1319,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 @@ -1339,7 +1342,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
Loading