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.
This commit is contained in:
YeonGyu-Kim
2026-05-15 19:44:44 +09:00
parent 2b43147c41
commit 7caf74a9b9
4 changed files with 78 additions and 2 deletions
@@ -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`, {
+33
View File
@@ -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")
@@ -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<string, boolean> | undefined
@@ -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 })