From e52ccd1b04b6552c26fdaeece1ad7c069c8448c2 Mon Sep 17 00:00:00 2001 From: ismeth Date: Sat, 28 Feb 2026 20:25:50 +0100 Subject: [PATCH] fix(agent-switch): create fresh session for handoff apply --- src/features/agent-switch/applier.test.ts | 383 +++++++++--------- src/features/agent-switch/applier.ts | 42 +- src/features/agent-switch/scheduler.ts | 1 + .../agent-switch/session-creator.test.ts | 99 +++++ src/features/agent-switch/session-creator.ts | 61 +++ src/tools/switch-agent/tools.ts | 1 + 6 files changed, 369 insertions(+), 218 deletions(-) create mode 100644 src/features/agent-switch/session-creator.test.ts create mode 100644 src/features/agent-switch/session-creator.ts diff --git a/src/features/agent-switch/applier.test.ts b/src/features/agent-switch/applier.test.ts index 8c8b1654a..cf6fb54f3 100644 --- a/src/features/agent-switch/applier.test.ts +++ b/src/features/agent-switch/applier.test.ts @@ -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 + 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() }) - 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 | 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 }) => { + 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() + }) }) }) diff --git a/src/features/agent-switch/applier.ts b/src/features/agent-switch/applier.ts index 5fda952da..e68a4118d 100644 --- a/src/features/agent-switch/applier.ts +++ b/src/features/agent-switch/applier.ts @@ -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 + create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise messages: (input: { path: { id: string } }) => Promise status?: () => Promise } @@ -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, }) diff --git a/src/features/agent-switch/scheduler.ts b/src/features/agent-switch/scheduler.ts index 59e5418d0..c964086cd 100644 --- a/src/features/agent-switch/scheduler.ts +++ b/src/features/agent-switch/scheduler.ts @@ -12,6 +12,7 @@ type SessionClient = { 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 } diff --git a/src/features/agent-switch/session-creator.test.ts b/src/features/agent-switch/session-creator.test.ts new file mode 100644 index 000000000..40fe20f16 --- /dev/null +++ b/src/features/agent-switch/session-creator.test.ts @@ -0,0 +1,99 @@ +/// + +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 new file mode 100644 index 000000000..5851b6c2c --- /dev/null +++ b/src/features/agent-switch/session-creator.ts @@ -0,0 +1,61 @@ +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/tools/switch-agent/tools.ts b/src/tools/switch-agent/tools.ts index 239c894be..076cfecee 100644 --- a/src/tools/switch-agent/tools.ts +++ b/src/tools/switch-agent/tools.ts @@ -21,6 +21,7 @@ type SessionClient = { 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 }