Files
oh-my-opencode/src/hooks/todo-continuation-enforcer/continuation-injection.ts
T
YeonGyu-Kim 7caf74a9b9 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.
2026-05-15 19:44:44 +09:00

242 lines
8.0 KiB
TypeScript

import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import {
getSessionAgent,
resolveRegisteredAgentName,
} from "../../features/claude-code-session-state"
import {
createInternalAgentContinuationTextPart,
normalizeSDKResponse,
resolveInheritedPromptTools,
} from "../../shared"
import {
findNearestMessageWithFields,
findNearestMessageWithFieldsFromSDK,
type ToolPermission,
} from "../../features/hook-message-injector"
import { log } from "../../shared/logger"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import {
getAgentConfigKey,
normalizeAgentForPromptKey,
stripAgentListSortPrefix,
} from "../../shared/agent-display-names"
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
import {
CONTINUATION_PROMPT,
DEFAULT_SKIP_AGENTS,
HOOK_NAME,
} from "./constants"
import { isCompactionGuardActive } from "./compaction-guard"
import { getMessageDir } from "./message-directory"
import { isTokenLimitError } from "./token-limit-detection"
import { getIncompleteCount } from "./todo"
import type { ResolvedMessageInfo, Todo } from "./types"
import type { SessionStateStore } from "./session-state"
function hasWritePermission(tools: Record<string, ToolPermission> | undefined): boolean {
const editPermission = tools?.edit
const writePermission = tools?.write
return (
!tools ||
(editPermission !== false && editPermission !== "deny" && writePermission !== false && writePermission !== "deny")
)
}
export async function injectContinuation(args: {
ctx: PluginInput
sessionID: string
backgroundManager?: BackgroundManager
skipAgents?: string[]
resolvedInfo?: ResolvedMessageInfo
sessionStateStore: SessionStateStore
isContinuationStopped?: (sessionID: string) => boolean
}): Promise<void> {
const {
ctx,
sessionID,
backgroundManager,
skipAgents = DEFAULT_SKIP_AGENTS,
resolvedInfo,
sessionStateStore,
isContinuationStopped,
} = args
const state = sessionStateStore.getExistingState(sessionID)
if (state?.isRecovering) {
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
return
}
if (state?.wasCancelled) {
log(`[${HOOK_NAME}] Skipped injection: session was cancelled`, { sessionID })
return
}
if (isContinuationStopped?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped injection: continuation stopped for session`, { sessionID })
return
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running" || task.status === "pending")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID })
return
}
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true })
} catch (error) {
log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(error) })
return
}
const freshIncompleteCount = getIncompleteCount(todos)
if (freshIncompleteCount === 0) {
log(`[${HOOK_NAME}] Skipped injection: no incomplete todos`, { sessionID })
return
}
let agentName = resolvedInfo?.agent ?? getSessionAgent(sessionID)
let model = resolvedInfo?.model
let tools = resolvedInfo?.tools
if (!agentName || !model) {
let previousMessage = null
if (isSqliteBackend()) {
previousMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
} else {
const messageDir = getMessageDir(sessionID)
previousMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
}
agentName = agentName ?? previousMessage?.agent
model =
model ??
(previousMessage?.model?.providerID && previousMessage?.model?.modelID
? {
providerID: previousMessage.model.providerID,
modelID: previousMessage.model.modelID,
...(previousMessage.model.variant
? { variant: previousMessage.model.variant }
: {}),
}
: undefined)
tools = tools ?? previousMessage?.tools
}
const promptAgent = normalizeAgentForPromptKey(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 })
return
}
if (!promptAgent) {
const compactionState = sessionStateStore.getExistingState(sessionID)
if (compactionState && isCompactionGuardActive(compactionState, Date.now())) {
log(`[${HOOK_NAME}] Skipped: agent unknown after compaction`, { sessionID })
return
}
}
if (!hasWritePermission(tools)) {
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName })
return
}
const incompleteTodos = todos.filter((todo) => todo.status !== "completed" && todo.status !== "cancelled")
const todoList = incompleteTodos.map((todo) => `- [${todo.status}] ${todo.content}`).join("\n")
const prompt = `${CONTINUATION_PROMPT}
[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]
Remaining tasks:
${todoList}`
const injectionState = sessionStateStore.getExistingState(sessionID)
if (injectionState?.wasCancelled) {
log(`[${HOOK_NAME}] Skipped injection: session was cancelled before prompt`, { sessionID })
return
}
if (injectionState) {
injectionState.inFlight = true
}
try {
log(`[${HOOK_NAME}] Injecting continuation`, {
sessionID,
agent: launchAgent ?? promptAgent,
model,
incompleteCount: freshIncompleteCount,
})
const inheritedTools = resolveInheritedPromptTools(sessionID, tools)
const launchModel = model
? { providerID: model.providerID, modelID: model.modelID }
: undefined
const launchVariant = model?.variant
const promptResult = await promptAsyncAfterSessionIdle({
client: ctx.client,
sessionID,
source: HOOK_NAME,
settleMs: 0,
input: {
path: { id: sessionID },
body: {
agent: launchAgent ?? promptAgent,
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
...(inheritedTools ? { tools: inheritedTools } : {}),
parts: [createInternalAgentContinuationTextPart(prompt)],
},
query: { directory: ctx.directory },
},
})
if (promptResult.status === "failed") {
throw promptResult.error
}
if (promptResult.status !== "dispatched") {
log(`[${HOOK_NAME}] Injection skipped by promptAsync gate`, { sessionID, status: promptResult.status })
if (injectionState) {
injectionState.inFlight = false
}
return
}
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
if (injectionState) {
injectionState.inFlight = false
injectionState.lastInjectedAt = Date.now()
injectionState.awaitingPostInjectionProgressCheck = true
injectionState.consecutiveFailures = 0
}
} catch (error) {
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(error) })
if (injectionState) {
injectionState.inFlight = false
injectionState.lastInjectedAt = Date.now()
injectionState.consecutiveFailures = (injectionState.consecutiveFailures ?? 0) + 1
const errorObj = error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) }
if (isTokenLimitError(errorObj)) {
injectionState.tokenLimitDetected = true
log(`[${HOOK_NAME}] Token limit error detected during injection, stopping continuation`, { sessionID })
}
}
}
}