fix(agent-switch): make handoff durable and sync CLI TUI selection

This commit is contained in:
ismeth
2026-02-18 19:26:33 +01:00
committed by YeonGyu-Kim
parent 4764d65db1
commit 8de10c1f2b
17 changed files with 1698 additions and 35 deletions
+178 -20
View File
@@ -1,36 +1,194 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { consumePendingSwitch } from "../../features/agent-switch"
import { getPendingSwitch, setPendingSwitch } from "../../features/agent-switch"
import { applyPendingSwitch, clearPendingSwitchRuntime } from "../../features/agent-switch/applier"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import {
buildFallbackContext,
detectFallbackHandoffTarget,
extractTextPartsFromMessageResponse,
isTerminalFinishValue,
isTerminalStepFinishPart,
} from "./fallback-handoff"
const HOOK_NAME = "agent-switch" as const
const processedFallbackMessages = new Set<string>()
function getSessionIDFromStatusEvent(input: { event: { properties?: Record<string, unknown> } }): string | undefined {
const props = input.event.properties as Record<string, unknown> | undefined
const fromProps = typeof props?.sessionID === "string" ? props.sessionID : undefined
if (fromProps) {
return fromProps
}
const status = props?.status as Record<string, unknown> | undefined
const fromStatus = typeof status?.sessionID === "string" ? status.sessionID : undefined
return fromStatus
}
function getStatusTypeFromEvent(input: { event: { properties?: Record<string, unknown> } }): string | undefined {
const props = input.event.properties as Record<string, unknown> | undefined
const directType = typeof props?.type === "string" ? props.type : undefined
if (directType) {
return directType
}
const status = props?.status as Record<string, unknown> | undefined
const statusType = typeof status?.type === "string" ? status.type : undefined
return statusType
}
export function createAgentSwitchHook(ctx: PluginInput) {
return {
event: async (input: { event: { type: string; properties?: Record<string, unknown> } }): Promise<void> => {
if (input.event.type !== "session.idle") return
if (input.event.type === "session.deleted") {
const props = input.event.properties as Record<string, unknown> | undefined
const info = props?.info as Record<string, unknown> | undefined
const deletedSessionID = info?.id
if (typeof deletedSessionID === "string") {
clearPendingSwitchRuntime(deletedSessionID)
for (const key of Array.from(processedFallbackMessages)) {
if (key.startsWith(`${deletedSessionID}:`)) {
processedFallbackMessages.delete(key)
}
}
}
return
}
const props = input.event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
if (input.event.type === "message.updated") {
const props = input.event.properties as Record<string, unknown> | undefined
const info = props?.info as Record<string, unknown> | undefined
const sessionID = typeof info?.sessionID === "string" ? info.sessionID : undefined
const messageID = typeof info?.id === "string" ? info.id : undefined
const agent = typeof info?.agent === "string" ? info.agent : undefined
const finish = info?.finish
const pending = consumePendingSwitch(sessionID)
if (!pending) return
if (!sessionID) {
return
}
log(`[${HOOK_NAME}] Switching to ${pending.agent}`, { sessionID })
const isTerminalAssistantUpdate = isTerminalFinishValue(finish)
if (!isTerminalAssistantUpdate) {
return
}
try {
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
agent: pending.agent,
parts: [{ type: "text", text: pending.context }],
},
query: { directory: ctx.directory },
// Primary path: if switch_agent queued a pending switch, apply it as soon as
// assistant turn is terminal (no reliance on session.idle timing).
if (getPendingSwitch(sessionID)) {
await applyPendingSwitch({
sessionID,
client: ctx.client,
source: "message-updated",
})
return
}
if (!messageID) {
return
}
if (getAgentConfigKey(agent ?? "") !== "athena") {
return
}
const marker = `${sessionID}:${messageID}`
if (processedFallbackMessages.has(marker)) {
return
}
processedFallbackMessages.add(marker)
// If switch_agent already queued a handoff, do not synthesize fallback behavior.
if (getPendingSwitch(sessionID)) {
return
}
try {
const response = await ctx.client.session.message({
path: { id: sessionID, messageID },
})
const text = extractTextPartsFromMessageResponse(response)
const target = detectFallbackHandoffTarget(text)
if (!target) {
return
}
setPendingSwitch(sessionID, target, buildFallbackContext(target))
log("[agent-switch] Recovered missing switch_agent tool call from Athena handoff text", {
sessionID,
messageID,
target,
})
await applyPendingSwitch({
sessionID,
client: ctx.client,
source: "athena-message-fallback",
})
} catch (error) {
log("[agent-switch] Failed to recover fallback handoff from Athena message", {
sessionID,
messageID,
error: String(error),
})
}
return
}
if (input.event.type === "message.part.updated") {
const props = input.event.properties as Record<string, unknown> | undefined
const part = props?.part
const info = props?.info as Record<string, unknown> | undefined
const sessionIDFromPart = typeof (part as Record<string, unknown> | undefined)?.sessionID === "string"
? ((part as Record<string, unknown>).sessionID as string)
: undefined
const sessionIDFromInfo = typeof info?.sessionID === "string" ? info.sessionID : undefined
const sessionID = sessionIDFromPart ?? sessionIDFromInfo
if (!sessionID) {
return
}
if (!isTerminalStepFinishPart(part)) {
return
}
if (!getPendingSwitch(sessionID)) {
return
}
await applyPendingSwitch({
sessionID,
client: ctx.client,
source: "message-part-step-finish",
})
return
}
log(`[${HOOK_NAME}] Switch to ${pending.agent} complete`, { sessionID })
} catch (err) {
log(`[${HOOK_NAME}] Switch failed`, { sessionID, error: String(err) })
if (input.event.type === "session.idle") {
const props = input.event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
await applyPendingSwitch({
sessionID,
client: ctx.client,
source: "idle",
})
return
}
if (input.event.type === "session.status") {
const sessionID = getSessionIDFromStatusEvent(input)
const statusType = getStatusTypeFromEvent(input)
if (!sessionID || statusType !== "idle") {
return
}
await applyPendingSwitch({
sessionID,
client: ctx.client,
source: "status-idle",
})
}
},
}