fix(agent-switch): create fresh session for handoff apply
This commit is contained in:
@@ -9,218 +9,221 @@ import {
|
|||||||
} from "./applier"
|
} from "./applier"
|
||||||
import { schedulePendingSwitchApply } from "./scheduler"
|
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", () => {
|
describe("agent-switch applier", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetForTesting()
|
_resetForTesting()
|
||||||
_resetApplierForTesting()
|
_resetApplierForTesting()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("scheduled apply works without idle event", async () => {
|
describe("#given fresh session creation flow", () => {
|
||||||
const calls: string[] = []
|
test("#when scheduled apply runs, #then creates new session and prompts it", async () => {
|
||||||
let switched = false
|
const promptedSessions: string[] = []
|
||||||
const client = {
|
const promptedAgents: string[] = []
|
||||||
session: {
|
const client = createMockClient({
|
||||||
promptAsync: async (input: { body: { agent: string } }) => {
|
onCreate: () => ({ data: { id: "fresh-ses-1" } }),
|
||||||
calls.push(input.body.agent)
|
onPrompt: (input) => {
|
||||||
switched = true
|
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")
|
setPendingSwitch("ses-1", "prometheus", "create plan")
|
||||||
schedulePendingSwitchApply({
|
schedulePendingSwitchApply({
|
||||||
sessionID: "ses-1",
|
sessionID: "ses-1",
|
||||||
client: client as any,
|
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))
|
test("#when apply runs directly, #then creates fresh session with parentID linking to source", async () => {
|
||||||
|
let createInput: Record<string, unknown> | undefined
|
||||||
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
|
|
||||||
const client = {
|
const client = {
|
||||||
session: {
|
session: {
|
||||||
promptAsync: async (input: { body: { agent: string } }) => {
|
create: async (input?: { body?: Record<string, unknown> }) => {
|
||||||
promptCalls.push(input.body.agent)
|
createInput = input?.body
|
||||||
switched = true
|
return { data: { id: "fresh-ses-2" } }
|
||||||
},
|
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
|
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({
|
await applyPendingSwitch({
|
||||||
sessionID: "ses-6",
|
sessionID: "ses-6",
|
||||||
client: client as any,
|
client: client as any,
|
||||||
source: "message-updated",
|
source: "idle",
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(promptCalls).toEqual(["Atlas (Plan Executor)"])
|
clearPendingSwitchRuntime("ses-6")
|
||||||
expect(tuiCommands).toEqual(["agent.cycle.reverse"])
|
|
||||||
|
const attemptsAfterClear = attempts
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
|
||||||
|
expect(attempts).toBe(attemptsAfterClear)
|
||||||
expect(getPendingSwitch("ses-6")).toBeUndefined()
|
expect(getPendingSwitch("ses-6")).toBeUndefined()
|
||||||
} finally {
|
})
|
||||||
if (originalClientEnv === undefined) {
|
})
|
||||||
delete process.env["OPENCODE_CLIENT"]
|
|
||||||
} else {
|
describe("#given create not available on client", () => {
|
||||||
process.env["OPENCODE_CLIENT"] = originalClientEnv
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { normalizeAgentForPrompt } from "../../shared/agent-display-names"
|
|||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { clearPendingSwitch, getPendingSwitch } from "./state"
|
import { clearPendingSwitch, getPendingSwitch } from "./state"
|
||||||
import { waitForSessionIdle } from "./session-status"
|
import { waitForSessionIdle } from "./session-status"
|
||||||
import { fetchMessages, shouldClearAsAlreadyApplied, verifySwitchObserved } from "./apply-verification"
|
import { shouldClearAsAlreadyApplied } from "./apply-verification"
|
||||||
import { getLatestUserAgent } from "./message-inspection"
|
import { createFreshSession } from "./session-creator"
|
||||||
import { syncCliTuiAgentSelectionAfterSwitch } from "./tui-agent-sync"
|
|
||||||
import {
|
import {
|
||||||
clearInFlight,
|
clearInFlight,
|
||||||
clearRetryState,
|
clearRetryState,
|
||||||
@@ -24,6 +23,7 @@ type SessionClient = {
|
|||||||
path: { id: string }
|
path: { id: string }
|
||||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||||
}) => Promise<unknown>
|
}) => Promise<unknown>
|
||||||
|
create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise<unknown>
|
||||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||||
status?: () => Promise<unknown>
|
status?: () => Promise<unknown>
|
||||||
}
|
}
|
||||||
@@ -135,40 +135,26 @@ export async function applyPendingSwitch(args: {
|
|||||||
throw new Error("session not idle before applying agent switch")
|
throw new Error("session not idle before applying agent switch")
|
||||||
}
|
}
|
||||||
|
|
||||||
const beforeMessages = await fetchMessages({ client, sessionID })
|
const newSessionID = await createFreshSession({
|
||||||
const sourceUserAgent = getLatestUserAgent(beforeMessages)
|
|
||||||
|
|
||||||
const usedAgent = await tryPromptWithCandidates({
|
|
||||||
client,
|
client,
|
||||||
sessionID,
|
sourceSessionID: sessionID,
|
||||||
|
targetAgent: pending.agent,
|
||||||
|
})
|
||||||
|
|
||||||
|
await tryPromptWithCandidates({
|
||||||
|
client,
|
||||||
|
sessionID: newSessionID,
|
||||||
agent: pending.agent,
|
agent: pending.agent,
|
||||||
context: pending.context,
|
context: pending.context,
|
||||||
source,
|
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)
|
clearPendingSwitch(sessionID)
|
||||||
clearRetryState(sessionID)
|
clearRetryState(sessionID)
|
||||||
|
|
||||||
await syncCliTuiAgentSelectionAfterSwitch({
|
log("[agent-switch] Pending switch applied via fresh session", {
|
||||||
client,
|
sourceSessionID: sessionID,
|
||||||
sessionID,
|
newSessionID,
|
||||||
source,
|
|
||||||
sourceAgent: sourceUserAgent,
|
|
||||||
targetAgent: pending.agent,
|
|
||||||
})
|
|
||||||
|
|
||||||
log("[agent-switch] Pending switch applied", {
|
|
||||||
sessionID,
|
|
||||||
source,
|
source,
|
||||||
agent: pending.agent,
|
agent: pending.agent,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type SessionClient = {
|
|||||||
path: { id: string }
|
path: { id: string }
|
||||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||||
}) => Promise<unknown>
|
}) => Promise<unknown>
|
||||||
|
create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise<unknown>
|
||||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||||
status?: () => 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
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ type SessionClient = {
|
|||||||
path: { id: string }
|
path: { id: string }
|
||||||
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
body: { agent: string; parts: Array<{ type: "text"; text: string }> }
|
||||||
}) => Promise<unknown>
|
}) => Promise<unknown>
|
||||||
|
create?: (input?: { body?: { parentID?: string; title?: string } }) => Promise<unknown>
|
||||||
messages: (input: { path: { id: string } }) => Promise<unknown>
|
messages: (input: { path: { id: string } }) => Promise<unknown>
|
||||||
status?: () => Promise<unknown>
|
status?: () => Promise<unknown>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user