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 })