fix(agent-switch): create fresh session for handoff apply

This commit is contained in:
ismeth
2026-02-28 20:25:50 +01:00
committed by YeonGyu-Kim
parent 2c27298dc1
commit e52ccd1b04
6 changed files with 369 additions and 218 deletions
+193 -190
View File
@@ -9,218 +9,221 @@ import {
} from "./applier"
import { schedulePendingSwitchApply } from "./scheduler"
function createMockClient(overrides?: {
onPrompt?: (input: { path: { id: string }; body: { agent: string } }) => void
onCreate?: () => Record<string, unknown>
onMessages?: () => Record<string, unknown>
onStatus?: () => Record<string, unknown>
}) {
return {
session: {
create: async () => overrides?.onCreate?.() ?? { data: { id: "new-ses" } },
promptAsync: async (input: { path: { id: string }; body: { agent: string } }) => {
overrides?.onPrompt?.(input)
},
messages: async () => overrides?.onMessages?.() ?? ({ data: [] }),
status: overrides?.onStatus ? async () => overrides.onStatus!() : undefined,
},
}
}
describe("agent-switch applier", () => {
beforeEach(() => {
_resetForTesting()
_resetApplierForTesting()
})
test("scheduled apply works without idle event", async () => {
const calls: string[] = []
let switched = false
const client = {
session: {
promptAsync: async (input: { body: { agent: string } }) => {
calls.push(input.body.agent)
switched = true
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)
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
: ({ data: [] }),
},
}
})
setPendingSwitch("ses-1", "prometheus", "create plan")
schedulePendingSwitchApply({
sessionID: "ses-1",
client: client as any,
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()
})
await new Promise((resolve) => setTimeout(resolve, 300))
expect(calls).toEqual(["Prometheus (Plan Builder)"])
expect(getPendingSwitch("ses-1")).toBeUndefined()
})
test("normalizes pending agent to canonical prompt display name", async () => {
const calls: string[] = []
let switched = false
const client = {
session: {
promptAsync: async (input: { body: { agent: string } }) => {
calls.push(input.body.agent)
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Prometheus (Plan Builder)" } }] })
: ({ data: [] }),
},
}
setPendingSwitch("ses-2", "Prometheus (Plan Builder)", "create plan")
await applyPendingSwitch({
sessionID: "ses-2",
client: client as any,
source: "idle",
})
expect(calls).toEqual(["Prometheus (Plan Builder)"])
expect(getPendingSwitch("ses-2")).toBeUndefined()
})
test("retries transient failures and eventually clears pending switch", async () => {
let attempts = 0
let switched = false
const client = {
session: {
promptAsync: async () => {
attempts += 1
if (attempts < 3) {
throw new Error("temporary failure")
}
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
: ({ data: [] }),
},
}
setPendingSwitch("ses-3", "atlas", "fix this")
await applyPendingSwitch({
sessionID: "ses-3",
client: client as any,
source: "idle",
})
await new Promise((resolve) => setTimeout(resolve, 800))
expect(attempts).toBe(3)
expect(getPendingSwitch("ses-3")).toBeUndefined()
})
test("waits for session idle before applying switch", async () => {
let statusChecks = 0
let promptCalls = 0
let switched = false
const client = {
session: {
status: async () => {
statusChecks += 1
return {
"ses-5": { type: statusChecks < 3 ? "running" : "idle" },
}
},
promptAsync: async () => {
promptCalls += 1
switched = true
},
messages: async () => switched
? ({ data: [{ info: { role: "user", agent: "Atlas (Plan Executor)" } }] })
: ({ data: [] }),
},
}
setPendingSwitch("ses-5", "atlas", "fix now")
await applyPendingSwitch({
sessionID: "ses-5",
client: client as any,
source: "idle",
})
expect(statusChecks).toBeGreaterThanOrEqual(3)
expect(promptCalls).toBe(1)
expect(getPendingSwitch("ses-5")).toBeUndefined()
})
test("clearPendingSwitchRuntime cancels pending retries", async () => {
let attempts = 0
const client = {
session: {
promptAsync: async () => {
attempts += 1
throw new Error("always failing")
},
messages: async () => ({ data: [] }),
},
}
setPendingSwitch("ses-4", "atlas", "fix this")
await applyPendingSwitch({
sessionID: "ses-4",
client: client as any,
source: "idle",
})
clearPendingSwitchRuntime("ses-4")
const attemptsAfterClear = attempts
await new Promise((resolve) => setTimeout(resolve, 300))
expect(attempts).toBe(attemptsAfterClear)
expect(getPendingSwitch("ses-4")).toBeUndefined()
})
test("syncs CLI TUI agent selection for athena-to-atlas handoff", async () => {
const originalClientEnv = process.env["OPENCODE_CLIENT"]
process.env["OPENCODE_CLIENT"] = "cli"
try {
const promptCalls: string[] = []
const tuiCommands: string[] = []
let switched = false
test("#when apply runs directly, #then creates fresh session with parentID linking to source", async () => {
let createInput: Record<string, unknown> | undefined
const client = {
session: {
promptAsync: async (input: { body: { agent: string } }) => {
promptCalls.push(input.body.agent)
switched = true
},
messages: async () => switched
? ({
data: [
{ info: { role: "user", agent: "Athena (Council)" } },
{ info: { role: "user", agent: "Atlas (Plan Executor)" } },
],
})
: ({
data: [{ info: { role: "user", agent: "Athena (Council)" } }],
}),
},
app: {
agents: async () => ({
data: [
{ name: "Sisyphus (Ultraworker)", mode: "primary" },
{ name: "Hephaestus (Deep Agent)", mode: "primary" },
{ name: "Prometheus (Plan Builder)", mode: "primary" },
{ name: "Atlas (Plan Executor)", mode: "primary" },
{ name: "Athena (Council)", mode: "primary" },
],
}),
},
tui: {
publish: async (input: { body: { properties: { command: string } } }) => {
tuiCommands.push(input.body.properties.command)
create: async (input?: { body?: Record<string, unknown> }) => {
createInput = input?.body
return { data: { id: "fresh-ses-2" } }
},
promptAsync: async () => {},
messages: async () => ({ data: [] }),
},
}
setPendingSwitch("ses-6", "atlas", "fix now")
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: "message-updated",
source: "idle",
})
expect(promptCalls).toEqual(["Atlas (Plan Executor)"])
expect(tuiCommands).toEqual(["agent.cycle.reverse"])
clearPendingSwitchRuntime("ses-6")
const attemptsAfterClear = attempts
await new Promise((resolve) => setTimeout(resolve, 300))
expect(attempts).toBe(attemptsAfterClear)
expect(getPendingSwitch("ses-6")).toBeUndefined()
} finally {
if (originalClientEnv === undefined) {
delete process.env["OPENCODE_CLIENT"]
} else {
process.env["OPENCODE_CLIENT"] = originalClientEnv
})
})
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()
})
})
})
+14 -28
View File
@@ -2,9 +2,8 @@ import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { clearPendingSwitch, getPendingSwitch } from "./state"
import { waitForSessionIdle } from "./session-status"
import { fetchMessages, shouldClearAsAlreadyApplied, verifySwitchObserved } from "./apply-verification"
import { getLatestUserAgent } from "./message-inspection"
import { syncCliTuiAgentSelectionAfterSwitch } from "./tui-agent-sync"
import { shouldClearAsAlreadyApplied } from "./apply-verification"
import { createFreshSession } from "./session-creator"
import {
clearInFlight,
clearRetryState,
@@ -24,6 +23,7 @@ type SessionClient = {
path: { id: string }
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
}) => Promise<unknown>
create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise<unknown>
messages: (input: { path: { id: string } }) => Promise<unknown>
status?: () => Promise<unknown>
}
@@ -135,40 +135,26 @@ export async function applyPendingSwitch(args: {
throw new Error("session not idle before applying agent switch")
}
const beforeMessages = await fetchMessages({ client, sessionID })
const sourceUserAgent = getLatestUserAgent(beforeMessages)
const usedAgent = await tryPromptWithCandidates({
const newSessionID = await createFreshSession({
client,
sessionID,
sourceSessionID: sessionID,
targetAgent: pending.agent,
})
await tryPromptWithCandidates({
client,
sessionID: newSessionID,
agent: pending.agent,
context: pending.context,
source,
})
const verified = await verifySwitchObserved({
client,
sessionID,
targetAgent: pending.agent,
baselineCount: beforeMessages.length,
})
if (!verified) {
throw new Error(`agent switch not observed after prompt (attempted ${usedAgent})`)
}
clearPendingSwitch(sessionID)
clearRetryState(sessionID)
await syncCliTuiAgentSelectionAfterSwitch({
client,
sessionID,
source,
sourceAgent: sourceUserAgent,
targetAgent: pending.agent,
})
log("[agent-switch] Pending switch applied", {
sessionID,
log("[agent-switch] Pending switch applied via fresh session", {
sourceSessionID: sessionID,
newSessionID,
source,
agent: pending.agent,
})
+1
View File
@@ -12,6 +12,7 @@ type SessionClient = {
path: { id: string }
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
}) => Promise<unknown>
create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise<unknown>
messages: (input: { path: { id: string } }) => Promise<unknown>
status?: () => Promise<unknown>
}
@@ -0,0 +1,99 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { createFreshSession } from "./session-creator"
describe("session-creator", () => {
describe("#given SDK response with data wrapper", () => {
test("#when create returns { data: { id } }, #then extracts session ID", async () => {
const client = {
session: {
create: async () => ({ data: { id: "new-session-123" } }),
},
}
const result = await createFreshSession({
client,
sourceSessionID: "source-ses",
targetAgent: "atlas",
})
expect(result).toBe("new-session-123")
})
})
describe("#given SDK response without data wrapper", () => {
test("#when create returns { id } directly, #then extracts session ID", async () => {
const client = {
session: {
create: async () => ({ id: "direct-session-456" }),
},
}
const result = await createFreshSession({
client,
sourceSessionID: "source-ses",
targetAgent: "prometheus",
})
expect(result).toBe("direct-session-456")
})
})
describe("#given create not available", () => {
test("#when session.create is undefined, #then throws", async () => {
const client = { session: {} }
await expect(
createFreshSession({
client: client as any,
sourceSessionID: "source-ses",
targetAgent: "atlas",
}),
).rejects.toThrow("session.create not available")
})
})
describe("#given invalid response", () => {
test("#when create returns no id, #then throws", async () => {
const client = {
session: {
create: async () => ({ data: {} }),
},
}
await expect(
createFreshSession({
client,
sourceSessionID: "source-ses",
targetAgent: "atlas",
}),
).rejects.toThrow("failed to extract session ID")
})
})
describe("#given parentID and title", () => {
test("#when creating session, #then passes sourceSessionID as parentID", async () => {
let capturedInput: Record<string, unknown> | undefined
const client = {
session: {
create: async (input?: { body?: Record<string, unknown> }) => {
capturedInput = input?.body
return { id: "new-ses" }
},
},
}
await createFreshSession({
client,
sourceSessionID: "parent-ses-id",
targetAgent: "atlas",
})
expect(capturedInput).toEqual({
parentID: "parent-ses-id",
title: "atlas (handoff)",
})
})
})
})
@@ -0,0 +1,61 @@
import { log } from "../../shared/logger"
type CreateClient = {
session: {
create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise<unknown>
}
}
function extractSessionId(response: unknown): string | undefined {
if (typeof response !== "object" || response === null) {
return undefined
}
const root = response as Record<string, unknown>
if (typeof root.id === "string" && root.id.length > 0) {
return root.id
}
const data = root.data
if (typeof data === "object" && data !== null) {
const dataRecord = data as Record<string, unknown>
if (typeof dataRecord.id === "string" && dataRecord.id.length > 0) {
return dataRecord.id
}
}
return undefined
}
export async function createFreshSession(args: {
client: CreateClient
sourceSessionID: string
targetAgent: string
}): Promise<string> {
const { client, sourceSessionID, targetAgent } = args
if (!client.session.create) {
throw new Error("session.create not available on SDK client")
}
const response = await client.session.create({
body: {
parentID: sourceSessionID,
title: `${targetAgent} (handoff)`,
},
})
const newSessionID = extractSessionId(response)
if (!newSessionID) {
throw new Error(`failed to extract session ID from create response: ${JSON.stringify(response)}`)
}
log("[agent-switch] Created fresh session for handoff", {
sourceSessionID,
newSessionID,
targetAgent,
})
return newSessionID
}
+1
View File
@@ -21,6 +21,7 @@ type SessionClient = {
path: { id: string }
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
}) => Promise<unknown>
create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise<unknown>
messages: (input: { path: { id: string } }) => Promise<unknown>
status?: () => Promise<unknown>
}