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:
parent
045fa79d92
commit
54575ad259
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user