832 lines
30 KiB
TypeScript
832 lines
30 KiB
TypeScript
/// <reference types="bun-types" />
|
|
|
|
import { afterEach, describe, expect, test } from "bun:test"
|
|
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"
|
|
import { randomUUID } from "node:crypto"
|
|
import { tmpdir } from "node:os"
|
|
import path from "node:path"
|
|
|
|
import { type ToolContext } from "@opencode-ai/plugin/tool"
|
|
import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
|
|
import { _resetForTesting, registerAgentName } from "../../claude-code-session-state"
|
|
import { SessionCategoryRegistry } from "../../../shared/session-category-registry"
|
|
import {
|
|
clearAllSessionPromptParams,
|
|
getSessionPromptParams,
|
|
} from "../../../shared/session-prompt-params-state"
|
|
import { releaseAllPromptAsyncReservationsForTesting } from "../../../hooks/shared/prompt-async-gate"
|
|
import { listUnreadMessages } from "../team-mailbox/inbox"
|
|
import { BroadcastNotPermittedError } from "../team-mailbox/send"
|
|
import { getInboxDir, resolveBaseDir } from "../team-registry/paths"
|
|
import { createRuntimeState, saveRuntimeState } from "../team-state-store/store"
|
|
import { clearTeamSessionRegistry, registerTeamSession } from "../team-session-registry"
|
|
import type { Message } from "../types"
|
|
import { MessageSchema } from "../types"
|
|
import { createTeamIdleWakeHint } from "../../../hooks/team-session-events/team-idle-wake-hint"
|
|
import { createTeamSendMessageTool } from "./messaging"
|
|
|
|
type PromptAsyncCall = {
|
|
sessionId: string
|
|
parts: Array<{ type: string; text?: string }>
|
|
agent?: string
|
|
model?: { providerID: string; modelID: string }
|
|
variant?: string
|
|
directory?: string
|
|
}
|
|
|
|
type LiveDeliveryClient = {
|
|
session: {
|
|
promptAsync(input: {
|
|
path: { id: string }
|
|
body: {
|
|
parts: Array<{ type: "text"; text: string }>
|
|
agent?: string
|
|
model?: { providerID: string; modelID: string }
|
|
variant?: string
|
|
}
|
|
query?: { directory: string }
|
|
}): Promise<unknown>
|
|
}
|
|
}
|
|
|
|
function createRecordingClient(): { client: LiveDeliveryClient; calls: PromptAsyncCall[] } {
|
|
const calls: PromptAsyncCall[] = []
|
|
const client = {
|
|
session: {
|
|
promptAsync: async (input: {
|
|
path: { id: string }
|
|
body: {
|
|
parts: Array<{ type: "text"; text: string }>
|
|
agent?: string
|
|
model?: { providerID: string; modelID: string }
|
|
variant?: string
|
|
}
|
|
query?: { directory: string }
|
|
}) => {
|
|
calls.push({
|
|
sessionId: input.path.id,
|
|
parts: input.body.parts,
|
|
agent: input.body.agent,
|
|
model: input.body.model,
|
|
variant: input.body.variant,
|
|
directory: input.query?.directory,
|
|
})
|
|
return undefined
|
|
},
|
|
},
|
|
}
|
|
return { client, calls }
|
|
}
|
|
|
|
const mockClient: LiveDeliveryClient = {
|
|
session: {
|
|
promptAsync: async () => { throw new Error("live delivery disabled in fixture") },
|
|
},
|
|
}
|
|
|
|
afterEach(() => {
|
|
clearTeamSessionRegistry()
|
|
SessionCategoryRegistry.clear()
|
|
clearAllSessionPromptParams()
|
|
releaseAllPromptAsyncReservationsForTesting()
|
|
_resetForTesting()
|
|
})
|
|
|
|
async function createFixtureBaseDir(): Promise<string> {
|
|
return await mkdtemp(path.join(tmpdir(), "team-send-message-"))
|
|
}
|
|
|
|
function createConfig(baseDir: string) {
|
|
return TeamModeConfigSchema.parse({ base_dir: baseDir })
|
|
}
|
|
|
|
function createToolContext(sessionID: string, directory: string): ToolContext {
|
|
return {
|
|
sessionID,
|
|
messageID: randomUUID(),
|
|
agent: "test-agent",
|
|
directory,
|
|
worktree: directory,
|
|
abort: new AbortController().signal,
|
|
metadata: () => {},
|
|
ask: async () => undefined,
|
|
}
|
|
}
|
|
|
|
async function createTeamFixture() {
|
|
const baseDir = await createFixtureBaseDir()
|
|
const config = createConfig(baseDir)
|
|
const leadSessionId = randomUUID()
|
|
const memberOneSessionId = randomUUID()
|
|
const memberTwoSessionId = randomUUID()
|
|
|
|
const runtimeState = await createRuntimeState(
|
|
{
|
|
version: 1,
|
|
name: "team-alpha",
|
|
createdAt: Date.now(),
|
|
leadAgentId: "team-lead",
|
|
members: [
|
|
{ kind: "subagent_type", name: "team-lead", subagent_type: "sisyphus-junior", backendType: "in-process", isActive: true },
|
|
{ kind: "subagent_type", name: "m1", subagent_type: "sisyphus-junior", backendType: "in-process", isActive: true },
|
|
{ kind: "subagent_type", name: "m2", subagent_type: "sisyphus-junior", backendType: "in-process", isActive: true },
|
|
],
|
|
},
|
|
leadSessionId,
|
|
"project",
|
|
config,
|
|
)
|
|
|
|
runtimeState.leadSessionId = leadSessionId
|
|
runtimeState.members[0].sessionId = leadSessionId
|
|
runtimeState.members[1].sessionId = memberOneSessionId
|
|
runtimeState.members[2].sessionId = memberTwoSessionId
|
|
runtimeState.members[0].status = "idle"
|
|
runtimeState.members[1].status = "idle"
|
|
runtimeState.members[2].status = "idle"
|
|
await saveRuntimeState(runtimeState, config)
|
|
|
|
return {
|
|
config,
|
|
teamRunId: runtimeState.teamRunId,
|
|
leadSessionId,
|
|
memberOneSessionId,
|
|
memberTwoSessionId,
|
|
tool: createTeamSendMessageTool(config, mockClient),
|
|
toolContext: (sessionID: string) => createToolContext(sessionID, baseDir),
|
|
}
|
|
}
|
|
|
|
describe("createTeamSendMessageTool", () => {
|
|
test("routes a member message to one recipient", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
|
|
// when
|
|
const result = await fixture.tool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "hello",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
const parsedResult = JSON.parse(result)
|
|
|
|
// then
|
|
expect(parsedResult.deliveredTo).toEqual(["m2"])
|
|
const inboxDir = getInboxDir(resolveBaseDir(fixture.config), fixture.teamRunId, "m2")
|
|
const [messageFile] = (await readdir(inboxDir)).filter((entry) => entry.endsWith(".json"))
|
|
const message = MessageSchema.parse(JSON.parse(await readFile(path.join(inboxDir, messageFile), "utf8")))
|
|
expect(message.from).toBe("m1")
|
|
})
|
|
|
|
test("gates broadcast to the lead and fans out to active members", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
|
|
// when
|
|
const nonLeadResult = fixture.tool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "*",
|
|
body: "hello everyone",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
expect(nonLeadResult).rejects.toBeInstanceOf(BroadcastNotPermittedError)
|
|
|
|
// when
|
|
const leadResult = await fixture.tool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "*",
|
|
body: "team announcement",
|
|
kind: "announcement",
|
|
}, fixture.toolContext(fixture.leadSessionId))
|
|
const parsedLeadResult = JSON.parse(leadResult)
|
|
|
|
// then
|
|
expect(parsedLeadResult.deliveredTo).toEqual(["m1", "m2"])
|
|
const memberOneInbox = await readdir(getInboxDir(resolveBaseDir(fixture.config), fixture.teamRunId, "m1"))
|
|
const memberTwoInbox = await readdir(getInboxDir(resolveBaseDir(fixture.config), fixture.teamRunId, "m2"))
|
|
expect(memberOneInbox.filter((entry) => entry.endsWith(".json") && !entry.startsWith("."))).toHaveLength(1)
|
|
expect(memberTwoInbox.filter((entry) => entry.endsWith(".json") && !entry.startsWith("."))).toHaveLength(1)
|
|
})
|
|
|
|
test("live-delivers the envelope via promptAsync to the recipient session", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
const { client, calls } = createRecordingClient()
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "ping",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0].sessionId).toBe(fixture.memberTwoSessionId)
|
|
expect(calls[0].directory).toBe(resolveBaseDir(fixture.config))
|
|
const envelopeText = calls[0].parts[0]?.text ?? ""
|
|
expect(envelopeText).toContain("<peer_message")
|
|
expect(envelopeText).toContain('from="m1"')
|
|
expect(envelopeText).toContain("ping")
|
|
})
|
|
|
|
test("live delivery targets the recipient worktree when available", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
const { loadRuntimeState: loadState, saveRuntimeState: saveState } = await import("../team-state-store/store")
|
|
const state = await loadState(fixture.teamRunId, fixture.config)
|
|
const memberTwo = state.members.find((member) => member.name === "m2")
|
|
if (!memberTwo) throw new Error("m2 runtime member missing")
|
|
memberTwo.worktreePath = "/tmp/team-worker-m2"
|
|
await saveState(state, fixture.config)
|
|
|
|
const { client, calls } = createRecordingClient()
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "ping",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]?.directory).toBe("/tmp/team-worker-m2")
|
|
})
|
|
|
|
test("#given runtime marks recipient running #when team_send_message sends a peer message #then it leaves the unread mailbox path without promptAsync", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
const { loadRuntimeState: loadState, saveRuntimeState: saveState } = await import("../team-state-store/store")
|
|
const state = await loadState(fixture.teamRunId, fixture.config)
|
|
const memberTwo = state.members.find((member) => member.name === "m2")
|
|
if (!memberTwo) throw new Error("m2 runtime member missing")
|
|
memberTwo.status = "running"
|
|
await saveState(state, fixture.config)
|
|
|
|
const { client, calls } = createRecordingClient()
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
|
|
|
// when
|
|
const result = await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "ping",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
const parsedResult = JSON.parse(result)
|
|
|
|
// then
|
|
expect(parsedResult.deliveredTo).toEqual(["m2"])
|
|
expect(calls).toHaveLength(0)
|
|
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
|
expect(unread).toHaveLength(1)
|
|
expect(unread[0]?.body).toBe("ping")
|
|
const inboxEntries = await readdir(getInboxDir(resolveBaseDir(fixture.config), fixture.teamRunId, "m2"))
|
|
expect(inboxEntries.filter((entry) => entry.endsWith(".json") && !entry.startsWith("."))).toHaveLength(1)
|
|
expect(inboxEntries.some((entry) => entry.startsWith(".delivering-"))).toBe(false)
|
|
})
|
|
|
|
test("#given recipient OpenCode session is busy #when team_send_message attempts live delivery #then it releases the message for later mailbox injection", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
let promptCalls = 0
|
|
const client = {
|
|
session: {
|
|
status: async () => ({ data: { [fixture.memberTwoSessionId]: { type: "busy" } } }),
|
|
promptAsync: async () => {
|
|
promptCalls += 1
|
|
},
|
|
},
|
|
}
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "ping while busy",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
expect(promptCalls).toBe(0)
|
|
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
|
expect(unread).toHaveLength(1)
|
|
expect(unread[0]?.body).toBe("ping while busy")
|
|
})
|
|
|
|
test("#given rapid live deliveries to one recipient #when the first prompt just dispatched #then the next message waits for mailbox injection", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
const { client, calls } = createRecordingClient()
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "first ping",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "second ping",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]?.parts[0]?.text).toContain("first ping")
|
|
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
|
expect(unread).toHaveLength(1)
|
|
expect(unread[0]?.body).toBe("second ping")
|
|
})
|
|
|
|
test("#given live delivery deferred a rapid message #when recipient idle wake fires immediately #then the wake hint does not start a second reply", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
const { client, calls } = createRecordingClient()
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
|
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "first ping",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "second ping",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
const wakeHint = createTeamIdleWakeHint({
|
|
directory: resolveBaseDir(fixture.config),
|
|
client,
|
|
}, fixture.config, { idleSettleMs: 0 })
|
|
|
|
// when
|
|
await wakeHint({
|
|
event: {
|
|
type: "session.idle",
|
|
properties: { sessionID: fixture.memberTwoSessionId },
|
|
},
|
|
})
|
|
|
|
// then
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]?.parts[0]?.text).toContain("first ping")
|
|
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
|
expect(unread).toHaveLength(1)
|
|
expect(unread[0]?.body).toBe("second ping")
|
|
})
|
|
|
|
test("live delivery pins the recipient's resolved subagent_type and model on promptAsync", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
const { loadRuntimeState: loadState, saveRuntimeState: saveState } = await import("../team-state-store/store")
|
|
const state = await loadState(fixture.teamRunId, fixture.config)
|
|
const memberTwo = state.members.find((member) => member.name === "m2")
|
|
if (!memberTwo) throw new Error("m2 runtime member missing")
|
|
memberTwo.subagent_type = "atlas"
|
|
memberTwo.model = { providerID: "anthropic", modelID: "claude-opus-4-7", variant: "high" }
|
|
await saveState(state, fixture.config)
|
|
|
|
const { client, calls } = createRecordingClient()
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "ping",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0].sessionId).toBe(fixture.memberTwoSessionId)
|
|
expect(calls[0].agent).toBe("atlas")
|
|
expect(calls[0].model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-7" })
|
|
expect(calls[0].variant).toBe("high")
|
|
})
|
|
|
|
test("live delivery uses the registered agent alias when the runtime stores a config-key agent name", async () => {
|
|
// given
|
|
registerAgentName("\u200B\u200B\u200B\u200BAtlas - Plan Executor")
|
|
const fixture = await createTeamFixture()
|
|
const { loadRuntimeState: loadState, saveRuntimeState: saveState } = await import("../team-state-store/store")
|
|
const state = await loadState(fixture.teamRunId, fixture.config)
|
|
const memberTwo = state.members.find((member) => member.name === "m2")
|
|
if (!memberTwo) throw new Error("m2 runtime member missing")
|
|
memberTwo.subagent_type = "atlas"
|
|
await saveState(state, fixture.config)
|
|
|
|
const { client, calls } = createRecordingClient()
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "ping",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]?.agent).toBe("\u200B\u200B\u200B\u200BAtlas - Plan Executor")
|
|
})
|
|
|
|
test("live delivery reapplies category routing and advanced model params for category members", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
const { loadRuntimeState: loadState, saveRuntimeState: saveState } = await import("../team-state-store/store")
|
|
const state = await loadState(fixture.teamRunId, fixture.config)
|
|
const memberTwo = state.members.find((member) => member.name === "m2")
|
|
if (!memberTwo) throw new Error("m2 runtime member missing")
|
|
memberTwo.subagent_type = "Sisyphus-Junior"
|
|
memberTwo.category = "quick"
|
|
memberTwo.model = {
|
|
providerID: "openai",
|
|
modelID: "gpt-5.4",
|
|
variant: "medium",
|
|
reasoningEffort: "high",
|
|
temperature: 0.2,
|
|
top_p: 0.8,
|
|
maxTokens: 4096,
|
|
thinking: { type: "enabled", budgetTokens: 2048 },
|
|
}
|
|
await saveState(state, fixture.config)
|
|
|
|
const { client, calls } = createRecordingClient()
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "ping",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0].agent).toBe("Sisyphus-Junior")
|
|
expect(calls[0].model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
|
|
expect(calls[0].variant).toBe("medium")
|
|
expect(SessionCategoryRegistry.get(fixture.memberTwoSessionId)).toBe("quick")
|
|
expect(getSessionPromptParams(fixture.memberTwoSessionId)).toEqual({
|
|
temperature: 0.2,
|
|
topP: 0.8,
|
|
maxOutputTokens: 4096,
|
|
options: {
|
|
reasoningEffort: "high",
|
|
thinking: { type: "enabled", budgetTokens: 2048 },
|
|
},
|
|
})
|
|
})
|
|
|
|
test("live delivery omits agent and model on promptAsync when the runtime member has none recorded", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
const { client, calls } = createRecordingClient()
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "ping",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0].agent).toBeUndefined()
|
|
expect(calls[0].model).toBeUndefined()
|
|
expect(calls[0].variant).toBeUndefined()
|
|
})
|
|
|
|
test("prefers the team session registry when the runtime member session has not been persisted yet", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
registerTeamSession(fixture.memberOneSessionId, {
|
|
teamRunId: fixture.teamRunId,
|
|
memberName: "m1",
|
|
role: "member",
|
|
})
|
|
|
|
const { loadRuntimeState: loadState, saveRuntimeState: saveState } = await import("../team-state-store/store")
|
|
const runtimeState = await loadState(fixture.teamRunId, fixture.config)
|
|
const memberOne = runtimeState.members.find((member) => member.name === "m1")
|
|
if (!memberOne) throw new Error("m1 runtime member missing")
|
|
memberOne.sessionId = undefined
|
|
await saveState(runtimeState, fixture.config)
|
|
|
|
// when
|
|
const result = await fixture.tool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "hello",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
const parsedResult = JSON.parse(result)
|
|
|
|
// then
|
|
expect(parsedResult.deliveredTo).toEqual(["m2"])
|
|
const inboxDir = getInboxDir(resolveBaseDir(fixture.config), fixture.teamRunId, "m2")
|
|
const [messageFile] = (await readdir(inboxDir)).filter((entry) => entry.endsWith(".json"))
|
|
const message = MessageSchema.parse(JSON.parse(await readFile(path.join(inboxDir, messageFile), "utf8")))
|
|
expect(message.from).toBe("m1")
|
|
})
|
|
|
|
test("keeps live-delivered messages reserved until the recipient idles", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
const { client } = createRecordingClient()
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "ping",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
const inboxDir = getInboxDir(resolveBaseDir(fixture.config), fixture.teamRunId, "m2")
|
|
const inboxEntries = (await readdir(inboxDir)).filter((entry) => entry.endsWith(".json"))
|
|
expect(inboxEntries).toHaveLength(1)
|
|
expect(inboxEntries[0]?.startsWith(".delivering-")).toBe(true)
|
|
|
|
const { loadRuntimeState: loadState } = await import("../team-state-store/store")
|
|
const runtimeState = await loadState(fixture.teamRunId, fixture.config)
|
|
const recipient = runtimeState.members.find((member) => member.name === "m2")
|
|
expect(recipient?.pendingInjectedMessageIds).toHaveLength(1)
|
|
})
|
|
|
|
test("broadcast fans out live delivery to every member except the sender", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
const { client, calls } = createRecordingClient()
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "*",
|
|
body: "broadcast ping",
|
|
kind: "announcement",
|
|
}, fixture.toolContext(fixture.leadSessionId))
|
|
|
|
// then
|
|
const targetedSessionIds = calls.map((entry) => entry.sessionId).sort()
|
|
expect(targetedSessionIds).toEqual([
|
|
fixture.memberOneSessionId,
|
|
fixture.memberTwoSessionId,
|
|
].sort())
|
|
})
|
|
|
|
test("broadcast still queues for members whose session has not spawned yet", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
const { loadRuntimeState: loadState } = await import("../team-state-store/store")
|
|
const stateBefore = await loadState(fixture.teamRunId, fixture.config)
|
|
const pendingMember = stateBefore.members.find((member) => member.name === "m2")
|
|
if (!pendingMember) throw new Error("m2 runtime member missing")
|
|
pendingMember.sessionId = undefined
|
|
await saveRuntimeState(stateBefore, fixture.config)
|
|
|
|
const { client, calls } = createRecordingClient()
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
|
|
|
// when
|
|
const result = await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "*",
|
|
body: "broadcast ping",
|
|
kind: "announcement",
|
|
}, fixture.toolContext(fixture.leadSessionId))
|
|
const parsedResult = JSON.parse(result)
|
|
|
|
// then
|
|
expect(parsedResult.deliveredTo).toEqual(["m1", "m2"])
|
|
const targetedSessionIds = calls.map((entry) => entry.sessionId)
|
|
expect(targetedSessionIds).toEqual([fixture.memberOneSessionId])
|
|
const memberTwoInbox = await readdir(getInboxDir(resolveBaseDir(fixture.config), fixture.teamRunId, "m2"))
|
|
expect(memberTwoInbox.filter((entry) => entry.endsWith(".json") && !entry.startsWith("."))).toHaveLength(1)
|
|
})
|
|
|
|
test("inbox stays intact when live delivery fails so the fallback path still works", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
const failingClient = {
|
|
session: {
|
|
promptAsync: async () => { throw new Error("network down") },
|
|
},
|
|
} satisfies LiveDeliveryClient
|
|
const liveTool = createTeamSendMessageTool(fixture.config, failingClient)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "ping",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
const inboxDir = getInboxDir(resolveBaseDir(fixture.config), fixture.teamRunId, "m2")
|
|
const inboxEntries = (await readdir(inboxDir)).filter((entry) => entry.endsWith(".json") && !entry.startsWith("."))
|
|
expect(inboxEntries).toHaveLength(1)
|
|
})
|
|
|
|
test("#given live delivery promptAsync fails after dispatch may have been accepted #when delivery falls back #then it keeps the delivered message reserved instead of surfacing a duplicate unread", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
let promptCalls = 0
|
|
const failingClient = {
|
|
session: {
|
|
promptAsync: async () => {
|
|
promptCalls += 1
|
|
throw new Error("JSON Parse error: Unexpected EOF")
|
|
},
|
|
},
|
|
} satisfies LiveDeliveryClient
|
|
const liveTool = createTeamSendMessageTool(fixture.config, failingClient)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "maybe already accepted",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
expect(promptCalls).toBe(1)
|
|
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
|
expect(unread).toHaveLength(0)
|
|
|
|
const inboxDir = getInboxDir(resolveBaseDir(fixture.config), fixture.teamRunId, "m2")
|
|
const inboxEntries = (await readdir(inboxDir)).filter((entry) => entry.endsWith(".json"))
|
|
expect(inboxEntries).toHaveLength(1)
|
|
expect(inboxEntries[0]?.startsWith(".delivering-")).toBe(true)
|
|
|
|
const { loadRuntimeState: loadState } = await import("../team-state-store/store")
|
|
const runtimeState = await loadState(fixture.teamRunId, fixture.config)
|
|
const recipient = runtimeState.members.find((member) => member.name === "m2")
|
|
expect(recipient?.pendingInjectedMessageIds).toHaveLength(1)
|
|
})
|
|
|
|
test("#given live delivery prompt dispatches but pending mark fails #when delivery finishes #then the message is not re-exposed as unread", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
let promptCalls = 0
|
|
const client = {
|
|
session: {
|
|
promptAsync: async () => {
|
|
promptCalls += 1
|
|
await rm(path.join(resolveBaseDir(fixture.config), "runtime", fixture.teamRunId, "state.json"))
|
|
},
|
|
},
|
|
} satisfies LiveDeliveryClient
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "accepted before state vanished",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
expect(promptCalls).toBe(1)
|
|
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
|
expect(unread).toHaveLength(0)
|
|
|
|
const inboxDir = getInboxDir(resolveBaseDir(fixture.config), fixture.teamRunId, "m2")
|
|
const inboxEntries = (await readdir(inboxDir)).filter((entry) => entry.endsWith(".json"))
|
|
expect(inboxEntries).toHaveLength(1)
|
|
expect(inboxEntries[0]?.startsWith(".delivering-")).toBe(true)
|
|
})
|
|
|
|
test("#given live delivery cannot reload runtime after pre-reserve #when delivery aborts #then the message is released for mailbox injection", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
const { loadRuntimeState: loadState } = await import("../team-state-store/store")
|
|
const runtimeState = await loadState(fixture.teamRunId, fixture.config)
|
|
let loadCount = 0
|
|
const deps = {
|
|
loadRuntimeState: async () => {
|
|
loadCount += 1
|
|
if (loadCount === 3) {
|
|
throw new Error("runtime reload failed")
|
|
}
|
|
return runtimeState
|
|
},
|
|
}
|
|
const { client, calls } = createRecordingClient()
|
|
const liveTool = createTeamSendMessageTool(fixture.config, client, deps)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "fallback unread",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
expect(calls).toHaveLength(0)
|
|
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
|
expect(unread).toHaveLength(1)
|
|
expect(unread[0]?.body).toBe("fallback unread")
|
|
|
|
const inboxEntries = await readdir(getInboxDir(resolveBaseDir(fixture.config), fixture.teamRunId, "m2"))
|
|
expect(inboxEntries.filter((entry) => entry.endsWith(".json") && !entry.startsWith("."))).toHaveLength(1)
|
|
expect(inboxEntries.some((entry) => entry.startsWith(".delivering-"))).toBe(false)
|
|
})
|
|
|
|
test("reserves the message during live delivery so concurrent listings cannot surface it", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
let unreadDuringDelivery: Message[] = []
|
|
const reservingClient = {
|
|
session: {
|
|
promptAsync: async () => {
|
|
unreadDuringDelivery = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
|
return undefined
|
|
},
|
|
},
|
|
} satisfies LiveDeliveryClient
|
|
const liveTool = createTeamSendMessageTool(fixture.config, reservingClient)
|
|
|
|
// when
|
|
await liveTool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "ping",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
expect(unreadDuringDelivery).toHaveLength(0)
|
|
})
|
|
|
|
test("hides the message from the inbox from the moment it is written for a live recipient", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
const { sendMessage } = await import("../team-mailbox/send")
|
|
const messageId = randomUUID()
|
|
|
|
// when
|
|
await sendMessage({
|
|
version: 1,
|
|
messageId,
|
|
from: "m1",
|
|
to: "m2",
|
|
kind: "message",
|
|
body: "ping",
|
|
timestamp: Date.now(),
|
|
}, fixture.teamRunId, fixture.config, {
|
|
isLead: false,
|
|
activeMembers: ["m2"],
|
|
reservedRecipients: new Set(["m2"]),
|
|
})
|
|
const unreadImmediately = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
|
const inboxDir = getInboxDir(resolveBaseDir(fixture.config), fixture.teamRunId, "m2")
|
|
const rawEntries = (await readdir(inboxDir))
|
|
.filter((entry) => entry.endsWith(".json"))
|
|
|
|
// then
|
|
expect(unreadImmediately).toHaveLength(0)
|
|
expect(rawEntries).toEqual([`.delivering-${messageId}.json`])
|
|
})
|
|
|
|
test("rejects shutdown_request kind", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
|
|
// when
|
|
const result = fixture.tool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m1",
|
|
body: "stop",
|
|
kind: "shutdown_request",
|
|
}, fixture.toolContext(fixture.leadSessionId))
|
|
|
|
// then
|
|
expect(result).rejects.toBeInstanceOf(Error)
|
|
})
|
|
|
|
test("rejects a non-UUID correlationId before writing the message", async () => {
|
|
// given
|
|
const fixture = await createTeamFixture()
|
|
|
|
// when
|
|
const result = fixture.tool.execute({
|
|
teamRunId: fixture.teamRunId,
|
|
to: "m2",
|
|
body: "hello",
|
|
correlationId: "task-1",
|
|
}, fixture.toolContext(fixture.memberOneSessionId))
|
|
|
|
// then
|
|
await expect(result).rejects.toThrow("correlationId")
|
|
await expect(readdir(getInboxDir(resolveBaseDir(fixture.config), fixture.teamRunId, "m2"))).rejects.toThrow()
|
|
})
|
|
})
|