refactor(athena): rename session_handoff to switch_agent to avoid confusion with /handoff command

Rename across all layers to eliminate naming ambiguity:
- Tool: session_handoff → switch_agent
- Hook: agent-handoff → agent-switch
- Feature: agent-handoff/ → agent-switch/
- Types: SessionHandoffArgs → SwitchAgentArgs, PendingHandoff → PendingSwitch
- Functions: setPendingHandoff → setPendingSwitch, consumePendingHandoff → consumePendingSwitch

/handoff = inter-session context summary (existing command)
switch_agent = intra-session active agent change (our new tool)
This commit is contained in:
ismeth
2026-02-13 13:18:07 +01:00
committed by YeonGyu-Kim
parent 7a71d4fb4f
commit 5a72f21fc8
22 changed files with 139 additions and 139 deletions

View File

@@ -96,11 +96,11 @@ Question({
})
Step 6: After the user selects an action:
- **"Fix now (Atlas)"** → Call session_handoff with agent="atlas" and context containing the confirmed findings summary, the original question, and instruction to implement the fixes.
- **"Create plan (Prometheus)"** → Call session_handoff with agent="prometheus" and context containing the confirmed findings summary, the original question, and instruction to create a phased plan.
- **"Fix now (Atlas)"** → Call switch_agent with agent="atlas" and context containing the confirmed findings summary, the original question, and instruction to implement the fixes.
- **"Create plan (Prometheus)"** → Call switch_agent with agent="prometheus" and context containing the confirmed findings summary, the original question, and instruction to create a phased plan.
- **"No action"** → Acknowledge and end. Do not delegate.
The session_handoff 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 switches the active agent. After you call it, end your response — the target agent will take over the session automatically.
## Constraints
- Use the Question tool for member selection BEFORE calling athena_council (unless user pre-specified).

View File

@@ -49,7 +49,7 @@ export const HookNameSchema = z.enum([
"write-existing-file-guard",
"anthropic-effort",
"hashline-read-enhancer",
"agent-handoff",
"agent-switch",
])
export type HookName = z.infer<typeof HookNameSchema>

View File

@@ -1,2 +0,0 @@
export { setPendingHandoff, consumePendingHandoff, _resetForTesting } from "./state"
export type { PendingHandoff } from "./state"

View File

@@ -1,50 +0,0 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { setPendingHandoff, consumePendingHandoff, _resetForTesting } from "./state"
describe("agent-handoff state", () => {
beforeEach(() => {
_resetForTesting()
})
//#given a pending handoff is set
//#when consumePendingHandoff is called
//#then it returns the handoff and removes it
test("should store and consume a pending handoff", () => {
setPendingHandoff("session-1", "atlas", "Fix these findings")
const handoff = consumePendingHandoff("session-1")
expect(handoff).toEqual({ agent: "atlas", context: "Fix these findings" })
expect(consumePendingHandoff("session-1")).toBeUndefined()
})
//#given no pending handoff exists
//#when consumePendingHandoff is called
//#then it returns undefined
test("should return undefined when no handoff is pending", () => {
expect(consumePendingHandoff("session-1")).toBeUndefined()
})
//#given a pending handoff is set
//#when a new handoff is set for the same session
//#then the latest handoff wins
test("should overwrite previous handoff for same session", () => {
setPendingHandoff("session-1", "atlas", "Fix A")
setPendingHandoff("session-1", "prometheus", "Plan B")
const handoff = consumePendingHandoff("session-1")
expect(handoff).toEqual({ agent: "prometheus", context: "Plan B" })
})
//#given handoffs for different sessions
//#when consumed separately
//#then each session gets its own handoff
test("should isolate handoffs by session", () => {
setPendingHandoff("session-1", "atlas", "Fix A")
setPendingHandoff("session-2", "prometheus", "Plan B")
expect(consumePendingHandoff("session-1")).toEqual({ agent: "atlas", context: "Fix A" })
expect(consumePendingHandoff("session-2")).toEqual({ agent: "prometheus", context: "Plan B" })
})
})

View File

@@ -1,23 +0,0 @@
export interface PendingHandoff {
agent: string
context: string
}
const pendingHandoffs = new Map<string, PendingHandoff>()
export function setPendingHandoff(sessionID: string, agent: string, context: string): void {
pendingHandoffs.set(sessionID, { agent, context })
}
export function consumePendingHandoff(sessionID: string): PendingHandoff | undefined {
const handoff = pendingHandoffs.get(sessionID)
if (handoff) {
pendingHandoffs.delete(sessionID)
}
return handoff
}
/** @internal For testing only */
export function _resetForTesting(): void {
pendingHandoffs.clear()
}

View File

@@ -0,0 +1,2 @@
export { setPendingSwitch, consumePendingSwitch, _resetForTesting } from "./state"
export type { PendingSwitch } from "./state"

View File

@@ -0,0 +1,50 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { setPendingSwitch, 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" })
})
})

View File

@@ -0,0 +1,23 @@
export interface PendingSwitch {
agent: string
context: string
}
const pendingSwitches = new Map<string, PendingSwitch>()
export function setPendingSwitch(sessionID: string, agent: string, context: string): void {
pendingSwitches.set(sessionID, { agent, context })
}
export function consumePendingSwitch(sessionID: string): PendingSwitch | undefined {
const entry = pendingSwitches.get(sessionID)
if (entry) {
pendingSwitches.delete(sessionID)
}
return entry
}
/** @internal For testing only */
export function _resetForTesting(): void {
pendingSwitches.clear()
}

View File

@@ -1 +0,0 @@
export { createAgentHandoffHook } from "./hook"

View File

@@ -1,10 +1,10 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { consumePendingHandoff } from "../../features/agent-handoff"
import { consumePendingSwitch } from "../../features/agent-switch"
import { log } from "../../shared/logger"
const HOOK_NAME = "agent-handoff" as const
const HOOK_NAME = "agent-switch" as const
export function createAgentHandoffHook(ctx: PluginInput) {
export function createAgentSwitchHook(ctx: PluginInput) {
return {
event: async (input: { event: { type: string; properties?: Record<string, unknown> } }): Promise<void> => {
if (input.event.type !== "session.idle") return
@@ -13,24 +13,24 @@ export function createAgentHandoffHook(ctx: PluginInput) {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const handoff = consumePendingHandoff(sessionID)
if (!handoff) return
const pending = consumePendingSwitch(sessionID)
if (!pending) return
log(`[${HOOK_NAME}] Executing handoff to ${handoff.agent}`, { sessionID })
log(`[${HOOK_NAME}] Switching to ${pending.agent}`, { sessionID })
try {
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: {
agent: handoff.agent,
parts: [{ type: "text", text: handoff.context }],
agent: pending.agent,
parts: [{ type: "text", text: pending.context }],
},
query: { directory: ctx.directory },
})
log(`[${HOOK_NAME}] Handoff to ${handoff.agent} complete`, { sessionID })
log(`[${HOOK_NAME}] Switch to ${pending.agent} complete`, { sessionID })
} catch (err) {
log(`[${HOOK_NAME}] Handoff failed`, { sessionID, error: String(err) })
log(`[${HOOK_NAME}] Switch failed`, { sessionID, error: String(err) })
}
},
}

View File

@@ -0,0 +1 @@
export { createAgentSwitchHook } from "./hook"

View File

@@ -51,4 +51,4 @@ export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
export { createHashlineEditDiffEnhancerHook } from "./hashline-edit-diff-enhancer";
export { createAgentHandoffHook } from "./agent-handoff";
export { createAgentSwitchHook } from "./agent-switch";

View File

@@ -156,7 +156,7 @@ export function createEventHandler(args: {
await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input));
await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input));
await Promise.resolve(hooks.atlasHook?.handler?.(input));
await Promise.resolve(hooks.agentHandoffHook?.event?.(input));
await Promise.resolve(hooks.agentSwitchHook?.event?.(input));
};
const recentSyntheticIdles = new Map<string, number>();

View File

@@ -9,7 +9,7 @@ import {
createCompactionContextInjector,
createCompactionTodoPreserverHook,
createAtlasHook,
createAgentHandoffHook,
createAgentSwitchHook,
} from "../../hooks"
import { safeCreateHook } from "../../shared/safe-create-hook"
import { createUnstableAgentBabysitter } from "../unstable-agent-babysitter"
@@ -22,7 +22,7 @@ export type ContinuationHooks = {
unstableAgentBabysitter: ReturnType<typeof createUnstableAgentBabysitter> | null
backgroundNotificationHook: ReturnType<typeof createBackgroundNotificationHook> | null
atlasHook: ReturnType<typeof createAtlasHook> | null
agentHandoffHook: ReturnType<typeof createAgentHandoffHook> | null
agentSwitchHook: ReturnType<typeof createAgentSwitchHook> | null
}
type SessionRecovery = {
@@ -113,8 +113,8 @@ export function createContinuationHooks(args: {
}))
: null
const agentHandoffHook = isHookEnabled("agent-handoff")
? safeHook("agent-handoff", () => createAgentHandoffHook(ctx))
const agentSwitchHook = isHookEnabled("agent-switch")
? safeHook("agent-switch", () => createAgentSwitchHook(ctx))
: null
return {
@@ -125,6 +125,6 @@ export function createContinuationHooks(args: {
unstableAgentBabysitter,
backgroundNotificationHook,
atlasHook,
agentHandoffHook,
agentSwitchHook,
}
}

View File

@@ -11,7 +11,7 @@ import {
createBackgroundTools,
createCallOmoAgent,
createAthenaCouncilTool,
createSessionHandoffTool,
createSwitchAgentTool,
createLookAt,
createSkillMcpTool,
createSkillTool,
@@ -134,7 +134,7 @@ export function createToolRegistry(args: {
...backgroundTools,
call_omo_agent: callOmoAgent,
athena_council: athenaCouncilTool,
session_handoff: createSessionHandoffTool(),
switch_agent: createSwitchAgentTool(),
...(lookAt ? { look_at: lookAt } : {}),
task: delegateTask,
skill_mcp: skillMcpTool,

View File

@@ -38,7 +38,7 @@ export { createCallOmoAgent } from "./call-omo-agent"
export { createAthenaCouncilTool } from "./athena-council"
export { createLookAt } from "./look-at"
export { createDelegateTask } from "./delegate-task"
export { createSessionHandoffTool } from "./session-handoff"
export { createSwitchAgentTool } from "./switch-agent"
export {
createTaskCreateTool,
createTaskGetTool,

View File

@@ -1 +0,0 @@
export { createSessionHandoffTool } from "./tools"

View File

@@ -1,4 +0,0 @@
export interface SessionHandoffArgs {
agent: string
context: string
}

View File

@@ -0,0 +1 @@
export { createSwitchAgentTool } from "./tools"

View File

@@ -1,9 +1,9 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { createSessionHandoffTool } from "./tools"
import { consumePendingHandoff, _resetForTesting as resetHandoff } from "../../features/agent-handoff"
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("session_handoff tool", () => {
describe("switch_agent tool", () => {
const sessionID = "test-session-123"
const messageID = "msg-456"
const agent = "athena"
@@ -16,25 +16,25 @@ describe("session_handoff tool", () => {
}
beforeEach(() => {
resetHandoff()
resetSwitch()
resetSession()
})
//#given valid atlas handoff args
//#given valid atlas switch args
//#when execute is called
//#then it stores pending handoff and updates session agent
test("should queue handoff to atlas", async () => {
const tool = createSessionHandoffTool()
//#then it stores pending switch and updates session agent
test("should queue switch to atlas", async () => {
const tool = createSwitchAgentTool()
const result = await tool.execute(
{ agent: "atlas", context: "Fix the auth bug based on council findings" },
toolContext
)
expect(result).toContain("atlas")
expect(result).toContain("Handoff queued")
expect(result).toContain("switch")
const handoff = consumePendingHandoff(sessionID)
expect(handoff).toEqual({
const entry = consumePendingSwitch(sessionID)
expect(entry).toEqual({
agent: "atlas",
context: "Fix the auth bug based on council findings",
})
@@ -42,50 +42,50 @@ describe("session_handoff tool", () => {
expect(getSessionAgent(sessionID)).toBe("atlas")
})
//#given valid prometheus handoff args
//#given valid prometheus switch args
//#when execute is called
//#then it stores pending handoff for prometheus
test("should queue handoff to prometheus", async () => {
const tool = createSessionHandoffTool()
//#then it stores pending switch for prometheus
test("should queue switch to prometheus", async () => {
const tool = createSwitchAgentTool()
const result = await tool.execute(
{ agent: "Prometheus", context: "Create a plan for the refactoring" },
toolContext
)
expect(result).toContain("prometheus")
expect(result).toContain("Handoff queued")
expect(result).toContain("switch")
const handoff = consumePendingHandoff(sessionID)
expect(handoff?.agent).toBe("prometheus")
const entry = consumePendingSwitch(sessionID)
expect(entry?.agent).toBe("prometheus")
})
//#given an invalid agent name
//#when execute is called
//#then it returns an error
test("should reject invalid agent names", async () => {
const tool = createSessionHandoffTool()
const tool = createSwitchAgentTool()
const result = await tool.execute(
{ agent: "librarian", context: "Some context" },
toolContext
)
expect(result).toContain("Invalid handoff target")
expect(result).toContain("Invalid switch target")
expect(result).toContain("librarian")
expect(consumePendingHandoff(sessionID)).toBeUndefined()
expect(consumePendingSwitch(sessionID)).toBeUndefined()
})
//#given agent name with different casing
//#when execute is called
//#then it normalizes to lowercase
test("should handle case-insensitive agent names", async () => {
const tool = createSessionHandoffTool()
const tool = createSwitchAgentTool()
await tool.execute(
{ agent: "ATLAS", context: "Fix things" },
toolContext
)
const handoff = consumePendingHandoff(sessionID)
expect(handoff?.agent).toBe("atlas")
const entry = consumePendingSwitch(sessionID)
expect(entry?.agent).toBe("atlas")
expect(getSessionAgent(sessionID)).toBe("atlas")
})
})

View File

@@ -1,37 +1,37 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import { setPendingHandoff } from "../../features/agent-handoff"
import { setPendingSwitch } from "../../features/agent-switch"
import { updateSessionAgent } from "../../features/claude-code-session-state"
import type { SessionHandoffArgs } from "./types"
import type { SwitchAgentArgs } from "./types"
const DESCRIPTION =
"Switch the active session agent. After calling this tool, the session will transition to the specified agent " +
"with the provided context as its starting prompt. Use this to hand off work to another agent " +
"(e.g., Atlas for fixes, Prometheus for planning). The handoff executes when the current agent's turn completes."
"with the provided context as its starting prompt. Use this to route work to another agent " +
"(e.g., Atlas for fixes, Prometheus for planning). The switch executes when the current agent's turn completes."
const ALLOWED_AGENTS = new Set(["atlas", "prometheus", "sisyphus", "hephaestus"])
export function createSessionHandoffTool(): ToolDefinition {
export function createSwitchAgentTool(): ToolDefinition {
return tool({
description: DESCRIPTION,
args: {
agent: tool.schema
.string()
.describe("Target agent name to hand off to (e.g., 'atlas', 'prometheus')"),
.describe("Target agent name to switch to (e.g., 'atlas', 'prometheus')"),
context: tool.schema
.string()
.describe("Context message for the target agent — include confirmed findings, the original question, and what action to take"),
},
async execute(args: SessionHandoffArgs, toolContext) {
async execute(args: SwitchAgentArgs, toolContext) {
const agentName = args.agent.toLowerCase()
if (!ALLOWED_AGENTS.has(agentName)) {
return `Invalid handoff target: "${args.agent}". Allowed agents: ${[...ALLOWED_AGENTS].join(", ")}`
return `Invalid switch target: "${args.agent}". Allowed agents: ${[...ALLOWED_AGENTS].join(", ")}`
}
updateSessionAgent(toolContext.sessionID, agentName)
setPendingHandoff(toolContext.sessionID, agentName, args.context)
setPendingSwitch(toolContext.sessionID, agentName, args.context)
return `Handoff queued. Session will switch to ${agentName} when your turn completes.`
return `Agent switch queued. Session will switch to ${agentName} when your turn completes.`
},
})
}

View File

@@ -0,0 +1,4 @@
export interface SwitchAgentArgs {
agent: string
context: string
}