304 lines
13 KiB
TypeScript
304 lines
13 KiB
TypeScript
/// <reference types="bun-types" />
|
|
|
|
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
|
|
|
import type { RuntimeState } from "../types"
|
|
import {
|
|
approveShutdownMock,
|
|
backgroundManager,
|
|
config,
|
|
createSpec,
|
|
createTeamRunMock,
|
|
createToolContext,
|
|
deleteTeamMock,
|
|
getLatestShutdownRequest,
|
|
hasRuntime,
|
|
listActiveTeamsMock,
|
|
loadRuntimeStateMock,
|
|
loadTeamSpecMock,
|
|
mockClient,
|
|
parseToolResult,
|
|
rejectShutdownMock,
|
|
requestShutdownOfMemberMock,
|
|
requireRuntime,
|
|
resetLifecycleTestState,
|
|
} from "./lifecycle-test-fixture"
|
|
|
|
const {
|
|
createTeamApproveShutdownTool,
|
|
createTeamCreateTool,
|
|
createTeamDeleteTool,
|
|
createTeamRejectShutdownTool,
|
|
createTeamShutdownRequestTool,
|
|
} = await import("./lifecycle")
|
|
|
|
const lifecycleDeps = {
|
|
createTeamRun: createTeamRunMock,
|
|
loadTeamSpec: loadTeamSpecMock,
|
|
listActiveTeams: listActiveTeamsMock,
|
|
loadRuntimeState: loadRuntimeStateMock,
|
|
deleteTeam: deleteTeamMock,
|
|
requestShutdownOfMember: requestShutdownOfMemberMock,
|
|
approveShutdown: approveShutdownMock,
|
|
rejectShutdown: rejectShutdownMock,
|
|
}
|
|
|
|
function createTeamCreateToolForTest() {
|
|
return createTeamCreateTool(config, mockClient, backgroundManager, undefined, undefined, lifecycleDeps)
|
|
}
|
|
|
|
describe("team lifecycle tools", () => {
|
|
afterAll(() => {
|
|
mock.restore()
|
|
})
|
|
|
|
beforeEach(() => {
|
|
resetLifecycleTestState()
|
|
})
|
|
|
|
test("team_create works without toolContext.client field", async () => {
|
|
// given
|
|
const teamCreateTool = createTeamCreateToolForTest()
|
|
|
|
// when
|
|
const result = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
|
|
|
// then
|
|
expect(result.teamRunId).toBe("team-run-1")
|
|
expect(createTeamRunMock).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"lead-session",
|
|
expect.objectContaining({ client: mockClient }),
|
|
config,
|
|
backgroundManager,
|
|
undefined,
|
|
{ callerAgentTypeId: undefined, parentMessageID: expect.any(String) },
|
|
)
|
|
})
|
|
|
|
test("team_create resolves a visible sort-prefixed sisyphus caller into callerAgentTypeId", async () => {
|
|
// given
|
|
const teamCreateTool = createTeamCreateToolForTest()
|
|
const toolContext = {
|
|
...createToolContext("lead-session"),
|
|
agent: "00|Sisyphus",
|
|
}
|
|
|
|
// when
|
|
await teamCreateTool.execute({ inline_spec: createSpec() }, toolContext)
|
|
|
|
// then
|
|
expect(createTeamRunMock).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"lead-session",
|
|
expect.objectContaining({ client: mockClient }),
|
|
config,
|
|
backgroundManager,
|
|
undefined,
|
|
{ callerAgentTypeId: "sisyphus", parentMessageID: expect.any(String) },
|
|
)
|
|
})
|
|
|
|
test("team_create returns teamRunId and sanitized runtimeState for inline specs", async () => {
|
|
// given
|
|
const teamCreateTool = createTeamCreateToolForTest()
|
|
|
|
// when
|
|
const result = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
|
|
|
// then
|
|
expect(result.teamRunId).toBe("team-run-1")
|
|
expect(result.runtimeState.status).toBe("active")
|
|
expect(result.runtimeState.members).toHaveLength(2)
|
|
expect(result.runtimeState.members[0]).not.toHaveProperty("lastInjectedTurnMarker")
|
|
expect(result.runtimeState.members[0]).not.toHaveProperty("pendingInjectedMessageIds")
|
|
})
|
|
|
|
test("team_create normalizes inline lead shorthand before creating the runtime", async () => {
|
|
// given
|
|
const teamCreateTool = createTeamCreateToolForTest()
|
|
const inlineSpec = {
|
|
name: "alpha-team",
|
|
lead: { kind: "subagent_type", subagent_type: "sisyphus" },
|
|
members: [{ kind: "category", name: "member-a", category: "quick", prompt: "Do the assigned work" }],
|
|
}
|
|
|
|
// when
|
|
const result = parseToolResult<{ runtimeState: RuntimeState }>(await teamCreateTool.execute({ inline_spec: inlineSpec }, createToolContext("lead-session")))
|
|
|
|
// then
|
|
expect(createTeamRunMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ leadAgentId: "lead" }),
|
|
"lead-session",
|
|
expect.anything(),
|
|
config,
|
|
expect.anything(),
|
|
undefined,
|
|
{ callerAgentTypeId: undefined, parentMessageID: expect.any(String) },
|
|
)
|
|
expect(result.runtimeState.members).toHaveLength(2)
|
|
expect(result.runtimeState.members[0]).toMatchObject({ name: "lead", agentType: "leader" })
|
|
})
|
|
|
|
test("team_create rejects an empty leadSessionId override", async () => {
|
|
// given
|
|
const teamCreateTool = createTeamCreateToolForTest()
|
|
|
|
// when
|
|
let errorMessage = ""
|
|
try {
|
|
await teamCreateTool.execute({ inline_spec: createSpec(), leadSessionId: "" }, createToolContext("lead-session"))
|
|
} catch (error) {
|
|
errorMessage = error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
// then
|
|
expect(errorMessage).toContain("leadSessionId")
|
|
})
|
|
|
|
test("team_delete propagates active-member errors", async () => {
|
|
// given
|
|
const createTool = createTeamCreateToolForTest()
|
|
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager, undefined, lifecycleDeps)
|
|
const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
|
|
|
// when
|
|
const result = deleteTool.execute({ teamRunId: created.teamRunId }, createToolContext("lead-session"))
|
|
|
|
// then
|
|
expect(result).rejects.toThrow("members still active")
|
|
})
|
|
|
|
test("team_delete force=true succeeds even with active members", async () => {
|
|
// given
|
|
const createTool = createTeamCreateToolForTest()
|
|
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager, undefined, lifecycleDeps)
|
|
const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
|
|
|
// when
|
|
const result = parseToolResult<{ deleted: boolean }>(await deleteTool.execute({ teamRunId: created.teamRunId, force: true }, createToolContext("lead-session")))
|
|
|
|
// then
|
|
expect(result.deleted).toBe(true)
|
|
expect(hasRuntime(created.teamRunId)).toBe(false)
|
|
})
|
|
|
|
test("team_delete force=true allows non-lead caller on orphaned team", async () => {
|
|
// given
|
|
const createTool = createTeamCreateToolForTest()
|
|
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager, undefined, lifecycleDeps)
|
|
const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
|
const runtimeState = requireRuntime(created.teamRunId)
|
|
runtimeState.status = "orphaned"
|
|
const memberSessionId = runtimeState.members.find((member) => member.name === "member-a")?.sessionId
|
|
|
|
// when
|
|
const result = parseToolResult<{ deleted: boolean }>(await deleteTool.execute(
|
|
{ teamRunId: created.teamRunId, force: true },
|
|
createToolContext(memberSessionId ?? "member-a-session"),
|
|
))
|
|
|
|
// then
|
|
expect(result.deleted).toBe(true)
|
|
expect(hasRuntime(created.teamRunId)).toBe(false)
|
|
})
|
|
|
|
test("team_delete still rejects non-participants even with force=true", async () => {
|
|
// given
|
|
const createTool = createTeamCreateToolForTest()
|
|
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager, undefined, lifecycleDeps)
|
|
const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
|
requireRuntime(created.teamRunId).status = "orphaned"
|
|
|
|
// when
|
|
const result = deleteTool.execute({ teamRunId: created.teamRunId, force: true }, createToolContext("outside-session"))
|
|
|
|
// then
|
|
expect(result).rejects.toThrow("team_delete is lead-only")
|
|
})
|
|
|
|
test("team_delete force=true allows member participant to recover a stuck deleting team", async () => {
|
|
// given
|
|
const createTool = createTeamCreateToolForTest()
|
|
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager, undefined, lifecycleDeps)
|
|
const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
|
const runtimeState = requireRuntime(created.teamRunId)
|
|
runtimeState.status = "deleting"
|
|
const memberSessionId = runtimeState.members.find((member) => member.name === "member-a")?.sessionId
|
|
|
|
// when
|
|
const result = parseToolResult<{ deleted: boolean }>(await deleteTool.execute({ teamRunId: created.teamRunId, force: true }, createToolContext(memberSessionId ?? "member-a-session")))
|
|
|
|
// then
|
|
expect(result.deleted).toBe(true)
|
|
expect(hasRuntime(created.teamRunId)).toBe(false)
|
|
})
|
|
|
|
test("team_delete force=false on orphaned team still requires lead", async () => {
|
|
// given
|
|
const createTool = createTeamCreateToolForTest()
|
|
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager, undefined, lifecycleDeps)
|
|
const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
|
const runtimeState = requireRuntime(created.teamRunId)
|
|
runtimeState.status = "orphaned"
|
|
const memberSessionId = runtimeState.members.find((member) => member.name === "member-a")?.sessionId
|
|
|
|
// when
|
|
const result = deleteTool.execute({ teamRunId: created.teamRunId }, createToolContext(memberSessionId ?? "member-a-session"))
|
|
|
|
// then
|
|
expect(result).rejects.toThrow("team_delete is lead-only")
|
|
})
|
|
|
|
test("team_create is idempotent for the same spec and lead session", async () => {
|
|
// given
|
|
const teamCreateTool = createTeamCreateToolForTest()
|
|
|
|
// when
|
|
const firstResult = parseToolResult<{ teamRunId: string }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
|
const secondResult = parseToolResult<{ teamRunId: string }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
|
|
|
// then
|
|
expect(firstResult.teamRunId).toBe(secondResult.teamRunId)
|
|
expect(createTeamRunMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
test("runs full lifecycle through create, request, approve, and delete", async () => {
|
|
// given
|
|
const createTool = createTeamCreateToolForTest()
|
|
const requestTool = createTeamShutdownRequestTool(config, mockClient, lifecycleDeps)
|
|
const approveTool = createTeamApproveShutdownTool(config, mockClient, lifecycleDeps)
|
|
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager, undefined, lifecycleDeps)
|
|
const created = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
|
const memberSessionId = created.runtimeState.members.find((member) => member.name === "member-a")?.sessionId
|
|
|
|
// when
|
|
const requestResult = parseToolResult<{ status: string }>(await requestTool.execute({ teamRunId: created.teamRunId, targetMemberName: "member-a" }, createToolContext("lead-session")))
|
|
const approveResult = parseToolResult<{ status: string }>(await approveTool.execute({ teamRunId: created.teamRunId, memberName: "member-a" }, createToolContext(memberSessionId ?? "member-a-session")))
|
|
const deleteResult = parseToolResult<{ deleted: boolean }>(await deleteTool.execute({ teamRunId: created.teamRunId }, createToolContext("lead-session")))
|
|
|
|
// then
|
|
expect(requestResult.status).toBe("shutdown_requested")
|
|
expect(approveResult.status).toBe("shutdown_approved")
|
|
expect(deleteResult.deleted).toBe(true)
|
|
expect(hasRuntime(created.teamRunId)).toBe(false)
|
|
})
|
|
|
|
test("team_reject_shutdown records the rejection reason", async () => {
|
|
// given
|
|
const createTool = createTeamCreateToolForTest()
|
|
const requestTool = createTeamShutdownRequestTool(config, mockClient, lifecycleDeps)
|
|
const rejectTool = createTeamRejectShutdownTool(config, mockClient, lifecycleDeps)
|
|
const created = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await createTool.execute({ teamName: "alpha-team" }, createToolContext("lead-session")))
|
|
const memberSessionId = created.runtimeState.members.find((member) => member.name === "member-a")?.sessionId
|
|
await requestTool.execute({ teamRunId: created.teamRunId, targetMemberName: "member-a" }, createToolContext("lead-session"))
|
|
|
|
// when
|
|
const result = parseToolResult<{ teamRunId: string; memberName: string; rejectedBy: string; reason: string; status: string }>(await rejectTool.execute({ teamRunId: created.teamRunId, memberName: "member-a", reason: "still working" }, createToolContext(memberSessionId ?? "member-a-session")))
|
|
|
|
// then
|
|
expect(result).toEqual({ teamRunId: created.teamRunId, memberName: "member-a", rejectedBy: "member-a", reason: "still working", status: "shutdown_rejected" })
|
|
expect(getLatestShutdownRequest(requireRuntime(created.teamRunId), "member-a")).toEqual(expect.objectContaining({ rejectedReason: "still working", rejectedAt: expect.any(Number) }))
|
|
})
|
|
})
|