From de6c74bfb436bf00c4405157c7611f423d9d558c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 6 Apr 2026 11:44:11 +0900 Subject: [PATCH] fix(todo-continuation): normalize agent name to config key before promptAsync (#3149) The todo-continuation-enforcer was passing raw agent names (which could be display names like 'Sisyphus (Ultraworker)') to promptAsync. These names contain spaces/parentheses that violate HTTP header specs, causing the x-opencode-agent-name header validation to fail with 'unknown error' toast. Added normalizeAgentForPromptKey() that converts display names to config keys (e.g., 'Sisyphus (Ultraworker)' -> 'sisyphus') before API calls. TDD: Added regression test that verifies config key is sent to promptAsync. --- .../continuation-injection.test.ts | 38 +++++++++++++++++++ .../continuation-injection.ts | 15 +++++--- src/shared/agent-display-names.test.ts | 12 +++++- src/shared/agent-display-names.ts | 22 +++++++++++ 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts index f43a7c22d..8feac9863 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts @@ -5,6 +5,44 @@ import { injectContinuation } from "./continuation-injection" import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker" describe("injectContinuation", () => { + test("normalizes built-in display names to config keys before promptAsync", async () => { + // given + let capturedAgent: string | undefined + const ctx = { + directory: "/tmp/test", + client: { + session: { + todo: async () => ({ data: [{ id: "1", content: "todo", status: "pending", priority: "high" }] }), + promptAsync: async (input: { + body: { + agent?: string + } + }) => { + capturedAgent = input.body.agent + return {} + }, + }, + }, + } + const sessionStateStore = { + getExistingState: () => ({ inFlight: false, lastInjectedAt: 0, consecutiveFailures: 0 }), + } + + // when + await injectContinuation({ + ctx: ctx as never, + sessionID: "ses_display_name_agent", + resolvedInfo: { + agent: "Sisyphus (Ultraworker)", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, + }, + sessionStateStore: sessionStateStore as never, + }) + + // then + expect(capturedAgent).toBe("sisyphus") + }) + test("inherits tools from resolved message info when reinjecting", async () => { // given let capturedTools: Record | undefined diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index 5b2c43c91..a65146ff9 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -14,7 +14,10 @@ import { } from "../../features/hook-message-injector" import { log } from "../../shared/logger" import { isSqliteBackend } from "../../shared/opencode-storage-detection" -import { getAgentConfigKey } from "../../shared/agent-display-names" +import { + getAgentConfigKey, + normalizeAgentForPromptKey, +} from "../../shared/agent-display-names" import { CONTINUATION_PROMPT, @@ -122,12 +125,14 @@ export async function injectContinuation(args: { tools = tools ?? previousMessage?.tools } - if (agentName && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(agentName))) { + const promptAgent = normalizeAgentForPromptKey(agentName) + + if (promptAgent && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(promptAgent))) { log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName }) return } - if (!agentName) { + if (!promptAgent) { const compactionState = sessionStateStore.getExistingState(sessionID) if (compactionState && isCompactionGuardActive(compactionState, Date.now())) { log(`[${HOOK_NAME}] Skipped: agent unknown after compaction`, { sessionID }) @@ -162,7 +167,7 @@ ${todoList}` try { log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, - agent: agentName, + agent: promptAgent, model, incompleteCount: freshIncompleteCount, }) @@ -172,7 +177,7 @@ ${todoList}` await ctx.client.session.promptAsync({ path: { id: sessionID }, body: { - agent: agentName, + agent: promptAgent, ...(model !== undefined ? { model } : {}), ...(inheritedTools ? { tools: inheritedTools } : {}), parts: [createInternalAgentTextPart(prompt)], diff --git a/src/shared/agent-display-names.test.ts b/src/shared/agent-display-names.test.ts index 19de11a4b..824f89762 100644 --- a/src/shared/agent-display-names.test.ts +++ b/src/shared/agent-display-names.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test" -import { AGENT_DISPLAY_NAMES, getAgentConfigKey, getAgentDisplayName, getAgentListDisplayName, normalizeAgentForPrompt } from "./agent-display-names" +import { AGENT_DISPLAY_NAMES, getAgentConfigKey, getAgentDisplayName, getAgentListDisplayName, normalizeAgentForPrompt, normalizeAgentForPromptKey } from "./agent-display-names" describe("getAgentDisplayName", () => { it("returns display name for lowercase config key (new format)", () => { @@ -196,6 +196,16 @@ describe("normalizeAgentForPrompt", () => { }) }) +describe("normalizeAgentForPromptKey", () => { + it("converts built-in display names to config keys", () => { + expect(normalizeAgentForPromptKey("Sisyphus (Ultraworker)")).toBe("sisyphus") + }) + + it("preserves custom agents", () => { + expect(normalizeAgentForPromptKey("MyCustomAgent")).toBe("MyCustomAgent") + }) +}) + describe("AGENT_DISPLAY_NAMES", () => { it("contains all expected agent mappings", () => { // given expected mappings diff --git a/src/shared/agent-display-names.ts b/src/shared/agent-display-names.ts index b9eac59ef..5cdf0396f 100644 --- a/src/shared/agent-display-names.ts +++ b/src/shared/agent-display-names.ts @@ -98,3 +98,25 @@ export function normalizeAgentForPrompt(agentName: string | undefined): string | return trimmed } + +export function normalizeAgentForPromptKey(agentName: string | undefined): string | undefined { + if (typeof agentName !== "string") { + return undefined + } + + const trimmed = stripAgentListSortPrefix(agentName.trim()) + if (!trimmed) { + return undefined + } + + const lower = trimmed.toLowerCase() + const reversed = REVERSE_DISPLAY_NAMES[lower] + if (reversed !== undefined) { + return reversed + } + if (AGENT_DISPLAY_NAMES[lower] !== undefined) { + return lower + } + + return trimmed +}