diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index cbab1a6bc..0aeedf6b7 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -675,119 +675,141 @@ describe("LaunchInput.skillContent", () => { }) }) -describe("BackgroundManager.notifyParentSession - agent context preservation", () => { - test("should never pass model field - let OpenCode use session's lastModel", async () => { - // #given - task with parentModel defined +interface CurrentMessage { + agent?: string + model?: { providerID?: string; modelID?: string } +} + +describe("BackgroundManager.notifyParentSession - dynamic message lookup", () => { + test("should use currentMessage model/agent when available", async () => { + // #given - currentMessage has model and agent const task: BackgroundTask = { - id: "task-with-model", + id: "task-1", sessionID: "session-child", parentSessionID: "session-parent", parentMessageID: "msg-parent", - description: "task with model context", + description: "task with dynamic lookup", prompt: "test", agent: "explore", status: "completed", startedAt: new Date(), completedAt: new Date(), - parentAgent: "Sisyphus", - parentModel: { providerID: "anthropic", modelID: "claude-opus-4-5" }, + parentAgent: "OldAgent", + parentModel: { providerID: "old", modelID: "old-model" }, + } + const currentMessage: CurrentMessage = { + agent: "Sisyphus", + model: { providerID: "anthropic", modelID: "claude-opus-4-5" }, } // #when - const promptBody = buildNotificationPromptBody(task) + const promptBody = buildNotificationPromptBody(task, currentMessage) - // #then - model MUST NOT be passed (OpenCode uses session's lastModel) - expect("model" in promptBody).toBe(false) + // #then - uses currentMessage values, not task.parentModel/parentAgent expect(promptBody.agent).toBe("Sisyphus") + expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-5" }) }) - test("should not pass agent field when parentAgent is undefined", async () => { + test("should fallback to parentAgent when currentMessage.agent is undefined", async () => { // #given const task: BackgroundTask = { - id: "task-no-agent", + id: "task-2", sessionID: "session-child", parentSessionID: "session-parent", parentMessageID: "msg-parent", - description: "task without agent context", + description: "task fallback agent", prompt: "test", agent: "explore", status: "completed", startedAt: new Date(), completedAt: new Date(), - parentAgent: undefined, - parentModel: { providerID: "anthropic", modelID: "claude-opus" }, - } - - // #when - const promptBody = buildNotificationPromptBody(task) - - // #then - no agent, no model (let OpenCode handle) - expect("agent" in promptBody).toBe(false) - expect("model" in promptBody).toBe(false) - }) - - test("should include agent field when parentAgent is defined", async () => { - // #given - const task: BackgroundTask = { - id: "task-with-agent", - sessionID: "session-child", - parentSessionID: "session-parent", - parentMessageID: "msg-parent", - description: "task with agent context", - prompt: "test", - agent: "explore", - status: "completed", - startedAt: new Date(), - completedAt: new Date(), - parentAgent: "Sisyphus", - parentModel: { providerID: "anthropic", modelID: "claude-opus" }, - } - - // #when - const promptBody = buildNotificationPromptBody(task) - - // #then - expect(promptBody.agent).toBe("Sisyphus") - expect("model" in promptBody).toBe(false) - }) - - test("should not pass model field even when parentModel is undefined", async () => { - // #given - const task: BackgroundTask = { - id: "task-no-model", - sessionID: "session-child", - parentSessionID: "session-parent", - parentMessageID: "msg-parent", - description: "task without model context", - prompt: "test", - agent: "explore", - status: "completed", - startedAt: new Date(), - completedAt: new Date(), - parentAgent: "Sisyphus", + parentAgent: "FallbackAgent", parentModel: undefined, } + const currentMessage: CurrentMessage = { agent: undefined, model: undefined } // #when - const promptBody = buildNotificationPromptBody(task) + const promptBody = buildNotificationPromptBody(task, currentMessage) - // #then - model never passed regardless of parentModel + // #then - falls back to task.parentAgent + expect(promptBody.agent).toBe("FallbackAgent") expect("model" in promptBody).toBe(false) + }) + + test("should not pass model when currentMessage.model is incomplete", async () => { + // #given - model missing modelID + const task: BackgroundTask = { + id: "task-3", + sessionID: "session-child", + parentSessionID: "session-parent", + parentMessageID: "msg-parent", + description: "task incomplete model", + prompt: "test", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + parentAgent: "Sisyphus", + parentModel: { providerID: "anthropic", modelID: "claude-opus" }, + } + const currentMessage: CurrentMessage = { + agent: "Sisyphus", + model: { providerID: "anthropic" }, + } + + // #when + const promptBody = buildNotificationPromptBody(task, currentMessage) + + // #then - model not passed due to incomplete data expect(promptBody.agent).toBe("Sisyphus") + expect("model" in promptBody).toBe(false) + }) + + test("should handle null currentMessage gracefully", async () => { + // #given - no message found (messageDir lookup failed) + const task: BackgroundTask = { + id: "task-4", + sessionID: "session-child", + parentSessionID: "session-parent", + parentMessageID: "msg-parent", + description: "task no message", + prompt: "test", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + parentAgent: "Sisyphus", + parentModel: { providerID: "anthropic", modelID: "claude-opus" }, + } + + // #when + const promptBody = buildNotificationPromptBody(task, null) + + // #then - falls back to task.parentAgent, no model + expect(promptBody.agent).toBe("Sisyphus") + expect("model" in promptBody).toBe(false) }) }) -function buildNotificationPromptBody(task: BackgroundTask): Record { +function buildNotificationPromptBody( + task: BackgroundTask, + currentMessage: CurrentMessage | null +): Record { const body: Record = { parts: [{ type: "text", text: `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished.` }], } - if (task.parentAgent !== undefined) { - body.agent = task.parentAgent - } + const agent = currentMessage?.agent ?? task.parentAgent + const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } + : undefined - // Don't pass model - let OpenCode use session's existing lastModel - // This prevents model switching when parentModel is undefined or different + if (agent !== undefined) { + body.agent = agent + } + if (model !== undefined) { + body.model = model + } return body } diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index f0322e483..16c38d037 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -11,6 +11,9 @@ import type { BackgroundTaskConfig } from "../../config/schema" 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 { join } from "node:path" const TASK_TTL_MS = 30 * 60 * 1000 const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in @@ -638,15 +641,32 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea ` } - // Inject notification via session.prompt with noReply - // Don't pass model - let OpenCode use session's existing lastModel (like todo-continuation) - // This prevents model switching when parentModel is undefined + // Dynamically lookup the parent session's current message context + // This ensures we use the CURRENT model/agent, not the stale one from task creation time + const messageDir = getMessageDir(task.parentSessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + + const agent = currentMessage?.agent ?? task.parentAgent + const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } + : undefined + + log("[background-agent] notifyParentSession context:", { + taskId: task.id, + messageDir: !!messageDir, + currentAgent: currentMessage?.agent, + currentModel: currentMessage?.model, + resolvedAgent: agent, + resolvedModel: model, + }) + try { await this.client.session.prompt({ path: { id: task.parentSessionID }, body: { noReply: !allComplete, - ...(task.parentAgent !== undefined ? { agent: task.parentAgent } : {}), + ...(agent !== undefined ? { agent } : {}), + ...(model !== undefined ? { model } : {}), parts: [{ type: "text", text: notification }], }, }) @@ -841,3 +861,16 @@ if (lastMessage) { } } } + +function getMessageDir(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null + + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) return directPath + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) return sessionPath + } + return null +} diff --git a/src/hooks/ralph-loop/index.ts b/src/hooks/ralph-loop/index.ts index 6115caf22..6fcc31c9b 100644 --- a/src/hooks/ralph-loop/index.ts +++ b/src/hooks/ralph-loop/index.ts @@ -1,5 +1,6 @@ -import { existsSync, readFileSync } from "node:fs" import type { PluginInput } from "@opencode-ai/plugin" +import { existsSync, readFileSync, readdirSync } from "node:fs" +import { join } from "node:path" import { log } from "../../shared/logger" import { readState, writeState, clearState, incrementIteration } from "./storage" import { @@ -9,6 +10,18 @@ import { } from "./constants" import type { RalphLoopState, RalphLoopOptions } from "./types" import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript" +import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" + +function getMessageDir(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) return directPath + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) return sessionPath + } + return null +} export * from "./types" export * from "./constants" @@ -302,9 +315,18 @@ export function createRalphLoopHook( .catch(() => {}) try { + const messageDir = getMessageDir(sessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + const agent = currentMessage?.agent + const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } + : undefined + await ctx.client.session.prompt({ path: { id: sessionID }, body: { + ...(agent !== undefined ? { agent } : {}), + ...(model !== undefined ? { model } : {}), parts: [{ type: "text", text: continuationPrompt }], }, query: { directory: ctx.directory }, diff --git a/src/hooks/sisyphus-orchestrator/index.ts b/src/hooks/sisyphus-orchestrator/index.ts index c6677ba7d..7e3be54aa 100644 --- a/src/hooks/sisyphus-orchestrator/index.ts +++ b/src/hooks/sisyphus-orchestrator/index.ts @@ -407,10 +407,17 @@ export function createSisyphusOrchestratorHook( try { log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining }) + const messageDir = getMessageDir(sessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } + : undefined + await ctx.client.session.prompt({ path: { id: sessionID }, body: { agent: "orchestrator-sisyphus", + ...(model !== undefined ? { model } : {}), parts: [{ type: "text", text: prompt }], }, query: { directory: ctx.directory }, diff --git a/src/tools/sisyphus-task/tools.ts b/src/tools/sisyphus-task/tools.ts index 6127cf526..d4b720793 100644 --- a/src/tools/sisyphus-task/tools.ts +++ b/src/tools/sisyphus-task/tools.ts @@ -218,9 +218,18 @@ Use \`background_output\` with task_id="${task.id}" to check progress.` }) try { + const resumeMessageDir = getMessageDir(args.resume) + const resumeMessage = resumeMessageDir ? findNearestMessageWithFields(resumeMessageDir) : null + const resumeAgent = resumeMessage?.agent + const resumeModel = resumeMessage?.model?.providerID && resumeMessage?.model?.modelID + ? { providerID: resumeMessage.model.providerID, modelID: resumeMessage.model.modelID } + : undefined + await client.session.prompt({ path: { id: args.resume }, body: { + ...(resumeAgent !== undefined ? { agent: resumeAgent } : {}), + ...(resumeModel !== undefined ? { model: resumeModel } : {}), tools: { task: false, sisyphus_task: false,