From 28efc4e81f70dfe729a92c69e101261c17159d2c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 25 May 2026 17:48:32 +0900 Subject: [PATCH] fix(continuation): resolve registered agent name before dispatching prompt (#4417) /start-work was unresponsive under Atlas because ralph-loop and todo-continuation-enforcer normalized the inherited agent to a config key (e.g. 'atlas') while OpenCode only accepts the registered display name (e.g. 'Atlas (Plan Executor)'), producing 'Agent not found' on dispatch. Prefer resolveRegisteredAgentName(agent) and fall back to normalizeAgentForPromptKey only when no registration exists, mirroring the start-work hook's resolution chain. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...n-prompt-injector-agent-resolution.test.ts | 47 ++++++++++++++++ .../continuation-prompt-injector.ts | 21 +++----- ...nuation-injection-agent-resolution.test.ts | 54 +++++++++++++++++++ .../continuation-injection.ts | 7 ++- 4 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 src/hooks/ralph-loop/continuation-prompt-injector-agent-resolution.test.ts create mode 100644 src/hooks/todo-continuation-enforcer/continuation-injection-agent-resolution.test.ts diff --git a/src/hooks/ralph-loop/continuation-prompt-injector-agent-resolution.test.ts b/src/hooks/ralph-loop/continuation-prompt-injector-agent-resolution.test.ts new file mode 100644 index 000000000..8b5af4070 --- /dev/null +++ b/src/hooks/ralph-loop/continuation-prompt-injector-agent-resolution.test.ts @@ -0,0 +1,47 @@ +/// + +import { afterEach, describe, expect, test } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" + +import { + _resetForTesting, + registerAgentName, +} from "../../features/claude-code-session-state" +import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" +import { injectContinuationPrompt } from "./continuation-prompt-injector" + +describe("ralph-loop continuation prompt agent resolution", () => { + afterEach(() => { + releaseAllPromptAsyncReservationsForTesting() + _resetForTesting() + }) + + test("#given OpenCode registered Atlas under legacy display name #when inherited agent is config key #then prompt uses registered name", async () => { + // given + registerAgentName("Atlas (Plan Executor)") + let capturedAgent: string | undefined + const ctx = unsafeTestValue({ + client: { + session: { + messages: async () => ({ data: [{ info: { agent: "atlas" } }] }), + promptAsync: async (input: { readonly body: { readonly agent?: string } }) => { + capturedAgent = input.body.agent + return {} + }, + }, + }, + }) + + // when + await injectContinuationPrompt(ctx, { + sessionID: "ses_ralph_registered_atlas", + prompt: "continue", + directory: "/tmp/test", + apiTimeoutMs: 50, + }) + + // then + expect(capturedAgent).toBe("Atlas (Plan Executor)") + }) +}) diff --git a/src/hooks/ralph-loop/continuation-prompt-injector.ts b/src/hooks/ralph-loop/continuation-prompt-injector.ts index 7d54543cb..8d384a33a 100644 --- a/src/hooks/ralph-loop/continuation-prompt-injector.ts +++ b/src/hooks/ralph-loop/continuation-prompt-injector.ts @@ -10,7 +10,8 @@ import { normalizeSDKResponse, resolveInheritedPromptTools, } from "../../shared" -import { normalizeAgentForPrompt, stripAgentListSortPrefix } from "../../shared/agent-display-names" +import { resolveRegisteredAgentName } from "../../features/claude-code-session-state" +import { normalizeAgentForPromptKey, stripAgentListSortPrefix } from "../../shared/agent-display-names" import { dispatchInternalPrompt } from "../shared/prompt-async-gate" type MessageInfo = { @@ -62,20 +63,10 @@ function createPromptAsyncError(prefix: string, error: unknown): Error { } function normalizeInheritedAgentForPrompt(agent: string | undefined): string | undefined { - if (typeof agent !== "string") { - return undefined - } - - const inheritedAgent = stripAgentListSortPrefix(agent).trim() - if (!inheritedAgent) { - return undefined - } - - if (inheritedAgent.includes(" - ")) { - return inheritedAgent - } - - return normalizeAgentForPrompt(inheritedAgent) + const resolvedAgent = resolveRegisteredAgentName(agent) ?? normalizeAgentForPromptKey(agent) + if (typeof resolvedAgent !== "string") return undefined + const cleanAgent = stripAgentListSortPrefix(resolvedAgent).trim() + return cleanAgent || undefined } export async function injectContinuationPrompt( diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection-agent-resolution.test.ts b/src/hooks/todo-continuation-enforcer/continuation-injection-agent-resolution.test.ts new file mode 100644 index 000000000..3cc5df7cb --- /dev/null +++ b/src/hooks/todo-continuation-enforcer/continuation-injection-agent-resolution.test.ts @@ -0,0 +1,54 @@ +/// + +import { afterEach, describe, expect, test } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" + +import { + _resetForTesting, + registerAgentName, +} from "../../features/claude-code-session-state" +import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" +import { injectContinuation } from "./continuation-injection" + +describe("todo continuation registered agent resolution", () => { + afterEach(() => { + releaseAllPromptAsyncReservationsForTesting() + _resetForTesting() + }) + + test("#given OpenCode registered Atlas under legacy display name #when continuation inherits config key #then prompt uses registered name", async () => { + // given + registerAgentName("Atlas (Plan Executor)") + let capturedAgent: string | undefined + const ctx = unsafeTestValue({ + directory: "/tmp/test", + client: { + session: { + todo: async () => ({ data: [{ id: "1", content: "todo", status: "pending", priority: "high" }] }), + promptAsync: async (input: { readonly body: { readonly agent?: string } }) => { + capturedAgent = input.body.agent + return {} + }, + }, + }, + }) + const sessionStateStore = { + getExistingState: () => ({ inFlight: false, lastInjectedAt: 0, consecutiveFailures: 0 }), + } + + // when + await injectContinuation({ + ctx, + sessionID: "ses_todo_registered_atlas", + resolvedInfo: { + agent: "atlas", + model: { providerID: "openai", modelID: "gpt-5.5" }, + }, + sessionStateStore: unsafeTestValue(sessionStateStore), + }) + + // then + expect(capturedAgent).toBe("Atlas (Plan Executor)") + }) +}) diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index f22da95f7..8b799595d 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -20,8 +20,8 @@ import { log } from "../../shared/logger" import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { getAgentConfigKey, - normalizeAgentForPrompt, normalizeAgentForPromptKey, + stripAgentListSortPrefix, } from "../../shared/agent-display-names" import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate" @@ -132,9 +132,8 @@ export async function injectContinuation(args: { tools = tools ?? previousMessage?.tools } - const promptAgent = normalizeAgentForPromptKey(agentName) - const resolvedAgent = resolveRegisteredAgentName(agentName) - const launchAgent = normalizeAgentForPrompt(resolvedAgent ?? agentName) + const promptAgent = resolveRegisteredAgentName(agentName) ?? normalizeAgentForPromptKey(agentName) + const launchAgent = promptAgent ? stripAgentListSortPrefix(promptAgent).trim() || undefined : undefined if (promptAgent && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(promptAgent))) { log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })