fix(prompt-gate): pin duplicate prompt dispatches

Keep prompt reservations briefly after successful dispatch so rapid idle/message/error transitions cannot inject the same follow-up twice.

Route all production session prompt calls through the shared gate, restore skipped background resume state, release holds after abort/recovery paths, and preserve Ralph/ULW loop state when a dispatch is deferred.

Add regression coverage for session routing, static prompt route auditing, team-mode live messaging, model suggestion retries, call-omo-agent reuse, background parent wakes, runtime fallback, compaction recovery, Atlas, and Ralph/ULW loops.
This commit is contained in:
YeonGyu-Kim
2026-05-15 13:19:10 +09:00
parent 05189700fb
commit c2aa180e7e
26 changed files with 893 additions and 48 deletions
@@ -21,6 +21,7 @@ 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 = {
@@ -310,6 +311,70 @@ describe("createTeamSendMessageTool", () => {
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 stays unread instead of starting another reply", 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 left a rapid message unread #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()