From 7caf74a9b9195343415cf4e1cae1145bd3dc1903 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 15 May 2026 19:44:44 +0900 Subject: [PATCH] fix(atlas,todo-continuation): strip ZWSP sort prefix before promptAsync agent Both injectors call resolveRegisteredAgentName, which returns the registered alias verbatim. OpenCode TUI registers agent names with leading zero-width characters (U+200B) for sort ordering, so that alias can be e.g. "\u200B\u200BAtlas - Plan Executor". Passing it directly to promptAsync produces "Agent not found" because the OpenCode SDK does an exact match against its canonical display name registry. Strip the ZWSP sort prefix on the resolved name before sending it to promptAsync in: - src/hooks/atlas/boulder-continuation-injector.ts - src/hooks/todo-continuation-enforcer/continuation-injection.ts Add regression tests asserting promptAsync receives the canonical display name (no \u200B) even when the registered alias carries a ZWSP sort prefix. Same root cause class as #3494 / #3547. Tests were RED on dev before the fix and GREEN after. --- .../atlas/boulder-continuation-injector.ts | 4 +- src/hooks/atlas/index.test.ts | 33 ++++++++++++++++ .../continuation-injection.test.ts | 39 +++++++++++++++++++ .../continuation-injection.ts | 4 +- 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts index 1931da6c9..4535b2470 100644 --- a/src/hooks/atlas/boulder-continuation-injector.ts +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -3,6 +3,7 @@ import { isAgentRegistered, resolveRegisteredAgentName, } from "../../features/claude-code-session-state" +import { stripAgentListSortPrefix } from "../../shared/agent-display-names" import { log } from "../../shared/logger" import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools } from "../../shared" import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate" @@ -67,9 +68,10 @@ export async function injectBoulderContinuation(input: { `\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` + preferredSessionContext + worktreeContext - const continuationAgent = resolveRegisteredAgentName( + const resolvedContinuationAgent = resolveRegisteredAgentName( agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined), ) + const continuationAgent = resolvedContinuationAgent ? stripAgentListSortPrefix(resolvedContinuationAgent) : resolvedContinuationAgent if (!continuationAgent || !isAgentRegistered(continuationAgent)) { log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, { diff --git a/src/hooks/atlas/index.test.ts b/src/hooks/atlas/index.test.ts index 576780b8b..019468224 100644 --- a/src/hooks/atlas/index.test.ts +++ b/src/hooks/atlas/index.test.ts @@ -2070,6 +2070,39 @@ session_id: ses_untrusted_999 expect(callArgs.body.agent).not.toBe("atlas") }) + test("#given boulder agent registered with ZWSP sort prefix #when continuation injects #then promptAsync receives display name without ZWSP", async () => { + // given - OpenCode TUI registers agent names with leading ZWSP for sort ordering + const planPath = join(TEST_DIR, "test-plan.md") + writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") + + const state: BoulderState = { + active_plan: planPath, + started_at: "2026-01-02T10:00:00Z", + session_ids: [MAIN_SESSION_ID], + plan_name: "test-plan", + agent: "\u200B\u200BAtlas - Plan Executor", + } + writeBoulderState(TEST_DIR, state) + registerAgentName("\u200B\u200BAtlas - Plan Executor") + + const mockInput = createMockPluginInput() + const hook = createTestAtlasHook(mockInput) + + // when + await hook.handler({ + event: { + type: "session.idle", + properties: { sessionID: MAIN_SESSION_ID }, + }, + }) + + // then + expect(mockInput._promptMock).toHaveBeenCalled() + const callArgs = mockInput._promptMock.mock.calls[0][0] + expect(callArgs.body.agent).toBe("Atlas - Plan Executor") + expect(callArgs.body.agent).not.toContain("\u200B") + }) + test("should debounce rapid continuation injections (prevent infinite loop)", async () => { // given - boulder state with incomplete plan const planPath = join(TEST_DIR, "test-plan.md") diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts index 8ab033ed7..884c041e3 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts @@ -43,6 +43,45 @@ describe("injectContinuation", () => { expect(capturedAgent).toBe("Sisyphus - Ultraworker") }) + test("#given resolved agent name still carries a ZWSP sort prefix #when continuation is injected #then promptAsync receives the agent name without the ZWSP prefix", 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_zwsp_agent", + resolvedInfo: { + agent: "\u200B\u200BSisyphus - Ultraworker", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, + }, + sessionStateStore: sessionStateStore as never, + }) + + // then + expect(capturedAgent).toBe("Sisyphus - Ultraworker") + expect(capturedAgent).not.toContain("\u200B") + }) + 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 cfb9214a6..d5a988bf8 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -20,6 +20,7 @@ import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { getAgentConfigKey, normalizeAgentForPromptKey, + stripAgentListSortPrefix, } from "../../shared/agent-display-names" import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate" @@ -131,7 +132,8 @@ export async function injectContinuation(args: { } const promptAgent = normalizeAgentForPromptKey(agentName) - const launchAgent = resolveRegisteredAgentName(agentName) + const resolvedAgent = resolveRegisteredAgentName(agentName) + const launchAgent = resolvedAgent ? stripAgentListSortPrefix(resolvedAgent) : resolvedAgent if (promptAgent && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(promptAgent))) { log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })