refactor(switch-agent): replace deferred hook system with direct session.create + promptAsync

Adopt the opencode-handoff pattern: switch_agent now creates a new session
and sends the context via promptAsync immediately in the tool's execute
function, then navigates the TUI to the new session.

Removes ~1700 LOC of complexity: persistent state management, event-based
hook monitoring, retry logic, session idle waiting, apply verification,
and terminal detection — all replaced by 3 SDK calls.

Deleted: src/features/agent-switch/ (14 files), src/hooks/agent-switch/ (4 files)
Updated: Athena prompt to announce handoff before calling switch_agent
This commit is contained in:
ismeth
2026-03-01 14:42:27 +01:00
committed by YeonGyu-Kim
parent 75f07b4d8e
commit 34faa7c67c
21 changed files with 181 additions and 1868 deletions
-363
View File
@@ -1,363 +0,0 @@
/// <reference types="bun-types" />
import { beforeEach, describe, expect, test } from "bun:test"
import { createAgentSwitchHook } from "./hook"
import {
_resetForTesting,
getPendingSwitch,
setPendingSwitch,
} from "../../features/agent-switch"
import { _resetApplierForTesting, clearPendingSwitchRuntime } from "../../features/agent-switch/applier"
describe("agent-switch hook", () => {
beforeEach(() => {
_resetForTesting()
_resetApplierForTesting()
})
test("consumes pending switch only after successful promptAsync", async () => {
const promptAsyncCalls: Array<Record<string, unknown>> = []
let switched = false
const ctx = {
client: {
session: {
create: async () => ({ data: { id: "fresh-ses-1" } }),
promptAsync: async (args: Record<string, unknown>) => {
promptAsyncCalls.push(args)
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
: ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-1", "prometheus", "plan this")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "ses-1" },
},
})
expect(promptAsyncCalls).toHaveLength(1)
expect(getPendingSwitch("ses-1")).toBeUndefined()
})
test("keeps pending switch when promptAsync fails", async () => {
const ctx = {
client: {
session: {
create: async () => ({ data: { id: "fresh-ses-2" } }),
promptAsync: async () => {
throw new Error("temporary failure")
},
messages: async () => ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-2", "atlas", "fix this")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "ses-2" },
},
})
expect(getPendingSwitch("ses-2")).toEqual({
agent: "atlas",
context: "fix this",
})
clearPendingSwitchRuntime("ses-2")
})
test("retries after transient failure and eventually clears pending switch", async () => {
let attempts = 0
let switched = false
const ctx = {
client: {
session: {
create: async () => ({ data: { id: "fresh-ses-3" } }),
promptAsync: async () => {
attempts += 1
if (attempts === 1) {
throw new Error("temporary failure")
}
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
: ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-3", "prometheus", "plan this")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "ses-3" },
},
})
await new Promise((resolve) => setTimeout(resolve, 350))
expect(attempts).toBe(2)
expect(getPendingSwitch("ses-3")).toBeUndefined()
})
test("clears pending switch on session.deleted", async () => {
const ctx = {
client: {
session: {
promptAsync: async () => {},
messages: async () => ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-4", "atlas", "fix this")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "session.deleted",
properties: { info: { id: "ses-4" } },
},
})
expect(getPendingSwitch("ses-4")).toBeUndefined()
})
test("clears pending switch on session.error with info.id", async () => {
const ctx = {
client: {
session: {
promptAsync: async () => {},
messages: async () => ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-10", "atlas", "fix this")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "session.error",
properties: { info: { id: "ses-10" } },
},
})
expect(getPendingSwitch("ses-10")).toBeUndefined()
})
test("clears pending switch on session.error with sessionID property", async () => {
const ctx = {
client: {
session: {
promptAsync: async () => {},
messages: async () => ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-11", "atlas", "fix this")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "session.error",
properties: { sessionID: "ses-11" },
},
})
expect(getPendingSwitch("ses-11")).toBeUndefined()
})
test("applies queued pending switch on terminal message.updated", async () => {
const promptAsyncCalls: Array<Record<string, unknown>> = []
let switched = false
const ctx = {
client: {
session: {
create: async () => ({ data: { id: "fresh-ses-6" } }),
promptAsync: async (args: Record<string, unknown>) => {
promptAsyncCalls.push(args)
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
: ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-6", "atlas", "fix now")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
id: "msg-6",
sessionID: "ses-6",
role: "assistant",
agent: "Athena (Council)",
finish: "stop",
},
},
},
})
expect(promptAsyncCalls).toHaveLength(1)
const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
expect(body?.agent).toBe("Atlas (Plan Executor)")
expect(getPendingSwitch("ses-6")).toBeUndefined()
})
test("applies queued pending switch on terminal message.updated even when role is missing", async () => {
const promptAsyncCalls: Array<Record<string, unknown>> = []
let switched = false
const ctx = {
client: {
session: {
create: async () => ({ data: { id: "fresh-ses-8" } }),
promptAsync: async (args: Record<string, unknown>) => {
promptAsyncCalls.push(args)
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
: ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-8", "atlas", "fix now")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
id: "msg-8",
sessionID: "ses-8",
agent: "Athena (Council)",
finish: true,
},
},
},
})
expect(promptAsyncCalls).toHaveLength(1)
const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
expect(body?.agent).toBe("Atlas (Plan Executor)")
expect(getPendingSwitch("ses-8")).toBeUndefined()
})
test("applies queued pending switch on terminal message.part.updated step-finish", async () => {
const promptAsyncCalls: Array<Record<string, unknown>> = []
let switched = false
const ctx = {
client: {
session: {
create: async () => ({ data: { id: "fresh-ses-7" } }),
promptAsync: async (args: Record<string, unknown>) => {
promptAsyncCalls.push(args)
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
: ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-7", "atlas", "fix now")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "message.part.updated",
properties: {
info: {
sessionID: "ses-7",
role: "assistant",
},
part: {
id: "part-finish-1",
sessionID: "ses-7",
type: "step-finish",
reason: "stop",
},
},
},
})
expect(promptAsyncCalls).toHaveLength(1)
const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
expect(body?.agent).toBe("Atlas (Plan Executor)")
expect(getPendingSwitch("ses-7")).toBeUndefined()
})
test("applies queued pending switch on session.status idle", async () => {
const promptAsyncCalls: Array<Record<string, unknown>> = []
let switched = false
const ctx = {
client: {
session: {
create: async () => ({ data: { id: "fresh-ses-9" } }),
promptAsync: async (args: Record<string, unknown>) => {
promptAsyncCalls.push(args)
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
: ({ data: [] }),
message: async () => ({ data: { parts: [] } }),
},
},
} as any
setPendingSwitch("ses-9", "atlas", "fix now")
const hook = createAgentSwitchHook(ctx)
await hook.event({
event: {
type: "session.status",
properties: {
sessionID: "ses-9",
status: {
type: "idle",
},
},
},
})
expect(promptAsyncCalls).toHaveLength(1)
const body = promptAsyncCalls[0]?.body as { agent?: string } | undefined
expect(body?.agent).toBe("Atlas (Plan Executor)")
expect(getPendingSwitch("ses-9")).toBeUndefined()
})
})
-141
View File
@@ -1,141 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { getPendingSwitch } from "../../features/agent-switch"
import { applyPendingSwitch, clearPendingSwitchRuntime } from "../../features/agent-switch/applier"
import {
isTerminalFinishValue,
isTerminalStepFinishPart,
} from "./terminal-detection"
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.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)
}
return
}
if (input.event.type === "session.error") {
const props = input.event.properties as Record<string, unknown> | undefined
const info = props?.info as Record<string, unknown> | undefined
const erroredSessionID = info?.id ?? props?.sessionID
if (typeof erroredSessionID === "string") {
clearPendingSwitchRuntime(erroredSessionID)
}
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 finish = info?.finish
if (!sessionID) {
return
}
const isTerminalAssistantUpdate = isTerminalFinishValue(finish)
if (!isTerminalAssistantUpdate) {
return
}
// 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 (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
}
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",
})
}
},
}
}
-1
View File
@@ -1 +0,0 @@
export { createAgentSwitchHook } from "./hook"
@@ -1,36 +0,0 @@
export function isTerminalFinishValue(finish: unknown): boolean {
if (typeof finish === "boolean") {
return finish
}
if (typeof finish === "string") {
const normalized = finish.toLowerCase()
return normalized !== "" && normalized !== "tool-calls" && normalized !== "unknown"
}
if (typeof finish === "object" && finish !== null) {
const record = finish as Record<string, unknown>
const kind = record.type ?? record.reason
if (typeof kind === "string") {
const normalized = kind.toLowerCase()
return normalized !== "" && normalized !== "tool-calls" && normalized !== "unknown"
}
}
return false
}
export function isTerminalStepFinishPart(part: unknown): boolean {
if (typeof part !== "object" || part === null) {
return false
}
const record = part as Record<string, unknown>
if (record.type !== "step-finish") {
return false
}
return isTerminalFinishValue(record.reason)
}