fix(prompt-gate): harden internal prompt dispatch
This commit is contained in:
@@ -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`, {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user