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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-05-25 17:48:32 +09:00
parent a5cee76961
commit 28efc4e81f
4 changed files with 110 additions and 19 deletions
@@ -0,0 +1,47 @@
/// <reference types="bun-types" />
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<PluginInput>({
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)")
})
})
@@ -10,7 +10,8 @@ import {
normalizeSDKResponse, normalizeSDKResponse,
resolveInheritedPromptTools, resolveInheritedPromptTools,
} from "../../shared" } 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" import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
type MessageInfo = { type MessageInfo = {
@@ -62,20 +63,10 @@ function createPromptAsyncError(prefix: string, error: unknown): Error {
} }
function normalizeInheritedAgentForPrompt(agent: string | undefined): string | undefined { function normalizeInheritedAgentForPrompt(agent: string | undefined): string | undefined {
if (typeof agent !== "string") { const resolvedAgent = resolveRegisteredAgentName(agent) ?? normalizeAgentForPromptKey(agent)
return undefined if (typeof resolvedAgent !== "string") return undefined
} const cleanAgent = stripAgentListSortPrefix(resolvedAgent).trim()
return cleanAgent || undefined
const inheritedAgent = stripAgentListSortPrefix(agent).trim()
if (!inheritedAgent) {
return undefined
}
if (inheritedAgent.includes(" - ")) {
return inheritedAgent
}
return normalizeAgentForPrompt(inheritedAgent)
} }
export async function injectContinuationPrompt( export async function injectContinuationPrompt(
@@ -0,0 +1,54 @@
/// <reference types="bun-types" />
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<PluginInput>({
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)")
})
})
@@ -20,8 +20,8 @@ import { log } from "../../shared/logger"
import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { import {
getAgentConfigKey, getAgentConfigKey,
normalizeAgentForPrompt,
normalizeAgentForPromptKey, normalizeAgentForPromptKey,
stripAgentListSortPrefix,
} from "../../shared/agent-display-names" } from "../../shared/agent-display-names"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate" import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
@@ -132,9 +132,8 @@ export async function injectContinuation(args: {
tools = tools ?? previousMessage?.tools tools = tools ?? previousMessage?.tools
} }
const promptAgent = normalizeAgentForPromptKey(agentName) const promptAgent = resolveRegisteredAgentName(agentName) ?? normalizeAgentForPromptKey(agentName)
const resolvedAgent = resolveRegisteredAgentName(agentName) const launchAgent = promptAgent ? stripAgentListSortPrefix(promptAgent).trim() || undefined : undefined
const launchAgent = normalizeAgentForPrompt(resolvedAgent ?? agentName)
if (promptAgent && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(promptAgent))) { if (promptAgent && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(promptAgent))) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })