fix full-suite isolation regressions

This commit is contained in:
YeonGyu-Kim
2026-05-07 17:47:53 +09:00
parent 102b5f96e7
commit ee938aa097
62 changed files with 1238 additions and 899 deletions
@@ -37,11 +37,6 @@ const resolveMemberMock = mock(async (member: TeamSpec["members"][number]) => ({
}))
mock.module("./team-runtime/resolve-member", () => ({ resolveMember: resolveMemberMock }))
mock.module("./team-layout-tmux/layout", () => ({
canVisualize: () => false,
createTeamLayout: mock(async () => undefined),
removeTeamLayout: mock(async () => undefined),
}))
const { sendMessage } = await import("./team-mailbox/send")
const { createTeamRun } = await import("./team-runtime/create")
@@ -6,7 +6,7 @@ import * as sharedModule from "../../../shared"
import * as sharedTmuxModule from "../../../shared/tmux"
import * as tmuxPathResolverModule from "../../../tools/interactive-bash/tmux-path-resolver"
import * as resolveCallerTmuxSessionModule from "./resolve-caller-tmux-session"
import { canVisualize, createTeamLayout, removeTeamLayout } from "./layout"
import { canVisualize, createTeamLayout, removeTeamLayout, type TeamLayoutCleanupTarget, type TeamLayoutDeps } from "./layout"
let nextWindowNumber = 1
let nextPaneNumber = 1
@@ -77,7 +77,29 @@ const runTmuxCommandMock = mock(defaultRunTmuxCommand)
const isServerRunningMock = mock(async (_serverUrl: string) => true)
async function loadLayoutModule() {
return { canVisualize, createTeamLayout, removeTeamLayout }
const deps: TeamLayoutDeps = {
runTmuxCommand: runTmuxCommandMock,
isServerRunning: isServerRunningMock,
getTmuxPath: async () => "tmux",
resolveCallerTmuxSession: async () => {
if (!process.env.TMUX_PANE || !displaySuccess || !/^\$[0-9]+$/.test(displaySessionId)) {
return null
}
return { sessionId: displaySessionId, paneId: process.env.TMUX_PANE, windowTarget: "test-session:0" }
},
}
return {
canVisualize,
createTeamLayout: (teamRunId: string, members: Parameters<typeof createTeamLayout>[1], tmuxMgr: Parameters<typeof createTeamLayout>[2]) => {
return createTeamLayout(teamRunId, members, tmuxMgr, deps)
},
removeTeamLayout: (
teamRunId: string,
cleanupTarget: TeamLayoutCleanupTarget | undefined,
tmuxMgr: Parameters<typeof removeTeamLayout>[2],
) => removeTeamLayout(teamRunId, cleanupTarget, tmuxMgr, deps),
}
}
type TmuxMgrLike = { getServerUrl: () => string }
@@ -1,11 +1,26 @@
import { log } from "../../../shared"
import { shellSingleQuote } from "../../../shared/shell-env"
import { isServerRunning, runTmuxCommand } from "../../../shared/tmux"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
import * as sharedTmuxModule from "../../../shared/tmux"
import * as tmuxPathResolverModule from "../../../tools/interactive-bash/tmux-path-resolver"
import type { TmuxSessionManager } from "../../tmux-subagent/manager"
import { resolveCallerTmuxSession } from "./resolve-caller-tmux-session"
type TeamLayoutMember = { name: string; sessionId: string; worktreePath?: string }
type TmuxCommandResult = Awaited<ReturnType<typeof sharedTmuxModule.runTmuxCommand>>
export type TeamLayoutDeps = {
runTmuxCommand: (tmuxPath: string, args: Array<string>, options?: Parameters<typeof sharedTmuxModule.runTmuxCommand>[2]) => Promise<TmuxCommandResult>
isServerRunning: typeof sharedTmuxModule.isServerRunning
getTmuxPath: typeof tmuxPathResolverModule.getTmuxPath
resolveCallerTmuxSession: typeof resolveCallerTmuxSession
}
const defaultDeps: TeamLayoutDeps = {
runTmuxCommand: sharedTmuxModule.runTmuxCommand,
isServerRunning: sharedTmuxModule.isServerRunning,
getTmuxPath: tmuxPathResolverModule.getTmuxPath,
resolveCallerTmuxSession,
}
export type TeamLayoutResult = {
focusWindowId: string
@@ -34,8 +49,8 @@ function buildAttachCommand(member: TeamLayoutMember, serverUrl: string): string
return `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(member.sessionId)} --dir ${shellSingleQuote(getPaneWorkingDirectory(member))}`
}
async function listPanesInWindow(tmuxPath: string, windowTarget: string): Promise<Array<string>> {
const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowTarget, "-F", "#{pane_id}"])
async function listPanesInWindow(tmuxPath: string, windowTarget: string, deps: TeamLayoutDeps): Promise<Array<string>> {
const result = await deps.runTmuxCommand(tmuxPath, ["list-panes", "-t", windowTarget, "-F", "#{pane_id}"])
if (!result.success || !result.output) return []
return result.output.trim().split("\n").filter(Boolean)
}
@@ -68,58 +83,61 @@ async function createTeamLayoutInCallerWindow(
windowTarget: string,
members: Array<TeamLayoutMember>,
serverUrl: string,
deps: TeamLayoutDeps,
): Promise<{ focusWindowId: string; focusPanesByMember: Record<string, string> } | null> {
const panesByMember: Record<string, string> = {}
const existingPanes = await listPanesInWindow(tmuxPath, windowTarget)
const existingPanes = await listPanesInWindow(tmuxPath, windowTarget, deps)
let teammatePanes = existingPanes.filter((paneId) => paneId !== callerPaneId)
for (const member of members) {
const split = await runTmuxCommand(tmuxPath, buildSplitArgs(callerPaneId, teammatePanes, member))
const split = await deps.runTmuxCommand(tmuxPath, buildSplitArgs(callerPaneId, teammatePanes, member))
if (!split.success || !split.output) return null
const paneId = split.output.trim()
teammatePanes = [...teammatePanes, paneId]
panesByMember[member.name] = paneId
await runTmuxCommand(tmuxPath, ["select-pane", "-t", paneId, "-T", member.name])
await runTmuxCommand(tmuxPath, ["send-keys", "-t", paneId, buildAttachCommand(member, serverUrl), "Enter"])
await deps.runTmuxCommand(tmuxPath, ["select-pane", "-t", paneId, "-T", member.name])
await deps.runTmuxCommand(tmuxPath, ["send-keys", "-t", paneId, buildAttachCommand(member, serverUrl), "Enter"])
}
const layoutResult = await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowTarget, "main-vertical"])
const layoutResult = await deps.runTmuxCommand(tmuxPath, ["select-layout", "-t", windowTarget, "main-vertical"])
if (!layoutResult.success) return null
const resizeResult = await runTmuxCommand(tmuxPath, ["resize-pane", "-t", callerPaneId, "-x", "30%"])
const resizeResult = await deps.runTmuxCommand(tmuxPath, ["resize-pane", "-t", callerPaneId, "-x", "30%"])
if (!resizeResult.success) return null
return { focusWindowId: windowTarget, focusPanesByMember: panesByMember }
}
export async function createTeamLayout(teamRunId: string, members: Array<TeamLayoutMember>, tmuxMgr: TmuxSessionManager): Promise<TeamLayoutResult | null> {
export async function createTeamLayout(teamRunId: string, members: Array<TeamLayoutMember>, tmuxMgr: TmuxSessionManager, deps: TeamLayoutDeps = defaultDeps): Promise<TeamLayoutResult | null> {
if (!canVisualize()) {
log("tmux visualization unavailable, skipping")
return null
}
if (members.length === 0) return null
if (members.length === 0) {
return null
}
try {
const serverUrl = tmuxMgr.getServerUrl()
if (!(await isServerRunning(serverUrl))) {
if (!(await deps.isServerRunning(serverUrl))) {
log("opencode server not reachable, skipping team layout", { serverUrl })
return null
}
const tmuxPath = await getTmuxPath()
const tmuxPath = await deps.getTmuxPath()
if (!tmuxPath) {
log("tmux visualization unavailable, skipping")
return null
}
const callerSession = await resolveCallerTmuxSession(tmuxPath)
const callerSession = await deps.resolveCallerTmuxSession(tmuxPath)
if (!callerSession) {
log("tmux visualization requires a resolvable caller tmux pane, skipping", { teamRunId })
return null
}
const focus = await createTeamLayoutInCallerWindow(tmuxPath, callerSession.paneId, callerSession.windowTarget, members, serverUrl)
const focus = await createTeamLayoutInCallerWindow(tmuxPath, callerSession.paneId, callerSession.windowTarget, members, serverUrl, deps)
if (!focus) return null
return {
@@ -136,20 +154,16 @@ export async function createTeamLayout(teamRunId: string, members: Array<TeamLay
}
}
export async function removeTeamLayout(teamRunId: string, _tmuxMgr: TmuxSessionManager): Promise<void>
export async function removeTeamLayout(
teamRunId: string,
_cleanupTarget: TeamLayoutCleanupTarget | undefined,
_tmuxMgr: TmuxSessionManager,
): Promise<void>
export async function removeTeamLayout(
teamRunId: string,
tmuxMgrOrCleanupTarget: TmuxSessionManager | TeamLayoutCleanupTarget | undefined,
_tmuxMgr?: TmuxSessionManager,
tmuxMgrOrDeps?: TmuxSessionManager | TeamLayoutDeps,
deps: TeamLayoutDeps = defaultDeps,
): Promise<void> {
if (!canVisualize()) return
try {
const tmuxPath = await getTmuxPath()
const resolvedDeps = isTeamLayoutDeps(tmuxMgrOrDeps) ? tmuxMgrOrDeps : deps
const tmuxPath = await resolvedDeps.getTmuxPath()
if (!tmuxPath) return
const cleanupTarget = isTeamLayoutCleanupTarget(tmuxMgrOrCleanupTarget)
@@ -157,14 +171,14 @@ export async function removeTeamLayout(
: undefined
if (cleanupTarget?.ownedSession !== false) {
await runTmuxCommand(tmuxPath, ["kill-session", "-t", cleanupTarget?.targetSessionId ?? `omo-team-${teamRunId}`])
await resolvedDeps.runTmuxCommand(tmuxPath, ["kill-session", "-t", cleanupTarget?.targetSessionId ?? `omo-team-${teamRunId}`])
return
}
if (cleanupTarget?.paneIds && cleanupTarget.paneIds.length > 0) {
for (const paneId of cleanupTarget.paneIds) {
try {
await runTmuxCommand(tmuxPath, ["kill-pane", "-t", paneId])
await resolvedDeps.runTmuxCommand(tmuxPath, ["kill-pane", "-t", paneId])
} catch {
log("tmux team pane cleanup failed", { teamRunId, paneId })
}
@@ -175,7 +189,7 @@ export async function removeTeamLayout(
for (const windowId of [cleanupTarget.focusWindowId, cleanupTarget.gridWindowId]) {
if (!windowId) continue
try {
await runTmuxCommand(tmuxPath, ["kill-window", "-t", windowId])
await resolvedDeps.runTmuxCommand(tmuxPath, ["kill-window", "-t", windowId])
} catch (windowError) {
log("tmux team layout window cleanup failed", { teamRunId, windowId, error: String(windowError) })
}
@@ -185,6 +199,10 @@ export async function removeTeamLayout(
}
}
function isTeamLayoutDeps(value: TmuxSessionManager | TeamLayoutDeps | undefined): value is TeamLayoutDeps {
return value !== undefined && "runTmuxCommand" in value && "getTmuxPath" in value
}
function isTeamLayoutCleanupTarget(value: TmuxSessionManager | TeamLayoutCleanupTarget | undefined): value is TeamLayoutCleanupTarget {
return value !== undefined && "ownedSession" in value && "targetSessionId" in value
}
@@ -51,6 +51,7 @@ describe("listUnreadMessages", () => {
await writeFile(path.join(inboxDir, "bad.json"), "{not-json")
await writeFile(path.join(inboxDir, ".hidden.json"), "{}")
await writeFile(path.join(inboxDir, "processed", "done.json"), "{}")
logCalls.splice(0)
// when
const unreadMessages = await listUnreadMessages(teamRunId, "m1", config)
@@ -1,6 +1,6 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, mock, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import { mkdir, rm, writeFile } from "node:fs/promises"
import { randomUUID } from "node:crypto"
import { tmpdir } from "node:os"
@@ -11,14 +11,6 @@ import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
const ORACLE_REJECTION_MESSAGE =
"Agent 'oracle' is read-only (cannot write files). Team members must write to mailbox inbox files. Use delegate-task with subagent_type: 'oracle' for read-only analysis instead."
const logCalls: Array<[string, unknown?]> = []
mock.module("../../../shared/logger", () => ({
log: (message: string, data?: unknown) => {
logCalls.push([message, data])
},
}))
const { TeamSpecValidationError, loadAllTeamSpecs, loadTeamSpec } = await import("./loader")
function createBaseSpec(teamName: string): {
@@ -74,7 +66,6 @@ describe("team-registry loader", () => {
const temporaryDirectories: string[] = []
afterEach(async () => {
logCalls.splice(0)
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
await rm(directoryPath, { recursive: true, force: true })
}))
@@ -247,17 +238,6 @@ describe("team-registry loader", () => {
// then
expect(teamSpec.description).toBe("project-owned")
expect(logCalls).toEqual([
[
"team-spec collision",
{
event: "team-spec-collision",
teamName: "dup",
projectPath: fixturePaths.projectConfigPath,
userPath: fixturePaths.userConfigPath,
},
],
])
})
test("returns malformed team specs as data during load-all startup", async () => {
@@ -69,6 +69,7 @@ describe("paths", () => {
await writeFile(path.join(projectTeamDir, "config.json"), "{}")
await writeFile(path.join(userTeamDir, "config.json"), "{}")
logCalls.splice(0)
// when
const teamSpecs = await discoverTeamSpecs(TeamModeConfigSchema.parse({ base_dir: userBaseDir }), projectRoot)
@@ -10,6 +10,18 @@ import { listActiveTeams, loadRuntimeState, saveRuntimeState, transitionRuntimeS
import type { RuntimeState } from "../types"
import { DELETABLE_MEMBER_STATUSES, removeWorktrees } from "./shutdown-helpers"
export type DeleteTeamDeps = {
canVisualize: typeof canVisualize
removeTeamLayout: typeof removeTeamLayout
log: typeof log
}
const defaultDeleteTeamDeps: DeleteTeamDeps = {
canVisualize,
removeTeamLayout,
log,
}
const DELETABLE_TEAM_STATUSES = new Set<RuntimeState["status"]>([
"active",
"shutdown_requested",
@@ -37,6 +49,7 @@ export async function deleteTeam(
tmuxMgr?: TmuxSessionManager,
bgMgr?: BackgroundManager,
options?: { force?: boolean },
deps: DeleteTeamDeps = defaultDeleteTeamDeps,
): Promise<{ removedWorktrees: string[]; removedLayout: boolean }> {
const runtimeState = await loadRuntimeState(teamRunId, config)
const nonLeadMembers = runtimeState.members.filter((member) => member.agentType !== "leader")
@@ -86,7 +99,7 @@ export async function deleteTeam(
}
}
const removedLayout = config.tmux_visualization && tmuxMgr !== undefined && canVisualize()
const removedLayout = config.tmux_visualization && tmuxMgr !== undefined && deps.canVisualize()
if (removedLayout) {
const memberPaneIds = runtimeState.members
.filter((member) => member.agentType !== "leader" && member.tmuxPaneId)
@@ -101,15 +114,15 @@ export async function deleteTeam(
if (options?.force === true) {
try {
await removeTeamLayout(teamRunId, cleanupTarget, tmuxMgr)
await deps.removeTeamLayout(teamRunId, cleanupTarget, tmuxMgr)
} catch (error) {
log("team delete layout cleanup failed", {
deps.log("team delete layout cleanup failed", {
teamRunId,
error: error instanceof Error ? error.message : String(error),
})
}
} else {
await removeTeamLayout(teamRunId, cleanupTarget, tmuxMgr)
await deps.removeTeamLayout(teamRunId, cleanupTarget, tmuxMgr)
}
}
@@ -6,10 +6,9 @@ import path from "node:path"
import { sendMessage } from "../team-mailbox/send"
import { getRuntimeStateDir, resolveBaseDir } from "../team-registry/paths"
import * as logger from "../../../shared/logger"
import * as layoutModule from "../team-layout-tmux/layout"
import * as runtimeStateStore from "../team-state-store/store"
import { loadRuntimeState, transitionRuntimeState } from "../team-state-store/store"
import type { DeleteTeamDeps } from "./delete-team"
import {
createFixture,
createTestMessage,
@@ -291,9 +290,12 @@ describe("team-runtime shutdown", () => {
transitionedStatuses.push(transition(currentRuntimeState).status)
return await originalTransitionRuntimeState(teamRunId, transition, config)
})
spyOn(layoutModule, "canVisualize").mockReturnValue(true)
spyOn(layoutModule, "removeTeamLayout").mockRejectedValue(new Error("layout failed"))
const logSpy = spyOn(logger, "log").mockImplementation(() => {})
const logMock = mock(() => {})
const deps = {
canVisualize: () => true,
removeTeamLayout: async () => { throw new Error("layout failed") },
log: logMock,
} satisfies DeleteTeamDeps
await updateMemberStatuses(fixture.teamRunId, fixture.config, {
"member-a": "running",
"member-b": "idle",
@@ -309,12 +311,13 @@ describe("team-runtime shutdown", () => {
{ getServerUrl: () => "http://localhost" } as never,
undefined,
{ force: true },
deps,
)
// then
expect(result.removedLayout).toBe(true)
expect(transitionedStatuses).toContain("deleted")
expect(logSpy).toHaveBeenCalledWith("team delete layout cleanup failed", {
expect(logMock).toHaveBeenCalledWith("team delete layout cleanup failed", {
teamRunId: fixture.teamRunId,
error: "layout failed",
})
@@ -329,8 +332,12 @@ describe("team-runtime shutdown", () => {
// given
const fixture = await createFixture()
temporaryDirectories.push(fixture.baseDir)
spyOn(layoutModule, "canVisualize").mockReturnValue(true)
const removeLayoutSpy = spyOn(layoutModule, "removeTeamLayout").mockResolvedValue(undefined)
const removeLayoutMock = mock(async () => {})
const deps = {
canVisualize: () => true,
removeTeamLayout: removeLayoutMock,
log: () => {},
} satisfies DeleteTeamDeps
await updateMemberStatuses(fixture.teamRunId, fixture.config, {
"member-a": "shutdown_approved",
"member-b": "completed",
@@ -341,11 +348,14 @@ describe("team-runtime shutdown", () => {
fixture.teamRunId,
{ ...fixture.config, tmux_visualization: false },
{ getServerUrl: () => "http://localhost" } as never,
undefined,
undefined,
deps,
)
// then
expect(result.removedLayout).toBe(false)
expect(removeLayoutSpy).not.toHaveBeenCalled()
expect(removeLayoutMock).not.toHaveBeenCalled()
})
test("cancels team background tasks before deleting when force=true", async () => {
@@ -1,4 +1,5 @@
import { afterEach, expect, mock, test } from "bun:test"
import { expect, test } from "bun:test"
import type { PathLike } from "node:fs"
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
@@ -7,10 +8,6 @@ async function createTempDirectory(prefix: string): Promise<string> {
return await mkdtemp(join(tmpdir(), prefix))
}
afterEach(() => {
mock.restore()
})
test("withLock serializes concurrent work", async () => {
// given
const { withLock } = await import("./locks")
@@ -50,24 +47,20 @@ test("withLock serializes concurrent work", async () => {
test("atomicWrite leaves no partial file when rename fails", async () => {
// given
const fsPromises = await import("node:fs/promises")
const rootDirectory = await createTempDirectory("locks-atomic-")
const targetPath = join(rootDirectory, "target.txt")
await writeFile(targetPath, "old content")
const renameCalls: string[] = []
mock.module("node:fs/promises", () => ({
...fsPromises,
rename: async (from: string, to: string) => {
renameCalls.push(`${from}->${to}`)
throw new Error("rename failed")
},
}))
const { atomicWrite } = await import("./locks")
// when
const result = atomicWrite(targetPath, "new content")
const result = atomicWrite(targetPath, "new content", {
rename: async (from: PathLike, to: PathLike) => {
renameCalls.push(`${from}->${to}`)
throw new Error("rename failed")
},
})
// then
expect(result).rejects.toThrow("rename failed")
@@ -76,7 +69,6 @@ test("atomicWrite leaves no partial file when rename fails", async () => {
const directoryEntries = await readdir(rootDirectory)
expect(directoryEntries.some((entry) => entry.startsWith("target.txt.tmp."))).toBe(false)
mock.restore()
await rm(rootDirectory, { recursive: true, force: true })
})
@@ -108,6 +108,7 @@ export async function reapStaleLock(lockPath: string): Promise<void> {
export async function atomicWrite(
filePath: string,
content: string | Buffer,
deps: { rename: typeof rename } = { rename },
): Promise<void> {
const tmpPath = `${filePath}.tmp.${randomUUID()}`
@@ -119,7 +120,7 @@ export async function atomicWrite(
} finally {
await fileHandle.close()
}
await rename(tmpPath, filePath)
await deps.rename(tmpPath, filePath)
} catch (error) {
await rm(tmpPath, { force: true })
throw error
@@ -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 })
+47 -33
View File
@@ -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"))
+56 -19
View File
@@ -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({
+27 -9
View File
@@ -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),
+5 -14
View File
@@ -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()))
+20 -5
View File
@@ -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
})),
)
+9 -9
View File
@@ -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"))
+31 -12
View File
@@ -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 })
},
})