fix(prompt-gate): harden internal prompt dispatch

This commit is contained in:
YeonGyu-Kim
2026-05-19 16:02:18 +09:00
parent 6c63372ef9
commit 1492bffd20
49 changed files with 1488 additions and 141 deletions
@@ -161,6 +161,42 @@ describe("injectBoulderContinuation", () => {
expect(promptAsyncMock).not.toHaveBeenCalled()
})
test("#given promptAsync may have accepted boulder continuation before EOF #when injector observes the failure #then it records the continuation as injected", async () => {
// given
registerAgentName("atlas")
const promptAsyncMock = mock(async (_request: unknown) => {
throw new Error("JSON Parse error: Unexpected EOF")
})
const messagesMock = mock(async () => ({ data: [] }))
const sessionState = { promptFailureCount: 2 }
const ctx = unsafeTestValue<PluginInput>({
directory: "/tmp",
client: {
session: {
messages: messagesMock,
promptAsync: promptAsyncMock,
},
},
})
// when
const result = await injectBoulderContinuation({
ctx,
sessionID: "ses_test_eof",
planName: "test-plan",
remaining: 1,
total: 2,
agent: "atlas",
sessionState,
})
// then
expect(result).toBe("injected")
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
expect(sessionState.promptFailureCount).toBe(0)
})
test("#given recent prompt context includes variant #when injecting boulder continuation #then promptAsync receives variant as a top-level field", async () => {
// given
registerAgentName("atlas")
@@ -6,7 +6,8 @@ import {
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools } from "../../shared"
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
import { isAmbiguousPromptDispatchFailure } from "../../shared/prompt-failure-classifier"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
import { HOOK_NAME } from "./hook-name"
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
import { markContinuationInjectedAwaitingToolProgress } from "./tool-progress"
@@ -114,7 +115,7 @@ export async function injectBoulderContinuation(input: {
if (promptResult.status === "failed") {
throw promptResult.error
}
if (promptResult.status !== "dispatched") {
if (!isInternalPromptDispatchAccepted(promptResult)) {
log(`[${HOOK_NAME}] Boulder continuation skipped by promptAsync gate`, {
sessionID,
status: promptResult.status,
@@ -127,6 +128,15 @@ export async function injectBoulderContinuation(input: {
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
return "injected"
} catch (err) {
if (isAmbiguousPromptDispatchFailure(err)) {
sessionState.promptFailureCount = 0
markContinuationInjectedAwaitingToolProgress(sessionState)
log(`[${HOOK_NAME}] Boulder continuation prompt failed after dispatch may have been accepted`, {
sessionID,
error: String(err),
})
return "injected"
}
sessionState.promptFailureCount += 1
sessionState.lastFailureAt = Date.now()
log(`[${HOOK_NAME}] Boulder continuation failed`, {
+66
View File
@@ -6,6 +6,10 @@ import { tmpdir } from "node:os"
import { join } from "node:path"
import { createBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
import {
releaseAllPromptAsyncReservationsForTesting,
releasePromptAsyncReservation,
} from "../shared/prompt-async-gate"
import { handleAtlasSessionIdle } from "./idle-event"
import type { SessionState } from "./types"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
@@ -29,6 +33,7 @@ describe("handleAtlasSessionIdle completion nudge", () => {
rmSync(testDirectory, { recursive: true, force: true })
}
_resetForTesting()
releaseAllPromptAsyncReservationsForTesting()
})
it("injects BOULDER COMPLETE prompt once per work with substituted elapsed and task breakdown", async () => {
@@ -144,4 +149,65 @@ describe("handleAtlasSessionIdle completion nudge", () => {
expect(persistedState.boulderCompletionNudgedAt?.[workId]).toBeNumber()
expect(readBoulderState(testDirectory)?.works?.[workId]?.status).toBe("completed")
})
it("#given completion nudge promptAsync may have been accepted before EOF #when idle repeats after the gate hold #then it does not send a duplicate completion nudge", async () => {
// given
const planPath = join(testDirectory, "plan.md")
writeFileSync(planPath, "## TODOs\n- [x] 1. Parse input\n")
const boulder = createBoulderState(planPath, SESSION_ID, "atlas")
const workId = boulder.active_work_id
if (!workId) {
throw new Error("Expected active_work_id")
}
const work = boulder.works?.[workId]
if (!work) {
throw new Error("Expected active work")
}
work.elapsed_ms = 1_000
boulder.elapsed_ms = 1_000
writeBoulderState(testDirectory, boulder)
const promptAsyncMock = mock(async () => {
throw new Error("JSON Parse error: Unexpected EOF")
})
const ctx = unsafeTestValue<PluginInput>({
directory: testDirectory,
client: {
session: {
promptAsync: promptAsyncMock,
},
},
})
const sessionStateById = new Map<string, SessionState>()
const getState = (sessionId: string): SessionState => {
let state = sessionStateById.get(sessionId)
if (!state) {
state = { promptFailureCount: 0 }
sessionStateById.set(sessionId, state)
}
return state
}
// when
await handleAtlasSessionIdle({
ctx,
sessionID: SESSION_ID,
getState,
})
const released = releasePromptAsyncReservation(SESSION_ID, "test:simulate-expired-hold", {
reservedBy: "atlas",
})
await handleAtlasSessionIdle({
ctx,
sessionID: SESSION_ID,
getState,
})
// then
expect(released).toBe(true)
expect(promptAsyncMock).toHaveBeenCalledTimes(1)
expect(getState(SESSION_ID).boulderCompletionNudgedAt?.[workId]).toBeNumber()
})
})
+9 -2
View File
@@ -19,8 +19,9 @@ import { isSessionInBoulderLineage } from "./boulder-session-lineage"
import { createInternalAgentContinuationTextPart } from "../../shared"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { isAmbiguousPromptDispatchFailure } from "../../shared/prompt-failure-classifier"
import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle"
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { HOOK_NAME } from "./hook-name"
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
@@ -313,7 +314,13 @@ export async function handleAtlasSessionIdle(input: {
query: { directory: ctx.directory },
},
})
if (promptResult.status !== "dispatched") {
if (!isInternalPromptDispatchAccepted(promptResult)) {
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
sessionState.boulderCompletionNudgedAt = {
...(sessionState.boulderCompletionNudgedAt ?? {}),
[work.work_id]: Date.now(),
}
}
log(`[${HOOK_NAME}] Boulder completion nudge skipped by promptAsync gate`, {
sessionID,
status: promptResult.status,