fix: use dynamic message lookup for model/agent context in prompts

Instead of using stale parentModel/parentAgent from task state, now
dynamically looks up the current message to get fresh model/agent values.
Applied across all prompt injection points:
- background-agent notifyParentSession
- ralph-loop continuation
- sisyphus-orchestrator boulder continuation
- sisyphus-task resume

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) assistance
This commit is contained in:
justsisyphus 2026-01-14 18:55:46 +09:00
parent 045fa79d92
commit 54575ad259
5 changed files with 170 additions and 77 deletions

View File

@ -675,119 +675,141 @@ describe("LaunchInput.skillContent", () => {
}) })
}) })
describe("BackgroundManager.notifyParentSession - agent context preservation", () => { interface CurrentMessage {
test("should never pass model field - let OpenCode use session's lastModel", async () => { agent?: string
// #given - task with parentModel defined 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 = { const task: BackgroundTask = {
id: "task-with-model", id: "task-1",
sessionID: "session-child", sessionID: "session-child",
parentSessionID: "session-parent", parentSessionID: "session-parent",
parentMessageID: "msg-parent", parentMessageID: "msg-parent",
description: "task with model context", description: "task with dynamic lookup",
prompt: "test", prompt: "test",
agent: "explore", agent: "explore",
status: "completed", status: "completed",
startedAt: new Date(), startedAt: new Date(),
completedAt: new Date(), completedAt: new Date(),
parentAgent: "Sisyphus", parentAgent: "OldAgent",
parentModel: { providerID: "anthropic", modelID: "claude-opus-4-5" }, parentModel: { providerID: "old", modelID: "old-model" },
}
const currentMessage: CurrentMessage = {
agent: "Sisyphus",
model: { providerID: "anthropic", modelID: "claude-opus-4-5" },
} }
// #when // #when
const promptBody = buildNotificationPromptBody(task) const promptBody = buildNotificationPromptBody(task, currentMessage)
// #then - model MUST NOT be passed (OpenCode uses session's lastModel) // #then - uses currentMessage values, not task.parentModel/parentAgent
expect("model" in promptBody).toBe(false)
expect(promptBody.agent).toBe("Sisyphus") 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 // #given
const task: BackgroundTask = { const task: BackgroundTask = {
id: "task-no-agent", id: "task-2",
sessionID: "session-child", sessionID: "session-child",
parentSessionID: "session-parent", parentSessionID: "session-parent",
parentMessageID: "msg-parent", parentMessageID: "msg-parent",
description: "task without agent context", description: "task fallback agent",
prompt: "test", prompt: "test",
agent: "explore", agent: "explore",
status: "completed", status: "completed",
startedAt: new Date(), startedAt: new Date(),
completedAt: new Date(), completedAt: new Date(),
parentAgent: undefined, parentAgent: "FallbackAgent",
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",
parentModel: undefined, parentModel: undefined,
} }
const currentMessage: CurrentMessage = { agent: undefined, model: undefined }
// #when // #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) 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(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<string, unknown> { function buildNotificationPromptBody(
task: BackgroundTask,
currentMessage: CurrentMessage | null
): Record<string, unknown> {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
parts: [{ type: "text", text: `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished.` }], parts: [{ type: "text", text: `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished.` }],
} }
if (task.parentAgent !== undefined) { const agent = currentMessage?.agent ?? task.parentAgent
body.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 if (agent !== undefined) {
// This prevents model switching when parentModel is undefined or different body.agent = agent
}
if (model !== undefined) {
body.model = model
}
return body return body
} }

View File

@ -11,6 +11,9 @@ import type { BackgroundTaskConfig } from "../../config/schema"
import { subagentSessions } from "../claude-code-session-state" import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager" 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 TASK_TTL_MS = 30 * 60 * 1000
const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in 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
</system-reminder>` </system-reminder>`
} }
// Inject notification via session.prompt with noReply // Dynamically lookup the parent session's current message context
// Don't pass model - let OpenCode use session's existing lastModel (like todo-continuation) // This ensures we use the CURRENT model/agent, not the stale one from task creation time
// This prevents model switching when parentModel is undefined 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 { try {
await this.client.session.prompt({ await this.client.session.prompt({
path: { id: task.parentSessionID }, path: { id: task.parentSessionID },
body: { body: {
noReply: !allComplete, noReply: !allComplete,
...(task.parentAgent !== undefined ? { agent: task.parentAgent } : {}), ...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
parts: [{ type: "text", text: notification }], 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
}

View File

@ -1,5 +1,6 @@
import { existsSync, readFileSync } from "node:fs"
import type { PluginInput } from "@opencode-ai/plugin" 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 { log } from "../../shared/logger"
import { readState, writeState, clearState, incrementIteration } from "./storage" import { readState, writeState, clearState, incrementIteration } from "./storage"
import { import {
@ -9,6 +10,18 @@ import {
} from "./constants" } from "./constants"
import type { RalphLoopState, RalphLoopOptions } from "./types" import type { RalphLoopState, RalphLoopOptions } from "./types"
import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript" 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 "./types"
export * from "./constants" export * from "./constants"
@ -302,9 +315,18 @@ export function createRalphLoopHook(
.catch(() => {}) .catch(() => {})
try { 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({ await ctx.client.session.prompt({
path: { id: sessionID }, path: { id: sessionID },
body: { body: {
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
parts: [{ type: "text", text: continuationPrompt }], parts: [{ type: "text", text: continuationPrompt }],
}, },
query: { directory: ctx.directory }, query: { directory: ctx.directory },

View File

@ -407,10 +407,17 @@ export function createSisyphusOrchestratorHook(
try { try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining }) 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({ await ctx.client.session.prompt({
path: { id: sessionID }, path: { id: sessionID },
body: { body: {
agent: "orchestrator-sisyphus", agent: "orchestrator-sisyphus",
...(model !== undefined ? { model } : {}),
parts: [{ type: "text", text: prompt }], parts: [{ type: "text", text: prompt }],
}, },
query: { directory: ctx.directory }, query: { directory: ctx.directory },

View File

@ -218,9 +218,18 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
}) })
try { 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({ await client.session.prompt({
path: { id: args.resume }, path: { id: args.resume },
body: { body: {
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
...(resumeModel !== undefined ? { model: resumeModel } : {}),
tools: { tools: {
task: false, task: false,
sisyphus_task: false, sisyphus_task: false,