From 7db3a7f933e2636331a5c5bc0133323e7f5dfef2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 19 May 2026 13:49:01 +0900 Subject: [PATCH] fix(team-mode): preserve live delivery holds after ambiguous prompt failure --- .../team-mode/tools/messaging.test.ts | 39 +++++++++++++++++++ src/features/team-mode/tools/messaging.ts | 31 +++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/features/team-mode/tools/messaging.test.ts b/src/features/team-mode/tools/messaging.test.ts index bb1474ad2..13dca9c6d 100644 --- a/src/features/team-mode/tools/messaging.test.ts +++ b/src/features/team-mode/tools/messaging.test.ts @@ -14,6 +14,7 @@ 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" @@ -87,6 +88,7 @@ afterEach(() => { clearTeamSessionRegistry() SessionCategoryRegistry.clear() clearAllSessionPromptParams() + releaseAllPromptAsyncReservationsForTesting() _resetForTesting() }) @@ -630,6 +632,43 @@ describe("createTeamSendMessageTool", () => { 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("reserves the message during live delivery so concurrent listings cannot surface it", async () => { // given const fixture = await createTeamFixture() diff --git a/src/features/team-mode/tools/messaging.ts b/src/features/team-mode/tools/messaging.ts index 3c95a8b5a..f1b03b4ad 100644 --- a/src/features/team-mode/tools/messaging.ts +++ b/src/features/team-mode/tools/messaging.ts @@ -68,6 +68,26 @@ const TeamSendMessageArgsSchema = z.object({ type DeliveryReservation = Awaited> +function extractPromptFailureMessage(error: unknown): string { + if (typeof error === "string") return error + if (error instanceof Error) return error.message + if (typeof error === "object" && error !== null) { + const record = error as Record + if (typeof record.message === "string") return record.message + try { + return JSON.stringify(error) + } catch { + return "" + } + } + return String(error) +} + +function shouldKeepReservationAfterFailedLivePrompt(error: unknown): boolean { + const message = extractPromptFailureMessage(error) + return message.includes("Unexpected EOF") || message.includes("timed out") +} + async function resolveTeamRuntimeDetails( teamRunId: string, sessionID: string, @@ -210,6 +230,17 @@ async function deliverLive( query: { directory: recipientMember.worktreePath ?? directory }, }, }) + if (promptResult.status === "failed" && shouldKeepReservationAfterFailedLivePrompt(promptResult.error)) { + await markLiveDeliveryPending(teamRunId, recipientName, message.messageId, config) + log("[team-mailbox] live delivery prompt failed after dispatch attempt, keeping reservation pending", { + teamRunId, + recipient: recipientName, + recipientSessionId, + messageId: message.messageId, + error: promptResult.error instanceof Error ? promptResult.error.message : String(promptResult.error), + }) + continue + } if (promptResult.status !== "dispatched") { log("[team-mailbox] live delivery skipped by promptAsync gate, falling back to inbox injection", { status: promptResult.status,