From a5cee7696143bfbef99b11051421938e95d1576b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 25 May 2026 17:48:14 +0900 Subject: [PATCH 1/2] fix(prompt-async-gate): retry object-form path on runtime type error (#4417) Atlas task() with run_in_background=false crashed with 'The "path" property must be of type string, got object' from Node's path.isAbsolute validation upstream of the SDK. All callers (sync-prompt-sender, boulder-continuation-injector, idle-event, session-route, model-suggestion-retry) already route through dispatchInternalPrompt, so centralizing the compatibility shim in prompt-async-gate.ts covers every Bug 1 site without touching individual hooks. On TypeError matching the object-path signature, retry once with path collapsed to its id string. Types broaden PromptSessionPath to string | { id }. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../prompt-async-gate-path-compat.test.ts | 92 +++++++++++++++++++ src/shared/prompt-async-gate.ts | 47 +++++++++- src/shared/prompt-async-gate/types.ts | 4 +- 3 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 src/shared/prompt-async-gate-path-compat.test.ts 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 From 28efc4e81f70dfe729a92c69e101261c17159d2c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 25 May 2026 17:48:32 +0900 Subject: [PATCH 2/2] 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 })