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
@@ -17,7 +17,7 @@ import {
findNearestMessageWithFields,
findNearestMessageWithFieldsFromSDK,
} from "../../features/hook-message-injector"
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
export async function runAggressiveTruncationStrategy(params: {
sessionID: string
@@ -106,7 +106,7 @@ export async function runAggressiveTruncationStrategy(params: {
query: { directory: params.directory },
} as never,
})
if (promptResult.status !== "dispatched") {
if (!isInternalPromptDispatchAccepted(promptResult)) {
log("[auto-compact] delayed auto prompt skipped by promptAsync gate", {
sessionID: params.sessionID,
status: promptResult.status,
@@ -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,
@@ -8,7 +8,7 @@ import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-ca
import type { PluginConfig } from "../types"
import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared"
import { resolveSessionEventID } from "../../../shared/event-session-id"
import { dispatchInternalPrompt } from "../../../shared/prompt-async-gate"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../../shared/prompt-async-gate"
import {
clearAllSessionHookState,
clearSessionHookState,
@@ -124,7 +124,7 @@ export function createSessionEventHandler(
})
if (promptResult.status === "failed") {
log("Failed to inject prompt from Stop hook", { error: String(promptResult.error) })
} else if (promptResult.status !== "dispatched") {
} else if (!isInternalPromptDispatchAccepted(promptResult)) {
log("Skipped prompt injection from Stop hook", { sessionID, status: promptResult.status })
}
} else if (stopResult.block) {
@@ -1,7 +1,11 @@
/// <reference path="../../../bun-test.d.ts" />
import { describe, expect, it } from "bun:test"
import { afterEach, describe, expect, it } from "bun:test"
import { setCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
import {
releaseAllPromptAsyncReservationsForTesting,
releasePromptAsyncReservation,
} from "../shared/prompt-async-gate"
import { createCompactionContextInjector } from "./index"
type SessionMessageResponse = Array<{
@@ -98,6 +102,10 @@ function createMeaningfulPartUpdatedEvent(
}
describe("createCompactionContextInjector recovery", () => {
afterEach(() => {
releaseAllPromptAsyncReservationsForTesting()
})
it("re-injects after compaction when agent and model match but tools are missing", async () => {
//#given
const promptAsyncRecorder = createPromptAsyncRecorder()
@@ -304,6 +312,68 @@ describe("createCompactionContextInjector recovery", () => {
expect(promptAsyncRecorder.calls.length).toBe(1)
})
it("#given recovery promptAsync may have been accepted before EOF #when compaction repeats after the gate hold #then recovery is not duplicated", async () => {
//#given
const calls: PromptAsyncInput[] = []
const checkpointedPromptConfig = [
{
info: {
role: "user",
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(
[
checkpointedPromptConfig,
incompletePromptConfig,
incompletePromptConfig,
incompletePromptConfig,
incompletePromptConfig,
incompletePromptConfig,
],
async (input: PromptAsyncInput) => {
calls.push(input)
throw new Error("JSON Parse error: Unexpected EOF")
},
)
const injector = createCompactionContextInjector({ ctx })
const sessionID = "ses_recovery_eof_duplicate"
//#when
await injector.capture(sessionID)
await injector.event({
event: {
type: "session.compacted",
properties: { sessionID },
},
})
const released = releasePromptAsyncReservation(sessionID, "test:simulate-expired-hold", {
reservedBy: "compaction-context-injector",
})
await injector.event({
event: {
type: "session.compacted",
properties: { sessionID },
},
})
//#then
expect(released).toBe(true)
expect(calls.length).toBe(1)
})
it("does not treat reasoning-only assistant messages as a no-text tail", async () => {
//#given
const promptAsyncRecorder = createPromptAsyncRecorder()
@@ -7,6 +7,7 @@ import {
} from "../../shared/compaction-agent-config-checkpoint"
import { createInternalAgentContinuationTextPart } from "../../shared/internal-initiator-marker"
import { log } from "../../shared/logger"
import { isAmbiguousPromptDispatchFailure } from "../../shared/prompt-failure-classifier"
import { setSessionModel } from "../../shared/session-model-state"
import { setSessionTools } from "../../shared/session-tools-store"
import {
@@ -21,7 +22,7 @@ import {
import { AGENT_RECOVERY_PROMPT, NO_TEXT_TAIL_THRESHOLD, RECOVERY_COOLDOWN_MS, RECENT_COMPACTION_WINDOW_MS } from "./constants"
import type { CompactionContextClient } from "./types"
import type { TailMonitorState } from "./tail-monitor"
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
export function createRecoveryLogic(
ctx: CompactionContextClient | undefined,
@@ -99,7 +100,10 @@ export function createRecoveryLogic(
query: { directory: ctx.directory },
},
})
if (promptResult.status !== "dispatched") {
if (!isInternalPromptDispatchAccepted(promptResult)) {
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
tailState.lastRecoveryAt = now
}
log(`[compaction-context-injector] Recovery skipped by promptAsync gate`, {
sessionID,
reason,
@@ -1,7 +1,12 @@
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
import { injectContinuationPrompt } from "./continuation-prompt-injector"
describe("ralph-loop continuation prompt injector", () => {
afterEach(() => {
releaseAllPromptAsyncReservationsForTesting()
})
test("#given promptAsync resolves SDK error #when injecting continuation prompt #then it returns rejection without throwing", async () => {
// given
const ctx = {
@@ -59,6 +64,32 @@ describe("ralph-loop continuation prompt injector", () => {
}
})
test("#given promptAsync may have accepted before EOF #when injecting continuation prompt #then it returns dispatched", async () => {
// given
const ctx = {
client: {
session: {
messages: async () => ({ data: [] }),
promptAsync: async () => {
throw new Error("JSON Parse error: Unexpected EOF")
},
},
},
}
// when
const result = await injectContinuationPrompt(ctx as never, {
sessionID: "ses_ralph_eof",
prompt: "continue",
directory: "/tmp/test",
apiTimeoutMs: 50,
})
// then
expect(result.status).toBe("dispatched")
})
test("#given inherited message agent has ZWSP prefix #when injecting continuation prompt #then promptAsync receives registered display agent", async () => {
// given
let promptBody: { agent?: string; noReply?: boolean } | undefined
@@ -5,6 +5,7 @@ import { getMessageDir } from "./message-storage-directory"
import { withTimeout } from "./with-timeout"
import {
createInternalAgentContinuationTextPart,
isAmbiguousPromptDispatchFailure,
isRecord,
normalizeSDKResponse,
resolveInheritedPromptTools,
@@ -145,6 +146,7 @@ export async function injectContinuationPrompt(
sessionID: options.sessionID,
source: "ralph-loop",
settleMs: options.idleSettleMs,
queueBehavior: "defer",
input: {
path: { id: options.sessionID },
body: {
@@ -158,8 +160,14 @@ export async function injectContinuationPrompt(
},
})
if (promptResult.status === "failed") {
if (isAmbiguousPromptDispatchFailure(promptResult.error)) {
return { status: "dispatched" }
}
throw promptResult.error
}
if (promptResult.status === "queued") {
return { status: "deferred", reason: "reserved" }
}
if (promptResult.status === "active" || promptResult.status === "reserved") {
return { status: "deferred", reason: promptResult.status }
}
@@ -171,6 +179,9 @@ export async function injectContinuationPrompt(
}
response = promptResult.response
} catch (error) {
if (isAmbiguousPromptDispatchFailure(error)) {
return { status: "dispatched" }
}
const promptError = error instanceof Error
? error
: createPromptAsyncError("promptAsync rejected", error)
+12 -2
View File
@@ -13,8 +13,10 @@ import { extractSessionMessages } from "./session-messages"
import { resolveRegisteredAgentName } from "../../features/claude-code-session-state"
import {
dispatchInternalPrompt,
isInternalPromptDispatchAccepted,
releasePromptAsyncReservation,
} from "../shared/prompt-async-gate"
import { isAmbiguousPromptDispatchFailure } from "../../shared/prompt-failure-classifier"
const SESSION_TTL_MS = 30 * 60 * 1000
@@ -141,6 +143,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
const previousPendingFallbackModel = sessionStates.get(sessionID)?.pendingFallbackModel
sessionRetryInFlight.add(sessionID)
let retryDispatched = false
let retryMayHaveBeenAccepted = false
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
@@ -180,9 +183,16 @@ export function createAutoRetryHelpers(deps: HookDeps) {
},
})
if (promptResult.status === "failed") {
if (isAmbiguousPromptDispatchFailure(promptResult.error)) {
retryMayHaveBeenAccepted = true
log(`[${HOOK_NAME}] Auto-retry prompt failed after dispatch may have been accepted (${source}); preserving fallback state`, {
sessionID,
error: String(promptResult.error),
})
}
throw promptResult.error
}
if (promptResult.status !== "dispatched") {
if (!isInternalPromptDispatchAccepted(promptResult)) {
log(`[${HOOK_NAME}] Auto-retry skipped by promptAsync gate (${source})`, {
sessionID,
status: promptResult.status,
@@ -201,7 +211,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
} finally {
sessionRetryInFlight.delete(sessionID)
if (!retryDispatched) {
if (!retryDispatched && !retryMayHaveBeenAccepted) {
if (hadAwaitingFallbackResult) {
sessionAwaitingFallbackResult.add(sessionID)
} else {
+71
View File
@@ -8,6 +8,10 @@ import {
} from "../../shared/delegated-child-session-bootstrap"
import * as loggerModule from "../../shared/logger"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import {
releaseAllPromptAsyncReservationsForTesting,
releasePromptAsyncReservation,
} from "../shared/prompt-async-gate"
import type { RuntimeFallbackPluginInput } from "./types"
type RuntimeFallbackModule = typeof import("./hook")
@@ -23,6 +27,7 @@ describe("runtime-fallback", () => {
toastCalls = []
SessionCategoryRegistry.clear()
clearAllDelegatedChildSessionBootstrap()
releaseAllPromptAsyncReservationsForTesting()
const cacheBuster = `${Date.now()}-${Math.random()}`
@@ -40,6 +45,7 @@ describe("runtime-fallback", () => {
afterEach(() => {
SessionCategoryRegistry.clear()
clearAllDelegatedChildSessionBootstrap()
releaseAllPromptAsyncReservationsForTesting()
mock.restore()
})
@@ -1350,6 +1356,71 @@ describe("runtime-fallback", () => {
void sessionErrorPromise
})
test("#given promptAsync fails after fallback retry may have been accepted #when the gate hold expires and the same error repeats #then the pending fallback state prevents a duplicate retry prompt", async () => {
// given
let promptCalls = 0
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }],
}),
promptAsync: async () => {
promptCalls += 1
throw new Error("JSON Parse error: Unexpected EOF")
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"provider-a/model-a",
"provider-b/model-b",
]),
}
)
const sessionID = "test-runtime-fallback-eof-preserves-pending"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
},
})
// when
await hook.event({
event: {
type: "session.error",
properties: {
sessionID,
model: "google/gemini-2.5-pro",
error: { statusCode: 429, message: "Rate limit" },
},
},
})
const released = releasePromptAsyncReservation(sessionID, "test:simulate-expired-hold", {
reservedBy: "runtime-fallback:session.error",
})
await hook.event({
event: {
type: "session.error",
properties: {
sessionID,
model: "google/gemini-2.5-pro",
error: { statusCode: 429, message: "Rate limit" },
},
},
})
// then
expect(released).toBe(true)
expect(promptCalls).toBe(1)
const skipLog = logCalls.find((call) => call.msg.includes("session.error skipped - awaiting fallback result"))
expect(skipLog).toBeDefined()
})
test("should force advance fallback from message.updated when Copilot auto-retry signal appears during in-flight retry", async () => {
const retriedModels: string[] = []
const pending = new Promise<never>(() => {})
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
import type { MessageData } from "./types"
let sqliteBackend = false
@@ -34,11 +35,14 @@ interface PromptAsyncInput {
}
}
function createMockClient(messages: MessageData[] = []) {
function createMockClient(
messages: MessageData[] = [],
promptAsyncImpl?: (input: PromptAsyncInput) => Promise<unknown>,
) {
const promptAsyncCalls: PromptAsyncInput[] = []
const promptAsync = mock((input: PromptAsyncInput) => {
promptAsyncCalls.push(input)
return Promise.resolve({})
return promptAsyncImpl ? promptAsyncImpl(input) : Promise.resolve({})
})
return {
@@ -69,6 +73,7 @@ describe("recoverToolResultMissing", () => {
afterEach(() => {
mock.restore()
releaseAllPromptAsyncReservationsForTesting()
})
it("returns false for sqlite fallback when tool part has no valid callID", async () => {
@@ -286,6 +291,27 @@ describe("recoverToolResultMissing", () => {
expect(call.body).not.toHaveProperty("model")
expect(call.body).not.toHaveProperty("variant")
})
it("#given recovered tool result may have been accepted before EOF #when promptAsync fails ambiguously #then recovery is treated as started", async () => {
// given
storedParts = [{
type: "tool",
id: "prt_stored_eof_call",
callID: "toolu_eof",
tool: "bash",
state: { input: {} },
}]
const { client, promptAsync } = createMockClient([], async () => {
throw new Error("JSON Parse error: Unexpected EOF")
})
// when
const result = await recoverToolResultMissing(client, "ses_eof_recovery", failedAssistantMsg)
// then
expect(result).toBe(true)
expect(promptAsync).toHaveBeenCalledTimes(1)
})
})
export {}
@@ -2,8 +2,8 @@ import type { createOpencodeClient } from "@opencode-ai/sdk"
import type { MessageData, ResumeConfig } from "./types"
import { readParts } from "./storage/parts-reader"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { normalizeSDKResponse } from "../../shared"
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
import { isAmbiguousPromptDispatchFailure, normalizeSDKResponse } from "../../shared"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
type Client = ReturnType<typeof createOpencodeClient>
type ToolResultContent = { type: "text"; text: string }
@@ -176,9 +176,13 @@ export async function recoverToolResultMissing(
source: options?.source ?? "session-recovery-tool-result-missing",
input: promptInput,
checkToolState: false,
queueBehavior: "defer",
})
return promptResult.status === "dispatched"
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
return true
}
return isInternalPromptDispatchAccepted(promptResult)
} catch {
return false
}
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
import type { MessageData } from "./types"
let sqliteBackend = false
@@ -24,8 +25,8 @@ const failedAssistantMsg: MessageData = {
parts: [],
}
function createMockClient(messages: MessageData[] = []) {
const promptAsync = mock(() => Promise.resolve({}))
function createMockClient(messages: MessageData[] = [], promptAsyncImpl?: () => Promise<unknown>) {
const promptAsync = mock(() => promptAsyncImpl ? promptAsyncImpl() : Promise.resolve({}))
return {
client: {
@@ -46,6 +47,7 @@ describe("recoverUnavailableTool", () => {
afterEach(() => {
mock.restore()
releaseAllPromptAsyncReservationsForTesting()
})
it("sends a schema-compatible recovered tool result for sqlite fallback", async () => {
@@ -109,4 +111,22 @@ describe("recoverUnavailableTool", () => {
},
})
})
it("#given unavailable-tool recovery may have been accepted before EOF #when promptAsync fails ambiguously #then recovery is treated as started", async () => {
//#given
const failedAssistantWithToolUse: MessageData = {
info: { id: "msg_failed_eof", role: "assistant", error: "No such tool: bash" },
parts: [{ type: "tool_use", id: "toolu_eof", name: "bash" }],
}
const { client, promptAsync } = createMockClient([], async () => {
throw new Error("JSON Parse error: Unexpected EOF")
})
//#when
const result = await recoverUnavailableTool(client, "ses_unavailable_eof", failedAssistantWithToolUse)
//#then
expect(result).toBe(true)
expect(promptAsync).toHaveBeenCalledTimes(1)
})
})
@@ -2,9 +2,9 @@ import type { createOpencodeClient } from "@opencode-ai/sdk"
import { extractUnavailableToolName } from "./detect-error-type"
import { readParts } from "./storage"
import type { MessageData } from "./types"
import { normalizeSDKResponse } from "../../shared"
import { isAmbiguousPromptDispatchFailure, normalizeSDKResponse } from "../../shared"
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
type Client = ReturnType<typeof createOpencodeClient>
@@ -126,9 +126,13 @@ export async function recoverUnavailableTool(
client,
sessionID,
source: "session-recovery-unavailable-tool",
queueBehavior: "defer",
input: promptInput,
})
return promptResult.status === "dispatched"
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
return true
}
return isInternalPromptDispatchAccepted(promptResult)
} catch {
return false
}
+29 -1
View File
@@ -1,11 +1,16 @@
declare const require: (name: string) => any
const { describe, expect, test } = require("bun:test")
const { afterEach, describe, expect, test } = require("bun:test")
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
import { extractResumeConfig, findLastUserMessage, resumeSession } from "./resume"
import type { MessageData } from "./types"
describe("session-recovery resume", () => {
afterEach(() => {
releaseAllPromptAsyncReservationsForTesting()
})
test("findLastUserMessage skips synthetic and internally marked user messages", () => {
// given
const realUserMessage: MessageData = {
@@ -123,4 +128,27 @@ describe("session-recovery resume", () => {
expect(firstPart?.metadata?.compaction_continue).toBe(true)
expect(promptBody?.noReply).toBeUndefined()
})
test("#given recovery resume may have been accepted before EOF #when promptAsync fails ambiguously #then resume is treated as started", async () => {
// given
let promptCalls = 0
const client = {
session: {
promptAsync: async () => {
promptCalls += 1
throw new Error("JSON Parse error: Unexpected EOF")
},
},
}
// when
const ok = await resumeSession(client as never, {
sessionID: "ses_resume_eof",
agent: "Hephaestus",
})
// then
expect(ok).toBe(true)
expect(promptCalls).toBe(1)
})
})
+7 -2
View File
@@ -1,10 +1,11 @@
import type { createOpencodeClient } from "@opencode-ai/sdk"
import {
createInternalAgentContinuationTextPart,
isAmbiguousPromptDispatchFailure,
isRealUserMessage,
resolveInheritedPromptTools,
} from "../../shared"
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
import type { MessageData, ResumeConfig } from "./types"
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
@@ -43,6 +44,7 @@ export async function resumeSession(client: Client, config: ResumeConfig): Promi
client,
sessionID: config.sessionID,
source: "session-recovery",
queueBehavior: "defer",
input: {
path: { id: config.sessionID },
body: {
@@ -54,7 +56,10 @@ export async function resumeSession(client: Client, config: ResumeConfig): Promi
},
},
})
return promptResult.status === "dispatched"
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
return true
}
return isInternalPromptDispatchAccepted(promptResult)
} catch {
return false
}
+250 -15
View File
@@ -7,6 +7,18 @@ import {
releasePromptAsyncReservation,
} from "./prompt-async-gate"
function waitForPromise<T>(promise: Promise<T>, label: string): Promise<T> {
let timeoutID: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timeoutID = setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), 1_000)
})
return Promise.race([promise, timeout]).finally(() => {
if (timeoutID !== undefined) {
clearTimeout(timeoutID)
}
})
}
describe("dispatchInternalPrompt", () => {
afterEach(() => {
// then
@@ -119,9 +131,232 @@ describe("dispatchInternalPrompt", () => {
// then
expect(first.status).toBe("dispatched")
expect(second).toEqual({ status: "reserved", reservedBy: "test:unified-shared:first" })
expect(second).toEqual({ status: "queued", queuedBy: "test:unified-shared:first", position: 1 })
expect(calls).toEqual(["async"])
})
test("#given a busy session #when an internal prompt is dispatched #then the unified dispatcher queues and sends after idle", async () => {
// given
let status = "busy"
let promptCalls = 0
let resolvePrompt: (() => void) | undefined
const promptSeen = new Promise<void>((resolve) => {
resolvePrompt = resolve
})
const client = {
session: {
status: async () => ({ data: { ses_queue_busy: { type: status } } }),
promptAsync: async () => {
promptCalls += 1
resolvePrompt?.()
},
},
}
// when
const result = await dispatchInternalPrompt({
mode: "async",
client,
sessionID: "ses_queue_busy",
input: { path: { id: "ses_queue_busy" }, body: { parts: [{ type: "text", text: "queued" }] } },
source: "test:queue-busy",
settleMs: 0,
queueRetryMs: 1,
})
status = "idle"
await waitForPromise(promptSeen, "queued prompt to dispatch after idle")
// then
expect(result.status).toBe("queued")
expect(promptCalls).toBe(1)
})
test("#given duplicate queued prompts for one session #when the session becomes idle #then the dispatcher coalesces them into one prompt", async () => {
// given
let status = "busy"
let promptCalls = 0
let resolvePrompt: (() => void) | undefined
const promptSeen = new Promise<void>((resolve) => {
resolvePrompt = resolve
})
const input = { path: { id: "ses_queue_dedupe" }, body: { parts: [{ type: "text", text: "same" }] } }
const client = {
session: {
status: async () => ({ data: { ses_queue_dedupe: { type: status } } }),
promptAsync: async () => {
promptCalls += 1
resolvePrompt?.()
},
},
}
// when
const first = await dispatchInternalPrompt({
mode: "async",
client,
sessionID: "ses_queue_dedupe",
input,
source: "test:queue-dedupe",
settleMs: 0,
queueRetryMs: 1,
})
const second = await dispatchInternalPrompt({
mode: "async",
client,
sessionID: "ses_queue_dedupe",
input,
source: "test:queue-dedupe",
settleMs: 0,
queueRetryMs: 1,
})
status = "idle"
await waitForPromise(promptSeen, "coalesced queued prompt")
// then
expect(first.status).toBe("queued")
expect(second.status).toBe("queued")
expect(promptCalls).toBe(1)
})
test("#given distinct queued prompts behind a dispatch hold #when the hold is released #then the dispatcher preserves FIFO order", async () => {
// given
const calls: string[] = []
let resolveSecondPrompt: (() => void) | undefined
const secondPromptSeen = new Promise<void>((resolve) => {
resolveSecondPrompt = resolve
})
const client = {
session: {
promptAsync: async (input: { body: { parts: Array<{ text: string }> } }) => {
const text = input.body.parts[0]?.text
if (text) {
calls.push(text)
}
if (calls.length === 2) {
resolveSecondPrompt?.()
}
},
},
}
// when
const first = await dispatchInternalPrompt({
mode: "async",
client,
sessionID: "ses_queue_fifo",
input: { path: { id: "ses_queue_fifo" }, body: { parts: [{ type: "text", text: "first" }] } },
source: "test:queue-fifo:first",
settleMs: 0,
})
const second = await dispatchInternalPrompt({
mode: "async",
client,
sessionID: "ses_queue_fifo",
input: { path: { id: "ses_queue_fifo" }, body: { parts: [{ type: "text", text: "second" }] } },
source: "test:queue-fifo:second",
settleMs: 0,
})
releasePromptAsyncReservation("ses_queue_fifo", "test:release-fifo", {
reservedBy: "test:queue-fifo:first",
})
await waitForPromise(secondPromptSeen, "second queued prompt")
// then
expect(first.status).toBe("dispatched")
expect(second.status).toBe("queued")
expect(calls).toEqual(["first", "second"])
})
test("#given a stateful route defers queued delivery #when a dispatch hold is active #then the prompt is not queued behind the hold", async () => {
// given
const calls: string[] = []
const client = {
session: {
promptAsync: async (input: { body: { parts: Array<{ text: string }> } }) => {
const text = input.body.parts[0]?.text
if (text) {
calls.push(text)
}
},
},
}
// when
const first = await dispatchInternalPrompt({
mode: "async",
client,
sessionID: "ses_queue_defer_hold",
input: { path: { id: "ses_queue_defer_hold" }, body: { parts: [{ type: "text", text: "first" }] } },
source: "test:queue-defer:first",
settleMs: 0,
})
const second = await dispatchInternalPrompt({
mode: "async",
client,
sessionID: "ses_queue_defer_hold",
input: { path: { id: "ses_queue_defer_hold" }, body: { parts: [{ type: "text", text: "second" }] } },
source: "test:queue-defer:second",
settleMs: 0,
queueBehavior: "defer",
})
releasePromptAsyncReservation("ses_queue_defer_hold", "test:queue-defer:release", {
reservedBy: "test:queue-defer:first",
})
// then
expect(first.status).toBe("dispatched")
expect(second).toEqual({ status: "reserved", reservedBy: "test:queue-defer:first" })
expect(calls).toEqual(["first"])
})
test("#given a queued prompt is waiting #when a stateful route defers queued delivery #then it does not cut ahead or enqueue", async () => {
// given
let status = "busy"
const calls: string[] = []
let resolvePrompt: (() => void) | undefined
const promptSeen = new Promise<void>((resolve) => {
resolvePrompt = resolve
})
const client = {
session: {
status: async () => ({ data: { ses_queue_defer_existing: { type: status } } }),
promptAsync: async (input: { body: { parts: Array<{ text: string }> } }) => {
const text = input.body.parts[0]?.text
if (text) {
calls.push(text)
}
resolvePrompt?.()
},
},
}
// when
const first = await dispatchInternalPrompt({
mode: "async",
client,
sessionID: "ses_queue_defer_existing",
input: { path: { id: "ses_queue_defer_existing" }, body: { parts: [{ type: "text", text: "first" }] } },
source: "test:queue-defer-existing:first",
settleMs: 0,
queueRetryMs: 1,
})
const second = await dispatchInternalPrompt({
mode: "async",
client,
sessionID: "ses_queue_defer_existing",
input: { path: { id: "ses_queue_defer_existing" }, body: { parts: [{ type: "text", text: "second" }] } },
source: "test:queue-defer-existing:second",
settleMs: 0,
queueBehavior: "defer",
})
status = "idle"
await waitForPromise(promptSeen, "first queued prompt after defer")
// then
expect(first.status).toBe("queued")
expect(second).toEqual({ status: "reserved", reservedBy: "test:queue-defer-existing:first" })
expect(calls).toEqual(["first"])
})
})
describe("dispatchInternalPrompt shared gate behavior", () => {
@@ -173,7 +408,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
// then
expect(firstResult.status).toBe("dispatched")
expect(second.status).toBe("reserved")
expect(second.status).toBe("queued")
expect(promptCalls).toBe(1)
})
@@ -209,7 +444,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
// then
expect(firstResult.status).toBe("dispatched")
expect(second.status).toBe("reserved")
expect(second.status).toBe("queued")
expect(promptCalls).toBe(1)
})
@@ -284,7 +519,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
})
// then
expect(result.status).toBe("active")
expect(result.status).toBe("queued")
expect(promptCalls).toBe(0)
})
@@ -312,7 +547,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
})
// then
expect(result.status).toBe("active")
expect(result.status).toBe("queued")
expect(promptCalls).toBe(0)
})
@@ -352,7 +587,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
})
// then
expect(result.status).toBe("active")
expect(result.status).toBe("queued")
expect(promptCalls).toBe(0)
})
@@ -392,7 +627,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
})
// then
expect(result.status).toBe("active")
expect(result.status).toBe("queued")
expect(promptCalls).toBe(0)
})
@@ -432,7 +667,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
})
// then
expect(result.status).toBe("active")
expect(result.status).toBe("queued")
expect(promptCalls).toBe(0)
})
@@ -476,7 +711,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
})
// then
expect(result.status).toBe("active")
expect(result.status).toBe("queued")
expect(promptCalls).toBe(0)
})
@@ -520,7 +755,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
})
// then
expect(result.status).toBe("active")
expect(result.status).toBe("queued")
expect(promptCalls).toBe(0)
})
@@ -723,7 +958,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
// then
expect(first.status).toBe("dispatched")
expect(second).toEqual({ status: "reserved", reservedBy: "team-live-delivery" })
expect(second).toEqual({ status: "queued", queuedBy: "team-live-delivery", position: 1 })
expect(promptCalls).toBe(1)
})
@@ -847,7 +1082,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
// then
expect(first.status).toBe("failed")
expect(second).toEqual({ status: "reserved", reservedBy: "test:reject:first" })
expect(second).toEqual({ status: "queued", queuedBy: "test:reject:first", position: 1 })
expect(promptCalls).toBe(1)
})
@@ -895,7 +1130,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
// then
expect(first.status).toBe("dispatched")
expect(released).toBe(false)
expect(second).toEqual({ status: "reserved", reservedBy: "model-fallbackx:message.updated" })
expect(second).toEqual({ status: "queued", queuedBy: "model-fallbackx:message.updated", position: 1 })
expect(promptCalls).toBe(1)
})
@@ -935,7 +1170,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
// then
expect(firstResult.status).toBe("dispatched")
expect(second.status).toBe("reserved")
expect(second.status).toBe("queued")
expect(promptCalls).toBe(1)
})
@@ -965,7 +1200,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
// then
expect(firstResult.status).toBe("dispatched")
expect(second.status).toBe("reserved")
expect(second.status).toBe("queued")
expect(promptCalls).toBe(1)
})
@@ -22,6 +22,10 @@ import {
clearAllSessionPromptParams,
getSessionPromptParams,
} from "../../shared/session-prompt-params-state"
import {
releaseAllPromptAsyncReservationsForTesting,
releasePromptAsyncReservation,
} from "../shared/prompt-async-gate"
import { createTeamIdleWakeHint } from "./team-idle-wake-hint"
type WakeHintPromptInput = {
@@ -183,6 +187,7 @@ afterEach(async () => {
clearTeamSessionRegistry()
SessionCategoryRegistry.clear()
clearAllSessionPromptParams()
releaseAllPromptAsyncReservationsForTesting()
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
await rm(directoryPath, { recursive: true, force: true })
}))
@@ -295,6 +300,44 @@ describe("createTeamIdleWakeHint", () => {
expect(promptAsyncSpy).toHaveBeenCalledTimes(0)
})
test("#given wake hint promptAsync may have been accepted before EOF #when idle repeats after the gate hold #then the same unread batch is not hinted twice", async () => {
// given
const baseDir = await createTemporaryBaseDir()
const config = createConfig(baseDir)
const teamRunId = randomUUID()
await seedRuntimeState(createRuntimeState(teamRunId), config)
await seedUnreadMessage(teamRunId, config, randomUUID(), "first message body", 100)
const promptAsyncSpy = mock(async (_input: WakeHintPromptInput) => {
throw new Error("JSON Parse error: Unexpected EOF")
})
const handler = createTeamIdleWakeHint({
directory: "/tmp/project",
client: { session: { promptAsync: promptAsyncSpy } },
}, config, { idleSettleMs: 0 })
// when
await handler({
event: {
type: "session.idle",
properties: { sessionID: "member-session" },
},
})
const released = releasePromptAsyncReservation("member-session", "test:simulate-expired-hold", {
reservedBy: "team-idle-wake-hint",
})
await handler({
event: {
type: "session.idle",
properties: { sessionID: "member-session" },
},
})
// then
expect(released).toBe(true)
expect(promptAsyncSpy).toHaveBeenCalledTimes(1)
})
test("pins the recipient's resolved subagent_type and model on the wake-hint promptAsync", async () => {
// given
const baseDir = await createTemporaryBaseDir()
@@ -8,8 +8,9 @@ import { ackMessages } from "../../features/team-mode/team-mailbox/ack"
import { listUnreadMessages } from "../../features/team-mode/team-mailbox/inbox"
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { isAmbiguousPromptDispatchFailure } from "../../shared/prompt-failure-classifier"
import { log } from "../../shared/logger"
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
type PromptAsyncInput = {
path: { id: string }
@@ -35,6 +36,7 @@ type TeamIdleWakeHintContext = {
type HookInput = { event: { type: string; properties?: unknown } }
export type HookImpl = (input: HookInput) => Promise<void>
type TeamIdleWakeHintOptions = { idleSettleMs?: number }
const WAKE_HINT_DUPLICATE_SUPPRESSION_MS = 30_000
function getIdleSessionID(properties: unknown): string | undefined {
return resolveSessionEventID(properties)
@@ -44,7 +46,13 @@ function buildWakeHint(unreadCount: number): string {
return `You have ${unreadCount} new team messages. They will be injected on your next turn.`
}
function buildWakeHintBatchKey(teamRunId: string, memberName: string, messageIds: string[]): string {
return `${teamRunId}:${memberName}:${messageIds.toSorted().join(",")}`
}
export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: TeamModeConfig, options?: TeamIdleWakeHintOptions): HookImpl {
const recentWakeHintBatches = new Map<string, number>()
return async ({ event }: HookInput): Promise<void> => {
if (event.type !== "session.idle") return
@@ -110,6 +118,27 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea
return
}
const now = Date.now()
const wakeHintBatchKey = buildWakeHintBatchKey(
runtimeState.teamRunId,
memberEntry.name,
unreadMessages.map((message) => message.messageId),
)
const suppressedUntil = recentWakeHintBatches.get(wakeHintBatchKey)
if (suppressedUntil !== undefined && suppressedUntil > now) {
log("team idle wake hint skipped for recently hinted unread batch", {
event: "team-mode-idle-wake-hint-duplicate-suppressed",
teamRunId: runtimeState.teamRunId,
memberName: memberEntry.name,
sessionID,
unreadCount: unreadMessages.length,
})
return
}
if (suppressedUntil !== undefined) {
recentWakeHintBatches.delete(wakeHintBatchKey)
}
applyMemberSessionRouting(sessionID, memberEntry)
const promptResult = await dispatchInternalPrompt({
mode: "async",
@@ -123,7 +152,10 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea
query: { directory: ctx.directory },
},
})
if (promptResult.status !== "dispatched") {
if (!isInternalPromptDispatchAccepted(promptResult)) {
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
recentWakeHintBatches.set(wakeHintBatchKey, Date.now() + WAKE_HINT_DUPLICATE_SUPPRESSION_MS)
}
log("team idle wake hint skipped by promptAsync gate", {
event: "team-mode-idle-wake-hint-gated",
teamRunId: runtimeState.teamRunId,
@@ -134,6 +166,7 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea
})
return
}
recentWakeHintBatches.set(wakeHintBatchKey, Date.now() + WAKE_HINT_DUPLICATE_SUPPRESSION_MS)
log("team idle wake hint sent", {
event: "team-mode-idle-wake-hint",
@@ -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 skips and clears in-flight state", async () => {
test("#given a peer-message hold survives an unrelated release #when todo continuation injects #then it queues behind the peer message", async () => {
// given
const sessionID = "ses_todo_reserved_by_peer_message"
let promptCalls = 0
@@ -286,6 +286,50 @@ describe("injectContinuation", () => {
expect(peerMessageResult.status).toBe("dispatched")
expect(promptCalls).toBe(1)
expect(state.inFlight).toBe(false)
expect(state.lastInjectedAt).toBe(0)
expect(state.lastInjectedAt).toBeGreaterThan(0)
})
test("#given promptAsync may have accepted before EOF #when continuation injection observes the failure #then it records an optimistic injection", async () => {
// given
const state = {
inFlight: false,
lastInjectedAt: 0,
awaitingPostInjectionProgressCheck: false,
consecutiveFailures: 2,
}
let promptCalls = 0
const ctx = {
directory: "/tmp/test",
client: {
session: {
todo: async () => ({ data: [{ id: "1", content: "todo", status: "pending", priority: "high" }] }),
promptAsync: async () => {
promptCalls += 1
throw new Error("JSON Parse error: Unexpected EOF")
},
},
},
}
const sessionStateStore = {
getExistingState: () => state,
}
// when
await injectContinuation({
ctx: ctx as never,
sessionID: "ses_continuation_eof",
resolvedInfo: {
agent: "Sisyphus - Ultraworker",
model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
},
sessionStateStore: sessionStateStore as never,
})
// then
expect(promptCalls).toBe(1)
expect(state.inFlight).toBe(false)
expect(state.awaitingPostInjectionProgressCheck).toBe(true)
expect(state.consecutiveFailures).toBe(0)
expect(state.lastInjectedAt).toBeGreaterThan(0)
})
})
@@ -7,6 +7,7 @@ import {
} from "../../features/claude-code-session-state"
import {
createInternalAgentContinuationTextPart,
isAmbiguousPromptDispatchFailure,
normalizeSDKResponse,
resolveInheritedPromptTools,
} from "../../shared"
@@ -22,7 +23,7 @@ import {
normalizeAgentForPromptKey,
stripAgentListSortPrefix,
} from "../../shared/agent-display-names"
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
import {
CONTINUATION_PROMPT,
@@ -208,7 +209,7 @@ ${todoList}`
if (promptResult.status === "failed") {
throw promptResult.error
}
if (promptResult.status !== "dispatched") {
if (!isInternalPromptDispatchAccepted(promptResult)) {
log(`[${HOOK_NAME}] Injection skipped by promptAsync gate`, { sessionID, status: promptResult.status })
if (injectionState) {
injectionState.inFlight = false
@@ -216,7 +217,7 @@ ${todoList}`
return
}
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
log(`[${HOOK_NAME}] Injection successful`, { sessionID, status: promptResult.status })
if (injectionState) {
injectionState.inFlight = false
injectionState.lastInjectedAt = Date.now()
@@ -228,6 +229,11 @@ ${todoList}`
if (injectionState) {
injectionState.inFlight = false
injectionState.lastInjectedAt = Date.now()
if (isAmbiguousPromptDispatchFailure(error)) {
injectionState.awaitingPostInjectionProgressCheck = true
injectionState.consecutiveFailures = 0
return
}
injectionState.consecutiveFailures = (injectionState.consecutiveFailures ?? 0) + 1
const errorObj = error instanceof Error
@@ -2,7 +2,10 @@ import { afterEach, describe, expect, test } from "bun:test"
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
import type { BackgroundTask } from "../../features/background-agent"
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
import {
releaseAllPromptAsyncReservationsForTesting,
releasePromptAsyncReservation,
} from "../shared/prompt-async-gate"
import { createUnstableAgentBabysitterHook } from "./index"
const projectDir = process.cwd()
@@ -12,6 +15,7 @@ type BabysitterContext = Parameters<typeof createUnstableAgentBabysitterHook>[0]
function createMockPluginInput(options: {
messagesBySession: Record<string, unknown[]>
promptCalls: Array<{ input: unknown }>
promptAsyncImpl?: (input: unknown) => Promise<unknown>
}): BabysitterContext {
const { messagesBySession, promptCalls } = options
return {
@@ -26,6 +30,9 @@ function createMockPluginInput(options: {
},
promptAsync: async (input: unknown) => {
promptCalls.push({ input })
if (options.promptAsyncImpl) {
return options.promptAsyncImpl(input)
}
},
},
},
@@ -219,6 +226,48 @@ describe("unstable-agent-babysitter hook", () => {
Date.now = originalNow
})
test("#given reminder prompt may have been accepted before EOF #when the main session idles again inside cooldown #then no duplicate reminder is injected", async () => {
// #given
setMainSession("main-1")
const promptCalls: Array<{ input: unknown }> = []
const now = Date.now()
const originalNow = Date.now
Date.now = () => now
const ctx = createMockPluginInput({
messagesBySession: {
"main-1": [
{ info: { agent: "sisyphus", model: { providerID: "openai", modelID: "gpt-4" } } },
],
"bg-1": [
{ info: { role: "assistant" }, parts: [{ type: "thinking", thinking: "deep thought" }] },
],
},
promptCalls,
promptAsyncImpl: async () => {
throw new Error("JSON Parse error: Unexpected EOF")
},
})
const backgroundManager = createBackgroundManager([createTask()])
const hook = createUnstableAgentBabysitterHook(ctx, {
backgroundManager,
config: { timeout_ms: 120000 },
})
try {
// #when
await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } })
releasePromptAsyncReservation("main-1", "test:simulate-expired-hold", {
reservedBy: "unstable-agent-babysitter",
})
await hook.event({ event: { type: "session.idle", properties: { sessionID: "main-1" } } })
// #then
expect(promptCalls.length).toBe(1)
} finally {
Date.now = originalNow
}
})
test("skips follow-up reminder after the main session is cancelled", async () => {
setMainSession("main-1")
const promptCalls: Array<{ input: unknown }> = []
@@ -1,7 +1,7 @@
import type { BackgroundManager } from "../../features/background-agent"
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
import { createInternalAgentTextPart, isAmbiguousPromptDispatchFailure, resolveInheritedPromptTools } from "../../shared"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { isAbortError } from "../../shared/is-abort-error"
import {
@@ -13,7 +13,7 @@ import {
isUnstableTask,
THINKING_SUMMARY_MAX_CHARS,
} from "./task-message-analyzer"
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
const HOOK_NAME = "unstable-agent-babysitter"
const DEFAULT_TIMEOUT_MS = 120000
@@ -29,17 +29,6 @@ type BabysitterContext = {
client: {
session: {
messages: (args: { path: { id: string } }) => Promise<{ data?: unknown } | unknown[]>
prompt: (args: {
path: { id: string }
body: {
parts: Array<{ type: "text"; text: string }>
agent?: string
variant?: string
model?: { providerID: string; modelID: string }
tools?: Record<string, boolean>
}
query?: { directory?: string }
}) => Promise<unknown>
promptAsync: (args: {
path: { id: string }
body: {
@@ -270,7 +259,10 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
query: { directory: ctx.directory },
},
})
if (promptResult.status !== "dispatched") {
if (!isInternalPromptDispatchAccepted(promptResult)) {
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
reminderCooldowns.set(task.id, now)
}
log(`[${HOOK_NAME}] Reminder skipped by promptAsync gate`, {
taskId: task.id,
sessionID: mainSessionID,