diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 240fd187b..679274385 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -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 | undefined + const client = { + session: { + prompt: async () => ({}), + promptAsync: async (args: { body: Record }) => { + 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 }) + .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 = { diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 0fe24d936..7ebe6176b 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -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="")\` to retrieve each result. ` - } else { - // Individual completion - silent notification - notification = ` + } else { + // Individual completion - silent notification + notification = ` [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. ` - } + } 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 +}