fix(team-mode): gate member wake prompts

This commit is contained in:
YeonGyu-Kim
2026-05-15 11:29:52 +09:00
parent 174cbd0fbd
commit f1a62a9cd1
3 changed files with 70 additions and 14 deletions
@@ -282,6 +282,34 @@ describe("createTeamSendMessageTool", () => {
expect(calls[0]?.directory).toBe(resolveBaseDir(fixture.config)) expect(calls[0]?.directory).toBe(resolveBaseDir(fixture.config))
}) })
test("#given recipient OpenCode session is busy #when team_send_message attempts live delivery #then it leaves the message unread without starting another reply", 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("live delivery pins the recipient's resolved subagent_type and model on promptAsync", async () => { test("live delivery pins the recipient's resolved subagent_type and model on promptAsync", async () => {
// given // given
const fixture = await createTeamFixture() const fixture = await createTeamFixture()
+26 -4
View File
@@ -15,6 +15,7 @@ import {
reserveMessageForDelivery, reserveMessageForDelivery,
} from "../team-mailbox/reservation" } from "../team-mailbox/reservation"
import { BroadcastNotPermittedError, sendMessage } from "../team-mailbox/send" import { BroadcastNotPermittedError, sendMessage } from "../team-mailbox/send"
import { promptAsyncAfterSessionIdle } from "../../../hooks/shared/prompt-async-gate"
import type { Message } from "../types" import type { Message } from "../types"
import { MessageSchema } from "../types" import { MessageSchema } from "../types"
@@ -33,6 +34,7 @@ export type LiveDeliveryClient = {
} }
query?: { directory: string } query?: { directory: string }
}): Promise<unknown> }): Promise<unknown>
status?: () => Promise<unknown>
} }
} }
@@ -180,11 +182,31 @@ async function deliverLive(
applyMemberSessionRouting(recipientSessionId, recipientMember) applyMemberSessionRouting(recipientSessionId, recipientMember)
try { try {
await client.session.promptAsync({ const promptResult = await promptAsyncAfterSessionIdle({
path: { id: recipientSessionId }, client,
body: buildMemberPromptBody(recipientMember, envelope), sessionID: recipientSessionId,
query: { directory: recipientMember.worktreePath ?? directory }, source: "team-live-delivery",
input: {
path: { id: recipientSessionId },
body: buildMemberPromptBody(recipientMember, envelope),
query: { directory: recipientMember.worktreePath ?? directory },
},
}) })
if (promptResult.status !== "dispatched") {
log("[team-mailbox] live delivery skipped by promptAsync gate, falling back to inbox injection", {
status: promptResult.status,
teamRunId,
recipient: recipientName,
recipientSessionId,
messageId: message.messageId,
})
await releaseReservationSafely(reservation, {
teamRunId,
recipient: recipientName,
messageId: message.messageId,
})
continue
}
await commitDeliveryReservation(reservation) await commitDeliveryReservation(reservation)
log("[team-mailbox] live delivery committed", { log("[team-mailbox] live delivery committed", {
teamRunId, teamRunId,
@@ -9,7 +9,7 @@ import {
} from "../../features/team-mode/member-session-routing" } from "../../features/team-mode/member-session-routing"
import { resolveSessionEventID } from "../../shared/event-session-id" import { resolveSessionEventID } from "../../shared/event-session-id"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle" import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
type PromptAsyncInput = { type PromptAsyncInput = {
path: { id: string } path: { id: string }
@@ -100,23 +100,29 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea
} }
applyMemberSessionRouting(sessionID, memberEntry) applyMemberSessionRouting(sessionID, memberEntry)
if (!(await shouldPromptAfterSessionIdle(ctx.client, sessionID, options?.idleSettleMs))) { const promptResult = await promptAsyncAfterSessionIdle({
log("team idle wake hint skipped because session is active", { client: ctx.client,
event: "team-mode-idle-wake-hint-active-session", sessionID,
source: "team-idle-wake-hint",
settleMs: options?.idleSettleMs,
input: {
path: { id: sessionID },
body: buildMemberPromptBody(memberEntry, buildWakeHint(unreadMessages.length)),
query: { directory: ctx.directory },
},
})
if (promptResult.status !== "dispatched") {
log("team idle wake hint skipped by promptAsync gate", {
event: "team-mode-idle-wake-hint-gated",
teamRunId: runtimeState.teamRunId, teamRunId: runtimeState.teamRunId,
memberName: memberEntry.name, memberName: memberEntry.name,
sessionID, sessionID,
unreadCount: unreadMessages.length, unreadCount: unreadMessages.length,
status: promptResult.status,
}) })
return return
} }
await ctx.client.session.promptAsync({
path: { id: sessionID },
body: buildMemberPromptBody(memberEntry, buildWakeHint(unreadMessages.length)),
query: { directory: ctx.directory },
})
log("team idle wake hint sent", { log("team idle wake hint sent", {
event: "team-mode-idle-wake-hint", event: "team-mode-idle-wake-hint",
teamRunId: runtimeState.teamRunId, teamRunId: runtimeState.teamRunId,