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