From 34faa7c67cb405a010a40cdb1f93f3415fcf667d Mon Sep 17 00:00:00 2001 From: ismeth Date: Sun, 1 Mar 2026 14:42:27 +0100 Subject: [PATCH] refactor(switch-agent): replace deferred hook system with direct session.create + promptAsync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/agents/athena/agent.ts | 2 +- src/features/agent-switch/applier.test.ts | 229 ----------- src/features/agent-switch/applier.ts | 197 ---------- .../agent-switch/apply-verification.ts | 59 --- src/features/agent-switch/index.ts | 8 - .../agent-switch/message-inspection.ts | 107 ------ src/features/agent-switch/retry-state.ts | 66 ---- src/features/agent-switch/scheduler.ts | 44 --- .../agent-switch/session-creator.test.ts | 99 ----- src/features/agent-switch/session-creator.ts | 61 --- src/features/agent-switch/session-status.ts | 68 ---- src/features/agent-switch/state.test.ts | 73 ---- src/features/agent-switch/state.ts | 102 ----- src/features/agent-switch/tui-agent-sync.ts | 132 ------- src/hooks/agent-switch/hook.test.ts | 363 ------------------ src/hooks/agent-switch/hook.ts | 141 ------- src/hooks/agent-switch/index.ts | 1 - src/hooks/agent-switch/terminal-detection.ts | 36 -- src/plugin/hooks/create-continuation-hooks.ts | 6 - src/tools/switch-agent/tools.test.ts | 154 +++++--- src/tools/switch-agent/tools.ts | 101 ++++- 21 files changed, 181 insertions(+), 1868 deletions(-) delete mode 100644 src/features/agent-switch/applier.test.ts delete mode 100644 src/features/agent-switch/applier.ts delete mode 100644 src/features/agent-switch/apply-verification.ts delete mode 100644 src/features/agent-switch/index.ts delete mode 100644 src/features/agent-switch/message-inspection.ts delete mode 100644 src/features/agent-switch/retry-state.ts delete mode 100644 src/features/agent-switch/scheduler.ts delete mode 100644 src/features/agent-switch/session-creator.test.ts delete mode 100644 src/features/agent-switch/session-creator.ts delete mode 100644 src/features/agent-switch/session-status.ts delete mode 100644 src/features/agent-switch/state.test.ts delete mode 100644 src/features/agent-switch/state.ts delete mode 100644 src/features/agent-switch/tui-agent-sync.ts delete mode 100644 src/hooks/agent-switch/hook.test.ts delete mode 100644 src/hooks/agent-switch/hook.ts delete mode 100644 src/hooks/agent-switch/index.ts delete mode 100644 src/hooks/agent-switch/terminal-detection.ts diff --git a/src/agents/athena/agent.ts b/src/agents/athena/agent.ts index 61c5eb3e2..9f56d7d64 100644 --- a/src/agents/athena/agent.ts +++ b/src/agents/athena/agent.ts @@ -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). diff --git a/src/features/agent-switch/applier.test.ts b/src/features/agent-switch/applier.test.ts deleted file mode 100644 index cf6fb54f3..000000000 --- a/src/features/agent-switch/applier.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -/// - -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 - onMessages?: () => Record - onStatus?: () => Record -}) { - 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 | undefined - const client = { - session: { - create: async (input?: { body?: Record }) => { - 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() - }) - }) -}) diff --git a/src/features/agent-switch/applier.ts b/src/features/agent-switch/applier.ts deleted file mode 100644 index e68a4118d..000000000 --- a/src/features/agent-switch/applier.ts +++ /dev/null @@ -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 - promptAsync: (input: { - path: { id: string } - body: { agent: string; parts: Array<{ type: "text"; text: string }> } - }) => Promise - create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise - messages: (input: { path: { id: string } }) => Promise - status?: () => Promise - } - app?: { - agents?: () => Promise - } - tui?: { - publish?: (input: { - body: { - type: "tui.command.execute" - properties: { command: string } - } - }) => Promise - } -} - -async function tryPromptWithCandidates(args: { - client: SessionClient - sessionID: string - agent: string - context: string - source: string -}): Promise { - 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 { - 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() -} diff --git a/src/features/agent-switch/apply-verification.ts b/src/features/agent-switch/apply-verification.ts deleted file mode 100644 index 4cfa81afe..000000000 --- a/src/features/agent-switch/apply-verification.ts +++ /dev/null @@ -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 - } -} - -export async function fetchMessages(args: { - client: SessionClient - sessionID: string -}): Promise>> { - 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 { - 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 { - const { client, sessionID, targetAgent } = args - - try { - const messages = await fetchMessages({ client, sessionID }) - return hasRecentUserTurnForTargetAgent({ messages, targetAgent }) - } catch { - return false - } -} diff --git a/src/features/agent-switch/index.ts b/src/features/agent-switch/index.ts deleted file mode 100644 index 15be54231..000000000 --- a/src/features/agent-switch/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - setPendingSwitch, - getPendingSwitch, - clearPendingSwitch, - consumePendingSwitch, - _resetForTesting, -} from "./state" -export type { PendingSwitch } from "./state" diff --git a/src/features/agent-switch/message-inspection.ts b/src/features/agent-switch/message-inspection.ts deleted file mode 100644 index 4a2fc9d6c..000000000 --- a/src/features/agent-switch/message-inspection.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { getAgentConfigKey } from "../../shared/agent-display-names" - -export interface MessageRoleAgent { - role: string - agent: string -} - -export function extractMessageList(response: unknown): Array> { - if (Array.isArray(response)) { - return response.filter((item): item is Record => typeof item === "object" && item !== null) - } - if (typeof response === "object" && response !== null) { - const data = (response as Record).data - if (Array.isArray(data)) { - return data.filter((item): item is Record => typeof item === "object" && item !== null) - } - } - return [] -} - -function getRoleAgent(message: Record): MessageRoleAgent | undefined { - const info = message.info - if (typeof info !== "object" || info === null) { - return undefined - } - - const role = (info as Record).role - const agent = (info as Record).agent - if (typeof role !== "string" || typeof agent !== "string") { - return undefined - } - - return { role, agent } -} - -export function getLatestUserAgent(messages: Array>): 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> - 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> - 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 -} diff --git a/src/features/agent-switch/retry-state.ts b/src/features/agent-switch/retry-state.ts deleted file mode 100644 index 05dfc086f..000000000 --- a/src/features/agent-switch/retry-state.ts +++ /dev/null @@ -1,66 +0,0 @@ -const RETRY_DELAYS_MS = [50, 250, 500, 1000, 2000, 5000] as const - -const inFlightSessions = new Set() -const retryAttempts = new Map() -const retryTimers = new Map>() - -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() -} diff --git a/src/features/agent-switch/scheduler.ts b/src/features/agent-switch/scheduler.ts deleted file mode 100644 index c964086cd..000000000 --- a/src/features/agent-switch/scheduler.ts +++ /dev/null @@ -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 - promptAsync: (input: { - path: { id: string } - body: { agent: string; parts: Array<{ type: "text"; text: string }> } - }) => Promise - create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise - messages: (input: { path: { id: string } }) => Promise - status?: () => Promise - } -} - -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}`, - }) - }, - }) -} diff --git a/src/features/agent-switch/session-creator.test.ts b/src/features/agent-switch/session-creator.test.ts deleted file mode 100644 index 40fe20f16..000000000 --- a/src/features/agent-switch/session-creator.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/// - -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 | undefined - const client = { - session: { - create: async (input?: { body?: Record }) => { - 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)", - }) - }) - }) -}) diff --git a/src/features/agent-switch/session-creator.ts b/src/features/agent-switch/session-creator.ts deleted file mode 100644 index 5851b6c2c..000000000 --- a/src/features/agent-switch/session-creator.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { log } from "../../shared/logger" - -type CreateClient = { - session: { - create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise - } -} - -function extractSessionId(response: unknown): string | undefined { - if (typeof response !== "object" || response === null) { - return undefined - } - - const root = response as Record - - 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 - 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 { - 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 -} diff --git a/src/features/agent-switch/session-status.ts b/src/features/agent-switch/session-status.ts deleted file mode 100644 index dfdc09f94..000000000 --- a/src/features/agent-switch/session-status.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { log } from "../../shared/logger" - -type SessionClient = { - session: { - status?: () => Promise - } -} - -function sleep(ms: number): Promise { - 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 - const data = (typeof root.data === "object" && root.data !== null) - ? root.data as Record - : root - - const entry = data[sessionID] - if (typeof entry !== "object" || entry === null) { - return undefined - } - - const entryType = (entry as Record).type - return typeof entryType === "string" ? entryType : undefined -} - -export async function waitForSessionIdle(args: { - client: SessionClient - sessionID: string - timeoutMs?: number -}): Promise { - 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 { - await sleep(ms) -} diff --git a/src/features/agent-switch/state.test.ts b/src/features/agent-switch/state.test.ts deleted file mode 100644 index 00f879bec..000000000 --- a/src/features/agent-switch/state.test.ts +++ /dev/null @@ -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 {} diff --git a/src/features/agent-switch/state.ts b/src/features/agent-switch/state.ts deleted file mode 100644 index 17f956122..000000000 --- a/src/features/agent-switch/state.ts +++ /dev/null @@ -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() - -function isPendingSwitch(value: unknown): value is PendingSwitch { - if (typeof value !== "object" || value === null) return false - const entry = value as Record - return typeof entry.agent === "string" && typeof entry.context === "string" -} - -function readPersistentState(): Record { - 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 = {} - for (const [sessionID, value] of Object.entries(parsed)) { - if (isPendingSwitch(value)) { - state[sessionID] = value - } - } - - return state - } catch { - return {} - } -} - -function writePersistentState(state: Record): 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 }) -} diff --git a/src/features/agent-switch/tui-agent-sync.ts b/src/features/agent-switch/tui-agent-sync.ts deleted file mode 100644 index d4764ef39..000000000 --- a/src/features/agent-switch/tui-agent-sync.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { getAgentConfigKey } from "../../shared/agent-display-names" -import { log, normalizeSDKResponse } from "../../shared" - -type TuiClient = { - app?: { - agents?: () => Promise - } - tui?: { - publish?: (input: { - body: { - type: "tui.command.execute" - properties: { command: string } - } - }) => Promise - } -} - -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 { - 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), - }) - } -} diff --git a/src/hooks/agent-switch/hook.test.ts b/src/hooks/agent-switch/hook.test.ts deleted file mode 100644 index 1d55cd543..000000000 --- a/src/hooks/agent-switch/hook.test.ts +++ /dev/null @@ -1,363 +0,0 @@ -/// - -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> = [] - let switched = false - const ctx = { - client: { - session: { - create: async () => ({ data: { id: "fresh-ses-1" } }), - promptAsync: async (args: Record) => { - 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> = [] - let switched = false - const ctx = { - client: { - session: { - create: async () => ({ data: { id: "fresh-ses-6" } }), - promptAsync: async (args: Record) => { - 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> = [] - let switched = false - const ctx = { - client: { - session: { - create: async () => ({ data: { id: "fresh-ses-8" } }), - promptAsync: async (args: Record) => { - 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> = [] - let switched = false - const ctx = { - client: { - session: { - create: async () => ({ data: { id: "fresh-ses-7" } }), - promptAsync: async (args: Record) => { - 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> = [] - let switched = false - const ctx = { - client: { - session: { - create: async () => ({ data: { id: "fresh-ses-9" } }), - promptAsync: async (args: Record) => { - 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() - }) -}) diff --git a/src/hooks/agent-switch/hook.ts b/src/hooks/agent-switch/hook.ts deleted file mode 100644 index df151b2d5..000000000 --- a/src/hooks/agent-switch/hook.ts +++ /dev/null @@ -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 | undefined { - const props = input.event.properties as Record | undefined - const fromProps = typeof props?.sessionID === "string" ? props.sessionID : undefined - if (fromProps) { - return fromProps - } - - const status = props?.status as Record | undefined - const fromStatus = typeof status?.sessionID === "string" ? status.sessionID : undefined - return fromStatus -} - -function getStatusTypeFromEvent(input: { event: { properties?: Record } }): string | undefined { - const props = input.event.properties as Record | undefined - const directType = typeof props?.type === "string" ? props.type : undefined - if (directType) { - return directType - } - - const status = props?.status as Record | 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 } }): Promise => { - if (input.event.type === "session.deleted") { - const props = input.event.properties as Record | undefined - const info = props?.info as Record | 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 | undefined - const info = props?.info as Record | 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 | undefined - const info = props?.info as Record | 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 | undefined - const part = props?.part - const info = props?.info as Record | undefined - const sessionIDFromPart = typeof (part as Record | undefined)?.sessionID === "string" - ? ((part as Record).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 | 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", - }) - } - }, - } -} diff --git a/src/hooks/agent-switch/index.ts b/src/hooks/agent-switch/index.ts deleted file mode 100644 index 735a0aafb..000000000 --- a/src/hooks/agent-switch/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createAgentSwitchHook } from "./hook" diff --git a/src/hooks/agent-switch/terminal-detection.ts b/src/hooks/agent-switch/terminal-detection.ts deleted file mode 100644 index 926e7845d..000000000 --- a/src/hooks/agent-switch/terminal-detection.ts +++ /dev/null @@ -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 - 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 - if (record.type !== "step-finish") { - return false - } - - return isTerminalFinishValue(record.reason) -} - - diff --git a/src/plugin/hooks/create-continuation-hooks.ts b/src/plugin/hooks/create-continuation-hooks.ts index 3dae6415f..4c835fe37 100644 --- a/src/plugin/hooks/create-continuation-hooks.ts +++ b/src/plugin/hooks/create-continuation-hooks.ts @@ -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 | null backgroundNotificationHook: ReturnType | null atlasHook: ReturnType | null - agentSwitchHook: ReturnType | 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, } } diff --git a/src/tools/switch-agent/tools.test.ts b/src/tools/switch-agent/tools.test.ts index 248e43262..4dee970ee 100644 --- a/src/tools/switch-agent/tools.test.ts +++ b/src/tools/switch-agent/tools.test.ts @@ -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) { + function createToolWithMockClient(overrides?: { + createImpl?: () => Promise + promptAsyncImpl?: (input: any) => Promise + }) { 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 - messages: (input: { path: { id: string } }) => Promise - } - }, - }) + 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") }) }) diff --git a/src/tools/switch-agent/tools.ts b/src/tools/switch-agent/tools.ts index 076cfecee..9eebabe89 100644 --- a/src/tools/switch-agent/tools.ts +++ b/src/tools/switch-agent/tools.ts @@ -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 + create: (input?: { body?: { parentID?: string; title?: string } }) => Promise promptAsync: (input: { path: { id: string } - body: { agent: string; parts: Array<{ type: "text"; text: string }> } + body: { agent?: string; parts: Array<{ type: "text"; text: string }> } }) => Promise - create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise - messages: (input: { path: { id: string } }) => Promise - status?: () => Promise + } +} + +function extractSessionId(response: unknown): string | undefined { + if (typeof response !== "object" || response === null) { + return undefined + } + + const root = response as Record + + 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 + if (typeof dataRecord.id === "string" && dataRecord.id.length > 0) { + return dataRecord.id + } + } + + return undefined +} + +async function navigateTuiToSession(client: SessionClient, sessionID: string): Promise { + 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(" ") }, }) }