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
@@ -94,6 +94,7 @@ export async function runAggressiveTruncationStrategy(params: {
sessionID: params.sessionID,
source: "auto-compact",
settleMs: 0,
queueBehavior: "defer",
input: {
path: { id: params.sessionID },
body: {
@@ -100,6 +100,7 @@ export async function injectBoulderContinuation(input: {
sessionID,
source: HOOK_NAME,
settleMs: idleSettleMs,
queueBehavior: "defer",
input: {
path: { id: sessionID },
body: {
+1
View File
@@ -305,6 +305,7 @@ export async function handleAtlasSessionIdle(input: {
sessionID,
source: HOOK_NAME,
settleMs: options?.idleSettleMs,
queueBehavior: "defer",
input: {
path: { id: sessionID },
body: {
@@ -114,6 +114,7 @@ export function createSessionEventHandler(
client: ctx.client,
sessionID,
source: "claude-code-stop-hook:inject-prompt",
queueBehavior: "defer",
input: {
path: { id: sessionID },
body: {
@@ -3,6 +3,7 @@
import { afterEach, describe, expect, it } from "bun:test"
import { setCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
import {
dispatchInternalPrompt,
releaseAllPromptAsyncReservationsForTesting,
releasePromptAsyncReservation,
} from "../shared/prompt-async-gate"
@@ -155,6 +156,63 @@ describe("createCompactionContextInjector recovery", () => {
expect(promptAsyncRecorder.calls[0]?.body.tools).toEqual({ bash: true })
})
it("#given recovery is blocked by a peer prompt hold #when compaction fires again after the hold is released #then queued recovery is not treated as completed", async () => {
//#given
const promptAsyncRecorder = createPromptAsyncRecorder()
const sessionID = "ses_recovery_peer_hold"
setCompactionAgentConfigCheckpoint(sessionID, {
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
tools: { bash: true },
})
const incompletePromptConfig = [
{
info: {
role: "user",
agent: "atlas",
model: { providerID: "openai", modelID: "gpt-5" },
},
},
]
const ctx = createMockContext(
[incompletePromptConfig],
promptAsyncRecorder.promptAsync,
)
const hook = createCompactionContextInjector({ ctx })
const peerHold = await dispatchInternalPrompt({
mode: "async",
client: ctx.client,
sessionID,
source: "test-peer-hold",
settleMs: 0,
postDispatchHoldMs: 1000,
input: {
path: { id: sessionID },
body: {
parts: [{ type: "text", text: "peer message" }],
},
},
})
promptAsyncRecorder.calls.splice(0)
//#when
await hook.event({
event: { type: "session.compacted", properties: { sessionID } },
})
const released = releasePromptAsyncReservation(sessionID, "test-release", {
reservedBy: "test-peer-hold",
})
await hook.event({
event: { type: "session.compacted", properties: { sessionID } },
})
//#then
expect(peerHold.status).toBe("dispatched")
expect(released).toBe(true)
expect(promptAsyncRecorder.calls).toHaveLength(1)
expect(promptAsyncRecorder.calls[0]?.body.parts[0]?.text).toContain("restore checkpointed session agent configuration")
})
it("marks the recovery prompt as synthetic compaction continuation", async () => {
//#given
const promptAsyncRecorder = createPromptAsyncRecorder()
@@ -88,6 +88,7 @@ export function createRecoveryLogic(
client: ctx.client,
sessionID,
source: "compaction-context-injector",
queueBehavior: "defer",
input: {
path: { id: sessionID },
body: {
+2 -2
View File
@@ -849,7 +849,7 @@ describe("ralph-loop", () => {
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Build something" }] },
{
info: { role: "assistant" },
info: { role: "assistant", finish: "end_turn" },
parts: [
{ type: "reasoning", text: "I am done now. <promise>REASONING_DONE</promise>" },
],
@@ -1398,7 +1398,7 @@ Original task: Build something`
mockSessionMessages = [
{
info: { role: "assistant" },
info: { role: "assistant", finish: "end_turn" },
parts: [{ type: "text", text: "All work is complete. <promise>DONE</promise>" }],
},
]
@@ -109,7 +109,7 @@ describe("ralph-loop user message race guard", () => {
})
messagesBySession["session-123"] = [
{ info: { role: "user", time: { created: Date.now() - 1_000 } } },
{ info: { role: "assistant", agent: "sisyphus", time: { created: Date.now() - 500 } } },
{ info: { role: "assistant", finish: "end_turn", agent: "sisyphus", time: { created: Date.now() - 500 } } },
]
try {
+1
View File
@@ -170,6 +170,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
sessionID,
source: `runtime-fallback:${source}`,
settleMs: 0,
queueBehavior: "defer",
input: {
path: { id: sessionID },
body: {
@@ -127,6 +127,7 @@ export async function recoverUnavailableTool(
sessionID,
source: "session-recovery-unavailable-tool",
queueBehavior: "defer",
checkToolState: false,
input: promptInput,
})
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
+44 -4
View File
@@ -96,7 +96,7 @@ describe("dispatchInternalPrompt", () => {
expect(calls).toEqual(["sync:ses_unified_sync"])
})
test("#given async dispatch holds a session reservation #when sync mode targets the same session #then the unified service suppresses the duplicate", async () => {
test("#given async dispatch holds a session reservation #when sync mode targets the same session #then the unified service defers the duplicate", async () => {
// given
const calls: string[] = []
const client = {
@@ -131,7 +131,7 @@ describe("dispatchInternalPrompt", () => {
// then
expect(first.status).toBe("dispatched")
expect(second).toEqual({ status: "queued", queuedBy: "test:unified-shared:first", position: 1 })
expect(second).toEqual({ status: "reserved", reservedBy: "test:unified-shared:first" })
expect(calls).toEqual(["async"])
})
@@ -671,6 +671,46 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
expect(promptCalls).toBe(0)
})
test("#given latest assistant turn is still streaming without a finish reason #when an internal promptAsync is requested #then no prompt is sent", async () => {
// given
let promptCalls = 0
const client = {
session: {
status: async () => ({ data: { ses_streaming_assistant: { type: "idle" } } }),
messages: async () => ({
data: [
{
info: { id: "msg_user", role: "user" },
parts: [{ type: "text", text: "run work" }],
},
{
info: { id: "msg_assistant", role: "assistant" },
parts: [{ type: "reasoning", text: "still thinking" }],
},
],
}),
promptAsync: async () => {
promptCalls += 1
},
},
}
// when
const result = await dispatchInternalPrompt({
mode: "async",
client,
sessionID: "ses_streaming_assistant",
input: { path: { id: "ses_streaming_assistant" }, body: { parts: [] } },
source: "test:streaming-assistant",
settleMs: 0,
postDispatchHoldMs: 0,
})
// then
expect(result.status).toBe("queued")
expect(promptCalls).toBe(0)
})
test("#given internal user tail follows an assistant waiting on tools #when an internal promptAsync is requested #then no prompt is sent", async () => {
// given
let promptCalls = 0
@@ -1170,7 +1210,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
// then
expect(firstResult.status).toBe("dispatched")
expect(second.status).toBe("queued")
expect(second.status).toBe("reserved")
expect(promptCalls).toBe(1)
})
@@ -1200,7 +1240,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
// then
expect(firstResult.status).toBe("dispatched")
expect(second.status).toBe("queued")
expect(second.status).toBe("reserved")
expect(promptCalls).toBe(1)
})
@@ -567,6 +567,49 @@ describe("createTeamIdleWakeHint", () => {
expect(processedEntries).toContain(`${messageId}.json`)
})
test("#given a pending live-delivery ack and later unread message #when member idles after the live reply #then it wakes the member for the unread message", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
const pendingMessageId = randomUUID()
const unreadMessageId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId, [pendingMessageId]), config)
await seedReservedUnreadMessage(teamRunId, config, pendingMessageId, "already live delivered", 100)
await seedUnreadMessage(teamRunId, config, unreadMessageId, "waiting in inbox", 200)
const promptInputs: WakeHintPromptInput[] = []
const promptAsyncSpy = mock(async (input: WakeHintPromptInput) => {
promptInputs.push(input)
return {}
})
const handler = createTeamIdleWakeHint({
directory: "/tmp/project",
client: { session: { promptAsync: promptAsyncSpy } },
}, config)
// when
await handler({
event: {
type: "session.idle",
properties: { sessionID: "member-session" },
},
})
// then
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
expect(promptInputs[0]?.body.parts[0]?.text).toContain("1 new team messages")
const runtimeState = await loadRuntimeState(teamRunId, config)
expect(runtimeState.members[0]?.pendingInjectedMessageIds).toEqual([])
const inboxDir = getInboxDir(resolveBaseDir(config), teamRunId, "worker")
const processedEntries = await readdir(path.join(inboxDir, "processed"))
expect(processedEntries).toContain(`${pendingMessageId}.json`)
const inboxEntries = await readdir(inboxDir)
expect(inboxEntries).toContain(`${unreadMessageId}.json`)
})
test("acks pending lead messages on idle without sending a wake hint", async () => {
// given
const baseDir = await createTemporaryBaseDir()
@@ -82,6 +82,13 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea
: member
)),
}), config)
log("team idle handled pending live delivery ack", {
event: "team-mode-idle-pending-ack",
teamRunId: runtimeState.teamRunId,
memberName: memberEntry.name,
sessionID,
ackedCount: pendingInjectedMessageIds.length,
})
}
const unreadMessages = await listUnreadMessages(runtimeState.teamRunId, memberEntry.name, config)
@@ -146,6 +153,7 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea
sessionID,
source: "team-idle-wake-hint",
settleMs: options?.idleSettleMs,
queueBehavior: "defer",
input: {
path: { id: sessionID },
body: buildMemberPromptBody(memberEntry, buildWakeHint(unreadMessages.length)),
@@ -238,7 +238,7 @@ describe("injectContinuation", () => {
expect(capturedBody?.variant).toBe("max")
})
test("#given a peer-message hold survives an unrelated release #when todo continuation injects #then it queues behind the peer message", async () => {
test("#given a peer-message hold survives an unrelated release #when todo continuation injects #then it does not record a queued prompt as injected", async () => {
// given
const sessionID = "ses_todo_reserved_by_peer_message"
let promptCalls = 0
@@ -286,7 +286,8 @@ describe("injectContinuation", () => {
expect(peerMessageResult.status).toBe("dispatched")
expect(promptCalls).toBe(1)
expect(state.inFlight).toBe(false)
expect(state.lastInjectedAt).toBeGreaterThan(0)
expect(state.lastInjectedAt).toBe(0)
expect(state.awaitingPostInjectionProgressCheck).not.toBe(true)
})
test("#given promptAsync may have accepted before EOF #when continuation injection observes the failure #then it records an optimistic injection", async () => {
@@ -194,6 +194,7 @@ ${todoList}`
sessionID,
source: HOOK_NAME,
settleMs: 0,
queueBehavior: "defer",
input: {
path: { id: sessionID },
body: {
@@ -1225,7 +1225,7 @@ describe("todo-continuation-enforcer", () => {
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@@ -1248,7 +1248,7 @@ describe("todo-continuation-enforcer", () => {
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "assistant" } },
{ info: { id: "msg-1", role: "assistant", finish: "stop" } },
{ info: { id: "msg-2", role: "user" } },
]
@@ -1294,7 +1294,7 @@ describe("todo-continuation-enforcer", () => {
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@@ -1324,7 +1324,7 @@ describe("todo-continuation-enforcer", () => {
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@@ -1355,7 +1355,7 @@ describe("todo-continuation-enforcer", () => {
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@@ -1387,7 +1387,7 @@ describe("todo-continuation-enforcer", () => {
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@@ -1426,7 +1426,7 @@ describe("todo-continuation-enforcer", () => {
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@@ -1465,7 +1465,7 @@ describe("todo-continuation-enforcer", () => {
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@@ -1504,7 +1504,7 @@ describe("todo-continuation-enforcer", () => {
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@@ -1542,7 +1542,7 @@ describe("todo-continuation-enforcer", () => {
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@@ -1619,7 +1619,7 @@ describe("todo-continuation-enforcer", () => {
// OpenCode returns assistant messages with flat modelID/providerID, not nested model object
const mockMessagesWithAssistant = [
{ info: { id: "msg-1", role: "user", agent: "sisyphus", model: { providerID: "openai", modelID: "gpt-5.4" } } },
{ info: { id: "msg-2", role: "assistant", agent: "sisyphus", modelID: "gpt-5.4", providerID: "openai" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop", agent: "sisyphus", modelID: "gpt-5.4", providerID: "openai" } },
]
const mockInput = {
@@ -1677,8 +1677,8 @@ describe("todo-continuation-enforcer", () => {
const mockMessagesWithCompaction = [
{ info: { id: "msg-1", role: "user", agent: "sisyphus", model: { providerID: "anthropic", modelID: "claude-sonnet-4-6" } } },
{ info: { id: "msg-2", role: "assistant", agent: "sisyphus", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
{ info: { id: "msg-3", role: "assistant", agent: "compaction", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop", agent: "sisyphus", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
{ info: { id: "msg-3", role: "assistant", finish: "stop", agent: "compaction", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
]
const mockInput = {
@@ -1730,7 +1730,7 @@ describe("todo-continuation-enforcer", () => {
setMainSession(sessionID)
const mockMessagesOnlyCompaction = [
{ info: { id: "msg-1", role: "assistant", agent: "compaction" } },
{ info: { id: "msg-1", role: "assistant", finish: "stop", agent: "compaction" } },
]
const mockInput = {
@@ -1783,7 +1783,7 @@ describe("todo-continuation-enforcer", () => {
setMainSession(sessionID)
const mockMessagesWithCompactionMarker = [
{ info: { id: "msg-1", role: "assistant", agent: "sisyphus", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
{ info: { id: "msg-1", role: "assistant", finish: "stop", agent: "sisyphus", modelID: "claude-sonnet-4-6", providerID: "anthropic" } },
{
info: { id: "msg-2", role: "user", agent: "atlas", model: { providerID: "openai", modelID: "gpt-5.4" } },
parts: [{ type: "compaction" }],
@@ -1840,8 +1840,8 @@ describe("todo-continuation-enforcer", () => {
const mockMessagesPrometheusCompacted = [
{ info: { id: "msg-1", role: "user", agent: "prometheus" } },
{ info: { id: "msg-2", role: "assistant", agent: "prometheus" } },
{ info: { id: "msg-3", role: "assistant", agent: "compaction" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop", agent: "prometheus" } },
{ info: { id: "msg-3", role: "assistant", finish: "stop", agent: "compaction" } },
]
const mockInput = {
@@ -1896,7 +1896,7 @@ describe("todo-continuation-enforcer", () => {
const mockMessagesNoAgent = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
]
const mockInput = {
@@ -2103,7 +2103,7 @@ describe("todo-continuation-enforcer", () => {
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@@ -2136,7 +2136,7 @@ describe("todo-continuation-enforcer", () => {
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@@ -2240,7 +2240,7 @@ describe("todo-continuation-enforcer", () => {
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
{ info: { id: "msg-2", role: "assistant", finish: "stop" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
@@ -383,7 +383,49 @@ describe("unstable-agent-babysitter hook", () => {
}
})
test("#given the latest main-session message is assistant output after a fresh user message #when it becomes idle #then babysitter may inject a reminder", async () => {
test("#given the latest main-session assistant output has finished after a fresh user message #when it becomes idle #then babysitter may inject a reminder", async () => {
// given
const originalNow = Date.now
Date.now = () => 10 * 60 * 1000
setMainSession("main-1")
const promptCalls: Array<{ input: unknown }> = []
const ctx = createMockPluginInput({
messagesBySession: {
"main-1": [
{ info: { role: "user", time: { created: Date.now() - 1_500 } } },
{ info: { role: "assistant", time: { created: Date.now() - 500 }, agent: "sisyphus", finish: "stop" } },
],
"bg-1": [
{ info: { role: "assistant" }, parts: [{ type: "thinking", thinking: "deep thought" }] },
],
},
promptCalls,
})
const backgroundManager = createBackgroundManager([createTask({
progress: {
toolCalls: 1,
lastUpdate: new Date(0),
lastMessage: "still working",
lastMessageAt: new Date(0),
},
})])
const hook = createUnstableAgentBabysitterHook(ctx, {
backgroundManager,
config: { timeout_ms: 120000 },
})
try {
// when
await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } })
// then
expect(promptCalls.length).toBe(1)
} finally {
Date.now = originalNow
}
})
test("#given the latest main-session assistant output is still streaming after a fresh user message #when it becomes idle #then babysitter does not inject a reminder", async () => {
// given
const originalNow = Date.now
Date.now = () => 10 * 60 * 1000
@@ -419,7 +461,7 @@ describe("unstable-agent-babysitter hook", () => {
await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } })
// then
expect(promptCalls.length).toBe(1)
expect(promptCalls.length).toBe(0)
} finally {
Date.now = originalNow
}
@@ -247,6 +247,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
sessionID: mainSessionID,
source: HOOK_NAME,
settleMs: options.idleSettleMs,
queueBehavior: "defer",
input: {
path: { id: mainSessionID },
body: {