feat(team-mode): add team runtime layout activation with tests
This commit is contained in:
@@ -0,0 +1,166 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
|
|
||||||
|
import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
|
||||||
|
import * as layoutModule from "../team-layout-tmux/layout"
|
||||||
|
import * as storeModule from "../team-state-store/store"
|
||||||
|
import { RuntimeStateSchema, type RuntimeState } from "../types"
|
||||||
|
import { activateTeamLayout } from "./activate-team-layout"
|
||||||
|
|
||||||
|
let createTeamLayoutSpy: ReturnType<typeof spyOn<typeof layoutModule, "createTeamLayout">>
|
||||||
|
let transitionRuntimeStateSpy: ReturnType<typeof spyOn<typeof storeModule, "transitionRuntimeState">>
|
||||||
|
|
||||||
|
function createRuntimeState() {
|
||||||
|
return RuntimeStateSchema.parse({
|
||||||
|
version: 1,
|
||||||
|
teamRunId: crypto.randomUUID(),
|
||||||
|
teamName: "alpha-team",
|
||||||
|
specSource: "project",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
status: "creating",
|
||||||
|
leadSessionId: "ses-lead",
|
||||||
|
shutdownRequests: [],
|
||||||
|
bounds: {
|
||||||
|
maxMembers: 8,
|
||||||
|
maxParallelMembers: 4,
|
||||||
|
maxMessagesPerRun: 10000,
|
||||||
|
maxWallClockMinutes: 120,
|
||||||
|
maxMemberTurns: 500,
|
||||||
|
},
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: "lead",
|
||||||
|
sessionId: "ses-lead",
|
||||||
|
tmuxPaneId: undefined,
|
||||||
|
agentType: "leader",
|
||||||
|
status: "running",
|
||||||
|
pendingInjectedMessageIds: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "member-a",
|
||||||
|
sessionId: "ses-member-a",
|
||||||
|
tmuxPaneId: undefined,
|
||||||
|
agentType: "general-purpose",
|
||||||
|
status: "running",
|
||||||
|
pendingInjectedMessageIds: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConfig(tmuxVisualization: boolean) {
|
||||||
|
return TeamModeConfigSchema.parse({ enabled: true, tmux_visualization: tmuxVisualization })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("activateTeamLayout", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
createTeamLayoutSpy = spyOn(layoutModule, "createTeamLayout")
|
||||||
|
createTeamLayoutSpy.mockResolvedValue(null)
|
||||||
|
transitionRuntimeStateSpy = spyOn(storeModule, "transitionRuntimeState")
|
||||||
|
transitionRuntimeStateSpy.mockImplementation(async (
|
||||||
|
_teamRunId,
|
||||||
|
transition,
|
||||||
|
_config,
|
||||||
|
): Promise<RuntimeState> => transition(createRuntimeState()))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given a leader and one member #when activateTeamLayout runs #then it excludes the leader from layout members and only persists panes for non-leaders", async () => {
|
||||||
|
// given
|
||||||
|
const runtimeState = createRuntimeState()
|
||||||
|
createTeamLayoutSpy.mockResolvedValue({
|
||||||
|
focusWindowId: "@10",
|
||||||
|
gridWindowId: "@11",
|
||||||
|
focusPanesByMember: { "member-a": "%11" },
|
||||||
|
gridPanesByMember: { "member-a": "%21" },
|
||||||
|
targetSessionId: "$caller",
|
||||||
|
ownedSession: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await activateTeamLayout(
|
||||||
|
runtimeState,
|
||||||
|
createConfig(true),
|
||||||
|
"/project",
|
||||||
|
{ getServerUrl: () => "http://127.0.0.1:12345" } as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(createTeamLayoutSpy).toHaveBeenCalledTimes(1)
|
||||||
|
const createLayoutCall = createTeamLayoutSpy.mock.calls[0]
|
||||||
|
expect(createLayoutCall?.[1]).toEqual([
|
||||||
|
{
|
||||||
|
name: "member-a",
|
||||||
|
sessionId: "ses-member-a",
|
||||||
|
color: undefined,
|
||||||
|
worktreePath: "/project",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(transitionRuntimeStateSpy).toHaveBeenCalledTimes(1)
|
||||||
|
const transitionCall = transitionRuntimeStateSpy.mock.calls[0]
|
||||||
|
if (!transitionCall) {
|
||||||
|
throw new Error("expected transitionRuntimeState to be called")
|
||||||
|
}
|
||||||
|
const [teamRunId, transition] = transitionCall
|
||||||
|
expect(teamRunId).toBe(runtimeState.teamRunId)
|
||||||
|
const nextState = transition(runtimeState)
|
||||||
|
expect(nextState.members).toEqual([
|
||||||
|
{
|
||||||
|
...runtimeState.members[0],
|
||||||
|
tmuxPaneId: undefined,
|
||||||
|
tmuxGridPaneId: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...runtimeState.members[1],
|
||||||
|
tmuxPaneId: "%11",
|
||||||
|
tmuxGridPaneId: "%21",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(nextState.tmuxLayout).toEqual({
|
||||||
|
ownedSession: false,
|
||||||
|
targetSessionId: "$caller",
|
||||||
|
focusWindowId: "@10",
|
||||||
|
gridWindowId: "@11",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given createTeamLayout returns null #when activateTeamLayout runs #then returns false and no state transition fires", async () => {
|
||||||
|
// given
|
||||||
|
const runtimeState = createRuntimeState()
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await activateTeamLayout(
|
||||||
|
runtimeState,
|
||||||
|
createConfig(true),
|
||||||
|
"/project",
|
||||||
|
{ getServerUrl: () => "http://127.0.0.1:12345" } as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
expect(transitionRuntimeStateSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given config.tmux_visualization is false #when activateTeamLayout runs #then it short-circuits, no state change, returns false", async () => {
|
||||||
|
// given
|
||||||
|
const runtimeState = createRuntimeState()
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = await activateTeamLayout(
|
||||||
|
runtimeState,
|
||||||
|
createConfig(false),
|
||||||
|
"/project",
|
||||||
|
{ getServerUrl: () => "http://127.0.0.1:12345" } as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe(false)
|
||||||
|
expect(createTeamLayoutSpy).not.toHaveBeenCalled()
|
||||||
|
expect(transitionRuntimeStateSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { TeamModeConfig } from "../../../config/schema/team-mode"
|
||||||
|
import type { TmuxSessionManager } from "../../tmux-subagent/manager"
|
||||||
|
import { createTeamLayout } from "../team-layout-tmux/layout"
|
||||||
|
import type { TeamLayoutResult } from "../team-layout-tmux/layout"
|
||||||
|
import type { RuntimeState } from "../types"
|
||||||
|
import { transitionRuntimeState } from "../team-state-store/store"
|
||||||
|
|
||||||
|
function normalizeTeamLayout(teamRunId: string, layout: TeamLayoutResult): TeamLayoutResult {
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
targetSessionId: layout.targetSessionId ?? `omo-team-${teamRunId}`,
|
||||||
|
ownedSession: layout.ownedSession ?? true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activateTeamLayout(
|
||||||
|
runtimeState: RuntimeState,
|
||||||
|
config: TeamModeConfig,
|
||||||
|
projectRoot: string,
|
||||||
|
tmuxMgr?: TmuxSessionManager,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!config.tmux_visualization || !tmuxMgr) return false
|
||||||
|
|
||||||
|
const layout = await createTeamLayout(
|
||||||
|
runtimeState.teamRunId,
|
||||||
|
runtimeState.members.flatMap((member) => member.sessionId && member.agentType !== "leader"
|
||||||
|
? [{
|
||||||
|
name: member.name,
|
||||||
|
sessionId: member.sessionId,
|
||||||
|
color: member.color,
|
||||||
|
worktreePath: member.worktreePath ?? projectRoot,
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
tmuxMgr,
|
||||||
|
)
|
||||||
|
if (!layout) return false
|
||||||
|
const normalizedLayout = normalizeTeamLayout(runtimeState.teamRunId, layout)
|
||||||
|
|
||||||
|
await transitionRuntimeState(runtimeState.teamRunId, (currentState) => ({
|
||||||
|
...currentState,
|
||||||
|
tmuxLayout: {
|
||||||
|
ownedSession: normalizedLayout.ownedSession,
|
||||||
|
targetSessionId: normalizedLayout.targetSessionId,
|
||||||
|
focusWindowId: normalizedLayout.focusWindowId,
|
||||||
|
gridWindowId: normalizedLayout.gridWindowId,
|
||||||
|
},
|
||||||
|
members: currentState.members.map((member) => ({
|
||||||
|
...member,
|
||||||
|
tmuxPaneId: normalizedLayout.focusPanesByMember[member.name] ?? member.tmuxPaneId,
|
||||||
|
tmuxGridPaneId: normalizedLayout.gridPanesByMember[member.name] ?? member.tmuxGridPaneId,
|
||||||
|
})),
|
||||||
|
}), config)
|
||||||
|
return true
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user