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:
@@ -316,7 +316,7 @@ Present the synthesis as a direct answer — the synthesis IS the deliverable. A
|
||||
|
||||
---------------------------
|
||||
|
||||
The switch_agent tool switches the active agent. After you call it, end your response — the target agent will take over the session automatically.
|
||||
The switch_agent tool creates a new session with the target agent. First announce the handoff to the user (e.g., "Switching to Hephaestus — see you on the other side."), then call switch_agent. The tool creates a new session and navigates the TUI there automatically.
|
||||
|
||||
## Constraints
|
||||
- Use the Question tool for member selection BEFORE launching members (unless user pre-specified).
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import { _resetForTesting, getPendingSwitch, setPendingSwitch } from "./state"
|
||||
import {
|
||||
_resetApplierForTesting,
|
||||
applyPendingSwitch,
|
||||
clearPendingSwitchRuntime,
|
||||
} from "./applier"
|
||||
import { schedulePendingSwitchApply } from "./scheduler"
|
||||
|
||||
function createMockClient(overrides?: {
|
||||
onPrompt?: (input: { path: { id: string }; body: { agent: string } }) => void
|
||||
onCreate?: () => Record<string, unknown>
|
||||
onMessages?: () => Record<string, unknown>
|
||||
onStatus?: () => Record<string, unknown>
|
||||
}) {
|
||||
return {
|
||||
session: {
|
||||
create: async () => overrides?.onCreate?.() ?? { data: { id: "new-ses" } },
|
||||
promptAsync: async (input: { path: { id: string }; body: { agent: string } }) => {
|
||||
overrides?.onPrompt?.(input)
|
||||
},
|
||||
messages: async () => overrides?.onMessages?.() ?? ({ data: [] }),
|
||||
status: overrides?.onStatus ? async () => overrides.onStatus!() : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("agent-switch applier", () => {
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
_resetApplierForTesting()
|
||||
})
|
||||
|
||||
describe("#given fresh session creation flow", () => {
|
||||
test("#when scheduled apply runs, #then creates new session and prompts it", async () => {
|
||||
const promptedSessions: string[] = []
|
||||
const promptedAgents: string[] = []
|
||||
const client = createMockClient({
|
||||
onCreate: () => ({ data: { id: "fresh-ses-1" } }),
|
||||
onPrompt: (input) => {
|
||||
promptedSessions.push(input.path.id)
|
||||
promptedAgents.push(input.body.agent)
|
||||
},
|
||||
})
|
||||
|
||||
setPendingSwitch("ses-1", "prometheus", "create plan")
|
||||
schedulePendingSwitchApply({
|
||||
sessionID: "ses-1",
|
||||
client: client as any,
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
|
||||
expect(promptedSessions).toEqual(["fresh-ses-1"])
|
||||
expect(promptedAgents).toEqual(["Prometheus (Plan Builder)"])
|
||||
expect(getPendingSwitch("ses-1")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("#when apply runs directly, #then creates fresh session with parentID linking to source", async () => {
|
||||
let createInput: Record<string, unknown> | undefined
|
||||
const client = {
|
||||
session: {
|
||||
create: async (input?: { body?: Record<string, unknown> }) => {
|
||||
createInput = input?.body
|
||||
return { data: { id: "fresh-ses-2" } }
|
||||
},
|
||||
promptAsync: async () => {},
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
setPendingSwitch("ses-2", "atlas", "fix now")
|
||||
await applyPendingSwitch({
|
||||
sessionID: "ses-2",
|
||||
client: client as any,
|
||||
source: "idle",
|
||||
})
|
||||
|
||||
expect(createInput).toEqual({
|
||||
parentID: "ses-2",
|
||||
title: "atlas (handoff)",
|
||||
})
|
||||
expect(getPendingSwitch("ses-2")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given agent name normalization", () => {
|
||||
test("#when agent has canonical display name, #then normalizes for prompt", async () => {
|
||||
const promptedAgents: string[] = []
|
||||
const client = createMockClient({
|
||||
onPrompt: (input) => {
|
||||
promptedAgents.push(input.body.agent)
|
||||
},
|
||||
})
|
||||
|
||||
setPendingSwitch("ses-3", "Prometheus (Plan Builder)", "create plan")
|
||||
await applyPendingSwitch({
|
||||
sessionID: "ses-3",
|
||||
client: client as any,
|
||||
source: "idle",
|
||||
})
|
||||
|
||||
expect(promptedAgents).toEqual(["Prometheus (Plan Builder)"])
|
||||
expect(getPendingSwitch("ses-3")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given transient failures", () => {
|
||||
test("#when create fails transiently, #then retries and eventually succeeds", async () => {
|
||||
let createAttempts = 0
|
||||
const client = {
|
||||
session: {
|
||||
create: async () => {
|
||||
createAttempts += 1
|
||||
if (createAttempts < 3) {
|
||||
throw new Error("temporary failure")
|
||||
}
|
||||
return { data: { id: "fresh-ses-retry" } }
|
||||
},
|
||||
promptAsync: async () => {},
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
setPendingSwitch("ses-4", "atlas", "fix this")
|
||||
await applyPendingSwitch({
|
||||
sessionID: "ses-4",
|
||||
client: client as any,
|
||||
source: "idle",
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
|
||||
expect(createAttempts).toBe(3)
|
||||
expect(getPendingSwitch("ses-4")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given session idle wait", () => {
|
||||
test("#when session is busy, #then waits for idle before creating fresh session", async () => {
|
||||
let statusChecks = 0
|
||||
let createCalled = false
|
||||
const client = {
|
||||
session: {
|
||||
status: async () => {
|
||||
statusChecks += 1
|
||||
return {
|
||||
"ses-5": { type: statusChecks < 3 ? "running" : "idle" },
|
||||
}
|
||||
},
|
||||
create: async () => {
|
||||
createCalled = true
|
||||
return { data: { id: "fresh-ses-idle" } }
|
||||
},
|
||||
promptAsync: async () => {},
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
setPendingSwitch("ses-5", "atlas", "fix now")
|
||||
await applyPendingSwitch({
|
||||
sessionID: "ses-5",
|
||||
client: client as any,
|
||||
source: "idle",
|
||||
})
|
||||
|
||||
expect(statusChecks).toBeGreaterThanOrEqual(3)
|
||||
expect(createCalled).toBe(true)
|
||||
expect(getPendingSwitch("ses-5")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given runtime cancellation", () => {
|
||||
test("#when clearPendingSwitchRuntime called, #then cancels pending retries", async () => {
|
||||
let attempts = 0
|
||||
const client = {
|
||||
session: {
|
||||
create: async () => {
|
||||
attempts += 1
|
||||
throw new Error("always failing")
|
||||
},
|
||||
promptAsync: async () => {},
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
setPendingSwitch("ses-6", "atlas", "fix this")
|
||||
await applyPendingSwitch({
|
||||
sessionID: "ses-6",
|
||||
client: client as any,
|
||||
source: "idle",
|
||||
})
|
||||
|
||||
clearPendingSwitchRuntime("ses-6")
|
||||
|
||||
const attemptsAfterClear = attempts
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
|
||||
expect(attempts).toBe(attemptsAfterClear)
|
||||
expect(getPendingSwitch("ses-6")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given create not available on client", () => {
|
||||
test("#when session.create is missing, #then enters retry path", async () => {
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {},
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
setPendingSwitch("ses-7", "atlas", "fix now")
|
||||
await applyPendingSwitch({
|
||||
sessionID: "ses-7",
|
||||
client: client as any,
|
||||
source: "idle",
|
||||
})
|
||||
|
||||
expect(getPendingSwitch("ses-7")).toBeDefined()
|
||||
|
||||
clearPendingSwitchRuntime("ses-7")
|
||||
expect(getPendingSwitch("ses-7")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,197 +0,0 @@
|
||||
import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import { clearPendingSwitch, getPendingSwitch } from "./state"
|
||||
import { waitForSessionIdle } from "./session-status"
|
||||
import { shouldClearAsAlreadyApplied } from "./apply-verification"
|
||||
import { createFreshSession } from "./session-creator"
|
||||
import {
|
||||
clearInFlight,
|
||||
clearRetryState,
|
||||
isApplyInFlight,
|
||||
markApplyInFlight,
|
||||
resetRetryStateForTesting,
|
||||
scheduleRetry,
|
||||
} from "./retry-state"
|
||||
|
||||
type SessionClient = {
|
||||
session: {
|
||||
prompt?: (input: {
|
||||
path: { id: string }
|
||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||
}) => Promise<unknown>
|
||||
promptAsync: (input: {
|
||||
path: { id: string }
|
||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||
}) => Promise<unknown>
|
||||
create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise<unknown>
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
status?: () => Promise<unknown>
|
||||
}
|
||||
app?: {
|
||||
agents?: () => Promise<unknown>
|
||||
}
|
||||
tui?: {
|
||||
publish?: (input: {
|
||||
body: {
|
||||
type: "tui.command.execute"
|
||||
properties: { command: string }
|
||||
}
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
async function tryPromptWithCandidates(args: {
|
||||
client: SessionClient
|
||||
sessionID: string
|
||||
agent: string
|
||||
context: string
|
||||
source: string
|
||||
}): Promise<string> {
|
||||
const { client, sessionID, agent, context, source } = args
|
||||
const targetAgent = normalizeAgentForPrompt(agent)
|
||||
if (!targetAgent) {
|
||||
throw new Error(`invalid target agent for switch prompt: ${agent}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const promptInput = {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: targetAgent,
|
||||
parts: [{ type: "text" as const, text: context }],
|
||||
},
|
||||
}
|
||||
|
||||
if (client.session.prompt) {
|
||||
await client.session.prompt(promptInput)
|
||||
} else {
|
||||
await client.session.promptAsync(promptInput)
|
||||
}
|
||||
|
||||
if (targetAgent !== agent) {
|
||||
log("[agent-switch] Normalized pending switch agent for prompt", {
|
||||
sessionID,
|
||||
source,
|
||||
requestedAgent: agent,
|
||||
usedAgent: targetAgent,
|
||||
})
|
||||
}
|
||||
|
||||
return targetAgent
|
||||
} catch (error) {
|
||||
log("[agent-switch] Prompt attempt failed", {
|
||||
sessionID,
|
||||
source,
|
||||
requestedAgent: agent,
|
||||
attemptedAgent: targetAgent,
|
||||
error: String(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyPendingSwitch(args: {
|
||||
sessionID: string
|
||||
client: SessionClient
|
||||
source: string
|
||||
}): Promise<void> {
|
||||
const { sessionID, client, source } = args
|
||||
const pending = getPendingSwitch(sessionID)
|
||||
if (!pending) {
|
||||
clearRetryState(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (isApplyInFlight(sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
markApplyInFlight(sessionID)
|
||||
log("[agent-switch] Applying pending switch", {
|
||||
sessionID,
|
||||
source,
|
||||
agent: pending.agent,
|
||||
})
|
||||
|
||||
try {
|
||||
const alreadyApplied = await shouldClearAsAlreadyApplied({
|
||||
client,
|
||||
sessionID,
|
||||
targetAgent: pending.agent,
|
||||
})
|
||||
if (alreadyApplied) {
|
||||
clearPendingSwitch(sessionID)
|
||||
clearRetryState(sessionID)
|
||||
log("[agent-switch] Pending switch already applied by user-turn evidence; clearing state", {
|
||||
sessionID,
|
||||
source,
|
||||
agent: pending.agent,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const idleReady = await waitForSessionIdle({ client, sessionID })
|
||||
if (!idleReady) {
|
||||
throw new Error("session not idle before applying agent switch")
|
||||
}
|
||||
|
||||
const newSessionID = await createFreshSession({
|
||||
client,
|
||||
sourceSessionID: sessionID,
|
||||
targetAgent: pending.agent,
|
||||
})
|
||||
|
||||
await tryPromptWithCandidates({
|
||||
client,
|
||||
sessionID: newSessionID,
|
||||
agent: pending.agent,
|
||||
context: pending.context,
|
||||
source,
|
||||
})
|
||||
|
||||
clearPendingSwitch(sessionID)
|
||||
clearRetryState(sessionID)
|
||||
|
||||
log("[agent-switch] Pending switch applied via fresh session", {
|
||||
sourceSessionID: sessionID,
|
||||
newSessionID,
|
||||
source,
|
||||
agent: pending.agent,
|
||||
})
|
||||
} catch (error) {
|
||||
clearInFlight(sessionID)
|
||||
log("[agent-switch] Pending switch apply failed", {
|
||||
sessionID,
|
||||
source,
|
||||
error: String(error),
|
||||
})
|
||||
scheduleRetry({
|
||||
sessionID,
|
||||
source,
|
||||
onLimitReached: (attempts) => {
|
||||
log("[agent-switch] Retry limit reached; waiting for next trigger", {
|
||||
sessionID,
|
||||
attempts,
|
||||
source,
|
||||
})
|
||||
},
|
||||
retryFn: (attemptNumber) => {
|
||||
void applyPendingSwitch({
|
||||
sessionID,
|
||||
client,
|
||||
source: `retry:${attemptNumber}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingSwitchRuntime(sessionID: string): void {
|
||||
clearPendingSwitch(sessionID)
|
||||
clearRetryState(sessionID)
|
||||
}
|
||||
|
||||
/** @internal For testing only */
|
||||
export function _resetApplierForTesting(): void {
|
||||
resetRetryStateForTesting()
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { extractMessageList, hasNewUserTurnForTargetAgent, hasRecentUserTurnForTargetAgent } from "./message-inspection"
|
||||
import { log } from "../../shared/logger"
|
||||
import { sleepWithDelay } from "./session-status"
|
||||
|
||||
type SessionClient = {
|
||||
session: {
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMessages(args: {
|
||||
client: SessionClient
|
||||
sessionID: string
|
||||
}): Promise<Array<Record<string, unknown>>> {
|
||||
const response = await args.client.session.messages({ path: { id: args.sessionID } })
|
||||
return extractMessageList(response)
|
||||
}
|
||||
|
||||
export async function verifySwitchObserved(args: {
|
||||
client: SessionClient
|
||||
sessionID: string
|
||||
targetAgent: string
|
||||
baselineCount: number
|
||||
}): Promise<boolean> {
|
||||
const { client, sessionID, targetAgent, baselineCount } = args
|
||||
const delays = [100, 300, 800, 1500] as const
|
||||
|
||||
for (const delay of delays) {
|
||||
await sleepWithDelay(delay)
|
||||
try {
|
||||
const messages = await fetchMessages({ client, sessionID })
|
||||
if (hasNewUserTurnForTargetAgent({ messages, targetAgent, baselineCount })) {
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
log("[agent-switch] Verification read failed", {
|
||||
sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function shouldClearAsAlreadyApplied(args: {
|
||||
client: SessionClient
|
||||
sessionID: string
|
||||
targetAgent: string
|
||||
}): Promise<boolean> {
|
||||
const { client, sessionID, targetAgent } = args
|
||||
|
||||
try {
|
||||
const messages = await fetchMessages({ client, sessionID })
|
||||
return hasRecentUserTurnForTargetAgent({ messages, targetAgent })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export {
|
||||
setPendingSwitch,
|
||||
getPendingSwitch,
|
||||
clearPendingSwitch,
|
||||
consumePendingSwitch,
|
||||
_resetForTesting,
|
||||
} from "./state"
|
||||
export type { PendingSwitch } from "./state"
|
||||
@@ -1,107 +0,0 @@
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
|
||||
export interface MessageRoleAgent {
|
||||
role: string
|
||||
agent: string
|
||||
}
|
||||
|
||||
export function extractMessageList(response: unknown): Array<Record<string, unknown>> {
|
||||
if (Array.isArray(response)) {
|
||||
return response.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
||||
}
|
||||
if (typeof response === "object" && response !== null) {
|
||||
const data = (response as Record<string, unknown>).data
|
||||
if (Array.isArray(data)) {
|
||||
return data.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function getRoleAgent(message: Record<string, unknown>): MessageRoleAgent | undefined {
|
||||
const info = message.info
|
||||
if (typeof info !== "object" || info === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const role = (info as Record<string, unknown>).role
|
||||
const agent = (info as Record<string, unknown>).agent
|
||||
if (typeof role !== "string" || typeof agent !== "string") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { role, agent }
|
||||
}
|
||||
|
||||
export function getLatestUserAgent(messages: Array<Record<string, unknown>>): string | undefined {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index]
|
||||
if (!message) {
|
||||
continue
|
||||
}
|
||||
|
||||
const roleAgent = getRoleAgent(message)
|
||||
if (!roleAgent || roleAgent.role !== "user") {
|
||||
continue
|
||||
}
|
||||
|
||||
return roleAgent.agent
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function hasRecentUserTurnForTargetAgent(args: {
|
||||
messages: Array<Record<string, unknown>>
|
||||
targetAgent: string
|
||||
lookback?: number
|
||||
}): boolean {
|
||||
const { messages, targetAgent, lookback = 8 } = args
|
||||
const targetKey = getAgentConfigKey(targetAgent)
|
||||
const start = Math.max(0, messages.length - lookback)
|
||||
|
||||
for (let index = messages.length - 1; index >= start; index -= 1) {
|
||||
const message = messages[index]
|
||||
if (!message) {
|
||||
continue
|
||||
}
|
||||
|
||||
const roleAgent = getRoleAgent(message)
|
||||
if (!roleAgent || roleAgent.role !== "user") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (getAgentConfigKey(roleAgent.agent) === targetKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function hasNewUserTurnForTargetAgent(args: {
|
||||
messages: Array<Record<string, unknown>>
|
||||
targetAgent: string
|
||||
baselineCount: number
|
||||
}): boolean {
|
||||
const { messages, targetAgent, baselineCount } = args
|
||||
const targetKey = getAgentConfigKey(targetAgent)
|
||||
|
||||
if (messages.length <= baselineCount) {
|
||||
return false
|
||||
}
|
||||
|
||||
const newMessages = messages.slice(Math.max(0, baselineCount))
|
||||
for (const message of newMessages) {
|
||||
const roleAgent = getRoleAgent(message)
|
||||
if (!roleAgent || roleAgent.role !== "user") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (getAgentConfigKey(roleAgent.agent) === targetKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
const RETRY_DELAYS_MS = [50, 250, 500, 1000, 2000, 5000] as const
|
||||
|
||||
const inFlightSessions = new Set<string>()
|
||||
const retryAttempts = new Map<string, number>()
|
||||
const retryTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
export function isApplyInFlight(sessionID: string): boolean {
|
||||
return inFlightSessions.has(sessionID)
|
||||
}
|
||||
|
||||
export function markApplyInFlight(sessionID: string): void {
|
||||
inFlightSessions.add(sessionID)
|
||||
}
|
||||
|
||||
export function clearRetryState(sessionID: string): void {
|
||||
const timer = retryTimers.get(sessionID)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
retryTimers.delete(sessionID)
|
||||
}
|
||||
retryAttempts.delete(sessionID)
|
||||
inFlightSessions.delete(sessionID)
|
||||
}
|
||||
|
||||
export function clearInFlight(sessionID: string): void {
|
||||
inFlightSessions.delete(sessionID)
|
||||
}
|
||||
|
||||
export function scheduleRetry(args: {
|
||||
sessionID: string
|
||||
source: string
|
||||
retryFn: (attemptNumber: number) => void
|
||||
onLimitReached: (attempts: number) => void
|
||||
}): void {
|
||||
const { sessionID, retryFn, onLimitReached } = args
|
||||
const attempts = retryAttempts.get(sessionID) ?? 0
|
||||
if (attempts >= RETRY_DELAYS_MS.length) {
|
||||
onLimitReached(attempts)
|
||||
return
|
||||
}
|
||||
|
||||
const delay = RETRY_DELAYS_MS[attempts]
|
||||
retryAttempts.set(sessionID, attempts + 1)
|
||||
|
||||
const existing = retryTimers.get(sessionID)
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
retryTimers.delete(sessionID)
|
||||
retryFn(attempts + 1)
|
||||
}, delay)
|
||||
|
||||
retryTimers.set(sessionID, timer)
|
||||
}
|
||||
|
||||
/** @internal For testing only */
|
||||
export function resetRetryStateForTesting(): void {
|
||||
for (const timer of retryTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
retryTimers.clear()
|
||||
retryAttempts.clear()
|
||||
inFlightSessions.clear()
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { log } from "../../shared/logger"
|
||||
import { scheduleRetry } from "./retry-state"
|
||||
import { applyPendingSwitch } from "./applier"
|
||||
|
||||
type SessionClient = {
|
||||
session: {
|
||||
prompt?: (input: {
|
||||
path: { id: string }
|
||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||
}) => Promise<unknown>
|
||||
promptAsync: (input: {
|
||||
path: { id: string }
|
||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||
}) => Promise<unknown>
|
||||
create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise<unknown>
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
status?: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export function schedulePendingSwitchApply(args: {
|
||||
sessionID: string
|
||||
client: SessionClient
|
||||
}): void {
|
||||
const { sessionID, client } = args
|
||||
scheduleRetry({
|
||||
sessionID,
|
||||
source: "tool",
|
||||
onLimitReached: (attempts) => {
|
||||
log("[agent-switch] Retry limit reached; waiting for next trigger", {
|
||||
sessionID,
|
||||
attempts,
|
||||
source: "tool",
|
||||
})
|
||||
},
|
||||
retryFn: (attemptNumber) => {
|
||||
void applyPendingSwitch({
|
||||
sessionID,
|
||||
client,
|
||||
source: `retry:${attemptNumber}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createFreshSession } from "./session-creator"
|
||||
|
||||
describe("session-creator", () => {
|
||||
describe("#given SDK response with data wrapper", () => {
|
||||
test("#when create returns { data: { id } }, #then extracts session ID", async () => {
|
||||
const client = {
|
||||
session: {
|
||||
create: async () => ({ data: { id: "new-session-123" } }),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await createFreshSession({
|
||||
client,
|
||||
sourceSessionID: "source-ses",
|
||||
targetAgent: "atlas",
|
||||
})
|
||||
|
||||
expect(result).toBe("new-session-123")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given SDK response without data wrapper", () => {
|
||||
test("#when create returns { id } directly, #then extracts session ID", async () => {
|
||||
const client = {
|
||||
session: {
|
||||
create: async () => ({ id: "direct-session-456" }),
|
||||
},
|
||||
}
|
||||
|
||||
const result = await createFreshSession({
|
||||
client,
|
||||
sourceSessionID: "source-ses",
|
||||
targetAgent: "prometheus",
|
||||
})
|
||||
|
||||
expect(result).toBe("direct-session-456")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given create not available", () => {
|
||||
test("#when session.create is undefined, #then throws", async () => {
|
||||
const client = { session: {} }
|
||||
|
||||
await expect(
|
||||
createFreshSession({
|
||||
client: client as any,
|
||||
sourceSessionID: "source-ses",
|
||||
targetAgent: "atlas",
|
||||
}),
|
||||
).rejects.toThrow("session.create not available")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given invalid response", () => {
|
||||
test("#when create returns no id, #then throws", async () => {
|
||||
const client = {
|
||||
session: {
|
||||
create: async () => ({ data: {} }),
|
||||
},
|
||||
}
|
||||
|
||||
await expect(
|
||||
createFreshSession({
|
||||
client,
|
||||
sourceSessionID: "source-ses",
|
||||
targetAgent: "atlas",
|
||||
}),
|
||||
).rejects.toThrow("failed to extract session ID")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given parentID and title", () => {
|
||||
test("#when creating session, #then passes sourceSessionID as parentID", async () => {
|
||||
let capturedInput: Record<string, unknown> | undefined
|
||||
const client = {
|
||||
session: {
|
||||
create: async (input?: { body?: Record<string, unknown> }) => {
|
||||
capturedInput = input?.body
|
||||
return { id: "new-ses" }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
await createFreshSession({
|
||||
client,
|
||||
sourceSessionID: "parent-ses-id",
|
||||
targetAgent: "atlas",
|
||||
})
|
||||
|
||||
expect(capturedInput).toEqual({
|
||||
parentID: "parent-ses-id",
|
||||
title: "atlas (handoff)",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type CreateClient = {
|
||||
session: {
|
||||
create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
function extractSessionId(response: unknown): string | undefined {
|
||||
if (typeof response !== "object" || response === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const root = response as Record<string, unknown>
|
||||
|
||||
if (typeof root.id === "string" && root.id.length > 0) {
|
||||
return root.id
|
||||
}
|
||||
|
||||
const data = root.data
|
||||
if (typeof data === "object" && data !== null) {
|
||||
const dataRecord = data as Record<string, unknown>
|
||||
if (typeof dataRecord.id === "string" && dataRecord.id.length > 0) {
|
||||
return dataRecord.id
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function createFreshSession(args: {
|
||||
client: CreateClient
|
||||
sourceSessionID: string
|
||||
targetAgent: string
|
||||
}): Promise<string> {
|
||||
const { client, sourceSessionID, targetAgent } = args
|
||||
|
||||
if (!client.session.create) {
|
||||
throw new Error("session.create not available on SDK client")
|
||||
}
|
||||
|
||||
const response = await client.session.create({
|
||||
body: {
|
||||
parentID: sourceSessionID,
|
||||
title: `${targetAgent} (handoff)`,
|
||||
},
|
||||
})
|
||||
|
||||
const newSessionID = extractSessionId(response)
|
||||
if (!newSessionID) {
|
||||
throw new Error(`failed to extract session ID from create response: ${JSON.stringify(response)}`)
|
||||
}
|
||||
|
||||
log("[agent-switch] Created fresh session for handoff", {
|
||||
sourceSessionID,
|
||||
newSessionID,
|
||||
targetAgent,
|
||||
})
|
||||
|
||||
return newSessionID
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type SessionClient = {
|
||||
session: {
|
||||
status?: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function getSessionStatusType(statusResponse: unknown, sessionID: string): string | undefined {
|
||||
if (typeof statusResponse !== "object" || statusResponse === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const root = statusResponse as Record<string, unknown>
|
||||
const data = (typeof root.data === "object" && root.data !== null)
|
||||
? root.data as Record<string, unknown>
|
||||
: root
|
||||
|
||||
const entry = data[sessionID]
|
||||
if (typeof entry !== "object" || entry === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const entryType = (entry as Record<string, unknown>).type
|
||||
return typeof entryType === "string" ? entryType : undefined
|
||||
}
|
||||
|
||||
export async function waitForSessionIdle(args: {
|
||||
client: SessionClient
|
||||
sessionID: string
|
||||
timeoutMs?: number
|
||||
}): Promise<boolean> {
|
||||
const { client, sessionID, timeoutMs = 15000 } = args
|
||||
if (!client.session.status) {
|
||||
return true
|
||||
}
|
||||
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const statusResponse = await client.session.status()
|
||||
const statusType = getSessionStatusType(statusResponse, sessionID)
|
||||
// /session/status only tracks non-idle sessions in SessionStatus.list().
|
||||
// Missing entry means idle.
|
||||
if (!statusType || statusType === "idle") {
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
log("[agent-switch] Session status check failed", {
|
||||
sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
await sleep(200)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function sleepWithDelay(ms: number): Promise<void> {
|
||||
await sleep(ms)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
const { describe, test, expect, beforeEach } = require("bun:test")
|
||||
import {
|
||||
setPendingSwitch,
|
||||
getPendingSwitch,
|
||||
clearPendingSwitch,
|
||||
consumePendingSwitch,
|
||||
_resetForTesting,
|
||||
} from "./state"
|
||||
|
||||
describe("agent-switch state", () => {
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
//#given a pending switch is set
|
||||
//#when consumePendingSwitch is called
|
||||
//#then it returns the switch and removes it
|
||||
test("should store and consume a pending switch", () => {
|
||||
setPendingSwitch("session-1", "atlas", "Fix these findings")
|
||||
|
||||
const entry = consumePendingSwitch("session-1")
|
||||
|
||||
expect(entry).toEqual({ agent: "atlas", context: "Fix these findings" })
|
||||
expect(consumePendingSwitch("session-1")).toBeUndefined()
|
||||
})
|
||||
|
||||
//#given no pending switch exists
|
||||
//#when consumePendingSwitch is called
|
||||
//#then it returns undefined
|
||||
test("should return undefined when no switch is pending", () => {
|
||||
expect(consumePendingSwitch("session-1")).toBeUndefined()
|
||||
})
|
||||
|
||||
//#given a pending switch is set
|
||||
//#when a new switch is set for the same session
|
||||
//#then the latest switch wins
|
||||
test("should overwrite previous switch for same session", () => {
|
||||
setPendingSwitch("session-1", "atlas", "Fix A")
|
||||
setPendingSwitch("session-1", "prometheus", "Plan B")
|
||||
|
||||
const entry = consumePendingSwitch("session-1")
|
||||
|
||||
expect(entry).toEqual({ agent: "prometheus", context: "Plan B" })
|
||||
})
|
||||
|
||||
//#given switches for different sessions
|
||||
//#when consumed separately
|
||||
//#then each session gets its own switch
|
||||
test("should isolate switches by session", () => {
|
||||
setPendingSwitch("session-1", "atlas", "Fix A")
|
||||
setPendingSwitch("session-2", "prometheus", "Plan B")
|
||||
|
||||
expect(consumePendingSwitch("session-1")).toEqual({ agent: "atlas", context: "Fix A" })
|
||||
expect(consumePendingSwitch("session-2")).toEqual({ agent: "prometheus", context: "Plan B" })
|
||||
})
|
||||
|
||||
test("should allow reading without consuming", () => {
|
||||
setPendingSwitch("session-1", "atlas", "Fix A")
|
||||
|
||||
expect(getPendingSwitch("session-1")).toEqual({ agent: "atlas", context: "Fix A" })
|
||||
expect(getPendingSwitch("session-1")).toEqual({ agent: "atlas", context: "Fix A" })
|
||||
})
|
||||
|
||||
test("should clear pending switch explicitly", () => {
|
||||
setPendingSwitch("session-1", "atlas", "Fix A")
|
||||
|
||||
clearPendingSwitch("session-1")
|
||||
|
||||
expect(getPendingSwitch("session-1")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
|
||||
export interface PendingSwitch {
|
||||
agent: string
|
||||
context: string
|
||||
}
|
||||
|
||||
const PENDING_SWITCH_STATE_FILE = process.platform === "win32"
|
||||
? join(tmpdir(), "oh-my-opencode-agent-switch.json")
|
||||
: "/tmp/oh-my-opencode-agent-switch.json"
|
||||
|
||||
const pendingSwitches = new Map<string, PendingSwitch>()
|
||||
|
||||
function isPendingSwitch(value: unknown): value is PendingSwitch {
|
||||
if (typeof value !== "object" || value === null) return false
|
||||
const entry = value as Record<string, unknown>
|
||||
return typeof entry.agent === "string" && typeof entry.context === "string"
|
||||
}
|
||||
|
||||
function readPersistentState(): Record<string, PendingSwitch> {
|
||||
try {
|
||||
if (!existsSync(PENDING_SWITCH_STATE_FILE)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const raw = readFileSync(PENDING_SWITCH_STATE_FILE, "utf8")
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const state: Record<string, PendingSwitch> = {}
|
||||
for (const [sessionID, value] of Object.entries(parsed)) {
|
||||
if (isPendingSwitch(value)) {
|
||||
state[sessionID] = value
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writePersistentState(state: Record<string, PendingSwitch>): void {
|
||||
try {
|
||||
const keys = Object.keys(state)
|
||||
if (keys.length === 0) {
|
||||
rmSync(PENDING_SWITCH_STATE_FILE, { force: true })
|
||||
return
|
||||
}
|
||||
|
||||
writeFileSync(PENDING_SWITCH_STATE_FILE, JSON.stringify(state), "utf8")
|
||||
} catch {
|
||||
// ignore persistence errors
|
||||
}
|
||||
}
|
||||
|
||||
export function setPendingSwitch(sessionID: string, agent: string, context: string): void {
|
||||
const entry = { agent, context }
|
||||
pendingSwitches.set(sessionID, entry)
|
||||
|
||||
const state = readPersistentState()
|
||||
state[sessionID] = entry
|
||||
writePersistentState(state)
|
||||
}
|
||||
|
||||
export function getPendingSwitch(sessionID: string): PendingSwitch | undefined {
|
||||
const inMemory = pendingSwitches.get(sessionID)
|
||||
if (inMemory) {
|
||||
return inMemory
|
||||
}
|
||||
|
||||
const state = readPersistentState()
|
||||
const fromDisk = state[sessionID]
|
||||
if (fromDisk) {
|
||||
pendingSwitches.set(sessionID, fromDisk)
|
||||
}
|
||||
return fromDisk
|
||||
}
|
||||
|
||||
export function clearPendingSwitch(sessionID: string): void {
|
||||
pendingSwitches.delete(sessionID)
|
||||
|
||||
const state = readPersistentState()
|
||||
delete state[sessionID]
|
||||
writePersistentState(state)
|
||||
}
|
||||
|
||||
export function consumePendingSwitch(sessionID: string): PendingSwitch | undefined {
|
||||
const entry = getPendingSwitch(sessionID)
|
||||
clearPendingSwitch(sessionID)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** @internal For testing only */
|
||||
export function _resetForTesting(): void {
|
||||
pendingSwitches.clear()
|
||||
rmSync(PENDING_SWITCH_STATE_FILE, { force: true })
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { log, normalizeSDKResponse } from "../../shared"
|
||||
|
||||
type TuiClient = {
|
||||
app?: {
|
||||
agents?: () => Promise<unknown>
|
||||
}
|
||||
tui?: {
|
||||
publish?: (input: {
|
||||
body: {
|
||||
type: "tui.command.execute"
|
||||
properties: { command: string }
|
||||
}
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type AgentInfo = {
|
||||
name?: string
|
||||
mode?: "subagent" | "primary" | "all"
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
function isCliClient(): boolean {
|
||||
return (process.env["OPENCODE_CLIENT"] ?? "cli") === "cli"
|
||||
}
|
||||
|
||||
function resolveCyclePlan(args: {
|
||||
orderedAgentNames: string[]
|
||||
sourceAgent: string
|
||||
targetAgent: string
|
||||
}): { command: "agent.cycle" | "agent.cycle.reverse"; steps: number } | undefined {
|
||||
const { orderedAgentNames, sourceAgent, targetAgent } = args
|
||||
if (orderedAgentNames.length < 2) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const orderedKeys = orderedAgentNames.map((name) => getAgentConfigKey(name))
|
||||
const sourceKey = getAgentConfigKey(sourceAgent)
|
||||
const targetKey = getAgentConfigKey(targetAgent)
|
||||
|
||||
const sourceIndex = orderedKeys.indexOf(sourceKey)
|
||||
const targetIndex = orderedKeys.indexOf(targetKey)
|
||||
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const size = orderedKeys.length
|
||||
const forward = (targetIndex - sourceIndex + size) % size
|
||||
const backward = (sourceIndex - targetIndex + size) % size
|
||||
|
||||
if (forward <= backward) {
|
||||
return { command: "agent.cycle", steps: forward }
|
||||
}
|
||||
|
||||
return { command: "agent.cycle.reverse", steps: backward }
|
||||
}
|
||||
|
||||
export async function syncCliTuiAgentSelectionAfterSwitch(args: {
|
||||
client: TuiClient
|
||||
sessionID: string
|
||||
sourceAgent: string | undefined
|
||||
targetAgent: string
|
||||
source: string
|
||||
}): Promise<void> {
|
||||
const { client, sessionID, sourceAgent, targetAgent, source } = args
|
||||
|
||||
if (!isCliClient()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!sourceAgent || !client.app?.agents || !client.tui?.publish) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceKey = getAgentConfigKey(sourceAgent)
|
||||
const targetKey = getAgentConfigKey(targetAgent)
|
||||
|
||||
// Scope to Athena handoffs where CLI TUI can show stale local-agent selection.
|
||||
if (sourceKey !== "athena" || (targetKey !== "atlas" && targetKey !== "prometheus")) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.app.agents()
|
||||
const agents = normalizeSDKResponse(response, [] as AgentInfo[], {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
|
||||
const orderedPrimaryAgents = agents
|
||||
.filter((agent) => typeof agent.name === "string" && agent.mode !== "subagent" && agent.hidden !== true)
|
||||
.map((agent) => agent.name as string)
|
||||
|
||||
const plan = resolveCyclePlan({
|
||||
orderedAgentNames: orderedPrimaryAgents,
|
||||
sourceAgent,
|
||||
targetAgent,
|
||||
})
|
||||
|
||||
if (!plan || plan.steps <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (let step = 0; step < plan.steps; step += 1) {
|
||||
await client.tui.publish({
|
||||
body: {
|
||||
type: "tui.command.execute",
|
||||
properties: {
|
||||
command: plan.command,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
log("[agent-switch] Synced CLI TUI local agent after handoff", {
|
||||
sessionID,
|
||||
source,
|
||||
sourceAgent,
|
||||
targetAgent,
|
||||
command: plan.command,
|
||||
steps: plan.steps,
|
||||
})
|
||||
} catch (error) {
|
||||
log("[agent-switch] Failed syncing CLI TUI local agent after handoff", {
|
||||
sessionID,
|
||||
source,
|
||||
sourceAgent,
|
||||
targetAgent,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 +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)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
createCompactionContextInjector,
|
||||
createCompactionTodoPreserverHook,
|
||||
createAtlasHook,
|
||||
createAgentSwitchHook,
|
||||
} from "../../hooks"
|
||||
import { safeCreateHook } from "../../shared/safe-create-hook"
|
||||
import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter"
|
||||
@@ -22,7 +21,6 @@ export type ContinuationHooks = {
|
||||
unstableAgentBabysitter: ReturnType<typeof createUnstableAgentBabysitter> | null
|
||||
backgroundNotificationHook: ReturnType<typeof createBackgroundNotificationHook> | null
|
||||
atlasHook: ReturnType<typeof createAtlasHook> | null
|
||||
agentSwitchHook: ReturnType<typeof createAgentSwitchHook> | null
|
||||
}
|
||||
|
||||
type SessionRecovery = {
|
||||
@@ -118,9 +116,6 @@ export function createContinuationHooks(args: {
|
||||
}))
|
||||
: null
|
||||
|
||||
const agentSwitchHook = isHookEnabled("agent-switch")
|
||||
? safeHook("agent-switch", () => createAgentSwitchHook(ctx))
|
||||
: null
|
||||
|
||||
return {
|
||||
stopContinuationGuard,
|
||||
@@ -130,6 +125,5 @@ export function createContinuationHooks(args: {
|
||||
unstableAgentBabysitter,
|
||||
backgroundNotificationHook,
|
||||
atlasHook,
|
||||
agentSwitchHook,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { createSwitchAgentTool } from "./tools"
|
||||
import { consumePendingSwitch, _resetForTesting as resetSwitch } from "../../features/agent-switch"
|
||||
import { getSessionAgent, _resetForTesting as resetSession } from "../../features/claude-code-session-state"
|
||||
|
||||
describe("switch_agent tool", () => {
|
||||
const sessionID = "test-session-123"
|
||||
@@ -17,40 +15,38 @@ describe("switch_agent tool", () => {
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
let createdSessions: Array<{ body?: { parentID?: string; title?: string } }>
|
||||
let promptedSessions: Array<{ path: { id: string }; body: { agent?: string; parts: Array<{ type: "text"; text: string }> } }>
|
||||
|
||||
beforeEach(() => {
|
||||
resetSwitch()
|
||||
resetSession()
|
||||
createdSessions = []
|
||||
promptedSessions = []
|
||||
})
|
||||
|
||||
function createToolWithMockClient(promptImpl?: () => Promise<unknown>) {
|
||||
function createToolWithMockClient(overrides?: {
|
||||
createImpl?: () => Promise<unknown>
|
||||
promptAsyncImpl?: (input: any) => Promise<unknown>
|
||||
}) {
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync:
|
||||
promptImpl ??
|
||||
(async () => {
|
||||
return undefined
|
||||
}),
|
||||
messages: async () => ({ data: [] }),
|
||||
create: overrides?.createImpl ?? (async (input?: { body?: { parentID?: string; title?: string } }) => {
|
||||
createdSessions.push(input ?? {})
|
||||
return { data: { id: "new-session-abc" } }
|
||||
}),
|
||||
promptAsync: overrides?.promptAsyncImpl ?? (async (input: any) => {
|
||||
promptedSessions.push(input)
|
||||
return undefined
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
return createSwitchAgentTool({
|
||||
client: client as unknown as {
|
||||
session: {
|
||||
promptAsync: (input: {
|
||||
path: { id: string }
|
||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||
}) => Promise<unknown>
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
}
|
||||
},
|
||||
})
|
||||
return createSwitchAgentTool({ client })
|
||||
}
|
||||
|
||||
//#given valid atlas switch args
|
||||
//#when execute is called
|
||||
//#then it stores pending switch and updates session agent
|
||||
test("should queue switch to atlas", async () => {
|
||||
//#then it creates a new session and prompts with the target agent
|
||||
test("should create session and prompt for atlas switch", async () => {
|
||||
const tool = createToolWithMockClient()
|
||||
const result = await tool.execute(
|
||||
{ agent: "atlas", context: "Fix the auth bug based on council findings" },
|
||||
@@ -58,21 +54,18 @@ describe("switch_agent tool", () => {
|
||||
)
|
||||
|
||||
expect(result).toContain("atlas")
|
||||
expect(result).toContain("switch")
|
||||
|
||||
const entry = consumePendingSwitch(sessionID)
|
||||
expect(entry).toEqual({
|
||||
agent: "atlas",
|
||||
context: "Fix the auth bug based on council findings",
|
||||
})
|
||||
|
||||
expect(getSessionAgent(sessionID)).toBe("atlas")
|
||||
expect(result).toContain("new-session-abc")
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
expect(promptedSessions).toHaveLength(1)
|
||||
expect(promptedSessions[0]!.path.id).toBe("new-session-abc")
|
||||
expect(promptedSessions[0]!.body.agent).toContain("Atlas")
|
||||
expect(promptedSessions[0]!.body.parts[0]!.text).toBe("Fix the auth bug based on council findings")
|
||||
})
|
||||
|
||||
//#given valid prometheus switch args
|
||||
//#when execute is called
|
||||
//#then it stores pending switch for prometheus
|
||||
test("should queue switch to prometheus", async () => {
|
||||
//#then it creates a new session and prompts with prometheus agent
|
||||
test("should create session and prompt for prometheus switch", async () => {
|
||||
const tool = createToolWithMockClient()
|
||||
const result = await tool.execute(
|
||||
{ agent: "Prometheus", context: "Create a plan for the refactoring" },
|
||||
@@ -80,16 +73,14 @@ describe("switch_agent tool", () => {
|
||||
)
|
||||
|
||||
expect(result).toContain("prometheus")
|
||||
expect(result).toContain("switch")
|
||||
|
||||
const entry = consumePendingSwitch(sessionID)
|
||||
expect(entry?.agent).toBe("prometheus")
|
||||
expect(promptedSessions).toHaveLength(1)
|
||||
expect(promptedSessions[0]!.body.parts[0]!.text).toBe("Create a plan for the refactoring")
|
||||
})
|
||||
|
||||
//#given valid hephaestus switch args
|
||||
//#when execute is called
|
||||
//#then it stores pending switch for hephaestus
|
||||
test("should queue switch to hephaestus", async () => {
|
||||
//#then it creates a new session for hephaestus
|
||||
test("should create session and prompt for hephaestus switch", async () => {
|
||||
const tool = createToolWithMockClient()
|
||||
const result = await tool.execute(
|
||||
{ agent: "Hephaestus", context: "Implement the selected diagnosis fix" },
|
||||
@@ -97,16 +88,14 @@ describe("switch_agent tool", () => {
|
||||
)
|
||||
|
||||
expect(result).toContain("hephaestus")
|
||||
expect(result).toContain("switch")
|
||||
|
||||
const entry = consumePendingSwitch(sessionID)
|
||||
expect(entry?.agent).toBe("hephaestus")
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
expect(promptedSessions).toHaveLength(1)
|
||||
})
|
||||
|
||||
//#given valid sisyphus switch args
|
||||
//#when execute is called
|
||||
//#then it stores pending switch for sisyphus
|
||||
test("should queue switch to sisyphus", async () => {
|
||||
//#then it creates a new session for sisyphus
|
||||
test("should create session and prompt for sisyphus switch", async () => {
|
||||
const tool = createToolWithMockClient()
|
||||
const result = await tool.execute(
|
||||
{ agent: "Sisyphus", context: "Implement the selected diagnosis fix" },
|
||||
@@ -114,15 +103,13 @@ describe("switch_agent tool", () => {
|
||||
)
|
||||
|
||||
expect(result).toContain("sisyphus")
|
||||
expect(result).toContain("switch")
|
||||
|
||||
const entry = consumePendingSwitch(sessionID)
|
||||
expect(entry?.agent).toBe("sisyphus")
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
expect(promptedSessions).toHaveLength(1)
|
||||
})
|
||||
|
||||
//#given an invalid agent name
|
||||
//#when execute is called
|
||||
//#then it returns an error
|
||||
//#then it returns an error without creating a session
|
||||
test("should reject invalid agent names", async () => {
|
||||
const tool = createToolWithMockClient()
|
||||
const result = await tool.execute(
|
||||
@@ -132,21 +119,72 @@ describe("switch_agent tool", () => {
|
||||
|
||||
expect(result).toContain("Invalid switch target")
|
||||
expect(result).toContain("librarian")
|
||||
expect(consumePendingSwitch(sessionID)).toBeUndefined()
|
||||
expect(createdSessions).toHaveLength(0)
|
||||
expect(promptedSessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
//#given agent name with different casing
|
||||
//#when execute is called
|
||||
//#then it normalizes to lowercase
|
||||
//#then it normalizes to lowercase and creates session
|
||||
test("should handle case-insensitive agent names", async () => {
|
||||
const tool = createToolWithMockClient()
|
||||
await tool.execute(
|
||||
const result = await tool.execute(
|
||||
{ agent: "ATLAS", context: "Fix things" },
|
||||
toolContext
|
||||
)
|
||||
|
||||
const entry = consumePendingSwitch(sessionID)
|
||||
expect(entry?.agent).toBe("atlas")
|
||||
expect(getSessionAgent(sessionID)).toBe("atlas")
|
||||
expect(result).toContain("atlas")
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
expect(promptedSessions).toHaveLength(1)
|
||||
})
|
||||
|
||||
//#given session.create fails
|
||||
//#when execute is called
|
||||
//#then it returns an error message
|
||||
test("should handle session creation failure gracefully", async () => {
|
||||
const tool = createToolWithMockClient({
|
||||
createImpl: async () => { throw new Error("connection refused") },
|
||||
})
|
||||
const result = await tool.execute(
|
||||
{ agent: "atlas", context: "Fix things" },
|
||||
toolContext
|
||||
)
|
||||
|
||||
expect(result).toContain("Failed to create handoff session")
|
||||
expect(result).toContain("connection refused")
|
||||
expect(promptedSessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
//#given promptAsync fails
|
||||
//#when execute is called
|
||||
//#then it returns a warning but still reports session created
|
||||
test("should handle prompt delivery failure gracefully", async () => {
|
||||
const tool = createToolWithMockClient({
|
||||
promptAsyncImpl: async () => { throw new Error("prompt failed") },
|
||||
})
|
||||
const result = await tool.execute(
|
||||
{ agent: "atlas", context: "Fix things" },
|
||||
toolContext
|
||||
)
|
||||
|
||||
expect(result).toContain("new-session-abc")
|
||||
expect(result).toContain("warning: prompt delivery failed")
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
})
|
||||
|
||||
//#given session.create returns response with id at root level
|
||||
//#when execute is called
|
||||
//#then it extracts the session ID correctly
|
||||
test("should extract session ID from root-level response", async () => {
|
||||
const tool = createToolWithMockClient({
|
||||
createImpl: async () => ({ id: "direct-id-123" }),
|
||||
})
|
||||
const result = await tool.execute(
|
||||
{ agent: "atlas", context: "Fix things" },
|
||||
toolContext
|
||||
)
|
||||
|
||||
expect(result).toContain("direct-id-123")
|
||||
expect(promptedSessions[0]!.path.id).toBe("direct-id-123")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { setPendingSwitch } from "../../features/agent-switch"
|
||||
import { schedulePendingSwitchApply } from "../../features/agent-switch/scheduler"
|
||||
import { updateSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import type { SwitchAgentArgs } from "./types"
|
||||
|
||||
const DESCRIPTION =
|
||||
@@ -13,17 +12,46 @@ const ALLOWED_AGENTS = new Set(["atlas", "prometheus", "sisyphus", "hephaestus"]
|
||||
|
||||
type SessionClient = {
|
||||
session: {
|
||||
prompt?: (input: {
|
||||
path: { id: string }
|
||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||
}) => Promise<unknown>
|
||||
create: (input?: { body?: { parentID?: string; title?: string } }) => Promise<unknown>
|
||||
promptAsync: (input: {
|
||||
path: { id: string }
|
||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||
body: { agent?: string; parts: Array<{ type: "text"; text: string }> }
|
||||
}) => Promise<unknown>
|
||||
create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise<unknown>
|
||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||
status?: () => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
function extractSessionId(response: unknown): string | undefined {
|
||||
if (typeof response !== "object" || response === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const root = response as Record<string, unknown>
|
||||
|
||||
if (typeof root.id === "string" && root.id.length > 0) {
|
||||
return root.id
|
||||
}
|
||||
|
||||
const data = root.data
|
||||
if (typeof data === "object" && data !== null) {
|
||||
const dataRecord = data as Record<string, unknown>
|
||||
if (typeof dataRecord.id === "string" && dataRecord.id.length > 0) {
|
||||
return dataRecord.id
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function navigateTuiToSession(client: SessionClient, sessionID: string): Promise<boolean> {
|
||||
try {
|
||||
await (client as any)._client.post({
|
||||
url: "/tui/select-session",
|
||||
body: { sessionID },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,14 +77,53 @@ export function createSwitchAgentTool(args: {
|
||||
return `Invalid switch target: "${args.agent}". Allowed agents: ${[...ALLOWED_AGENTS].join(", ")}`
|
||||
}
|
||||
|
||||
updateSessionAgent(toolContext.sessionID, agentName)
|
||||
setPendingSwitch(toolContext.sessionID, agentName, args.context)
|
||||
schedulePendingSwitchApply({
|
||||
sessionID: toolContext.sessionID,
|
||||
client,
|
||||
const targetAgent = normalizeAgentForPrompt(agentName)
|
||||
if (!targetAgent) {
|
||||
return `Invalid switch target: "${args.agent}". Could not resolve agent name.`
|
||||
}
|
||||
|
||||
const errors: string[] = []
|
||||
|
||||
const response = await client.session.create().catch((error: unknown) => {
|
||||
errors.push(`session.create failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return null
|
||||
})
|
||||
|
||||
return `Agent switch queued. Session will switch to ${agentName} when your turn completes.`
|
||||
if (!response) {
|
||||
return `Failed to create handoff session. ${errors.join("; ")}`
|
||||
}
|
||||
|
||||
const newSessionID = extractSessionId(response)
|
||||
if (!newSessionID) {
|
||||
return `Failed to extract session ID from create response: ${JSON.stringify(response)}`
|
||||
}
|
||||
|
||||
const promptResult = await client.session.promptAsync({
|
||||
path: { id: newSessionID },
|
||||
body: {
|
||||
agent: targetAgent,
|
||||
parts: [{ type: "text", text: args.context }],
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
errors.push(`promptAsync failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return null
|
||||
})
|
||||
|
||||
const tuiNavigated = await navigateTuiToSession(client, newSessionID)
|
||||
|
||||
log("[switch-agent] Agent switch applied via fresh session", {
|
||||
sourceSessionID: toolContext.sessionID,
|
||||
newSessionID,
|
||||
agent: targetAgent,
|
||||
tuiNavigated,
|
||||
promptDelivered: promptResult !== null,
|
||||
})
|
||||
|
||||
const parts = [`Agent switch to ${agentName} initiated. New session: ${newSessionID}`]
|
||||
if (!promptResult) parts.push("(warning: prompt delivery failed)")
|
||||
if (tuiNavigated) parts.push("Navigated TUI to new session.")
|
||||
if (errors.length > 0) parts.push(`Errors: ${errors.join("; ")}`)
|
||||
return parts.join(" ")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user