fix: skip compaction messages in parent-session context lookup

This commit is contained in:
YeonGyu-Kim
2026-02-17 01:36:52 +09:00
parent e1e449164a
commit 285d8d58dd
2 changed files with 185 additions and 72 deletions

View File

@@ -805,6 +805,62 @@ interface CurrentMessage {
}
describe("BackgroundManager.notifyParentSession - dynamic message lookup", () => {
test("should skip compaction agent and use nearest non-compaction message", async () => {
//#given
let capturedBody: Record<string, unknown> | undefined
const client = {
session: {
prompt: async () => ({}),
promptAsync: async (args: { body: Record<string, unknown> }) => {
capturedBody = args.body
return {}
},
abort: async () => ({}),
messages: async () => ({
data: [
{
info: {
agent: "sisyphus",
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
},
},
{
info: {
agent: "compaction",
model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
},
},
],
}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-skip-compaction",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task with compaction at tail",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
parentAgent: "fallback-agent",
}
getPendingByParent(manager).set("session-parent", new Set([task.id, "still-running"]))
//#when
await (manager as unknown as { notifyParentSession: (value: BackgroundTask) => Promise<void> })
.notifyParentSession(task)
//#then
expect(capturedBody?.agent).toBe("sisyphus")
expect(capturedBody?.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
manager.shutdown()
})
test("should use currentMessage model/agent when available", async () => {
// given - currentMessage has model and agent
const task: BackgroundTask = {

View File

@@ -23,8 +23,8 @@ import {
import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../hook-message-injector"
import { existsSync, readdirSync } from "node:fs"
import { MESSAGE_STORAGE, type StoredMessage } from "../hook-message-injector"
import { existsSync, readFileSync, readdirSync } from "node:fs"
import { join } from "node:path"
type ProcessCleanupEvent = NodeJS.Signals | "beforeExit" | "exit"
@@ -1211,12 +1211,11 @@ export class BackgroundManager {
.filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running" && t.status !== "pending")
: []
if (this.enableParentSessionNotifications) {
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
const errorInfo = task.error ? `\n**Error:** ${task.error}` : ""
const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED"
const errorInfo = task.error ? `\n**Error:** ${task.error}` : ""
let notification: string
if (allComplete) {
let notification: string
if (allComplete) {
const completedTasksText = completedTasks
.map(t => `- \`${t.id}\`: ${t.description}`)
.join("\n")
@@ -1229,9 +1228,9 @@ ${completedTasksText || `- \`${task.id}\`: ${task.description}`}
Use \`background_output(task_id="<id>")\` to retrieve each result.
</system-reminder>`
} else {
// Individual completion - silent notification
notification = `<system-reminder>
} else {
// Individual completion - silent notification
notification = `<system-reminder>
[BACKGROUND TASK ${statusText}]
**ID:** \`${task.id}\`
**Description:** ${task.description}
@@ -1242,77 +1241,81 @@ Do NOT poll - continue productive work.
Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready.
</system-reminder>`
}
}
let agent: string | undefined = task.parentAgent
let model: { providerID: string; modelID: string } | undefined
try {
const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } })
const messages = normalizeSDKResponse(messagesResp, [] as Array<{
info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
}>)
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
agent = info.agent ?? task.parentAgent
model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
break
if (this.enableParentSessionNotifications) {
try {
const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } })
const messages = normalizeSDKResponse(messagesResp, [] as Array<{
info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
}>)
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (isCompactionAgent(info?.agent)) {
continue
}
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
agent = info.agent ?? task.parentAgent
model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
break
}
}
} catch (error) {
if (this.isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
}
const messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageExcludingCompaction(messageDir) : null
agent = currentMessage?.agent ?? task.parentAgent
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
}
log("[background-agent] notifyParentSession context:", {
taskId: task.id,
resolvedAgent: agent,
resolvedModel: model,
})
try {
await this.client.session.promptAsync({
path: { id: task.parentSessionID },
body: {
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(task.parentTools ? { tools: task.parentTools } : {}),
parts: [{ type: "text", text: notification }],
},
})
log("[background-agent] Sent notification to parent session:", {
taskId: task.id,
allComplete,
noReply: !allComplete,
})
} catch (error) {
if (this.isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
} else {
log("[background-agent] Failed to send notification:", error)
}
}
} catch (error) {
if (this.isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
}
const messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
agent = currentMessage?.agent ?? task.parentAgent
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
: undefined
}
log("[background-agent] notifyParentSession context:", {
taskId: task.id,
resolvedAgent: agent,
resolvedModel: model,
})
try {
await this.client.session.promptAsync({
path: { id: task.parentSessionID },
body: {
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(task.parentTools ? { tools: task.parentTools } : {}),
parts: [{ type: "text", text: notification }],
},
})
log("[background-agent] Sent notification to parent session:", {
} else {
log("[background-agent] Parent session notifications disabled, skipping prompt injection:", {
taskId: task.id,
allComplete,
noReply: !allComplete,
parentSessionID: task.parentSessionID,
})
} catch (error) {
if (this.isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
} else {
log("[background-agent] Failed to send notification:", error)
}
}
} else {
log("[background-agent] Parent session notifications disabled, skipping prompt injection:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
}
if (allComplete) {
for (const completedTask of completedTasks) {
@@ -1715,3 +1718,57 @@ function getMessageDir(sessionID: string): string | null {
}
return null
}
function isCompactionAgent(agent: string | undefined): boolean {
return agent?.trim().toLowerCase() === "compaction"
}
function hasFullAgentAndModel(message: StoredMessage): boolean {
return !!message.agent &&
!isCompactionAgent(message.agent) &&
!!message.model?.providerID &&
!!message.model?.modelID
}
function hasPartialAgentOrModel(message: StoredMessage): boolean {
const hasAgent = !!message.agent && !isCompactionAgent(message.agent)
const hasModel = !!message.model?.providerID && !!message.model?.modelID
return hasAgent || hasModel
}
function findNearestMessageExcludingCompaction(messageDir: string): StoredMessage | null {
try {
const files = readdirSync(messageDir)
.filter((name) => 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
}
}
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
}
} catch {
continue
}
}
} catch {
return null
}
return null
}