Merge pull request #4046 from code-yeongyu/fix/3494-strip-zwsp-before-promptasync
fix(atlas,todo-continuation): strip ZWSP sort prefix before promptAsync agent (fixes #3494, supersedes #3547)
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
|||||||
isAgentRegistered,
|
isAgentRegistered,
|
||||||
resolveRegisteredAgentName,
|
resolveRegisteredAgentName,
|
||||||
} from "../../features/claude-code-session-state"
|
} from "../../features/claude-code-session-state"
|
||||||
|
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools } from "../../shared"
|
import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
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]` +
|
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
|
||||||
preferredSessionContext +
|
preferredSessionContext +
|
||||||
worktreeContext
|
worktreeContext
|
||||||
const continuationAgent = resolveRegisteredAgentName(
|
const resolvedContinuationAgent = resolveRegisteredAgentName(
|
||||||
agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
|
agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined),
|
||||||
)
|
)
|
||||||
|
const continuationAgent = resolvedContinuationAgent ? stripAgentListSortPrefix(resolvedContinuationAgent) : resolvedContinuationAgent
|
||||||
|
|
||||||
if (!continuationAgent || !isAgentRegistered(continuationAgent)) {
|
if (!continuationAgent || !isAgentRegistered(continuationAgent)) {
|
||||||
log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, {
|
log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, {
|
||||||
|
|||||||
@@ -2070,6 +2070,39 @@ session_id: ses_untrusted_999
|
|||||||
expect(callArgs.body.agent).not.toBe("atlas")
|
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 () => {
|
test("should debounce rapid continuation injections (prevent infinite loop)", async () => {
|
||||||
// given - boulder state with incomplete plan
|
// given - boulder state with incomplete plan
|
||||||
const planPath = join(TEST_DIR, "test-plan.md")
|
const planPath = join(TEST_DIR, "test-plan.md")
|
||||||
|
|||||||
@@ -43,6 +43,45 @@ describe("injectContinuation", () => {
|
|||||||
expect(capturedAgent).toBe("Sisyphus - Ultraworker")
|
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 () => {
|
test("inherits tools from resolved message info when reinjecting", async () => {
|
||||||
// given
|
// given
|
||||||
let capturedTools: Record<string, boolean> | undefined
|
let capturedTools: Record<string, boolean> | undefined
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
|||||||
import {
|
import {
|
||||||
getAgentConfigKey,
|
getAgentConfigKey,
|
||||||
normalizeAgentForPromptKey,
|
normalizeAgentForPromptKey,
|
||||||
|
stripAgentListSortPrefix,
|
||||||
} from "../../shared/agent-display-names"
|
} from "../../shared/agent-display-names"
|
||||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||||
|
|
||||||
@@ -131,7 +132,8 @@ export async function injectContinuation(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const promptAgent = normalizeAgentForPromptKey(agentName)
|
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))) {
|
if (promptAgent && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(promptAgent))) {
|
||||||
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
|
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
|
||||||
|
|||||||
Reference in New Issue
Block a user