fix full-suite isolation regressions
This commit is contained in:
@@ -13,9 +13,6 @@ import type { RuntimeState, TeamSpec } from "../types"
|
||||
const runtimes = new Map<string, RuntimeState>()
|
||||
let nextTeamRunNumber = 1
|
||||
|
||||
const lifecycleSpecifier = import.meta.resolve("./lifecycle")
|
||||
const teamRuntimeCreateSpecifier = import.meta.resolve("../team-runtime/create")
|
||||
|
||||
function clone<TValue>(value: TValue): TValue {
|
||||
return structuredClone(value)
|
||||
}
|
||||
@@ -65,12 +62,8 @@ const createTeamRunMock = mock(async (spec: TeamSpec, leadSessionId: string) =>
|
||||
return clone(runtimeState)
|
||||
})
|
||||
|
||||
function registerModuleMocks(): void {
|
||||
mock.module(teamRuntimeCreateSpecifier, () => ({ createTeamRun: createTeamRunMock }))
|
||||
}
|
||||
|
||||
async function loadCreateTeamCreateTool(): Promise<typeof import("./lifecycle").createTeamCreateTool> {
|
||||
const module = await import(`${lifecycleSpecifier}?test=${randomUUID()}`)
|
||||
const module = await import(`./lifecycle?test=${randomUUID()}`)
|
||||
return module.createTeamCreateTool
|
||||
}
|
||||
|
||||
@@ -81,6 +74,23 @@ function createConfig() {
|
||||
})
|
||||
}
|
||||
|
||||
function createTeamCreateToolForTest(
|
||||
factory: typeof import("./lifecycle").createTeamCreateTool,
|
||||
config: ReturnType<typeof createConfig>,
|
||||
executorConfig?: Parameters<typeof factory>[4],
|
||||
) {
|
||||
return factory(config, {} as never, {} as never, undefined, executorConfig, {
|
||||
createTeamRun: createTeamRunMock,
|
||||
loadTeamSpec: async () => {
|
||||
throw new Error("loadTeamSpec should not be called for inline_spec tests")
|
||||
},
|
||||
listActiveTeams: async () => [],
|
||||
loadRuntimeState: async () => {
|
||||
throw new Error("loadRuntimeState should not be called when no active teams exist")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("createTeamCreateTool inline_spec normalization", () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
@@ -88,7 +98,6 @@ describe("createTeamCreateTool inline_spec normalization", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mock.restore()
|
||||
registerModuleMocks()
|
||||
runtimes.clear()
|
||||
nextTeamRunNumber = 1
|
||||
createTeamRunMock.mockClear()
|
||||
@@ -98,7 +107,7 @@ describe("createTeamCreateTool inline_spec normalization", () => {
|
||||
// given
|
||||
const createTeamCreateTool = await loadCreateTeamCreateTool()
|
||||
const config = createConfig()
|
||||
const teamCreateTool = createTeamCreateTool(config, {} as never)
|
||||
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config)
|
||||
const inlineSpec = {
|
||||
name: "alpha-team",
|
||||
lead: { kind: "subagent_type", subagent_type: "sisyphus" },
|
||||
@@ -129,7 +138,7 @@ describe("createTeamCreateTool inline_spec normalization", () => {
|
||||
// given
|
||||
const createTeamCreateTool = await loadCreateTeamCreateTool()
|
||||
const config = createConfig()
|
||||
const teamCreateTool = createTeamCreateTool(config, {} as never)
|
||||
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config)
|
||||
const inlineSpec = JSON.stringify({
|
||||
name: "ccapi-explorers-v2",
|
||||
lead: { kind: "subagent_type", subagent_type: "sisyphus" },
|
||||
@@ -152,7 +161,7 @@ describe("createTeamCreateTool inline_spec normalization", () => {
|
||||
// given
|
||||
const createTeamCreateTool = await loadCreateTeamCreateTool()
|
||||
const config = createConfig()
|
||||
const teamCreateTool = createTeamCreateTool(config, {} as never)
|
||||
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config)
|
||||
const inlineSpec = {
|
||||
name: "project-analysis-team",
|
||||
description: "Analyze the codebase from structure, core logic, and quality angles.",
|
||||
@@ -198,35 +207,45 @@ describe("createTeamCreateTool inline_spec normalization", () => {
|
||||
// given
|
||||
const createTeamCreateTool = await loadCreateTeamCreateTool()
|
||||
const config = createConfig()
|
||||
const teamCreateTool = createTeamCreateTool(config, {} as never)
|
||||
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config)
|
||||
|
||||
// when
|
||||
const result = teamCreateTool.execute({}, createToolContext("lead-session", "Sisyphus"))
|
||||
let errorMessage = ""
|
||||
try {
|
||||
await teamCreateTool.execute({}, createToolContext("lead-session", "Sisyphus"))
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("team_create requires exactly one of teamName or inline_spec")
|
||||
await expect(result).rejects.toThrow("team_create({ inline_spec: { name:")
|
||||
expect(errorMessage).toContain("team_create requires exactly one of teamName or inline_spec")
|
||||
expect(errorMessage).toContain("team_create({ inline_spec: { name:")
|
||||
})
|
||||
|
||||
test("explains how to shape inline_spec when members are missing", async () => {
|
||||
// given
|
||||
const createTeamCreateTool = await loadCreateTeamCreateTool()
|
||||
const config = createConfig()
|
||||
const teamCreateTool = createTeamCreateTool(config, {} as never)
|
||||
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config)
|
||||
|
||||
// when
|
||||
const result = teamCreateTool.execute({ inline_spec: { name: "project-analysis-team" } }, createToolContext("lead-session", "Sisyphus"))
|
||||
let errorMessage = ""
|
||||
try {
|
||||
await teamCreateTool.execute({ inline_spec: { name: "project-analysis-team" } }, createToolContext("lead-session", "Sisyphus"))
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("Invalid inline_spec for team_create")
|
||||
await expect(result).rejects.toThrow("members array")
|
||||
expect(errorMessage).toContain("Invalid inline_spec for team_create")
|
||||
expect(errorMessage).toContain("members array")
|
||||
})
|
||||
|
||||
test("accepts natural team and member names in inline_spec", async () => {
|
||||
// given
|
||||
const createTeamCreateTool = await loadCreateTeamCreateTool()
|
||||
const config = createConfig()
|
||||
const teamCreateTool = createTeamCreateTool(config, {} as never)
|
||||
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config)
|
||||
const inlineSpec = {
|
||||
name: "Project Analysis Team",
|
||||
members: [
|
||||
@@ -256,7 +275,7 @@ describe("createTeamCreateTool inline_spec normalization", () => {
|
||||
// given
|
||||
const createTeamCreateTool = await loadCreateTeamCreateTool()
|
||||
const config = createConfig()
|
||||
const teamCreateTool = createTeamCreateTool(config, {} as never, undefined as never, undefined, {
|
||||
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config, {
|
||||
userCategories: {
|
||||
analysis: {},
|
||||
},
|
||||
|
||||
@@ -139,7 +139,13 @@ export const rejectShutdownMock = mock(async (teamRunId: string, memberName: str
|
||||
}
|
||||
})
|
||||
export const loadTeamSpecMock = mock(async () => createSpec())
|
||||
export const listActiveTeamsMock = mock(async () => Array.from(runtimes.values()).map((runtimeState) => ({ teamRunId: runtimeState.teamRunId, teamName: runtimeState.teamName, status: runtimeState.status })))
|
||||
export const listActiveTeamsMock = mock(async () => Array.from(runtimes.values()).map((runtimeState) => ({
|
||||
teamRunId: runtimeState.teamRunId,
|
||||
teamName: runtimeState.teamName,
|
||||
status: runtimeState.status,
|
||||
memberCount: runtimeState.members.length,
|
||||
scope: runtimeState.specSource,
|
||||
})))
|
||||
export const loadRuntimeStateMock = mock(async (teamRunId: string) => clone(requireRuntime(teamRunId)))
|
||||
|
||||
export const config = TeamModeConfigSchema.parse({ enabled: true })
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import { normalizeTeamSpecInput } from "../team-registry/team-spec-input-normalizer"
|
||||
import type { RuntimeState } from "../types"
|
||||
import {
|
||||
approveShutdownMock,
|
||||
@@ -25,11 +24,6 @@ import {
|
||||
resetLifecycleTestState,
|
||||
} from "./lifecycle-test-fixture"
|
||||
|
||||
mock.module("../team-runtime/create", () => ({ createTeamRun: createTeamRunMock }))
|
||||
mock.module("../team-runtime/shutdown", () => ({ approveShutdown: approveShutdownMock, deleteTeam: deleteTeamMock, rejectShutdown: rejectShutdownMock, requestShutdownOfMember: requestShutdownOfMemberMock }))
|
||||
mock.module("../team-registry/loader", () => ({ loadTeamSpec: loadTeamSpecMock, normalizeTeamSpecInput }))
|
||||
mock.module("../team-state-store/store", () => ({ listActiveTeams: listActiveTeamsMock, loadRuntimeState: loadRuntimeStateMock }))
|
||||
|
||||
const {
|
||||
createTeamApproveShutdownTool,
|
||||
createTeamCreateTool,
|
||||
@@ -38,6 +32,21 @@ const {
|
||||
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()
|
||||
@@ -49,7 +58,7 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_create works without toolContext.client field", async () => {
|
||||
// given
|
||||
const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const teamCreateTool = createTeamCreateToolForTest()
|
||||
|
||||
// when
|
||||
const result = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
||||
@@ -69,7 +78,7 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_create resolves a visible sort-prefixed sisyphus caller into callerAgentTypeId", async () => {
|
||||
// given
|
||||
const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const teamCreateTool = createTeamCreateToolForTest()
|
||||
const toolContext = {
|
||||
...createToolContext("lead-session"),
|
||||
agent: "00|Sisyphus",
|
||||
@@ -92,7 +101,7 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_create returns teamRunId and sanitized runtimeState for inline specs", async () => {
|
||||
// given
|
||||
const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const teamCreateTool = createTeamCreateToolForTest()
|
||||
|
||||
// when
|
||||
const result = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
||||
@@ -107,7 +116,7 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_create normalizes inline lead shorthand before creating the runtime", async () => {
|
||||
// given
|
||||
const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const teamCreateTool = createTeamCreateToolForTest()
|
||||
const inlineSpec = {
|
||||
name: "alpha-team",
|
||||
lead: { kind: "subagent_type", subagent_type: "sisyphus" },
|
||||
@@ -133,19 +142,24 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_create rejects an empty leadSessionId override", async () => {
|
||||
// given
|
||||
const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const teamCreateTool = createTeamCreateToolForTest()
|
||||
|
||||
// when
|
||||
const result = teamCreateTool.execute({ inline_spec: createSpec(), leadSessionId: "" }, createToolContext("lead-session"))
|
||||
let errorMessage = ""
|
||||
try {
|
||||
await teamCreateTool.execute({ inline_spec: createSpec(), leadSessionId: "" }, createToolContext("lead-session"))
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("leadSessionId")
|
||||
expect(errorMessage).toContain("leadSessionId")
|
||||
})
|
||||
|
||||
test("team_delete propagates active-member errors", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
|
||||
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
|
||||
@@ -157,8 +171,8 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_delete force=true succeeds even with active members", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
|
||||
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
|
||||
@@ -171,8 +185,8 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_delete force=true allows non-lead caller on orphaned team", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
|
||||
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"
|
||||
@@ -191,8 +205,8 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_delete still rejects non-participants even with force=true", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
|
||||
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"
|
||||
|
||||
@@ -205,8 +219,8 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_delete force=true allows member participant to recover a stuck deleting team", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
|
||||
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"
|
||||
@@ -222,8 +236,8 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_delete force=false on orphaned team still requires lead", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
|
||||
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"
|
||||
@@ -238,7 +252,7 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_create is idempotent for the same spec and lead session", async () => {
|
||||
// given
|
||||
const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const teamCreateTool = createTeamCreateToolForTest()
|
||||
|
||||
// when
|
||||
const firstResult = parseToolResult<{ teamRunId: string }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
||||
@@ -251,10 +265,10 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("runs full lifecycle through create, request, approve, and delete", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const requestTool = createTeamShutdownRequestTool(config, mockClient)
|
||||
const approveTool = createTeamApproveShutdownTool(config, mockClient)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
|
||||
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
|
||||
|
||||
@@ -272,9 +286,9 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_reject_shutdown records the rejection reason", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const requestTool = createTeamShutdownRequestTool(config, mockClient)
|
||||
const rejectTool = createTeamRejectShutdownTool(config, mockClient)
|
||||
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"))
|
||||
|
||||
@@ -116,17 +116,38 @@ function parseInlineTeamSpec(
|
||||
return parsedSpec
|
||||
}
|
||||
|
||||
async function findParticipantRuntime(sessionID: string, config: TeamModeConfig): Promise<RuntimeState | undefined> {
|
||||
for (const activeTeam of await listActiveTeams(config)) {
|
||||
const runtimeState = await loadRuntimeState(activeTeam.teamRunId, config).catch(() => undefined)
|
||||
type TeamRuntimeStoreDeps = {
|
||||
listActiveTeams: typeof listActiveTeams
|
||||
loadRuntimeState: typeof loadRuntimeState
|
||||
}
|
||||
|
||||
async function findParticipantRuntime(sessionID: string, config: TeamModeConfig, deps: TeamRuntimeStoreDeps): Promise<RuntimeState | undefined> {
|
||||
for (const activeTeam of await deps.listActiveTeams(config)) {
|
||||
const runtimeState = await deps.loadRuntimeState(activeTeam.teamRunId, config).catch(() => undefined)
|
||||
if (!runtimeState || !ACTIVE_RUNTIME_STATUSES.has(runtimeState.status)) continue
|
||||
if (runtimeState.leadSessionId === sessionID) return runtimeState
|
||||
if (runtimeState.members.some((member) => member.sessionId === sessionID)) return runtimeState
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveParticipant(teamRunId: string, sessionID: string, config: TeamModeConfig): Promise<{ runtimeState: RuntimeState; participant?: TeamParticipant }> {
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
type TeamShutdownToolDeps = TeamRuntimeStoreDeps & {
|
||||
deleteTeam: typeof deleteTeam
|
||||
requestShutdownOfMember: typeof requestShutdownOfMember
|
||||
approveShutdown: typeof approveShutdown
|
||||
rejectShutdown: typeof rejectShutdown
|
||||
}
|
||||
|
||||
const defaultTeamShutdownToolDeps: TeamShutdownToolDeps = {
|
||||
listActiveTeams,
|
||||
loadRuntimeState,
|
||||
deleteTeam,
|
||||
requestShutdownOfMember,
|
||||
approveShutdown,
|
||||
rejectShutdown,
|
||||
}
|
||||
|
||||
async function resolveParticipant(teamRunId: string, sessionID: string, config: TeamModeConfig, deps: TeamRuntimeStoreDeps): Promise<{ runtimeState: RuntimeState; participant?: TeamParticipant }> {
|
||||
const runtimeState = await deps.loadRuntimeState(teamRunId, config)
|
||||
if (runtimeState.leadSessionId === sessionID) {
|
||||
return { runtimeState, participant: { role: "lead", memberName: getLeadMemberName(runtimeState) } }
|
||||
}
|
||||
@@ -140,12 +161,27 @@ export type TeamCreateExecutorConfig = {
|
||||
agentOverrides?: AgentOverrides
|
||||
}
|
||||
|
||||
type TeamCreateToolDeps = {
|
||||
createTeamRun: typeof createTeamRun
|
||||
loadTeamSpec: typeof loadTeamSpec
|
||||
listActiveTeams: typeof listActiveTeams
|
||||
loadRuntimeState: typeof loadRuntimeState
|
||||
}
|
||||
|
||||
const defaultTeamCreateToolDeps: TeamCreateToolDeps = {
|
||||
createTeamRun,
|
||||
loadTeamSpec,
|
||||
listActiveTeams,
|
||||
loadRuntimeState,
|
||||
}
|
||||
|
||||
export function createTeamCreateTool(
|
||||
config: TeamModeConfig,
|
||||
client: OpencodeClient,
|
||||
bgMgr: BackgroundManager,
|
||||
tmuxMgr?: TmuxSessionManager,
|
||||
executorConfig?: TeamCreateExecutorConfig,
|
||||
deps: TeamCreateToolDeps = defaultTeamCreateToolDeps,
|
||||
): ToolDefinition {
|
||||
return tool({
|
||||
description: "Create a team run from a named or inline team spec.",
|
||||
@@ -163,13 +199,13 @@ export function createTeamCreateTool(
|
||||
const callerTeamLead = resolveCallerTeamLead(runtimeContext.agent)
|
||||
const defaultCategoryName = resolveDefaultInlineCategory(executorConfig?.userCategories)
|
||||
const spec = args.teamName
|
||||
? await loadTeamSpec(args.teamName, config, projectRoot, { callerTeamLead })
|
||||
? await deps.loadTeamSpec(args.teamName, config, projectRoot, { callerTeamLead })
|
||||
: parseInlineTeamSpec(args.inline_spec, { callerTeamLead, defaultCategoryName })
|
||||
const participantRuntime = await findParticipantRuntime(runtimeContext.sessionID, config)
|
||||
const participantRuntime = await findParticipantRuntime(runtimeContext.sessionID, config, deps)
|
||||
if (participantRuntime && (participantRuntime.teamName !== spec.name || participantRuntime.leadSessionId !== leadSessionId)) {
|
||||
throw new Error(`team_create denied: session is already a participant of team ${participantRuntime.teamRunId}`)
|
||||
}
|
||||
const runtimeState = await createTeamRun(
|
||||
const runtimeState = await deps.createTeamRun(
|
||||
spec,
|
||||
leadSessionId,
|
||||
{
|
||||
@@ -198,6 +234,7 @@ export function createTeamDeleteTool(
|
||||
client: OpencodeClient,
|
||||
backgroundManager: BackgroundManager,
|
||||
tmuxMgr?: TmuxSessionManager,
|
||||
deps: TeamShutdownToolDeps = defaultTeamShutdownToolDeps,
|
||||
): ToolDefinition {
|
||||
void client
|
||||
|
||||
@@ -207,19 +244,19 @@ export function createTeamDeleteTool(
|
||||
async execute(rawArgs, toolContext) {
|
||||
const args = TeamDeleteArgsSchema.parse(rawArgs)
|
||||
const runtimeContext = toolContext as TeamLifecycleToolContext
|
||||
const { runtimeState, participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config)
|
||||
const { runtimeState, participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config, deps)
|
||||
const isOrphanedForceDelete = args.force === true && runtimeState.status === "orphaned"
|
||||
const isStuckDeletingForceDelete = args.force === true && runtimeState.status === "deleting"
|
||||
const isForceBypass = (isStuckDeletingForceDelete || isOrphanedForceDelete) && participant !== undefined
|
||||
if (!isForceBypass && participant?.role !== "lead") {
|
||||
throw new Error("team_delete is lead-only")
|
||||
}
|
||||
return JSON.stringify({ teamRunId: args.teamRunId, teamName: runtimeState.teamName, deleted: true, ...(await deleteTeam(args.teamRunId, config, tmuxMgr, backgroundManager, { force: args.force })) })
|
||||
return JSON.stringify({ teamRunId: args.teamRunId, teamName: runtimeState.teamName, deleted: true, ...(await deps.deleteTeam(args.teamRunId, config, tmuxMgr, backgroundManager, { force: args.force })) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamShutdownRequestTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamShutdownRequestTool(config: TeamModeConfig, client: OpencodeClient, deps: TeamShutdownToolDeps = defaultTeamShutdownToolDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -228,15 +265,15 @@ export function createTeamShutdownRequestTool(config: TeamModeConfig, client: Op
|
||||
async execute(rawArgs, toolContext) {
|
||||
const args = TeamShutdownRequestArgsSchema.parse(rawArgs)
|
||||
const runtimeContext = toolContext as TeamLifecycleToolContext
|
||||
const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config)
|
||||
const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config, deps)
|
||||
if (participant?.role !== "lead") throw new Error("team_shutdown_request is lead-only")
|
||||
await requestShutdownOfMember(args.teamRunId, args.targetMemberName, participant.memberName, config)
|
||||
await deps.requestShutdownOfMember(args.teamRunId, args.targetMemberName, participant.memberName, config)
|
||||
return JSON.stringify({ teamRunId: args.teamRunId, targetMemberName: args.targetMemberName, requesterName: participant.memberName, status: "shutdown_requested" })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamApproveShutdownTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamApproveShutdownTool(config: TeamModeConfig, client: OpencodeClient, deps: TeamShutdownToolDeps = defaultTeamShutdownToolDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -245,15 +282,15 @@ export function createTeamApproveShutdownTool(config: TeamModeConfig, client: Op
|
||||
async execute(rawArgs, toolContext) {
|
||||
const args = TeamApproveShutdownArgsSchema.parse(rawArgs)
|
||||
const runtimeContext = toolContext as TeamLifecycleToolContext
|
||||
const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config)
|
||||
const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config, deps)
|
||||
if (!participant || (participant.role !== "lead" && participant.memberName !== args.memberName)) throw new Error("team_approve_shutdown: caller must be target member or team lead")
|
||||
await approveShutdown(args.teamRunId, args.memberName, participant.memberName, config)
|
||||
await deps.approveShutdown(args.teamRunId, args.memberName, participant.memberName, config)
|
||||
return JSON.stringify({ teamRunId: args.teamRunId, memberName: args.memberName, approverName: participant.memberName, status: "shutdown_approved" })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamRejectShutdownTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamRejectShutdownTool(config: TeamModeConfig, client: OpencodeClient, deps: TeamShutdownToolDeps = defaultTeamShutdownToolDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -262,9 +299,9 @@ export function createTeamRejectShutdownTool(config: TeamModeConfig, client: Ope
|
||||
async execute(rawArgs, toolContext) {
|
||||
const args = TeamRejectShutdownArgsSchema.parse(rawArgs)
|
||||
const runtimeContext = toolContext as TeamLifecycleToolContext
|
||||
const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config)
|
||||
const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config, deps)
|
||||
if (!participant || (participant.role !== "lead" && participant.memberName !== args.memberName)) throw new Error("team_reject_shutdown: caller must be target member or team lead")
|
||||
await rejectShutdown(args.teamRunId, args.memberName, args.reason, config)
|
||||
await deps.rejectShutdown(args.teamRunId, args.memberName, args.reason, config)
|
||||
return JSON.stringify({ teamRunId: args.teamRunId, memberName: args.memberName, rejectedBy: participant.memberName, reason: args.reason, status: "shutdown_rejected" })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, readdir } from "node:fs/promises"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { tmpdir } from "node:os"
|
||||
@@ -10,6 +10,8 @@ import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||
|
||||
import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
|
||||
import { getInboxDir, resolveBaseDir } from "../team-registry/paths"
|
||||
import type { RuntimeState } from "../types"
|
||||
import { createTeamSendMessageTool, type LiveDeliveryClient } from "./messaging"
|
||||
|
||||
function createToolContext(sessionID: string, directory: string): ToolContext {
|
||||
return {
|
||||
@@ -34,17 +36,29 @@ describe("createTeamSendMessageTool missing recipient session fallback", () => {
|
||||
const memberOneSessionId = randomUUID()
|
||||
const memberTwoSessionId = randomUUID()
|
||||
|
||||
const runtimeStateWithRecipientSession = {
|
||||
const runtimeStateWithRecipientSession: RuntimeState = {
|
||||
version: 1,
|
||||
teamRunId,
|
||||
teamName: "team-alpha",
|
||||
specSource: "project",
|
||||
createdAt: Date.now(),
|
||||
leadSessionId,
|
||||
status: "active",
|
||||
shutdownRequests: [],
|
||||
bounds: {
|
||||
maxMembers: 8,
|
||||
maxParallelMembers: 4,
|
||||
maxMessagesPerRun: 10000,
|
||||
maxWallClockMinutes: 120,
|
||||
maxMemberTurns: 500,
|
||||
},
|
||||
members: [
|
||||
{ name: "team-lead", agentType: "leader", sessionId: leadSessionId },
|
||||
{ name: "m1", agentType: "member", sessionId: memberOneSessionId },
|
||||
{ name: "m2", agentType: "member", sessionId: memberTwoSessionId },
|
||||
{ name: "team-lead", agentType: "leader", status: "idle", sessionId: leadSessionId, pendingInjectedMessageIds: [] },
|
||||
{ name: "m1", agentType: "general-purpose", status: "idle", sessionId: memberOneSessionId, pendingInjectedMessageIds: [] },
|
||||
{ name: "m2", agentType: "general-purpose", status: "idle", sessionId: memberTwoSessionId, pendingInjectedMessageIds: [] },
|
||||
],
|
||||
}
|
||||
const runtimeStateWithoutRecipientSession = {
|
||||
const runtimeStateWithoutRecipientSession: RuntimeState = {
|
||||
...runtimeStateWithRecipientSession,
|
||||
members: runtimeStateWithRecipientSession.members.map((member) => (
|
||||
member.name === "m2"
|
||||
@@ -54,18 +68,15 @@ describe("createTeamSendMessageTool missing recipient session fallback", () => {
|
||||
}
|
||||
|
||||
let loadRuntimeStateCalls = 0
|
||||
mock.module("../team-state-store/store", () => ({
|
||||
listActiveTeams: async () => [{ teamRunId }],
|
||||
const deps = {
|
||||
loadRuntimeState: async () => {
|
||||
loadRuntimeStateCalls += 1
|
||||
return loadRuntimeStateCalls >= 3
|
||||
? runtimeStateWithoutRecipientSession
|
||||
: runtimeStateWithRecipientSession
|
||||
},
|
||||
}))
|
||||
} satisfies NonNullable<Parameters<typeof createTeamSendMessageTool>[2]>
|
||||
|
||||
const { createTeamSendMessageTool } = await import("./messaging")
|
||||
type LiveDeliveryClient = Parameters<typeof createTeamSendMessageTool>[1]
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
@@ -73,7 +84,7 @@ describe("createTeamSendMessageTool missing recipient session fallback", () => {
|
||||
},
|
||||
},
|
||||
} satisfies LiveDeliveryClient
|
||||
const tool = createTeamSendMessageTool(config, client)
|
||||
const tool = createTeamSendMessageTool(config, client, deps)
|
||||
|
||||
// when
|
||||
const result = await tool.execute({
|
||||
|
||||
@@ -43,6 +43,14 @@ type TeamRuntimeDetails = {
|
||||
activeMembers: string[]
|
||||
}
|
||||
|
||||
export type TeamSendMessageToolDeps = {
|
||||
loadRuntimeState: typeof loadRuntimeState
|
||||
}
|
||||
|
||||
const defaultTeamSendMessageToolDeps: TeamSendMessageToolDeps = {
|
||||
loadRuntimeState,
|
||||
}
|
||||
|
||||
const TeamReferenceArgsSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
@@ -53,17 +61,22 @@ const TeamSendMessageArgsSchema = z.object({
|
||||
to: z.string().min(1),
|
||||
body: z.string(),
|
||||
kind: z.enum(MESSAGE_TOOL_KINDS).optional(),
|
||||
correlationId: z.string().uuid().optional(),
|
||||
correlationId: z.uuid().optional(),
|
||||
summary: z.string().optional(),
|
||||
references: z.array(TeamReferenceArgsSchema).optional(),
|
||||
})
|
||||
|
||||
type DeliveryReservation = Awaited<ReturnType<typeof reserveMessageForDelivery>>
|
||||
|
||||
async function resolveTeamRuntimeDetails(teamRunId: string, sessionID: string, config: TeamModeConfig): Promise<TeamRuntimeDetails> {
|
||||
async function resolveTeamRuntimeDetails(
|
||||
teamRunId: string,
|
||||
sessionID: string,
|
||||
config: TeamModeConfig,
|
||||
deps: TeamSendMessageToolDeps,
|
||||
): Promise<TeamRuntimeDetails> {
|
||||
const registryEntry = lookupTeamSession(sessionID)
|
||||
if (registryEntry?.teamRunId === teamRunId) {
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
const runtimeState = await deps.loadRuntimeState(teamRunId, config)
|
||||
|
||||
return {
|
||||
teamRunId: runtimeState.teamRunId,
|
||||
@@ -76,7 +89,7 @@ async function resolveTeamRuntimeDetails(teamRunId: string, sessionID: string, c
|
||||
}
|
||||
|
||||
try {
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
const runtimeState = await deps.loadRuntimeState(teamRunId, config)
|
||||
const isLead = runtimeState.leadSessionId === sessionID
|
||||
const leadMember = isLead
|
||||
? runtimeState.members.find((member) => member.agentType === "leader")
|
||||
@@ -127,8 +140,9 @@ async function deliverLive(
|
||||
deliveredTo: readonly string[],
|
||||
config: TeamModeConfig,
|
||||
directory: string,
|
||||
deps: TeamSendMessageToolDeps,
|
||||
): Promise<void> {
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
const runtimeState = await deps.loadRuntimeState(teamRunId, config)
|
||||
const envelope = buildEnvelope(message)
|
||||
|
||||
for (const recipientName of deliveredTo) {
|
||||
@@ -194,7 +208,11 @@ async function deliverLive(
|
||||
}
|
||||
}
|
||||
|
||||
export function createTeamSendMessageTool(config: TeamModeConfig, client: LiveDeliveryClient): ToolDefinition {
|
||||
export function createTeamSendMessageTool(
|
||||
config: TeamModeConfig,
|
||||
client: LiveDeliveryClient,
|
||||
deps: TeamSendMessageToolDeps = defaultTeamSendMessageToolDeps,
|
||||
): ToolDefinition {
|
||||
return tool({
|
||||
description: "Send a message to a team member or broadcast to the team.",
|
||||
args: {
|
||||
@@ -220,7 +238,7 @@ export function createTeamSendMessageTool(config: TeamModeConfig, client: LiveDe
|
||||
|
||||
const targetDirectory = typeof runtimeContext.directory === "string" ? runtimeContext.directory : process.cwd()
|
||||
|
||||
const teamRuntime = await resolveTeamRuntimeDetails(args.teamRunId, sessionID, config)
|
||||
const teamRuntime = await resolveTeamRuntimeDetails(args.teamRunId, sessionID, config, deps)
|
||||
const message = MessageSchema.parse({
|
||||
version: 1,
|
||||
messageId: randomUUID(),
|
||||
@@ -242,7 +260,7 @@ export function createTeamSendMessageTool(config: TeamModeConfig, client: LiveDe
|
||||
throw new BroadcastNotPermittedError()
|
||||
}
|
||||
|
||||
const runtimeState = await loadRuntimeState(teamRuntime.teamRunId, config)
|
||||
const runtimeState = await deps.loadRuntimeState(teamRuntime.teamRunId, config)
|
||||
const reservedRecipients = new Set<string>(
|
||||
runtimeState.members
|
||||
.filter((member) => member.sessionId !== undefined && member.name !== teamRuntime.senderName)
|
||||
@@ -256,7 +274,7 @@ export function createTeamSendMessageTool(config: TeamModeConfig, client: LiveDe
|
||||
})
|
||||
|
||||
try {
|
||||
await deliverLive(client, message, teamRuntime.teamRunId, result.deliveredTo, config, targetDirectory)
|
||||
await deliverLive(client, message, teamRuntime.teamRunId, result.deliveredTo, config, targetDirectory, deps)
|
||||
} catch (liveError) {
|
||||
log("[team-mailbox] deliverLive top-level error (message already in inbox, safe to ignore)", {
|
||||
error: liveError instanceof Error ? liveError.message : String(liveError),
|
||||
|
||||
@@ -24,21 +24,12 @@ let listActiveTeamsImplementation: typeof import("../team-state-store/store").li
|
||||
throw new Error("listActiveTeamsImplementation not set")
|
||||
}
|
||||
|
||||
mock.module("../team-runtime/status", () => ({
|
||||
const deps = {
|
||||
aggregateStatus: (...args: Parameters<typeof aggregateStatusImplementation>) => aggregateStatusImplementation(...args),
|
||||
}))
|
||||
|
||||
mock.module("../team-registry/paths", () => ({
|
||||
discoverTeamSpecs: (...args: Parameters<typeof discoverTeamSpecsImplementation>) => discoverTeamSpecsImplementation(...args),
|
||||
}))
|
||||
|
||||
mock.module("../team-registry/loader", () => ({
|
||||
loadTeamSpec: (...args: Parameters<typeof loadTeamSpecImplementation>) => loadTeamSpecImplementation(...args),
|
||||
}))
|
||||
|
||||
mock.module("../team-state-store/store", () => ({
|
||||
listActiveTeams: (...args: Parameters<typeof listActiveTeamsImplementation>) => listActiveTeamsImplementation(...args),
|
||||
}))
|
||||
}
|
||||
|
||||
import { createTeamListTool, createTeamStatusTool } from "./query"
|
||||
|
||||
@@ -64,7 +55,7 @@ describe("query tools", () => {
|
||||
teamName: "team-alpha",
|
||||
status: "active",
|
||||
createdAt: 1,
|
||||
members: [{ name: "worker", unreadMessages: 0 }],
|
||||
members: [{ name: "worker", status: "running", unreadMessages: 0 }],
|
||||
tasks: { pending: 0, claimed: 0, in_progress: 0, completed: 0, deleted: 0, total: 0 },
|
||||
shutdownRequests: [],
|
||||
concurrency: { runningOnSameModel: 0, queuedOnSameModel: 0 },
|
||||
@@ -76,7 +67,7 @@ describe("query tools", () => {
|
||||
expect(passedConfig).toBe(config)
|
||||
return expectedStatus
|
||||
}
|
||||
const tool = createTeamStatusTool(config, mockClient)
|
||||
const tool = createTeamStatusTool(config, mockClient, undefined, deps)
|
||||
|
||||
// when
|
||||
const result = JSON.parse(await tool.execute({ teamRunId: "team-run-1" }, createMockContext()))
|
||||
@@ -104,7 +95,7 @@ describe("query tools", () => {
|
||||
listActiveTeamsImplementation = async () => [
|
||||
{ teamRunId: "run-1", teamName: "bar", status: "active", memberCount: 3, scope: "user" },
|
||||
]
|
||||
const tool = createTeamListTool(config, mockClient)
|
||||
const tool = createTeamListTool(config, mockClient, deps)
|
||||
|
||||
// when
|
||||
const result = JSON.parse(await tool.execute({}, createMockContext()))
|
||||
|
||||
@@ -7,6 +7,20 @@ import { aggregateStatus } from "../team-runtime/status"
|
||||
import { discoverTeamSpecs } from "../team-registry/paths"
|
||||
import { listActiveTeams } from "../team-state-store/store"
|
||||
|
||||
type QueryToolDeps = {
|
||||
aggregateStatus: typeof aggregateStatus
|
||||
discoverTeamSpecs: typeof discoverTeamSpecs
|
||||
loadTeamSpec: typeof loadTeamSpec
|
||||
listActiveTeams: typeof listActiveTeams
|
||||
}
|
||||
|
||||
const defaultDeps: QueryToolDeps = {
|
||||
aggregateStatus,
|
||||
discoverTeamSpecs,
|
||||
loadTeamSpec,
|
||||
listActiveTeams,
|
||||
}
|
||||
|
||||
type TeamListScope = "user" | "project" | "all"
|
||||
|
||||
type TeamListEntry = {
|
||||
@@ -21,6 +35,7 @@ export function createTeamStatusTool(
|
||||
config: TeamModeConfig,
|
||||
client: OpencodeClient,
|
||||
backgroundManager?: Parameters<typeof aggregateStatus>[2],
|
||||
deps: QueryToolDeps = defaultDeps,
|
||||
): ToolDefinition {
|
||||
void client
|
||||
|
||||
@@ -29,11 +44,11 @@ export function createTeamStatusTool(
|
||||
args: {
|
||||
teamRunId: tool.schema.string().describe("Team run ID"),
|
||||
},
|
||||
execute: async (args: { teamRunId: string }) => JSON.stringify(await aggregateStatus(args.teamRunId, config, backgroundManager)),
|
||||
execute: async (args: { teamRunId: string }) => JSON.stringify(await deps.aggregateStatus(args.teamRunId, config, backgroundManager)),
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamListTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamListTool(config: TeamModeConfig, client: OpencodeClient, deps: QueryToolDeps = defaultDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -48,8 +63,8 @@ export function createTeamListTool(config: TeamModeConfig, client: OpencodeClien
|
||||
execute: async (args: { scope?: TeamListScope }) => {
|
||||
const scope = args.scope ?? "all"
|
||||
const projectRoot = process.cwd()
|
||||
const declaredTeamSpecs = await discoverTeamSpecs(config, projectRoot)
|
||||
const activeTeams = await listActiveTeams(config)
|
||||
const declaredTeamSpecs = await deps.discoverTeamSpecs(config, projectRoot)
|
||||
const activeTeams = await deps.listActiveTeams(config)
|
||||
|
||||
const filteredDeclaredTeamSpecs = scope === "all"
|
||||
? declaredTeamSpecs
|
||||
@@ -57,7 +72,7 @@ export function createTeamListTool(config: TeamModeConfig, client: OpencodeClien
|
||||
|
||||
const declaredTeamSpecsByName = new Map(
|
||||
await Promise.all(filteredDeclaredTeamSpecs.map(async (teamSpec) => {
|
||||
const loadedTeamSpec = await loadTeamSpec(teamSpec.name, config, projectRoot)
|
||||
const loadedTeamSpec = await deps.loadTeamSpec(teamSpec.name, config, projectRoot)
|
||||
return [teamSpec.name, loadedTeamSpec.members.length] as const
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -39,14 +39,14 @@ const loadRuntimeStateMock = mock(async (): Promise<RuntimeState> => ({
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("../team-state-store", () => ({ loadRuntimeState: loadRuntimeStateMock }))
|
||||
mock.module("../team-tasklist", () => ({
|
||||
const deps = {
|
||||
loadRuntimeState: loadRuntimeStateMock,
|
||||
createTask: createTaskMock,
|
||||
listTasks: listTasksMock,
|
||||
claimTask: claimTaskMock,
|
||||
updateTaskStatus: updateTaskStatusMock,
|
||||
getTask: getTaskMock,
|
||||
}))
|
||||
}
|
||||
|
||||
const {
|
||||
createTeamTaskCreateTool,
|
||||
@@ -96,10 +96,10 @@ describe("team task tools", () => {
|
||||
test("create -> list -> claim -> complete flow", async () => {
|
||||
// given
|
||||
const config = createConfig()
|
||||
const createTool = createTeamTaskCreateTool(config, mockClient)
|
||||
const listTool = createTeamTaskListTool(config, mockClient)
|
||||
const updateTool = createTeamTaskUpdateTool(config, mockClient)
|
||||
const getTool = createTeamTaskGetTool(config, mockClient)
|
||||
const createTool = createTeamTaskCreateTool(config, mockClient, deps)
|
||||
const listTool = createTeamTaskListTool(config, mockClient, deps)
|
||||
const updateTool = createTeamTaskUpdateTool(config, mockClient, deps)
|
||||
const getTool = createTeamTaskGetTool(config, mockClient, deps)
|
||||
|
||||
// when
|
||||
const created = JSON.parse(await createTool.execute({ teamRunId: "team-run-1", subject: "task one", description: "desc" }, createContext("member-session-a")))
|
||||
@@ -129,7 +129,7 @@ describe("team task tools", () => {
|
||||
// given
|
||||
const config = createConfig()
|
||||
updateTaskStatusMock.mockImplementationOnce(async () => { throw new Error("CrossOwnerUpdateError") })
|
||||
const updateTool = createTeamTaskUpdateTool(config, mockClient)
|
||||
const updateTool = createTeamTaskUpdateTool(config, mockClient, deps)
|
||||
|
||||
// when
|
||||
const result = updateTool.execute({ teamRunId: "team-run-1", taskId: "1", status: "in_progress", owner: "member-b" }, createContext("member-session-a"))
|
||||
@@ -142,7 +142,7 @@ describe("team task tools", () => {
|
||||
// given
|
||||
const config = createConfig()
|
||||
claimTaskMock.mockImplementationOnce(async () => { throw new Error("blocked by 2") })
|
||||
const updateTool = createTeamTaskUpdateTool(config, mockClient)
|
||||
const updateTool = createTeamTaskUpdateTool(config, mockClient, deps)
|
||||
|
||||
// when
|
||||
const result = updateTool.execute({ teamRunId: "team-run-1", taskId: "1", status: "claimed" }, createContext("member-session-a"))
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TeamModeConfig } from "../../../config/schema/team-mode"
|
||||
import type { OpencodeClient } from "../../../tools/delegate-task/types"
|
||||
import { loadRuntimeState } from "../team-state-store"
|
||||
import { createTask, getTask, listTasks, updateTaskStatus, claimTask } from "../team-tasklist"
|
||||
import type { RuntimeState, Task } from "../types"
|
||||
|
||||
type TeamTaskToolContext = ToolContext & {
|
||||
sessionID?: string
|
||||
@@ -39,8 +40,26 @@ type TeamTaskGetArgs = {
|
||||
taskId: string
|
||||
}
|
||||
|
||||
async function resolveSenderName(teamRunId: string, config: TeamModeConfig, sessionID: string | undefined): Promise<string> {
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
type TeamTaskToolDeps = {
|
||||
loadRuntimeState: typeof loadRuntimeState
|
||||
createTask: typeof createTask
|
||||
listTasks: typeof listTasks
|
||||
claimTask: typeof claimTask
|
||||
updateTaskStatus: typeof updateTaskStatus
|
||||
getTask: typeof getTask
|
||||
}
|
||||
|
||||
const defaultDeps: TeamTaskToolDeps = {
|
||||
loadRuntimeState,
|
||||
createTask,
|
||||
listTasks,
|
||||
claimTask,
|
||||
updateTaskStatus,
|
||||
getTask,
|
||||
}
|
||||
|
||||
async function resolveSenderName(teamRunId: string, config: TeamModeConfig, sessionID: string | undefined, deps: TeamTaskToolDeps): Promise<string> {
|
||||
const runtimeState: RuntimeState = await deps.loadRuntimeState(teamRunId, config)
|
||||
const matchedMember = runtimeState.members.find((member) => member.sessionId === sessionID)
|
||||
if (matchedMember) return matchedMember.name
|
||||
|
||||
@@ -50,7 +69,7 @@ async function resolveSenderName(teamRunId: string, config: TeamModeConfig, sess
|
||||
throw new Error(`team member not found for session ${sessionID ?? "unknown"}`)
|
||||
}
|
||||
|
||||
export function createTeamTaskCreateTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamTaskCreateTool(config: TeamModeConfig, client: OpencodeClient, deps: TeamTaskToolDeps = defaultDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -62,7 +81,7 @@ export function createTeamTaskCreateTool(config: TeamModeConfig, client: Opencod
|
||||
blockedBy: tool.schema.array(tool.schema.string()).optional().describe("Blocking task IDs"),
|
||||
},
|
||||
execute: async (args: TeamTaskCreateArgs): Promise<string> => {
|
||||
const createdTask = await createTask(args.teamRunId, {
|
||||
const createdTask: Task = await deps.createTask(args.teamRunId, {
|
||||
subject: args.subject,
|
||||
description: args.description,
|
||||
blocks: [],
|
||||
@@ -75,7 +94,7 @@ export function createTeamTaskCreateTool(config: TeamModeConfig, client: Opencod
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamTaskListTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamTaskListTool(config: TeamModeConfig, client: OpencodeClient, deps: TeamTaskToolDeps = defaultDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -86,13 +105,13 @@ export function createTeamTaskListTool(config: TeamModeConfig, client: OpencodeC
|
||||
owner: tool.schema.string().optional(),
|
||||
},
|
||||
execute: async (args: TeamTaskListArgs): Promise<string> => {
|
||||
const tasks = await listTasks(args.teamRunId, config, { status: args.status, owner: args.owner })
|
||||
const tasks = await deps.listTasks(args.teamRunId, config, { status: args.status, owner: args.owner })
|
||||
return JSON.stringify({ tasks })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamTaskUpdateTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamTaskUpdateTool(config: TeamModeConfig, client: OpencodeClient, deps: TeamTaskToolDeps = defaultDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -104,18 +123,18 @@ export function createTeamTaskUpdateTool(config: TeamModeConfig, client: Opencod
|
||||
owner: tool.schema.string().optional().describe("Task owner"),
|
||||
},
|
||||
execute: async (args: TeamTaskUpdateArgs, ctx?: TeamTaskToolContext): Promise<string> => {
|
||||
const senderName = await resolveSenderName(args.teamRunId, config, ctx?.sessionID)
|
||||
const senderName = await resolveSenderName(args.teamRunId, config, ctx?.sessionID, deps)
|
||||
|
||||
const updatedTask = args.status === "claimed"
|
||||
? await claimTask(args.teamRunId, args.taskId, senderName, config)
|
||||
: await updateTaskStatus(args.teamRunId, args.taskId, args.status, args.owner ?? senderName, config)
|
||||
? await deps.claimTask(args.teamRunId, args.taskId, senderName, config)
|
||||
: await deps.updateTaskStatus(args.teamRunId, args.taskId, args.status, args.owner ?? senderName, config)
|
||||
|
||||
return JSON.stringify({ task: updatedTask })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamTaskGetTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamTaskGetTool(config: TeamModeConfig, client: OpencodeClient, deps: TeamTaskToolDeps = defaultDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -125,7 +144,7 @@ export function createTeamTaskGetTool(config: TeamModeConfig, client: OpencodeCl
|
||||
taskId: tool.schema.string().describe("Task ID"),
|
||||
},
|
||||
execute: async (args: TeamTaskGetArgs): Promise<string> => {
|
||||
const task = await getTask(args.teamRunId, args.taskId, config)
|
||||
const task = await deps.getTask(args.teamRunId, args.taskId, config)
|
||||
return JSON.stringify({ task })
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user