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:
@@ -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