fix(prompt-gate): harden sync and team prompt dispatch

This commit is contained in:
YeonGyu-Kim
2026-05-19 17:43:37 +09:00
parent 1492bffd20
commit bcea4a9d28
44 changed files with 688 additions and 140 deletions
+17 -10
View File
@@ -257,7 +257,7 @@ describe("createTeamSendMessageTool", () => {
expect(calls[0]?.directory).toBe("/tmp/team-worker-m2")
})
test("live-delivers to running recipients so active teammates receive messages immediately", async () => {
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")
@@ -280,12 +280,16 @@ describe("createTeamSendMessageTool", () => {
// then
expect(parsedResult.deliveredTo).toEqual(["m2"])
expect(calls).toHaveLength(1)
expect(calls[0]?.sessionId).toBe(fixture.memberTwoSessionId)
expect(calls[0]?.directory).toBe(resolveBaseDir(fixture.config))
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 reserves the message in the central prompt queue", async () => {
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
@@ -309,10 +313,11 @@ describe("createTeamSendMessageTool", () => {
// then
expect(promptCalls).toBe(0)
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
expect(unread).toHaveLength(0)
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 is queued instead of starting another reply", async () => {
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()
@@ -334,10 +339,11 @@ describe("createTeamSendMessageTool", () => {
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(0)
expect(unread).toHaveLength(1)
expect(unread[0]?.body).toBe("second ping")
})
test("#given live delivery queued a rapid message #when recipient idle wake fires immediately #then the wake hint does not start a second reply", async () => {
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()
@@ -371,7 +377,8 @@ describe("createTeamSendMessageTool", () => {
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(0)
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 () => {
+36 -2
View File
@@ -16,7 +16,7 @@ import {
import { BroadcastNotPermittedError, sendMessage } from "../team-mailbox/send"
import { lookupTeamSession } from "../team-session-registry"
import { loadRuntimeState, transitionRuntimeState } from "../team-state-store/store"
import type { Message } from "../types"
import type { Message, RuntimeState } from "../types"
import { MessageSchema } from "../types"
const MESSAGE_TOOL_KINDS = ["message", "announcement"] as const
@@ -68,6 +68,14 @@ const TeamSendMessageArgsSchema = z.object({
})
type DeliveryReservation = Awaited<ReturnType<typeof reserveMessageForDelivery>>
type RuntimeMember = RuntimeState["members"][number]
function canPreReserveForLiveDelivery(member: RuntimeMember, senderName: string): boolean {
return member.name !== senderName
&& member.sessionId !== undefined
&& member.status === "idle"
&& member.pendingInjectedMessageIds.length === 0
}
async function resolveTeamRuntimeDetails(
teamRunId: string,
@@ -181,6 +189,31 @@ async function deliverLive(
continue
}
if (recipientMember.pendingInjectedMessageIds.length > 0) {
await releaseReservationSafely(reservation, {
teamRunId,
recipient: recipientName,
messageId: message.messageId,
})
continue
}
if (recipientMember.status !== "idle") {
log("[team-mailbox] live delivery unavailable, recipient is not idle", {
reason: "recipient-not-idle",
teamRunId,
recipient: recipientName,
status: recipientMember.status,
messageId: message.messageId,
})
await releaseReservationSafely(reservation, {
teamRunId,
recipient: recipientName,
messageId: message.messageId,
})
continue
}
const recipientSessionId = recipientMember.sessionId
if (!recipientSessionId) {
log("[team-mailbox] live delivery unavailable, falling back to inbox injection", {
@@ -205,6 +238,7 @@ async function deliverLive(
client,
sessionID: recipientSessionId,
source: "team-live-delivery",
queueBehavior: "defer",
input: {
path: { id: recipientSessionId },
body: buildMemberPromptBody(recipientMember, envelope),
@@ -315,7 +349,7 @@ export function createTeamSendMessageTool(
const runtimeState = await deps.loadRuntimeState(teamRuntime.teamRunId, config)
const reservedRecipients = new Set<string>(
runtimeState.members
.filter((member) => member.sessionId !== undefined && member.name !== teamRuntime.senderName)
.filter((member) => canPreReserveForLiveDelivery(member, teamRuntime.senderName))
.map((member) => member.name),
)