diff --git a/src/hooks/shared/prompt-async-gate.test.ts b/src/hooks/shared/prompt-async-gate.test.ts index 6f326bc60..c9062a855 100644 --- a/src/hooks/shared/prompt-async-gate.test.ts +++ b/src/hooks/shared/prompt-async-gate.test.ts @@ -243,6 +243,125 @@ describe("promptAsyncAfterSessionIdle", () => { expect(promptCalls).toBe(2) }) + test("#given promptAsync dispatch never settles #when dispatch timeout elapses #then reservation is released for the next caller", async () => { + // given + let promptCalls = 0 + const neverSettles = new Promise(() => {}) + const client = { + session: { + promptAsync: async () => { + promptCalls += 1 + await neverSettles + }, + }, + } + + // when + const first = await promptAsyncAfterSessionIdle({ + client, + sessionID: "ses_dispatch_timeout", + input: { path: { id: "ses_dispatch_timeout" }, body: { parts: [] } }, + source: "test:timeout:first", + settleMs: 0, + dispatchTimeoutMs: 1, + postDispatchHoldMs: 0, + }) + const second = await promptAsyncAfterSessionIdle({ + client, + sessionID: "ses_dispatch_timeout", + input: { path: { id: "ses_dispatch_timeout" }, body: { parts: [] } }, + source: "test:timeout:second", + settleMs: 0, + dispatchTimeoutMs: 1, + postDispatchHoldMs: 0, + }) + + // then + expect(first.status).toBe("failed") + expect(second.status).toBe("failed") + expect(promptCalls).toBe(2) + }) + + test("#given promptAsync rejects after dispatch #when a second caller races immediately #then post-dispatch hold still blocks duplicate", async () => { + // given + let promptCalls = 0 + const client = { + session: { + promptAsync: async () => { + promptCalls += 1 + throw new Error("post-dispatch failure") + }, + }, + } + + // when + const first = await promptAsyncAfterSessionIdle({ + client, + sessionID: "ses_post_dispatch_reject", + input: { path: { id: "ses_post_dispatch_reject" }, body: { parts: [] } }, + source: "test:reject:first", + settleMs: 0, + }) + const second = await promptAsyncAfterSessionIdle({ + client, + sessionID: "ses_post_dispatch_reject", + input: { path: { id: "ses_post_dispatch_reject" }, body: { parts: [] } }, + source: "test:reject:second", + settleMs: 0, + }) + + // then + expect(first.status).toBe("failed") + expect(second).toEqual({ status: "reserved", reservedBy: "test:reject:first" }) + expect(promptCalls).toBe(1) + }) + + test("#given a similarly named sibling route #when reservedByPrefix uses a strict family prefix #then release does not clear sibling reservation", async () => { + // given + let promptCalls = 0 + const client = { + session: { + promptAsync: async () => { + promptCalls += 1 + }, + }, + } + + // when + const first = await promptAsyncAfterSessionIdle({ + client, + sessionID: "ses_prefix_sibling", + input: { + path: { id: "ses_prefix_sibling" }, + body: { parts: [{ type: "text", text: "continue" }] }, + }, + source: "model-fallbackx:message.updated", + settleMs: 0, + }) + const released = releasePromptAsyncReservation( + "ses_prefix_sibling", + "model-fallback-abort:session.error", + { reservedByPrefix: "model-fallback:" }, + ) + const second = await promptAsyncAfterSessionIdle({ + client, + sessionID: "ses_prefix_sibling", + input: { + path: { id: "ses_prefix_sibling" }, + body: { parts: [{ type: "text", text: "continue again" }] }, + }, + source: "model-fallback:session.error", + settleMs: 0, + postDispatchHoldMs: 0, + }) + + // then + expect(first.status).toBe("dispatched") + expect(released).toBe(false) + expect(second).toEqual({ status: "reserved", reservedBy: "model-fallbackx:message.updated" }) + expect(promptCalls).toBe(1) + }) + test("#given two internal prompt calls race for one idle session #when they dispatch concurrently #then only one prompt is accepted", async () => { // given let promptCalls = 0