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 })
diff --git a/src/shared/prompt-async-gate-path-compat.test.ts b/src/shared/prompt-async-gate-path-compat.test.ts
new file mode 100644
index 000000000..8c147356c
--- /dev/null
+++ b/src/shared/prompt-async-gate-path-compat.test.ts
@@ -0,0 +1,92 @@
+///
+
+import { afterEach, describe, expect, mock, test } from "bun:test"
+
+import {
+ dispatchInternalPrompt,
+ releaseAllPromptAsyncReservationsForTesting,
+} from "./prompt-async-gate"
+
+type CompatPromptInput = {
+ readonly path: { readonly id: string } | string
+ readonly body: {
+ readonly parts: readonly []
+ }
+}
+
+function createPathSensitivePrompt() {
+ const calls: CompatPromptInput[] = []
+ const prompt = mock(async (input: CompatPromptInput) => {
+ calls.push(input)
+ if (typeof input.path !== "string") {
+ throw new TypeError('The "path" property must be of type string, got object')
+ }
+ return { ok: true }
+ })
+
+ return { calls, prompt }
+}
+
+describe("dispatchInternalPrompt path compatibility", () => {
+ afterEach(() => {
+ releaseAllPromptAsyncReservationsForTesting()
+ })
+
+ test("#given sync prompt rejects object-form session path #when dispatching #then it retries with string-form path", async () => {
+ // given
+ const { calls, prompt } = createPathSensitivePrompt()
+ const client = { session: { prompt } }
+
+ // when
+ const result = await dispatchInternalPrompt({
+ mode: "sync",
+ client,
+ sessionID: "ses_sync_path_compat",
+ source: "test:path-compat:sync",
+ settleMs: 0,
+ checkStatus: false,
+ checkToolState: false,
+ queueBehavior: "defer",
+ input: {
+ path: { id: "ses_sync_path_compat" },
+ body: { parts: [] },
+ },
+ })
+
+ // then
+ expect(result.status).toBe("dispatched")
+ expect(calls.map((call) => call.path)).toEqual([
+ { id: "ses_sync_path_compat" },
+ "ses_sync_path_compat",
+ ])
+ })
+
+ test("#given async prompt rejects object-form session path #when dispatching #then it retries with string-form path", async () => {
+ // given
+ const { calls, prompt } = createPathSensitivePrompt()
+ const client = { session: { promptAsync: prompt } }
+
+ // when
+ const result = await dispatchInternalPrompt({
+ mode: "async",
+ client,
+ sessionID: "ses_async_path_compat",
+ source: "test:path-compat:async",
+ settleMs: 0,
+ checkStatus: false,
+ checkToolState: false,
+ queueBehavior: "defer",
+ input: {
+ path: { id: "ses_async_path_compat" },
+ body: { parts: [] },
+ },
+ })
+
+ // then
+ expect(result.status).toBe("dispatched")
+ expect(calls.map((call) => call.path)).toEqual([
+ { id: "ses_async_path_compat" },
+ "ses_async_path_compat",
+ ])
+ })
+})
diff --git a/src/shared/prompt-async-gate.ts b/src/shared/prompt-async-gate.ts
index 234f1e65d..1236e7ade 100644
--- a/src/shared/prompt-async-gate.ts
+++ b/src/shared/prompt-async-gate.ts
@@ -68,6 +68,47 @@ function createDefaultDedupeKey(source: string, input: unknown): string {
return `${source}:${fingerprint.length}:${fingerprint.slice(0, 8192)}`
}
+type ObjectPathPromptInput = {
+ readonly path?: { readonly id?: string } | string
+ readonly [key: string]: unknown
+}
+
+function hasObjectSessionPath(input: unknown): input is ObjectPathPromptInput & { readonly path: { readonly id: string } } {
+ return typeof input === "object"
+ && input !== null
+ && "path" in input
+ && typeof input.path === "object"
+ && input.path !== null
+ && "id" in input.path
+ && typeof input.path.id === "string"
+}
+
+function isObjectPathTypeError(error: unknown): boolean {
+ const message = error instanceof Error
+ ? error.message
+ : typeof error === "string" ? error : ""
+ return message.includes('The "path" property must be of type string') && message.includes("got object")
+}
+
+async function dispatchWithPathCompatibility(
+ dispatch: (dispatchInput: TInput) => Promise,
+ input: TInput,
+): Promise {
+ try {
+ return await dispatch(input)
+ } catch (error) {
+ if (!isObjectPathTypeError(error) || !hasObjectSessionPath(input)) {
+ throw error
+ }
+
+ const retryInput = {
+ ...input,
+ path: input.path.id,
+ } as TInput
+ return dispatch(retryInput)
+ }
+}
+
export async function dispatchInternalPrompt(
args: InternalPromptDispatchArgs,
): Promise {
@@ -131,7 +172,7 @@ export async function dispatchInternalPrompt(
dispatchTimeoutMs,
checkStatus: args.checkStatus !== false,
checkToolState: args.checkToolState !== false,
- dispatch,
+ dispatch: (dispatchInput) => dispatchWithPathCompatibility(dispatch, dispatchInput),
})
}
@@ -150,7 +191,7 @@ export async function dispatchInternalPrompt(
queueRetryMs,
checkStatus: args.checkStatus !== false,
checkToolState: args.checkToolState !== false,
- dispatch: async (_dispatchInput: unknown) => dispatch(input),
+ dispatch: async (_dispatchInput: unknown) => dispatchWithPathCompatibility(dispatch, input),
})
}
@@ -166,7 +207,7 @@ export async function dispatchInternalPrompt(
dispatchTimeoutMs,
checkStatus: args.checkStatus !== false,
checkToolState: args.checkToolState !== false,
- dispatch,
+ dispatch: (dispatchInput) => dispatchWithPathCompatibility(dispatch, dispatchInput),
})
}
diff --git a/src/shared/prompt-async-gate/types.ts b/src/shared/prompt-async-gate/types.ts
index 082693692..521e90a97 100644
--- a/src/shared/prompt-async-gate/types.ts
+++ b/src/shared/prompt-async-gate/types.ts
@@ -1,5 +1,7 @@
+export type PromptSessionPath = { readonly id?: string } | string
+
export type PromptAsyncInput = {
- readonly path?: { readonly id?: string }
+ readonly path?: PromptSessionPath
readonly body?: unknown
readonly query?: unknown
readonly signal?: unknown