fix(prompt-gate): harden internal prompt dispatch
This commit is contained in:
@@ -13,7 +13,7 @@ import { loadAgentProfileColors } from "./agent-profile-colors"
|
|||||||
import { suppressRunInput } from "./stdin-suppression"
|
import { suppressRunInput } from "./stdin-suppression"
|
||||||
import { createTimestampedStdoutController } from "./timestamp-output"
|
import { createTimestampedStdoutController } from "./timestamp-output"
|
||||||
import { createCliPostHog, getPostHogDistinctId } from "../../shared/posthog"
|
import { createCliPostHog, getPostHogDistinctId } from "../../shared/posthog"
|
||||||
import { dispatchInternalPrompt } from "../../shared/prompt-async-gate"
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../shared/prompt-async-gate"
|
||||||
|
|
||||||
export { resolveRunAgent }
|
export { resolveRunAgent }
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ export async function run(options: RunOptions): Promise<number> {
|
|||||||
if (promptResult.status === "failed") {
|
if (promptResult.status === "failed") {
|
||||||
throw promptResult.error
|
throw promptResult.error
|
||||||
}
|
}
|
||||||
if (promptResult.status !== "dispatched") {
|
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||||
throw new Error(`Session ${sessionID} is not idle; promptAsync skipped by gate: ${promptResult.status}`)
|
throw new Error(`Session ${sessionID} is not idle; promptAsync skipped by gate: ${promptResult.status}`)
|
||||||
}
|
}
|
||||||
const exitCode = await pollForCompletion(ctx, eventState, abortController)
|
const exitCode = await pollForCompletion(ctx, eventState, abortController)
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
|||||||
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
|
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
|
||||||
import { setContinuationMarkerSource } from "../../features/run-continuation-state"
|
import { setContinuationMarkerSource } from "../../features/run-continuation-state"
|
||||||
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||||
import { dispatchInternalPrompt, type PromptAsyncGateResult } from "../../hooks/shared/prompt-async-gate"
|
import {
|
||||||
|
dispatchInternalPrompt,
|
||||||
|
type PromptAsyncGateResult,
|
||||||
|
} from "../../hooks/shared/prompt-async-gate"
|
||||||
import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/session-idle-settle"
|
import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/session-idle-settle"
|
||||||
import {
|
import {
|
||||||
createInternalAgentTextPart,
|
createInternalAgentTextPart,
|
||||||
@@ -485,7 +488,7 @@ export class BackgroundManager {
|
|||||||
private restoreTaskAfterSkippedResume(
|
private restoreTaskAfterSkippedResume(
|
||||||
task: BackgroundTask,
|
task: BackgroundTask,
|
||||||
snapshot: ResumeTaskSnapshot,
|
snapshot: ResumeTaskSnapshot,
|
||||||
skippedStatus: Exclude<PromptAsyncGateResult["status"], "dispatched" | "failed">,
|
skippedStatus: Exclude<PromptAsyncGateResult["status"], "dispatched" | "queued" | "failed">,
|
||||||
): void {
|
): void {
|
||||||
log("[background-agent] Restoring task after skipped resume prompt:", {
|
log("[background-agent] Restoring task after skipped resume prompt:", {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
@@ -1306,6 +1309,7 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
sessionID: existingTask.sessionId,
|
sessionID: existingTask.sessionId,
|
||||||
source: "background-agent-resume",
|
source: "background-agent-resume",
|
||||||
settleMs: 0,
|
settleMs: 0,
|
||||||
|
queueBehavior: "defer",
|
||||||
input: {
|
input: {
|
||||||
path: { id: existingTask.sessionId },
|
path: { id: existingTask.sessionId },
|
||||||
body: {
|
body: {
|
||||||
@@ -1332,6 +1336,14 @@ The fallback retry session is now created and can be inspected directly.
|
|||||||
if (promptResult.status === "failed") {
|
if (promptResult.status === "failed") {
|
||||||
throw promptResult.error
|
throw promptResult.error
|
||||||
}
|
}
|
||||||
|
if (promptResult.status === "queued") {
|
||||||
|
log("[background-agent] resume prompt queued by prompt dispatcher:", {
|
||||||
|
taskId: existingTask.id,
|
||||||
|
sessionID: existingTask.sessionId,
|
||||||
|
queuedBy: promptResult.queuedBy,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (promptResult.status !== "dispatched") {
|
if (promptResult.status !== "dispatched") {
|
||||||
log("[background-agent] resume prompt skipped by promptAsync gate:", {
|
log("[background-agent] resume prompt skipped by promptAsync gate:", {
|
||||||
taskId: existingTask.id,
|
taskId: existingTask.id,
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { resolveRegisteredAgentName } from "../claude-code-session-state"
|
import { resolveRegisteredAgentName } from "../claude-code-session-state"
|
||||||
import { createInternalAgentTextPart, isSyntheticOrInternalUserMessage, log, messagesInDirectory, normalizeSDKResponse } from "../../shared"
|
import {
|
||||||
|
createInternalAgentTextPart,
|
||||||
|
isAmbiguousPromptDispatchFailure,
|
||||||
|
isSyntheticOrInternalUserMessage,
|
||||||
|
log,
|
||||||
|
messagesInDirectory,
|
||||||
|
normalizeSDKResponse,
|
||||||
|
} from "../../shared"
|
||||||
import { isSessionActive as isOpenCodeSessionActive, settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle"
|
import { isSessionActive as isOpenCodeSessionActive, settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle"
|
||||||
import { dispatchInternalPrompt } from "../../hooks/shared/prompt-async-gate"
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../hooks/shared/prompt-async-gate"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
type OpencodeClient = PluginInput["client"]
|
type OpencodeClient = PluginInput["client"]
|
||||||
@@ -180,8 +187,9 @@ export class ParentWakeNotifier {
|
|||||||
|
|
||||||
const notificationContent = latestWake.notifications.join("\n\n")
|
const notificationContent = latestWake.notifications.join("\n\n")
|
||||||
|
|
||||||
|
let dispatchStartedAt = Date.now()
|
||||||
try {
|
try {
|
||||||
const dispatchStartedAt = Date.now()
|
dispatchStartedAt = Date.now()
|
||||||
const promptResult = await dispatchInternalPrompt({
|
const promptResult = await dispatchInternalPrompt({
|
||||||
mode: "async",
|
mode: "async",
|
||||||
client: this.deps.client,
|
client: this.deps.client,
|
||||||
@@ -209,7 +217,7 @@ export class ParentWakeNotifier {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (promptResult.status !== "dispatched") {
|
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||||
this.requeueWake(sessionID, latestWake)
|
this.requeueWake(sessionID, latestWake)
|
||||||
this.schedulePendingParentWakeFlush(sessionID)
|
this.schedulePendingParentWakeFlush(sessionID)
|
||||||
log("[background-agent] Deferred parent wake skipped by promptAsync gate:", {
|
log("[background-agent] Deferred parent wake skipped by promptAsync gate:", {
|
||||||
@@ -221,6 +229,18 @@ export class ParentWakeNotifier {
|
|||||||
log("[background-agent] Sent deferred parent wake:", { sessionID })
|
log("[background-agent] Sent deferred parent wake:", { sessionID })
|
||||||
this.trackDispatchedParentWake(sessionID, latestWake, dispatchStartedAt)
|
this.trackDispatchedParentWake(sessionID, latestWake, dispatchStartedAt)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isAmbiguousPromptDispatchFailure(error)) {
|
||||||
|
const dispatchedWake = this.cloneParentWake(latestWake)
|
||||||
|
dispatchedWake.dispatchedAt = dispatchStartedAt
|
||||||
|
if (await this.hasAcceptedMessageAfterDispatchedParentWake(sessionID, dispatchedWake)) {
|
||||||
|
this.trackDispatchedParentWake(sessionID, latestWake, dispatchStartedAt)
|
||||||
|
log("[background-agent] Treated failed parent wake prompt as accepted after observing session history:", {
|
||||||
|
sessionID,
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
this.requeueWake(sessionID, latestWake)
|
this.requeueWake(sessionID, latestWake)
|
||||||
this.schedulePendingParentWakeFlush(sessionID)
|
this.schedulePendingParentWakeFlush(sessionID)
|
||||||
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
|
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { ParentWakeNotifier } from "./parent-wake-notifier"
|
import { ParentWakeNotifier } from "./parent-wake-notifier"
|
||||||
import { releaseAllPromptAsyncReservationsForTesting } from "../../hooks/shared/prompt-async-gate"
|
import {
|
||||||
|
releaseAllPromptAsyncReservationsForTesting,
|
||||||
|
releasePromptAsyncReservation,
|
||||||
|
} from "../../hooks/shared/prompt-async-gate"
|
||||||
|
|
||||||
type PromptAsyncCall = {
|
type PromptAsyncCall = {
|
||||||
path: { id: string }
|
path: { id: string }
|
||||||
@@ -693,6 +696,81 @@ describe("ParentWakeNotifier — user message race guard (issue #4120)", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given promptAsync stores the wake then reports EOF #when the gate hold expires #then parent wake is not requeued into a duplicate prompt", async () => {
|
||||||
|
// given
|
||||||
|
const originalDateNow = Date.now
|
||||||
|
let now = 1_000
|
||||||
|
Date.now = () => now
|
||||||
|
const sessionMessages: SessionMessageStub[] = [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
role: "assistant",
|
||||||
|
finish: "stop",
|
||||||
|
time: { created: 500 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const promptAsyncCalls: PromptAsyncCall[] = []
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
status: async () => ({ data: { "parent-eof-before-return": { type: "idle" } } }),
|
||||||
|
messages: async () => ({ data: sessionMessages }),
|
||||||
|
promptAsync: async (call: PromptAsyncCall) => {
|
||||||
|
promptAsyncCalls.push(call)
|
||||||
|
sessionMessages.push({
|
||||||
|
info: {
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1_100 },
|
||||||
|
},
|
||||||
|
parts: [{ type: "text", text: "task complete\n<!-- OMO_INTERNAL_INITIATOR -->" }],
|
||||||
|
})
|
||||||
|
now = 2_000
|
||||||
|
throw new Error("JSON Parse error: Unexpected EOF")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as ConstructorParameters<typeof ParentWakeNotifier>[0]["client"]
|
||||||
|
const notifier = new ParentWakeNotifier(
|
||||||
|
{
|
||||||
|
client,
|
||||||
|
directory: "/tmp/test-omo",
|
||||||
|
enqueueNotificationForParent: async (_sessionID, operation) => {
|
||||||
|
await operation()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pendingRetryMs: 1_000,
|
||||||
|
acceptedMessageSkewMs: 100,
|
||||||
|
toolCallDeferMaxMs: 5_000,
|
||||||
|
failureRequeueWindowMs: 5_000,
|
||||||
|
userMessageInProgressWindowMs: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
notifier.queuePendingParentWake(
|
||||||
|
"parent-eof-before-return",
|
||||||
|
"task complete",
|
||||||
|
{ agent: "sisyphus" },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// when
|
||||||
|
await notifier.flushPendingParentWake("parent-eof-before-return")
|
||||||
|
const released = releasePromptAsyncReservation("parent-eof-before-return", "test:simulate-expired-hold", {
|
||||||
|
reservedBy: "background-agent-parent-wake",
|
||||||
|
})
|
||||||
|
await notifier.flushPendingParentWake("parent-eof-before-return")
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(released).toBe(true)
|
||||||
|
expect(promptAsyncCalls).toHaveLength(1)
|
||||||
|
expect(notifier.getPendingParentWakes().has("parent-eof-before-return")).toBe(false)
|
||||||
|
} finally {
|
||||||
|
Date.now = originalDateNow
|
||||||
|
notifier.shutdown()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("#given accepted wake produces sdk tool-call output #when late failure is requeued #then accepted dispatch is not duplicated", async () => {
|
test("#given accepted wake produces sdk tool-call output #when late failure is requeued #then accepted dispatch is not duplicated", async () => {
|
||||||
// given
|
// given
|
||||||
const originalDateNow = Date.now
|
const originalDateNow = Date.now
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
import { promptWithModelSuggestionRetry } from "../../shared"
|
import { promptWithModelSuggestionRetry } from "../../shared"
|
||||||
import { dispatchInternalPrompt } from "../../shared/prompt-async-gate"
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../shared/prompt-async-gate"
|
||||||
|
|
||||||
type OpencodeClient = PluginInput["client"]
|
type OpencodeClient = PluginInput["client"]
|
||||||
|
|
||||||
@@ -46,10 +46,10 @@ export function promptAsyncInDirectory(
|
|||||||
if (result.status === "failed") {
|
if (result.status === "failed") {
|
||||||
throw result.error
|
throw result.error
|
||||||
}
|
}
|
||||||
if (result.status !== "dispatched") {
|
if (!isInternalPromptDispatchAccepted(result)) {
|
||||||
throw new Error(`promptAsync skipped by gate: ${result.status}`)
|
throw new Error(`promptAsync skipped by gate: ${result.status}`)
|
||||||
}
|
}
|
||||||
return result.response
|
return result.status === "dispatched" ? result.response : undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ describe("createTeamSendMessageTool", () => {
|
|||||||
expect(calls[0]?.directory).toBe(resolveBaseDir(fixture.config))
|
expect(calls[0]?.directory).toBe(resolveBaseDir(fixture.config))
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given recipient OpenCode session is busy #when team_send_message attempts live delivery #then it leaves the message unread without starting another reply", async () => {
|
test("#given recipient OpenCode session is busy #when team_send_message attempts live delivery #then it reserves the message in the central prompt queue", async () => {
|
||||||
// given
|
// given
|
||||||
const fixture = await createTeamFixture()
|
const fixture = await createTeamFixture()
|
||||||
let promptCalls = 0
|
let promptCalls = 0
|
||||||
@@ -309,11 +309,10 @@ describe("createTeamSendMessageTool", () => {
|
|||||||
// then
|
// then
|
||||||
expect(promptCalls).toBe(0)
|
expect(promptCalls).toBe(0)
|
||||||
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
||||||
expect(unread).toHaveLength(1)
|
expect(unread).toHaveLength(0)
|
||||||
expect(unread[0]?.body).toBe("ping while busy")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given rapid live deliveries to one recipient #when the first prompt just dispatched #then the next message stays unread instead of starting another reply", async () => {
|
test("#given rapid live deliveries to one recipient #when the first prompt just dispatched #then the next message is queued instead of starting another reply", async () => {
|
||||||
// given
|
// given
|
||||||
const fixture = await createTeamFixture()
|
const fixture = await createTeamFixture()
|
||||||
const { client, calls } = createRecordingClient()
|
const { client, calls } = createRecordingClient()
|
||||||
@@ -335,11 +334,10 @@ describe("createTeamSendMessageTool", () => {
|
|||||||
expect(calls).toHaveLength(1)
|
expect(calls).toHaveLength(1)
|
||||||
expect(calls[0]?.parts[0]?.text).toContain("first ping")
|
expect(calls[0]?.parts[0]?.text).toContain("first ping")
|
||||||
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
||||||
expect(unread).toHaveLength(1)
|
expect(unread).toHaveLength(0)
|
||||||
expect(unread[0]?.body).toBe("second ping")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given live delivery left a rapid message unread #when recipient idle wake fires immediately #then the wake hint does not start a second reply", async () => {
|
test("#given live delivery queued a rapid message #when recipient idle wake fires immediately #then the wake hint does not start a second reply", async () => {
|
||||||
// given
|
// given
|
||||||
const fixture = await createTeamFixture()
|
const fixture = await createTeamFixture()
|
||||||
const { client, calls } = createRecordingClient()
|
const { client, calls } = createRecordingClient()
|
||||||
@@ -373,8 +371,7 @@ describe("createTeamSendMessageTool", () => {
|
|||||||
expect(calls).toHaveLength(1)
|
expect(calls).toHaveLength(1)
|
||||||
expect(calls[0]?.parts[0]?.text).toContain("first ping")
|
expect(calls[0]?.parts[0]?.text).toContain("first ping")
|
||||||
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
const unread = await listUnreadMessages(fixture.teamRunId, "m2", fixture.config)
|
||||||
expect(unread).toHaveLength(1)
|
expect(unread).toHaveLength(0)
|
||||||
expect(unread[0]?.body).toBe("second ping")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("live delivery pins the recipient's resolved subagent_type and model on promptAsync", async () => {
|
test("live delivery pins the recipient's resolved subagent_type and model on promptAsync", async () => {
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { type ToolDefinition, tool } from "@opencode-ai/plugin/tool"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import type { TeamModeConfig } from "../../../config/schema/team-mode"
|
import type { TeamModeConfig } from "../../../config/schema/team-mode"
|
||||||
import { dispatchInternalPrompt } from "../../../hooks/shared/prompt-async-gate"
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../../hooks/shared/prompt-async-gate"
|
||||||
import { log } from "../../../shared/logger"
|
import { log } from "../../../shared/logger"
|
||||||
|
import { isAmbiguousPromptDispatchFailure } from "../../../shared/prompt-failure-classifier"
|
||||||
import { applyMemberSessionRouting, buildMemberPromptBody } from "../member-session-routing"
|
import { applyMemberSessionRouting, buildMemberPromptBody } from "../member-session-routing"
|
||||||
import { buildEnvelope } from "../team-mailbox/poll"
|
import { buildEnvelope } from "../team-mailbox/poll"
|
||||||
import {
|
import {
|
||||||
@@ -68,26 +69,6 @@ const TeamSendMessageArgsSchema = z.object({
|
|||||||
|
|
||||||
type DeliveryReservation = Awaited<ReturnType<typeof reserveMessageForDelivery>>
|
type DeliveryReservation = Awaited<ReturnType<typeof reserveMessageForDelivery>>
|
||||||
|
|
||||||
function extractPromptFailureMessage(error: unknown): string {
|
|
||||||
if (typeof error === "string") return error
|
|
||||||
if (error instanceof Error) return error.message
|
|
||||||
if (typeof error === "object" && error !== null) {
|
|
||||||
const record = error as Record<string, unknown>
|
|
||||||
if (typeof record.message === "string") return record.message
|
|
||||||
try {
|
|
||||||
return JSON.stringify(error)
|
|
||||||
} catch {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return String(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldKeepReservationAfterFailedLivePrompt(error: unknown): boolean {
|
|
||||||
const message = extractPromptFailureMessage(error)
|
|
||||||
return message.includes("Unexpected EOF") || message.includes("timed out")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveTeamRuntimeDetails(
|
async function resolveTeamRuntimeDetails(
|
||||||
teamRunId: string,
|
teamRunId: string,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
@@ -230,7 +211,7 @@ async function deliverLive(
|
|||||||
query: { directory: recipientMember.worktreePath ?? directory },
|
query: { directory: recipientMember.worktreePath ?? directory },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (promptResult.status === "failed" && shouldKeepReservationAfterFailedLivePrompt(promptResult.error)) {
|
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
|
||||||
await markLiveDeliveryPending(teamRunId, recipientName, message.messageId, config)
|
await markLiveDeliveryPending(teamRunId, recipientName, message.messageId, config)
|
||||||
log("[team-mailbox] live delivery prompt failed after dispatch attempt, keeping reservation pending", {
|
log("[team-mailbox] live delivery prompt failed after dispatch attempt, keeping reservation pending", {
|
||||||
teamRunId,
|
teamRunId,
|
||||||
@@ -241,7 +222,7 @@ async function deliverLive(
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (promptResult.status !== "dispatched") {
|
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||||
log("[team-mailbox] live delivery skipped by promptAsync gate, falling back to inbox injection", {
|
log("[team-mailbox] live delivery skipped by promptAsync gate, falling back to inbox injection", {
|
||||||
status: promptResult.status,
|
status: promptResult.status,
|
||||||
teamRunId,
|
teamRunId,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
findNearestMessageWithFields,
|
findNearestMessageWithFields,
|
||||||
findNearestMessageWithFieldsFromSDK,
|
findNearestMessageWithFieldsFromSDK,
|
||||||
} from "../../features/hook-message-injector"
|
} 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: {
|
export async function runAggressiveTruncationStrategy(params: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -106,7 +106,7 @@ export async function runAggressiveTruncationStrategy(params: {
|
|||||||
query: { directory: params.directory },
|
query: { directory: params.directory },
|
||||||
} as never,
|
} as never,
|
||||||
})
|
})
|
||||||
if (promptResult.status !== "dispatched") {
|
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||||
log("[auto-compact] delayed auto prompt skipped by promptAsync gate", {
|
log("[auto-compact] delayed auto prompt skipped by promptAsync gate", {
|
||||||
sessionID: params.sessionID,
|
sessionID: params.sessionID,
|
||||||
status: promptResult.status,
|
status: promptResult.status,
|
||||||
|
|||||||
@@ -161,6 +161,42 @@ describe("injectBoulderContinuation", () => {
|
|||||||
expect(promptAsyncMock).not.toHaveBeenCalled()
|
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 () => {
|
test("#given recent prompt context includes variant #when injecting boulder continuation #then promptAsync receives variant as a top-level field", async () => {
|
||||||
// given
|
// given
|
||||||
registerAgentName("atlas")
|
registerAgentName("atlas")
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools } from "../../shared"
|
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 { HOOK_NAME } from "./hook-name"
|
||||||
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
|
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
|
||||||
import { markContinuationInjectedAwaitingToolProgress } from "./tool-progress"
|
import { markContinuationInjectedAwaitingToolProgress } from "./tool-progress"
|
||||||
@@ -114,7 +115,7 @@ export async function injectBoulderContinuation(input: {
|
|||||||
if (promptResult.status === "failed") {
|
if (promptResult.status === "failed") {
|
||||||
throw promptResult.error
|
throw promptResult.error
|
||||||
}
|
}
|
||||||
if (promptResult.status !== "dispatched") {
|
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||||
log(`[${HOOK_NAME}] Boulder continuation skipped by promptAsync gate`, {
|
log(`[${HOOK_NAME}] Boulder continuation skipped by promptAsync gate`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
status: promptResult.status,
|
status: promptResult.status,
|
||||||
@@ -127,6 +128,15 @@ export async function injectBoulderContinuation(input: {
|
|||||||
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
|
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
|
||||||
return "injected"
|
return "injected"
|
||||||
} catch (err) {
|
} 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.promptFailureCount += 1
|
||||||
sessionState.lastFailureAt = Date.now()
|
sessionState.lastFailureAt = Date.now()
|
||||||
log(`[${HOOK_NAME}] Boulder continuation failed`, {
|
log(`[${HOOK_NAME}] Boulder continuation failed`, {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { tmpdir } from "node:os"
|
|||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { createBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
import { createBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
|
||||||
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
|
import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
|
||||||
|
import {
|
||||||
|
releaseAllPromptAsyncReservationsForTesting,
|
||||||
|
releasePromptAsyncReservation,
|
||||||
|
} from "../shared/prompt-async-gate"
|
||||||
import { handleAtlasSessionIdle } from "./idle-event"
|
import { handleAtlasSessionIdle } from "./idle-event"
|
||||||
import type { SessionState } from "./types"
|
import type { SessionState } from "./types"
|
||||||
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
|
||||||
@@ -29,6 +33,7 @@ describe("handleAtlasSessionIdle completion nudge", () => {
|
|||||||
rmSync(testDirectory, { recursive: true, force: true })
|
rmSync(testDirectory, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
_resetForTesting()
|
_resetForTesting()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("injects BOULDER COMPLETE prompt once per work with substituted elapsed and task breakdown", async () => {
|
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(persistedState.boulderCompletionNudgedAt?.[workId]).toBeNumber()
|
||||||
expect(readBoulderState(testDirectory)?.works?.[workId]?.status).toBe("completed")
|
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 { createInternalAgentContinuationTextPart } from "../../shared"
|
||||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
import { isAmbiguousPromptDispatchFailure } from "../../shared/prompt-failure-classifier"
|
||||||
import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle"
|
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 { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||||
import { HOOK_NAME } from "./hook-name"
|
import { HOOK_NAME } from "./hook-name"
|
||||||
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
||||||
@@ -313,7 +314,13 @@ export async function handleAtlasSessionIdle(input: {
|
|||||||
query: { directory: ctx.directory },
|
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`, {
|
log(`[${HOOK_NAME}] Boulder completion nudge skipped by promptAsync gate`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
status: promptResult.status,
|
status: promptResult.status,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-ca
|
|||||||
import type { PluginConfig } from "../types"
|
import type { PluginConfig } from "../types"
|
||||||
import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared"
|
import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared"
|
||||||
import { resolveSessionEventID } from "../../../shared/event-session-id"
|
import { resolveSessionEventID } from "../../../shared/event-session-id"
|
||||||
import { dispatchInternalPrompt } from "../../../shared/prompt-async-gate"
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../../shared/prompt-async-gate"
|
||||||
import {
|
import {
|
||||||
clearAllSessionHookState,
|
clearAllSessionHookState,
|
||||||
clearSessionHookState,
|
clearSessionHookState,
|
||||||
@@ -124,7 +124,7 @@ export function createSessionEventHandler(
|
|||||||
})
|
})
|
||||||
if (promptResult.status === "failed") {
|
if (promptResult.status === "failed") {
|
||||||
log("Failed to inject prompt from Stop hook", { error: String(promptResult.error) })
|
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 })
|
log("Skipped prompt injection from Stop hook", { sessionID, status: promptResult.status })
|
||||||
}
|
}
|
||||||
} else if (stopResult.block) {
|
} else if (stopResult.block) {
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
/// <reference path="../../../bun-test.d.ts" />
|
/// <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 { setCompactionAgentConfigCheckpoint } from "../../shared/compaction-agent-config-checkpoint"
|
||||||
|
import {
|
||||||
|
releaseAllPromptAsyncReservationsForTesting,
|
||||||
|
releasePromptAsyncReservation,
|
||||||
|
} from "../shared/prompt-async-gate"
|
||||||
import { createCompactionContextInjector } from "./index"
|
import { createCompactionContextInjector } from "./index"
|
||||||
|
|
||||||
type SessionMessageResponse = Array<{
|
type SessionMessageResponse = Array<{
|
||||||
@@ -98,6 +102,10 @@ function createMeaningfulPartUpdatedEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("createCompactionContextInjector recovery", () => {
|
describe("createCompactionContextInjector recovery", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
|
})
|
||||||
|
|
||||||
it("re-injects after compaction when agent and model match but tools are missing", async () => {
|
it("re-injects after compaction when agent and model match but tools are missing", async () => {
|
||||||
//#given
|
//#given
|
||||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||||
@@ -304,6 +312,68 @@ describe("createCompactionContextInjector recovery", () => {
|
|||||||
expect(promptAsyncRecorder.calls.length).toBe(1)
|
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 () => {
|
it("does not treat reasoning-only assistant messages as a no-text tail", async () => {
|
||||||
//#given
|
//#given
|
||||||
const promptAsyncRecorder = createPromptAsyncRecorder()
|
const promptAsyncRecorder = createPromptAsyncRecorder()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "../../shared/compaction-agent-config-checkpoint"
|
} from "../../shared/compaction-agent-config-checkpoint"
|
||||||
import { createInternalAgentContinuationTextPart } from "../../shared/internal-initiator-marker"
|
import { createInternalAgentContinuationTextPart } from "../../shared/internal-initiator-marker"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
|
import { isAmbiguousPromptDispatchFailure } from "../../shared/prompt-failure-classifier"
|
||||||
import { setSessionModel } from "../../shared/session-model-state"
|
import { setSessionModel } from "../../shared/session-model-state"
|
||||||
import { setSessionTools } from "../../shared/session-tools-store"
|
import { setSessionTools } from "../../shared/session-tools-store"
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +22,7 @@ import {
|
|||||||
import { AGENT_RECOVERY_PROMPT, NO_TEXT_TAIL_THRESHOLD, RECOVERY_COOLDOWN_MS, RECENT_COMPACTION_WINDOW_MS } from "./constants"
|
import { AGENT_RECOVERY_PROMPT, NO_TEXT_TAIL_THRESHOLD, RECOVERY_COOLDOWN_MS, RECENT_COMPACTION_WINDOW_MS } from "./constants"
|
||||||
import type { CompactionContextClient } from "./types"
|
import type { CompactionContextClient } from "./types"
|
||||||
import type { TailMonitorState } from "./tail-monitor"
|
import type { TailMonitorState } from "./tail-monitor"
|
||||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
|
||||||
|
|
||||||
export function createRecoveryLogic(
|
export function createRecoveryLogic(
|
||||||
ctx: CompactionContextClient | undefined,
|
ctx: CompactionContextClient | undefined,
|
||||||
@@ -99,7 +100,10 @@ export function createRecoveryLogic(
|
|||||||
query: { directory: ctx.directory },
|
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`, {
|
log(`[compaction-context-injector] Recovery skipped by promptAsync gate`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
reason,
|
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"
|
import { injectContinuationPrompt } from "./continuation-prompt-injector"
|
||||||
|
|
||||||
describe("ralph-loop 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 () => {
|
test("#given promptAsync resolves SDK error #when injecting continuation prompt #then it returns rejection without throwing", async () => {
|
||||||
// given
|
// given
|
||||||
const ctx = {
|
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 () => {
|
test("#given inherited message agent has ZWSP prefix #when injecting continuation prompt #then promptAsync receives registered display agent", async () => {
|
||||||
// given
|
// given
|
||||||
let promptBody: { agent?: string; noReply?: boolean } | undefined
|
let promptBody: { agent?: string; noReply?: boolean } | undefined
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getMessageDir } from "./message-storage-directory"
|
|||||||
import { withTimeout } from "./with-timeout"
|
import { withTimeout } from "./with-timeout"
|
||||||
import {
|
import {
|
||||||
createInternalAgentContinuationTextPart,
|
createInternalAgentContinuationTextPart,
|
||||||
|
isAmbiguousPromptDispatchFailure,
|
||||||
isRecord,
|
isRecord,
|
||||||
normalizeSDKResponse,
|
normalizeSDKResponse,
|
||||||
resolveInheritedPromptTools,
|
resolveInheritedPromptTools,
|
||||||
@@ -145,6 +146,7 @@ export async function injectContinuationPrompt(
|
|||||||
sessionID: options.sessionID,
|
sessionID: options.sessionID,
|
||||||
source: "ralph-loop",
|
source: "ralph-loop",
|
||||||
settleMs: options.idleSettleMs,
|
settleMs: options.idleSettleMs,
|
||||||
|
queueBehavior: "defer",
|
||||||
input: {
|
input: {
|
||||||
path: { id: options.sessionID },
|
path: { id: options.sessionID },
|
||||||
body: {
|
body: {
|
||||||
@@ -158,8 +160,14 @@ export async function injectContinuationPrompt(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (promptResult.status === "failed") {
|
if (promptResult.status === "failed") {
|
||||||
|
if (isAmbiguousPromptDispatchFailure(promptResult.error)) {
|
||||||
|
return { status: "dispatched" }
|
||||||
|
}
|
||||||
throw promptResult.error
|
throw promptResult.error
|
||||||
}
|
}
|
||||||
|
if (promptResult.status === "queued") {
|
||||||
|
return { status: "deferred", reason: "reserved" }
|
||||||
|
}
|
||||||
if (promptResult.status === "active" || promptResult.status === "reserved") {
|
if (promptResult.status === "active" || promptResult.status === "reserved") {
|
||||||
return { status: "deferred", reason: promptResult.status }
|
return { status: "deferred", reason: promptResult.status }
|
||||||
}
|
}
|
||||||
@@ -171,6 +179,9 @@ export async function injectContinuationPrompt(
|
|||||||
}
|
}
|
||||||
response = promptResult.response
|
response = promptResult.response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isAmbiguousPromptDispatchFailure(error)) {
|
||||||
|
return { status: "dispatched" }
|
||||||
|
}
|
||||||
const promptError = error instanceof Error
|
const promptError = error instanceof Error
|
||||||
? error
|
? error
|
||||||
: createPromptAsyncError("promptAsync rejected", error)
|
: createPromptAsyncError("promptAsync rejected", error)
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import { extractSessionMessages } from "./session-messages"
|
|||||||
import { resolveRegisteredAgentName } from "../../features/claude-code-session-state"
|
import { resolveRegisteredAgentName } from "../../features/claude-code-session-state"
|
||||||
import {
|
import {
|
||||||
dispatchInternalPrompt,
|
dispatchInternalPrompt,
|
||||||
|
isInternalPromptDispatchAccepted,
|
||||||
releasePromptAsyncReservation,
|
releasePromptAsyncReservation,
|
||||||
} from "../shared/prompt-async-gate"
|
} from "../shared/prompt-async-gate"
|
||||||
|
import { isAmbiguousPromptDispatchFailure } from "../../shared/prompt-failure-classifier"
|
||||||
|
|
||||||
const SESSION_TTL_MS = 30 * 60 * 1000
|
const SESSION_TTL_MS = 30 * 60 * 1000
|
||||||
|
|
||||||
@@ -141,6 +143,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
|||||||
const previousPendingFallbackModel = sessionStates.get(sessionID)?.pendingFallbackModel
|
const previousPendingFallbackModel = sessionStates.get(sessionID)?.pendingFallbackModel
|
||||||
sessionRetryInFlight.add(sessionID)
|
sessionRetryInFlight.add(sessionID)
|
||||||
let retryDispatched = false
|
let retryDispatched = false
|
||||||
|
let retryMayHaveBeenAccepted = false
|
||||||
try {
|
try {
|
||||||
const messagesResp = await ctx.client.session.messages({
|
const messagesResp = await ctx.client.session.messages({
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
@@ -180,9 +183,16 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (promptResult.status === "failed") {
|
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
|
throw promptResult.error
|
||||||
}
|
}
|
||||||
if (promptResult.status !== "dispatched") {
|
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||||
log(`[${HOOK_NAME}] Auto-retry skipped by promptAsync gate (${source})`, {
|
log(`[${HOOK_NAME}] Auto-retry skipped by promptAsync gate (${source})`, {
|
||||||
sessionID,
|
sessionID,
|
||||||
status: promptResult.status,
|
status: promptResult.status,
|
||||||
@@ -201,7 +211,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
|||||||
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
|
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
|
||||||
} finally {
|
} finally {
|
||||||
sessionRetryInFlight.delete(sessionID)
|
sessionRetryInFlight.delete(sessionID)
|
||||||
if (!retryDispatched) {
|
if (!retryDispatched && !retryMayHaveBeenAccepted) {
|
||||||
if (hadAwaitingFallbackResult) {
|
if (hadAwaitingFallbackResult) {
|
||||||
sessionAwaitingFallbackResult.add(sessionID)
|
sessionAwaitingFallbackResult.add(sessionID)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
} from "../../shared/delegated-child-session-bootstrap"
|
} from "../../shared/delegated-child-session-bootstrap"
|
||||||
import * as loggerModule from "../../shared/logger"
|
import * as loggerModule from "../../shared/logger"
|
||||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
|
import {
|
||||||
|
releaseAllPromptAsyncReservationsForTesting,
|
||||||
|
releasePromptAsyncReservation,
|
||||||
|
} from "../shared/prompt-async-gate"
|
||||||
import type { RuntimeFallbackPluginInput } from "./types"
|
import type { RuntimeFallbackPluginInput } from "./types"
|
||||||
|
|
||||||
type RuntimeFallbackModule = typeof import("./hook")
|
type RuntimeFallbackModule = typeof import("./hook")
|
||||||
@@ -23,6 +27,7 @@ describe("runtime-fallback", () => {
|
|||||||
toastCalls = []
|
toastCalls = []
|
||||||
SessionCategoryRegistry.clear()
|
SessionCategoryRegistry.clear()
|
||||||
clearAllDelegatedChildSessionBootstrap()
|
clearAllDelegatedChildSessionBootstrap()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
|
|
||||||
const cacheBuster = `${Date.now()}-${Math.random()}`
|
const cacheBuster = `${Date.now()}-${Math.random()}`
|
||||||
|
|
||||||
@@ -40,6 +45,7 @@ describe("runtime-fallback", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
SessionCategoryRegistry.clear()
|
SessionCategoryRegistry.clear()
|
||||||
clearAllDelegatedChildSessionBootstrap()
|
clearAllDelegatedChildSessionBootstrap()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
mock.restore()
|
mock.restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1350,6 +1356,71 @@ describe("runtime-fallback", () => {
|
|||||||
void sessionErrorPromise
|
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 () => {
|
test("should force advance fallback from message.updated when Copilot auto-retry signal appears during in-flight retry", async () => {
|
||||||
const retriedModels: string[] = []
|
const retriedModels: string[] = []
|
||||||
const pending = new Promise<never>(() => {})
|
const pending = new Promise<never>(() => {})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||||
|
|
||||||
|
import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
|
||||||
import type { MessageData } from "./types"
|
import type { MessageData } from "./types"
|
||||||
|
|
||||||
let sqliteBackend = false
|
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 promptAsyncCalls: PromptAsyncInput[] = []
|
||||||
const promptAsync = mock((input: PromptAsyncInput) => {
|
const promptAsync = mock((input: PromptAsyncInput) => {
|
||||||
promptAsyncCalls.push(input)
|
promptAsyncCalls.push(input)
|
||||||
return Promise.resolve({})
|
return promptAsyncImpl ? promptAsyncImpl(input) : Promise.resolve({})
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -69,6 +73,7 @@ describe("recoverToolResultMissing", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("returns false for sqlite fallback when tool part has no valid callID", async () => {
|
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("model")
|
||||||
expect(call.body).not.toHaveProperty("variant")
|
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 {}
|
export {}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { createOpencodeClient } from "@opencode-ai/sdk"
|
|||||||
import type { MessageData, ResumeConfig } from "./types"
|
import type { MessageData, ResumeConfig } from "./types"
|
||||||
import { readParts } from "./storage/parts-reader"
|
import { readParts } from "./storage/parts-reader"
|
||||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||||
import { normalizeSDKResponse } from "../../shared"
|
import { isAmbiguousPromptDispatchFailure, normalizeSDKResponse } from "../../shared"
|
||||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
|
||||||
|
|
||||||
type Client = ReturnType<typeof createOpencodeClient>
|
type Client = ReturnType<typeof createOpencodeClient>
|
||||||
type ToolResultContent = { type: "text"; text: string }
|
type ToolResultContent = { type: "text"; text: string }
|
||||||
@@ -176,9 +176,13 @@ export async function recoverToolResultMissing(
|
|||||||
source: options?.source ?? "session-recovery-tool-result-missing",
|
source: options?.source ?? "session-recovery-tool-result-missing",
|
||||||
input: promptInput,
|
input: promptInput,
|
||||||
checkToolState: false,
|
checkToolState: false,
|
||||||
|
queueBehavior: "defer",
|
||||||
})
|
})
|
||||||
|
|
||||||
return promptResult.status === "dispatched"
|
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isInternalPromptDispatchAccepted(promptResult)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
||||||
|
|
||||||
|
import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
|
||||||
import type { MessageData } from "./types"
|
import type { MessageData } from "./types"
|
||||||
|
|
||||||
let sqliteBackend = false
|
let sqliteBackend = false
|
||||||
@@ -24,8 +25,8 @@ const failedAssistantMsg: MessageData = {
|
|||||||
parts: [],
|
parts: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockClient(messages: MessageData[] = []) {
|
function createMockClient(messages: MessageData[] = [], promptAsyncImpl?: () => Promise<unknown>) {
|
||||||
const promptAsync = mock(() => Promise.resolve({}))
|
const promptAsync = mock(() => promptAsyncImpl ? promptAsyncImpl() : Promise.resolve({}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
client: {
|
client: {
|
||||||
@@ -46,6 +47,7 @@ describe("recoverUnavailableTool", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("sends a schema-compatible recovered tool result for sqlite fallback", async () => {
|
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 { extractUnavailableToolName } from "./detect-error-type"
|
||||||
import { readParts } from "./storage"
|
import { readParts } from "./storage"
|
||||||
import type { MessageData } from "./types"
|
import type { MessageData } from "./types"
|
||||||
import { normalizeSDKResponse } from "../../shared"
|
import { isAmbiguousPromptDispatchFailure, normalizeSDKResponse } from "../../shared"
|
||||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
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>
|
type Client = ReturnType<typeof createOpencodeClient>
|
||||||
|
|
||||||
@@ -126,9 +126,13 @@ export async function recoverUnavailableTool(
|
|||||||
client,
|
client,
|
||||||
sessionID,
|
sessionID,
|
||||||
source: "session-recovery-unavailable-tool",
|
source: "session-recovery-unavailable-tool",
|
||||||
|
queueBehavior: "defer",
|
||||||
input: promptInput,
|
input: promptInput,
|
||||||
})
|
})
|
||||||
return promptResult.status === "dispatched"
|
if (promptResult.status === "failed" && isAmbiguousPromptDispatchFailure(promptResult.error)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isInternalPromptDispatchAccepted(promptResult)
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
declare const require: (name: string) => any
|
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 { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
|
||||||
|
import { releaseAllPromptAsyncReservationsForTesting } from "../shared/prompt-async-gate"
|
||||||
import { extractResumeConfig, findLastUserMessage, resumeSession } from "./resume"
|
import { extractResumeConfig, findLastUserMessage, resumeSession } from "./resume"
|
||||||
import type { MessageData } from "./types"
|
import type { MessageData } from "./types"
|
||||||
|
|
||||||
describe("session-recovery resume", () => {
|
describe("session-recovery resume", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
|
})
|
||||||
|
|
||||||
test("findLastUserMessage skips synthetic and internally marked user messages", () => {
|
test("findLastUserMessage skips synthetic and internally marked user messages", () => {
|
||||||
// given
|
// given
|
||||||
const realUserMessage: MessageData = {
|
const realUserMessage: MessageData = {
|
||||||
@@ -123,4 +128,27 @@ describe("session-recovery resume", () => {
|
|||||||
expect(firstPart?.metadata?.compaction_continue).toBe(true)
|
expect(firstPart?.metadata?.compaction_continue).toBe(true)
|
||||||
expect(promptBody?.noReply).toBeUndefined()
|
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 type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import {
|
import {
|
||||||
createInternalAgentContinuationTextPart,
|
createInternalAgentContinuationTextPart,
|
||||||
|
isAmbiguousPromptDispatchFailure,
|
||||||
isRealUserMessage,
|
isRealUserMessage,
|
||||||
resolveInheritedPromptTools,
|
resolveInheritedPromptTools,
|
||||||
} from "../../shared"
|
} from "../../shared"
|
||||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
|
||||||
import type { MessageData, ResumeConfig } from "./types"
|
import type { MessageData, ResumeConfig } from "./types"
|
||||||
|
|
||||||
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
|
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
|
||||||
@@ -43,6 +44,7 @@ export async function resumeSession(client: Client, config: ResumeConfig): Promi
|
|||||||
client,
|
client,
|
||||||
sessionID: config.sessionID,
|
sessionID: config.sessionID,
|
||||||
source: "session-recovery",
|
source: "session-recovery",
|
||||||
|
queueBehavior: "defer",
|
||||||
input: {
|
input: {
|
||||||
path: { id: config.sessionID },
|
path: { id: config.sessionID },
|
||||||
body: {
|
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 {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ import {
|
|||||||
releasePromptAsyncReservation,
|
releasePromptAsyncReservation,
|
||||||
} from "./prompt-async-gate"
|
} 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", () => {
|
describe("dispatchInternalPrompt", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// then
|
// then
|
||||||
@@ -119,9 +131,232 @@ describe("dispatchInternalPrompt", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(first.status).toBe("dispatched")
|
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"])
|
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", () => {
|
describe("dispatchInternalPrompt shared gate behavior", () => {
|
||||||
@@ -173,7 +408,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(firstResult.status).toBe("dispatched")
|
expect(firstResult.status).toBe("dispatched")
|
||||||
expect(second.status).toBe("reserved")
|
expect(second.status).toBe("queued")
|
||||||
expect(promptCalls).toBe(1)
|
expect(promptCalls).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -209,7 +444,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(firstResult.status).toBe("dispatched")
|
expect(firstResult.status).toBe("dispatched")
|
||||||
expect(second.status).toBe("reserved")
|
expect(second.status).toBe("queued")
|
||||||
expect(promptCalls).toBe(1)
|
expect(promptCalls).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -284,7 +519,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.status).toBe("active")
|
expect(result.status).toBe("queued")
|
||||||
expect(promptCalls).toBe(0)
|
expect(promptCalls).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -312,7 +547,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.status).toBe("active")
|
expect(result.status).toBe("queued")
|
||||||
expect(promptCalls).toBe(0)
|
expect(promptCalls).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -352,7 +587,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.status).toBe("active")
|
expect(result.status).toBe("queued")
|
||||||
expect(promptCalls).toBe(0)
|
expect(promptCalls).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -392,7 +627,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.status).toBe("active")
|
expect(result.status).toBe("queued")
|
||||||
expect(promptCalls).toBe(0)
|
expect(promptCalls).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -432,7 +667,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.status).toBe("active")
|
expect(result.status).toBe("queued")
|
||||||
expect(promptCalls).toBe(0)
|
expect(promptCalls).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -476,7 +711,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.status).toBe("active")
|
expect(result.status).toBe("queued")
|
||||||
expect(promptCalls).toBe(0)
|
expect(promptCalls).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -520,7 +755,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.status).toBe("active")
|
expect(result.status).toBe("queued")
|
||||||
expect(promptCalls).toBe(0)
|
expect(promptCalls).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -723,7 +958,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(first.status).toBe("dispatched")
|
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)
|
expect(promptCalls).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -847,7 +1082,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(first.status).toBe("failed")
|
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)
|
expect(promptCalls).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -895,7 +1130,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
// then
|
// then
|
||||||
expect(first.status).toBe("dispatched")
|
expect(first.status).toBe("dispatched")
|
||||||
expect(released).toBe(false)
|
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)
|
expect(promptCalls).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -935,7 +1170,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(firstResult.status).toBe("dispatched")
|
expect(firstResult.status).toBe("dispatched")
|
||||||
expect(second.status).toBe("reserved")
|
expect(second.status).toBe("queued")
|
||||||
expect(promptCalls).toBe(1)
|
expect(promptCalls).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -965,7 +1200,7 @@ describe("dispatchInternalPrompt shared gate behavior", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(firstResult.status).toBe("dispatched")
|
expect(firstResult.status).toBe("dispatched")
|
||||||
expect(second.status).toBe("reserved")
|
expect(second.status).toBe("queued")
|
||||||
expect(promptCalls).toBe(1)
|
expect(promptCalls).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import {
|
|||||||
clearAllSessionPromptParams,
|
clearAllSessionPromptParams,
|
||||||
getSessionPromptParams,
|
getSessionPromptParams,
|
||||||
} from "../../shared/session-prompt-params-state"
|
} from "../../shared/session-prompt-params-state"
|
||||||
|
import {
|
||||||
|
releaseAllPromptAsyncReservationsForTesting,
|
||||||
|
releasePromptAsyncReservation,
|
||||||
|
} from "../shared/prompt-async-gate"
|
||||||
import { createTeamIdleWakeHint } from "./team-idle-wake-hint"
|
import { createTeamIdleWakeHint } from "./team-idle-wake-hint"
|
||||||
|
|
||||||
type WakeHintPromptInput = {
|
type WakeHintPromptInput = {
|
||||||
@@ -183,6 +187,7 @@ afterEach(async () => {
|
|||||||
clearTeamSessionRegistry()
|
clearTeamSessionRegistry()
|
||||||
SessionCategoryRegistry.clear()
|
SessionCategoryRegistry.clear()
|
||||||
clearAllSessionPromptParams()
|
clearAllSessionPromptParams()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
|
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
|
||||||
await rm(directoryPath, { recursive: true, force: true })
|
await rm(directoryPath, { recursive: true, force: true })
|
||||||
}))
|
}))
|
||||||
@@ -295,6 +300,44 @@ describe("createTeamIdleWakeHint", () => {
|
|||||||
expect(promptAsyncSpy).toHaveBeenCalledTimes(0)
|
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 () => {
|
test("pins the recipient's resolved subagent_type and model on the wake-hint promptAsync", async () => {
|
||||||
// given
|
// given
|
||||||
const baseDir = await createTemporaryBaseDir()
|
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 { listUnreadMessages } from "../../features/team-mode/team-mailbox/inbox"
|
||||||
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
|
import { isAmbiguousPromptDispatchFailure } from "../../shared/prompt-failure-classifier"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
|
||||||
|
|
||||||
type PromptAsyncInput = {
|
type PromptAsyncInput = {
|
||||||
path: { id: string }
|
path: { id: string }
|
||||||
@@ -35,6 +36,7 @@ type TeamIdleWakeHintContext = {
|
|||||||
type HookInput = { event: { type: string; properties?: unknown } }
|
type HookInput = { event: { type: string; properties?: unknown } }
|
||||||
export type HookImpl = (input: HookInput) => Promise<void>
|
export type HookImpl = (input: HookInput) => Promise<void>
|
||||||
type TeamIdleWakeHintOptions = { idleSettleMs?: number }
|
type TeamIdleWakeHintOptions = { idleSettleMs?: number }
|
||||||
|
const WAKE_HINT_DUPLICATE_SUPPRESSION_MS = 30_000
|
||||||
|
|
||||||
function getIdleSessionID(properties: unknown): string | undefined {
|
function getIdleSessionID(properties: unknown): string | undefined {
|
||||||
return resolveSessionEventID(properties)
|
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.`
|
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 {
|
export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: TeamModeConfig, options?: TeamIdleWakeHintOptions): HookImpl {
|
||||||
|
const recentWakeHintBatches = new Map<string, number>()
|
||||||
|
|
||||||
return async ({ event }: HookInput): Promise<void> => {
|
return async ({ event }: HookInput): Promise<void> => {
|
||||||
if (event.type !== "session.idle") return
|
if (event.type !== "session.idle") return
|
||||||
|
|
||||||
@@ -110,6 +118,27 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea
|
|||||||
return
|
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)
|
applyMemberSessionRouting(sessionID, memberEntry)
|
||||||
const promptResult = await dispatchInternalPrompt({
|
const promptResult = await dispatchInternalPrompt({
|
||||||
mode: "async",
|
mode: "async",
|
||||||
@@ -123,7 +152,10 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea
|
|||||||
query: { directory: ctx.directory },
|
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", {
|
log("team idle wake hint skipped by promptAsync gate", {
|
||||||
event: "team-mode-idle-wake-hint-gated",
|
event: "team-mode-idle-wake-hint-gated",
|
||||||
teamRunId: runtimeState.teamRunId,
|
teamRunId: runtimeState.teamRunId,
|
||||||
@@ -134,6 +166,7 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
recentWakeHintBatches.set(wakeHintBatchKey, Date.now() + WAKE_HINT_DUPLICATE_SUPPRESSION_MS)
|
||||||
|
|
||||||
log("team idle wake hint sent", {
|
log("team idle wake hint sent", {
|
||||||
event: "team-mode-idle-wake-hint",
|
event: "team-mode-idle-wake-hint",
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ describe("injectContinuation", () => {
|
|||||||
expect(capturedBody?.variant).toBe("max")
|
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
|
// given
|
||||||
const sessionID = "ses_todo_reserved_by_peer_message"
|
const sessionID = "ses_todo_reserved_by_peer_message"
|
||||||
let promptCalls = 0
|
let promptCalls = 0
|
||||||
@@ -286,6 +286,50 @@ describe("injectContinuation", () => {
|
|||||||
expect(peerMessageResult.status).toBe("dispatched")
|
expect(peerMessageResult.status).toBe("dispatched")
|
||||||
expect(promptCalls).toBe(1)
|
expect(promptCalls).toBe(1)
|
||||||
expect(state.inFlight).toBe(false)
|
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"
|
} from "../../features/claude-code-session-state"
|
||||||
import {
|
import {
|
||||||
createInternalAgentContinuationTextPart,
|
createInternalAgentContinuationTextPart,
|
||||||
|
isAmbiguousPromptDispatchFailure,
|
||||||
normalizeSDKResponse,
|
normalizeSDKResponse,
|
||||||
resolveInheritedPromptTools,
|
resolveInheritedPromptTools,
|
||||||
} from "../../shared"
|
} from "../../shared"
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
normalizeAgentForPromptKey,
|
normalizeAgentForPromptKey,
|
||||||
stripAgentListSortPrefix,
|
stripAgentListSortPrefix,
|
||||||
} from "../../shared/agent-display-names"
|
} from "../../shared/agent-display-names"
|
||||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../shared/prompt-async-gate"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CONTINUATION_PROMPT,
|
CONTINUATION_PROMPT,
|
||||||
@@ -208,7 +209,7 @@ ${todoList}`
|
|||||||
if (promptResult.status === "failed") {
|
if (promptResult.status === "failed") {
|
||||||
throw promptResult.error
|
throw promptResult.error
|
||||||
}
|
}
|
||||||
if (promptResult.status !== "dispatched") {
|
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||||
log(`[${HOOK_NAME}] Injection skipped by promptAsync gate`, { sessionID, status: promptResult.status })
|
log(`[${HOOK_NAME}] Injection skipped by promptAsync gate`, { sessionID, status: promptResult.status })
|
||||||
if (injectionState) {
|
if (injectionState) {
|
||||||
injectionState.inFlight = false
|
injectionState.inFlight = false
|
||||||
@@ -216,7 +217,7 @@ ${todoList}`
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
|
log(`[${HOOK_NAME}] Injection successful`, { sessionID, status: promptResult.status })
|
||||||
if (injectionState) {
|
if (injectionState) {
|
||||||
injectionState.inFlight = false
|
injectionState.inFlight = false
|
||||||
injectionState.lastInjectedAt = Date.now()
|
injectionState.lastInjectedAt = Date.now()
|
||||||
@@ -228,6 +229,11 @@ ${todoList}`
|
|||||||
if (injectionState) {
|
if (injectionState) {
|
||||||
injectionState.inFlight = false
|
injectionState.inFlight = false
|
||||||
injectionState.lastInjectedAt = Date.now()
|
injectionState.lastInjectedAt = Date.now()
|
||||||
|
if (isAmbiguousPromptDispatchFailure(error)) {
|
||||||
|
injectionState.awaitingPostInjectionProgressCheck = true
|
||||||
|
injectionState.consecutiveFailures = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
injectionState.consecutiveFailures = (injectionState.consecutiveFailures ?? 0) + 1
|
injectionState.consecutiveFailures = (injectionState.consecutiveFailures ?? 0) + 1
|
||||||
|
|
||||||
const errorObj = error instanceof Error
|
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 { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
|
||||||
import type { BackgroundTask } from "../../features/background-agent"
|
import type { BackgroundTask } from "../../features/background-agent"
|
||||||
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
|
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"
|
import { createUnstableAgentBabysitterHook } from "./index"
|
||||||
|
|
||||||
const projectDir = process.cwd()
|
const projectDir = process.cwd()
|
||||||
@@ -12,6 +15,7 @@ type BabysitterContext = Parameters<typeof createUnstableAgentBabysitterHook>[0]
|
|||||||
function createMockPluginInput(options: {
|
function createMockPluginInput(options: {
|
||||||
messagesBySession: Record<string, unknown[]>
|
messagesBySession: Record<string, unknown[]>
|
||||||
promptCalls: Array<{ input: unknown }>
|
promptCalls: Array<{ input: unknown }>
|
||||||
|
promptAsyncImpl?: (input: unknown) => Promise<unknown>
|
||||||
}): BabysitterContext {
|
}): BabysitterContext {
|
||||||
const { messagesBySession, promptCalls } = options
|
const { messagesBySession, promptCalls } = options
|
||||||
return {
|
return {
|
||||||
@@ -26,6 +30,9 @@ function createMockPluginInput(options: {
|
|||||||
},
|
},
|
||||||
promptAsync: async (input: unknown) => {
|
promptAsync: async (input: unknown) => {
|
||||||
promptCalls.push({ input })
|
promptCalls.push({ input })
|
||||||
|
if (options.promptAsyncImpl) {
|
||||||
|
return options.promptAsyncImpl(input)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -219,6 +226,48 @@ describe("unstable-agent-babysitter hook", () => {
|
|||||||
Date.now = originalNow
|
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 () => {
|
test("skips follow-up reminder after the main session is cancelled", async () => {
|
||||||
setMainSession("main-1")
|
setMainSession("main-1")
|
||||||
const promptCalls: Array<{ input: unknown }> = []
|
const promptCalls: Array<{ input: unknown }> = []
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
|
import { getMainSessionID, getSessionAgent } from "../../features/claude-code-session-state"
|
||||||
import { log } from "../../shared/logger"
|
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 { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
|
||||||
import { isAbortError } from "../../shared/is-abort-error"
|
import { isAbortError } from "../../shared/is-abort-error"
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
isUnstableTask,
|
isUnstableTask,
|
||||||
THINKING_SUMMARY_MAX_CHARS,
|
THINKING_SUMMARY_MAX_CHARS,
|
||||||
} from "./task-message-analyzer"
|
} 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 HOOK_NAME = "unstable-agent-babysitter"
|
||||||
const DEFAULT_TIMEOUT_MS = 120000
|
const DEFAULT_TIMEOUT_MS = 120000
|
||||||
@@ -29,17 +29,6 @@ type BabysitterContext = {
|
|||||||
client: {
|
client: {
|
||||||
session: {
|
session: {
|
||||||
messages: (args: { path: { id: string } }) => Promise<{ data?: unknown } | unknown[]>
|
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: {
|
promptAsync: (args: {
|
||||||
path: { id: string }
|
path: { id: string }
|
||||||
body: {
|
body: {
|
||||||
@@ -270,7 +259,10 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
|||||||
query: { directory: ctx.directory },
|
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`, {
|
log(`[${HOOK_NAME}] Reminder skipped by promptAsync gate`, {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
sessionID: mainSessionID,
|
sessionID: mainSessionID,
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { createChatMessageHandler } from "./chat-message"
|
|||||||
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
|
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
|
||||||
import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/model-fallback/hook"
|
import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/model-fallback/hook"
|
||||||
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
||||||
|
import {
|
||||||
|
releaseAllPromptAsyncReservationsForTesting,
|
||||||
|
releasePromptAsyncReservation,
|
||||||
|
} from "../hooks/shared/prompt-async-gate"
|
||||||
import { unsafeTestValue } from "../../test-support/unsafe-test-value"
|
import { unsafeTestValue } from "../../test-support/unsafe-test-value"
|
||||||
|
|
||||||
type EventInput = { event: { type: string; properties?: unknown } }
|
type EventInput = { event: { type: string; properties?: unknown } }
|
||||||
@@ -95,6 +99,7 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
readConnectedProvidersCacheSpy = undefined
|
readConnectedProvidersCacheSpy = undefined
|
||||||
readProviderModelsCacheSpy = undefined
|
readProviderModelsCacheSpy = undefined
|
||||||
_resetForTesting()
|
_resetForTesting()
|
||||||
|
releaseAllPromptAsyncReservationsForTesting()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("triggers retry prompt for assistant message.updated APIError payloads (headless resume)", async () => {
|
test("triggers retry prompt for assistant message.updated APIError payloads (headless resume)", async () => {
|
||||||
@@ -139,6 +144,56 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
expect(promptCalls).toEqual([sessionID])
|
expect(promptCalls).toEqual([sessionID])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given model-fallback promptAsync may have been accepted before EOF #when the same assistant error repeats after the gate hold #then fallback continue is not duplicated", async () => {
|
||||||
|
//#given
|
||||||
|
const sessionID = "ses_message_updated_fallback_eof"
|
||||||
|
const modelFallback = createModelFallbackHook()
|
||||||
|
const { handler, abortCalls, promptAsyncCalls } = createHandler({
|
||||||
|
hooks: { modelFallback },
|
||||||
|
promptAsync: async () => {
|
||||||
|
throw new Error("JSON Parse error: Unexpected EOF")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const input: EventInput = {
|
||||||
|
event: {
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "msg_err_eof",
|
||||||
|
sessionID,
|
||||||
|
role: "assistant",
|
||||||
|
time: { created: 1, completed: 2 },
|
||||||
|
error: {
|
||||||
|
name: "APIError",
|
||||||
|
data: {
|
||||||
|
message:
|
||||||
|
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}",
|
||||||
|
isRetryable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parentID: "msg_user_eof",
|
||||||
|
modelID: "claude-opus-4-7-thinking",
|
||||||
|
providerID: "anthropic",
|
||||||
|
agent: "Sisyphus - Ultraworker",
|
||||||
|
path: { cwd: "/tmp", root: "/tmp" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await handler(input)
|
||||||
|
const released = releasePromptAsyncReservation(sessionID, "test:simulate-expired-hold", {
|
||||||
|
reservedBy: "model-fallback:message.updated",
|
||||||
|
})
|
||||||
|
await handler(input)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(released).toBe(true)
|
||||||
|
expect(abortCalls).toEqual([sessionID])
|
||||||
|
expect(promptAsyncCalls).toEqual([sessionID])
|
||||||
|
})
|
||||||
|
|
||||||
test("triggers retry prompt for nested model error payloads", async () => {
|
test("triggers retry prompt for nested model error payloads", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_main_fallback_nested"
|
const sessionID = "ses_main_fallback_nested"
|
||||||
|
|||||||
+8
-4
@@ -42,7 +42,11 @@ import { buildTeamIdleWakeHintClient } from "./build-team-idle-wake-hint-client"
|
|||||||
import { createTeamLeadOrphanHandler } from "../hooks/team-session-events/team-lead-orphan-handler";
|
import { createTeamLeadOrphanHandler } from "../hooks/team-session-events/team-lead-orphan-handler";
|
||||||
import { createTeamMemberErrorHandler } from "../hooks/team-session-events/team-member-error-handler";
|
import { createTeamMemberErrorHandler } from "../hooks/team-session-events/team-member-error-handler";
|
||||||
import { createTeamMemberStatusHandler } from "../hooks/team-session-events/team-member-status-handler";
|
import { createTeamMemberStatusHandler } from "../hooks/team-session-events/team-member-status-handler";
|
||||||
import { dispatchInternalPrompt, releasePromptAsyncReservation } from "../hooks/shared/prompt-async-gate";
|
import {
|
||||||
|
dispatchInternalPrompt,
|
||||||
|
isInternalPromptDispatchAccepted,
|
||||||
|
releasePromptAsyncReservation,
|
||||||
|
} from "../hooks/shared/prompt-async-gate";
|
||||||
|
|
||||||
import type { CreatedHooks } from "../create-hooks";
|
import type { CreatedHooks } from "../create-hooks";
|
||||||
import type { Managers } from "../create-managers";
|
import type { Managers } from "../create-managers";
|
||||||
@@ -519,7 +523,7 @@ export function createEventHandler(args: {
|
|||||||
source: `model-fallback:${source}`,
|
source: `model-fallback:${source}`,
|
||||||
input: promptBody,
|
input: promptBody,
|
||||||
});
|
});
|
||||||
if (promptResult.status === "dispatched") {
|
if (isInternalPromptDispatchAccepted(promptResult)) {
|
||||||
dispatched = true;
|
dispatched = true;
|
||||||
} else if (promptResult.status === "failed") {
|
} else if (promptResult.status === "failed") {
|
||||||
const error = promptResult.error;
|
const error = promptResult.error;
|
||||||
@@ -537,7 +541,7 @@ export function createEventHandler(args: {
|
|||||||
source: `model-fallback:${source}:sync`,
|
source: `model-fallback:${source}:sync`,
|
||||||
input: promptBody,
|
input: promptBody,
|
||||||
});
|
});
|
||||||
if (promptResult.status === "dispatched") {
|
if (isInternalPromptDispatchAccepted(promptResult)) {
|
||||||
dispatched = true;
|
dispatched = true;
|
||||||
} else if (promptResult.status === "failed") {
|
} else if (promptResult.status === "failed") {
|
||||||
log("[event] model-fallback prompt failed", { sessionID, source, error: promptResult.error });
|
log("[event] model-fallback prompt failed", { sessionID, source, error: promptResult.error });
|
||||||
@@ -959,7 +963,7 @@ export function createEventHandler(args: {
|
|||||||
});
|
});
|
||||||
if (promptResult.status === "failed") {
|
if (promptResult.status === "failed") {
|
||||||
log("[event] recovery continue prompt failed", { sessionID, error: promptResult.error });
|
log("[event] recovery continue prompt failed", { sessionID, error: promptResult.error });
|
||||||
} else if (promptResult.status !== "dispatched") {
|
} else if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||||
log("[event] recovery continue prompt skipped by gate", { sessionID, status: promptResult.status });
|
log("[event] recovery continue prompt skipped by gate", { sessionID, status: promptResult.status });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export function createUnstableAgentBabysitter(args: {
|
|||||||
return []
|
return []
|
||||||
},
|
},
|
||||||
status: async () => ctx.client.session.status(),
|
status: async () => ctx.client.session.status(),
|
||||||
prompt: async (promptArgs) => ctx.client.session.prompt(promptArgs),
|
|
||||||
promptAsync: async (promptArgs) => ctx.client.session.promptAsync(promptArgs),
|
promptAsync: async (promptArgs) => ctx.client.session.promptAsync(promptArgs),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export * from "./record-type-guard"
|
|||||||
export * from "./session-directory-resolver"
|
export * from "./session-directory-resolver"
|
||||||
export * from "./session-route"
|
export * from "./session-route"
|
||||||
export * from "./prompt-tools"
|
export * from "./prompt-tools"
|
||||||
|
export * from "./prompt-failure-classifier"
|
||||||
export * from "./compaction-marker"
|
export * from "./compaction-marker"
|
||||||
export * from "./internal-initiator-marker"
|
export * from "./internal-initiator-marker"
|
||||||
export * from "./plugin-command-discovery"
|
export * from "./plugin-command-discovery"
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
|||||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should reject concurrent promptAsync retries for the same session after one dispatch is reserved", async () => {
|
it("should coalesce concurrent promptAsync retries for the same session after one dispatch is reserved", async () => {
|
||||||
// given two callers racing to send into one session
|
// given two callers racing to send into one session
|
||||||
let releasePrompt: (() => void) | undefined
|
let releasePrompt: (() => void) | undefined
|
||||||
const promptGate = new Promise<void>((resolve) => {
|
const promptGate = new Promise<void>((resolve) => {
|
||||||
@@ -269,10 +269,10 @@ describe("promptWithModelSuggestionRetry", () => {
|
|||||||
// then only the reserved dispatch is sent to OpenCode
|
// then only the reserved dispatch is sent to OpenCode
|
||||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||||
expect(results[0]?.status).toBe("fulfilled")
|
expect(results[0]?.status).toBe("fulfilled")
|
||||||
expect(results[1]?.status).toBe("rejected")
|
expect(results[1]?.status).toBe("fulfilled")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given promptAsync retry just dispatched #when the same session is prompted again immediately #then the second caller is rejected by the gate", async () => {
|
it("#given promptAsync retry just dispatched #when the same session is prompted again immediately #then the second caller is coalesced by the queue", async () => {
|
||||||
// given
|
// given
|
||||||
const promptMock = mock(async () => undefined)
|
const promptMock = mock(async () => undefined)
|
||||||
const client = {
|
const client = {
|
||||||
@@ -290,14 +290,13 @@ describe("promptWithModelSuggestionRetry", () => {
|
|||||||
|
|
||||||
// when
|
// when
|
||||||
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||||
const second = promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
await expect(second).rejects.toThrow("promptAsync skipped by gate: reserved")
|
|
||||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given same-source retry observes a peer reservation #when it rejects #then the peer hold remains reserved", async () => {
|
it("#given same-source retry observes a peer reservation #when it coalesces #then a different prompt remains queued behind the hold", async () => {
|
||||||
// given
|
// given
|
||||||
const promptMock = mock(async () => undefined)
|
const promptMock = mock(async () => undefined)
|
||||||
const client = {
|
const client = {
|
||||||
@@ -315,9 +314,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
|||||||
|
|
||||||
// when
|
// when
|
||||||
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||||
await expect(
|
await promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||||
promptWithModelSuggestionRetry(unsafeTestValue(client), args)
|
|
||||||
).rejects.toThrow("promptAsync skipped by gate: reserved")
|
|
||||||
const third = await dispatchInternalPrompt({
|
const third = await dispatchInternalPrompt({
|
||||||
mode: "async",
|
mode: "async",
|
||||||
client,
|
client,
|
||||||
@@ -329,7 +326,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(third).toEqual({ status: "reserved", reservedBy: "model-suggestion-retry" })
|
expect(third).toEqual({ status: "queued", queuedBy: "model-suggestion-retry", position: 1 })
|
||||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -432,7 +429,7 @@ describe("promptWithModelSuggestionRetry", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(second).toEqual({ status: "reserved", reservedBy: "model-suggestion-retry" })
|
expect(second).toEqual({ status: "queued", queuedBy: "model-suggestion-retry", position: 1 })
|
||||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -564,7 +561,7 @@ describe("promptSyncWithModelSuggestionRetry", () => {
|
|||||||
expect(promptAsyncMock).toHaveBeenCalledTimes(0)
|
expect(promptAsyncMock).toHaveBeenCalledTimes(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given sync prompt retry just dispatched #when the same session is prompted again immediately #then the second caller is rejected by the gate", async () => {
|
it("#given sync prompt retry just dispatched #when the same session is prompted again immediately #then the second caller is coalesced by the queue", async () => {
|
||||||
// given
|
// given
|
||||||
const promptMock = mock(async () => undefined)
|
const promptMock = mock(async () => undefined)
|
||||||
const client = {
|
const client = {
|
||||||
@@ -582,10 +579,9 @@ describe("promptSyncWithModelSuggestionRetry", () => {
|
|||||||
|
|
||||||
// when
|
// when
|
||||||
await promptSyncWithModelSuggestionRetry(unsafeTestValue(client), args)
|
await promptSyncWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||||
const second = promptSyncWithModelSuggestionRetry(unsafeTestValue(client), args)
|
await promptSyncWithModelSuggestionRetry(unsafeTestValue(client), args)
|
||||||
|
|
||||||
// then
|
// then
|
||||||
await expect(second).rejects.toThrow("prompt skipped by gate: reserved")
|
|
||||||
expect(promptMock).toHaveBeenCalledTimes(1)
|
expect(promptMock).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "./prompt-timeout-context"
|
} from "./prompt-timeout-context"
|
||||||
import {
|
import {
|
||||||
dispatchInternalPrompt,
|
dispatchInternalPrompt,
|
||||||
|
isInternalPromptDispatchAccepted,
|
||||||
releasePromptAsyncReservation,
|
releasePromptAsyncReservation,
|
||||||
} from "./prompt-async-gate"
|
} from "./prompt-async-gate"
|
||||||
|
|
||||||
@@ -118,11 +119,12 @@ export async function promptWithModelSuggestionRetry(
|
|||||||
} as Parameters<typeof client.session.promptAsync>[0],
|
} as Parameters<typeof client.session.promptAsync>[0],
|
||||||
source: "model-suggestion-retry",
|
source: "model-suggestion-retry",
|
||||||
settleMs: 0,
|
settleMs: 0,
|
||||||
|
...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}),
|
||||||
})
|
})
|
||||||
if (promptResult.status === "failed") {
|
if (promptResult.status === "failed") {
|
||||||
throw promptResult.error
|
throw promptResult.error
|
||||||
}
|
}
|
||||||
if (promptResult.status !== "dispatched") {
|
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||||
throw new Error(`promptAsync skipped by gate: ${promptResult.status}`)
|
throw new Error(`promptAsync skipped by gate: ${promptResult.status}`)
|
||||||
}
|
}
|
||||||
if (timeoutContext.wasTimedOut()) {
|
if (timeoutContext.wasTimedOut()) {
|
||||||
@@ -162,11 +164,12 @@ export async function promptSyncWithModelSuggestionRetry(
|
|||||||
source: "model-suggestion-retry:sync",
|
source: "model-suggestion-retry:sync",
|
||||||
settleMs: 0,
|
settleMs: 0,
|
||||||
checkStatus: false,
|
checkStatus: false,
|
||||||
|
...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}),
|
||||||
})
|
})
|
||||||
if (promptResult.status === "failed") {
|
if (promptResult.status === "failed") {
|
||||||
throw promptResult.error
|
throw promptResult.error
|
||||||
}
|
}
|
||||||
if (promptResult.status !== "dispatched") {
|
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||||
throw new Error(`prompt skipped by gate: ${promptResult.status}`)
|
throw new Error(`prompt skipped by gate: ${promptResult.status}`)
|
||||||
}
|
}
|
||||||
if (timeoutContext.wasTimedOut()) {
|
if (timeoutContext.wasTimedOut()) {
|
||||||
@@ -220,11 +223,12 @@ export async function promptSyncWithModelSuggestionRetry(
|
|||||||
source: "model-suggestion-retry:sync-retry",
|
source: "model-suggestion-retry:sync-retry",
|
||||||
settleMs: 0,
|
settleMs: 0,
|
||||||
checkStatus: false,
|
checkStatus: false,
|
||||||
|
...(options.queueBehavior ? { queueBehavior: options.queueBehavior } : {}),
|
||||||
})
|
})
|
||||||
if (promptResult.status === "failed") {
|
if (promptResult.status === "failed") {
|
||||||
throw promptResult.error
|
throw promptResult.error
|
||||||
}
|
}
|
||||||
if (promptResult.status !== "dispatched") {
|
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||||
throw new Error(`prompt skipped by gate: ${promptResult.status}`)
|
throw new Error(`prompt skipped by gate: ${promptResult.status}`)
|
||||||
}
|
}
|
||||||
if (timeoutContext.wasTimedOut()) {
|
if (timeoutContext.wasTimedOut()) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
|
export const DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS = 250
|
||||||
export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
|
export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
|
||||||
export const DEFAULT_PROMPT_GATE_MESSAGES_FETCH_TIMEOUT_MS = 5_000
|
export const DEFAULT_PROMPT_GATE_MESSAGES_FETCH_TIMEOUT_MS = 5_000
|
||||||
|
export const DEFAULT_PROMPT_QUEUE_RETRY_MS = 250
|
||||||
|
|
||||||
type PromptAsyncInput = {
|
type PromptAsyncInput = {
|
||||||
path?: { id?: string }
|
path?: { id?: string }
|
||||||
@@ -44,11 +45,16 @@ type PromptClient<TInput> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type InternalPromptDispatchMode = "async" | "sync"
|
export type InternalPromptDispatchMode = "async" | "sync"
|
||||||
|
export type InternalPromptQueueBehavior = "enqueue" | "defer"
|
||||||
|
|
||||||
type InternalPromptDispatchCommonArgs<TInput> = {
|
type InternalPromptDispatchCommonArgs<TInput> = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
input: TInput
|
input: TInput
|
||||||
source: string
|
source: string
|
||||||
|
dedupeKey?: string
|
||||||
|
queueBehavior?: InternalPromptQueueBehavior
|
||||||
|
queue?: boolean
|
||||||
|
queueRetryMs?: number
|
||||||
settleMs?: number
|
settleMs?: number
|
||||||
postDispatchHoldMs?: number
|
postDispatchHoldMs?: number
|
||||||
dispatchTimeoutMs?: number
|
dispatchTimeoutMs?: number
|
||||||
@@ -63,6 +69,7 @@ export type InternalPromptDispatchArgs<TInput = PromptAsyncInput> = InternalProm
|
|||||||
|
|
||||||
type PromptAsyncReservation = {
|
type PromptAsyncReservation = {
|
||||||
source: string
|
source: string
|
||||||
|
dedupeKey: string
|
||||||
reservedAt: number
|
reservedAt: number
|
||||||
token: symbol
|
token: symbol
|
||||||
expiresAt?: number
|
expiresAt?: number
|
||||||
@@ -75,6 +82,7 @@ let promptGateMessagesFetchTimeoutMsForTesting: number | undefined
|
|||||||
|
|
||||||
export type InternalPromptDispatchResult =
|
export type InternalPromptDispatchResult =
|
||||||
| { status: "dispatched"; response: unknown }
|
| { status: "dispatched"; response: unknown }
|
||||||
|
| { status: "queued"; queuedBy: string; position: number }
|
||||||
| { status: "active" }
|
| { status: "active" }
|
||||||
| { status: "reserved"; reservedBy: string }
|
| { status: "reserved"; reservedBy: string }
|
||||||
| { status: "unavailable" }
|
| { status: "unavailable" }
|
||||||
@@ -88,6 +96,35 @@ type PromptAsyncReservationReleaseOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const promptAsyncReservations = new Map<string, PromptAsyncReservation>()
|
const promptAsyncReservations = new Map<string, PromptAsyncReservation>()
|
||||||
|
const promptQueues = new Map<string, QueuedInternalPrompt[]>()
|
||||||
|
const promptQueueDraining = new Set<string>()
|
||||||
|
const promptQueueInFlight = new Map<string, QueuedInternalPrompt>()
|
||||||
|
const promptQueueTimers = new Map<string, unknown>()
|
||||||
|
let promptQueueSequence = 0
|
||||||
|
|
||||||
|
type PromptDispatchClient = {
|
||||||
|
session?: {
|
||||||
|
status?: () => Promise<unknown>
|
||||||
|
messages?: (input: { path: { id: string }; query: PromptMessagesQuery }) => Promise<unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueuedInternalPrompt = {
|
||||||
|
id: number
|
||||||
|
sessionID: string
|
||||||
|
sessionName: "promptAsync" | "prompt"
|
||||||
|
client: PromptDispatchClient
|
||||||
|
input: unknown
|
||||||
|
source: string
|
||||||
|
dedupeKey: string
|
||||||
|
settleMs: number
|
||||||
|
postDispatchHoldMs: number
|
||||||
|
dispatchTimeoutMs: number
|
||||||
|
queueRetryMs: number
|
||||||
|
checkStatus: boolean
|
||||||
|
checkToolState: boolean
|
||||||
|
dispatch: (input: unknown) => Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
export function _setPromptGateMessagesFetchTimeoutMsForTesting(value: number | undefined): void {
|
export function _setPromptGateMessagesFetchTimeoutMsForTesting(value: number | undefined): void {
|
||||||
promptGateMessagesFetchTimeoutMsForTesting = value
|
promptGateMessagesFetchTimeoutMsForTesting = value
|
||||||
@@ -98,15 +135,20 @@ function getPromptGateMessagesFetchTimeoutMs(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pruneExpiredReservations(now = Date.now()): void {
|
function pruneExpiredReservations(now = Date.now()): void {
|
||||||
|
const expiredSessionIDs: string[] = []
|
||||||
for (const [sessionID, reservation] of promptAsyncReservations) {
|
for (const [sessionID, reservation] of promptAsyncReservations) {
|
||||||
if (typeof reservation.expiresAt === "number" && reservation.expiresAt <= now) {
|
if (typeof reservation.expiresAt === "number" && reservation.expiresAt <= now) {
|
||||||
promptAsyncReservations.delete(sessionID)
|
promptAsyncReservations.delete(sessionID)
|
||||||
|
expiredSessionIDs.push(sessionID)
|
||||||
log("[prompt-async-gate] expired reservation released", {
|
log("[prompt-async-gate] expired reservation released", {
|
||||||
sessionID,
|
sessionID,
|
||||||
source: reservation.source,
|
source: reservation.source,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const sessionID of expiredSessionIDs) {
|
||||||
|
schedulePromptQueueDrain(sessionID, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActiveReservation(sessionID: string): PromptAsyncReservation | undefined {
|
function getActiveReservation(sessionID: string): PromptAsyncReservation | undefined {
|
||||||
@@ -114,6 +156,102 @@ function getActiveReservation(sessionID: string): PromptAsyncReservation | undef
|
|||||||
return promptAsyncReservations.get(sessionID)
|
return promptAsyncReservations.get(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPromptQueue(sessionID: string): QueuedInternalPrompt[] {
|
||||||
|
const existing = promptQueues.get(sessionID)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue: QueuedInternalPrompt[] = []
|
||||||
|
promptQueues.set(sessionID, queue)
|
||||||
|
return queue
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPromptQueue(sessionID: string, queue: QueuedInternalPrompt[]): void {
|
||||||
|
if (queue.length === 0) {
|
||||||
|
promptQueues.delete(sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
promptQueues.set(sessionID, queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyPromptInputForDedupe(input: unknown): string {
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(input, (key: string, value: unknown): unknown => {
|
||||||
|
if (key === "signal") {
|
||||||
|
return "[AbortSignal]"
|
||||||
|
}
|
||||||
|
if (typeof value === "function") {
|
||||||
|
return `[Function:${value.name}]`
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
return serialized ?? String(input)
|
||||||
|
} catch {
|
||||||
|
return String(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultDedupeKey(source: string, input: unknown): string {
|
||||||
|
const fingerprint = stringifyPromptInputForDedupe(input)
|
||||||
|
return `${source}:${fingerprint.length}:${fingerprint.slice(0, 8192)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuedResult(entry: QueuedInternalPrompt, position: number, queuedBy = entry.source): InternalPromptDispatchResult {
|
||||||
|
return {
|
||||||
|
status: "queued",
|
||||||
|
queuedBy,
|
||||||
|
position,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPromptQueueTimer(sessionID: string): void {
|
||||||
|
const timer = promptQueueTimers.get(sessionID)
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
promptQueueTimers.delete(sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePromptQueueDrain(sessionID: string, delayMs: number): void {
|
||||||
|
const queue = promptQueues.get(sessionID)
|
||||||
|
if (!queue || queue.length === 0) {
|
||||||
|
clearPromptQueueTimer(sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPromptQueueTimer(sessionID)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
promptQueueTimers.delete(sessionID)
|
||||||
|
void drainPromptQueue(sessionID).catch((error: unknown) => {
|
||||||
|
log("[prompt-async-gate] queued prompt drain failed", {
|
||||||
|
sessionID,
|
||||||
|
error: String(error),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, Math.max(0, delayMs))
|
||||||
|
promptQueueTimers.set(sessionID, timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePromptQueueEntry(sessionID: string, entry: QueuedInternalPrompt): void {
|
||||||
|
const queue = promptQueues.get(sessionID)
|
||||||
|
if (!queue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextQueue = queue.filter((queued) => queued.id !== entry.id)
|
||||||
|
setPromptQueue(sessionID, nextQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueuedPromptBlocker(sessionID: string): string | undefined {
|
||||||
|
const inFlight = promptQueueInFlight.get(sessionID)
|
||||||
|
if (inFlight) {
|
||||||
|
return inFlight.source
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = promptQueues.get(sessionID)
|
||||||
|
return queue?.[0]?.source
|
||||||
|
}
|
||||||
|
|
||||||
function reservationSourceMatches(
|
function reservationSourceMatches(
|
||||||
reservationSource: string,
|
reservationSource: string,
|
||||||
expectedSource: string | readonly string[],
|
expectedSource: string | readonly string[],
|
||||||
@@ -341,6 +479,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
|||||||
sessionID: string
|
sessionID: string
|
||||||
input: TInput
|
input: TInput
|
||||||
source: string
|
source: string
|
||||||
|
dedupeKey: string
|
||||||
settleMs: number
|
settleMs: number
|
||||||
postDispatchHoldMs: number
|
postDispatchHoldMs: number
|
||||||
dispatchTimeoutMs: number
|
dispatchTimeoutMs: number
|
||||||
@@ -354,6 +493,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
|||||||
sessionID,
|
sessionID,
|
||||||
input,
|
input,
|
||||||
source,
|
source,
|
||||||
|
dedupeKey,
|
||||||
settleMs,
|
settleMs,
|
||||||
postDispatchHoldMs,
|
postDispatchHoldMs,
|
||||||
dispatchTimeoutMs,
|
dispatchTimeoutMs,
|
||||||
@@ -375,6 +515,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
|||||||
|
|
||||||
const reservation: PromptAsyncReservation = {
|
const reservation: PromptAsyncReservation = {
|
||||||
source,
|
source,
|
||||||
|
dedupeKey,
|
||||||
reservedAt: Date.now(),
|
reservedAt: Date.now(),
|
||||||
token: Symbol(source),
|
token: Symbol(source),
|
||||||
}
|
}
|
||||||
@@ -447,6 +588,117 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function drainPromptQueue(sessionID: string, awaitedEntry?: QueuedInternalPrompt): Promise<InternalPromptDispatchResult | undefined> {
|
||||||
|
if (promptQueueDraining.has(sessionID)) {
|
||||||
|
return awaitedEntry ? queuedResult(awaitedEntry, 1) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
promptQueueDraining.add(sessionID)
|
||||||
|
clearPromptQueueTimer(sessionID)
|
||||||
|
|
||||||
|
let awaitedResult: InternalPromptDispatchResult | undefined
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const queue = promptQueues.get(sessionID)
|
||||||
|
const entry = queue?.[0]
|
||||||
|
if (!entry) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
promptQueueInFlight.set(sessionID, entry)
|
||||||
|
const result = await dispatchAfterSessionIdle({
|
||||||
|
sessionName: entry.sessionName,
|
||||||
|
client: entry.client,
|
||||||
|
sessionID: entry.sessionID,
|
||||||
|
input: entry.input,
|
||||||
|
source: entry.source,
|
||||||
|
dedupeKey: entry.dedupeKey,
|
||||||
|
settleMs: entry.settleMs,
|
||||||
|
postDispatchHoldMs: entry.postDispatchHoldMs,
|
||||||
|
dispatchTimeoutMs: entry.dispatchTimeoutMs,
|
||||||
|
checkStatus: entry.checkStatus,
|
||||||
|
checkToolState: entry.checkToolState,
|
||||||
|
dispatch: entry.dispatch,
|
||||||
|
})
|
||||||
|
if (promptQueueInFlight.get(sessionID)?.id === entry.id) {
|
||||||
|
promptQueueInFlight.delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === "active" || result.status === "reserved") {
|
||||||
|
const queued = queuedResult(
|
||||||
|
entry,
|
||||||
|
1,
|
||||||
|
result.status === "reserved" ? result.reservedBy : entry.source,
|
||||||
|
)
|
||||||
|
if (awaitedEntry?.id === entry.id) {
|
||||||
|
awaitedResult = queued
|
||||||
|
}
|
||||||
|
schedulePromptQueueDrain(sessionID, entry.queueRetryMs)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
removePromptQueueEntry(sessionID, entry)
|
||||||
|
if (awaitedEntry?.id === entry.id) {
|
||||||
|
awaitedResult = result
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingQueue = promptQueues.get(sessionID)
|
||||||
|
if (!remainingQueue || remainingQueue.length === 0) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
schedulePromptQueueDrain(sessionID, entry.postDispatchHoldMs)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
promptQueueDraining.delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return awaitedResult
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueInternalPrompt(entry: QueuedInternalPrompt): Promise<InternalPromptDispatchResult> {
|
||||||
|
const activeReservation = getActiveReservation(entry.sessionID)
|
||||||
|
if (activeReservation?.dedupeKey === entry.dedupeKey) {
|
||||||
|
log("[prompt-async-gate] queued prompt coalesced with recent dispatch", {
|
||||||
|
sessionID: entry.sessionID,
|
||||||
|
source: entry.source,
|
||||||
|
queuedBy: activeReservation.source,
|
||||||
|
})
|
||||||
|
return queuedResult(entry, 0, activeReservation.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = getPromptQueue(entry.sessionID)
|
||||||
|
const existingIndex = queue.findIndex((queued) => queued.dedupeKey === entry.dedupeKey)
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const existing = queue[existingIndex]
|
||||||
|
if (existing) {
|
||||||
|
log("[prompt-async-gate] queued prompt coalesced with pending dispatch", {
|
||||||
|
sessionID: entry.sessionID,
|
||||||
|
source: entry.source,
|
||||||
|
queuedBy: existing.source,
|
||||||
|
position: existingIndex + 1,
|
||||||
|
})
|
||||||
|
return queuedResult(existing, existingIndex + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.push(entry)
|
||||||
|
log("[prompt-async-gate] queued prompt accepted", {
|
||||||
|
sessionID: entry.sessionID,
|
||||||
|
source: entry.source,
|
||||||
|
position: queue.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (queue.length > 1 || promptQueueDraining.has(entry.sessionID)) {
|
||||||
|
schedulePromptQueueDrain(entry.sessionID, 0)
|
||||||
|
return queuedResult(entry, queue.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await drainPromptQueue(entry.sessionID, entry)
|
||||||
|
return result ?? queuedResult(entry, 1)
|
||||||
|
}
|
||||||
|
|
||||||
export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
||||||
args: InternalPromptDispatchArgs<TInput>,
|
args: InternalPromptDispatchArgs<TInput>,
|
||||||
): Promise<InternalPromptDispatchResult> {
|
): Promise<InternalPromptDispatchResult> {
|
||||||
@@ -457,6 +709,8 @@ export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
|||||||
source,
|
source,
|
||||||
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
|
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
|
||||||
} = args
|
} = args
|
||||||
|
const dedupeKey = args.dedupeKey ?? createDefaultDedupeKey(source, input)
|
||||||
|
const queueRetryMs = args.queueRetryMs ?? DEFAULT_PROMPT_QUEUE_RETRY_MS
|
||||||
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
||||||
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
||||||
const sessionName = args.mode === "async" ? "promptAsync" : "prompt"
|
const sessionName = args.mode === "async" ? "promptAsync" : "prompt"
|
||||||
@@ -483,12 +737,59 @@ export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
|||||||
return { status: "unavailable" }
|
return { status: "unavailable" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.queueBehavior === "defer") {
|
||||||
|
const activeReservation = getActiveReservation(sessionID)
|
||||||
|
if (activeReservation) {
|
||||||
|
return { status: "reserved", reservedBy: activeReservation.source }
|
||||||
|
}
|
||||||
|
|
||||||
|
const queuedBy = getQueuedPromptBlocker(sessionID)
|
||||||
|
if (queuedBy !== undefined || promptQueueDraining.has(sessionID)) {
|
||||||
|
return { status: "reserved", reservedBy: queuedBy ?? source }
|
||||||
|
}
|
||||||
|
|
||||||
|
return dispatchAfterSessionIdle({
|
||||||
|
sessionName,
|
||||||
|
client,
|
||||||
|
sessionID,
|
||||||
|
input,
|
||||||
|
source,
|
||||||
|
dedupeKey,
|
||||||
|
settleMs,
|
||||||
|
postDispatchHoldMs,
|
||||||
|
dispatchTimeoutMs,
|
||||||
|
checkStatus: args.checkStatus !== false,
|
||||||
|
checkToolState: args.checkToolState !== false,
|
||||||
|
dispatch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.queue !== false) {
|
||||||
|
return enqueueInternalPrompt({
|
||||||
|
id: promptQueueSequence += 1,
|
||||||
|
sessionID,
|
||||||
|
sessionName,
|
||||||
|
client,
|
||||||
|
input,
|
||||||
|
source,
|
||||||
|
dedupeKey,
|
||||||
|
settleMs,
|
||||||
|
postDispatchHoldMs,
|
||||||
|
dispatchTimeoutMs,
|
||||||
|
queueRetryMs,
|
||||||
|
checkStatus: args.checkStatus !== false,
|
||||||
|
checkToolState: args.checkToolState !== false,
|
||||||
|
dispatch: dispatch as (dispatchInput: unknown) => Promise<unknown>,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return dispatchAfterSessionIdle({
|
return dispatchAfterSessionIdle({
|
||||||
sessionName,
|
sessionName,
|
||||||
client,
|
client,
|
||||||
sessionID,
|
sessionID,
|
||||||
input,
|
input,
|
||||||
source,
|
source,
|
||||||
|
dedupeKey,
|
||||||
settleMs,
|
settleMs,
|
||||||
postDispatchHoldMs,
|
postDispatchHoldMs,
|
||||||
dispatchTimeoutMs,
|
dispatchTimeoutMs,
|
||||||
@@ -500,9 +801,20 @@ export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
|||||||
|
|
||||||
export function releaseAllPromptAsyncReservationsForTesting(): void {
|
export function releaseAllPromptAsyncReservationsForTesting(): void {
|
||||||
promptAsyncReservations.clear()
|
promptAsyncReservations.clear()
|
||||||
|
promptQueues.clear()
|
||||||
|
promptQueueDraining.clear()
|
||||||
|
promptQueueInFlight.clear()
|
||||||
|
for (const timer of promptQueueTimers.values()) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
promptQueueTimers.clear()
|
||||||
promptGateMessagesFetchTimeoutMsForTesting = undefined
|
promptGateMessagesFetchTimeoutMsForTesting = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isInternalPromptDispatchAccepted(result: InternalPromptDispatchResult): boolean {
|
||||||
|
return result.status === "dispatched" || result.status === "queued"
|
||||||
|
}
|
||||||
|
|
||||||
export function releasePromptAsyncReservation(
|
export function releasePromptAsyncReservation(
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
source: string,
|
source: string,
|
||||||
@@ -524,6 +836,13 @@ export function releasePromptAsyncReservation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
promptAsyncReservations.delete(sessionID)
|
promptAsyncReservations.delete(sessionID)
|
||||||
|
const inFlight = promptQueueInFlight.get(sessionID)
|
||||||
|
if (inFlight?.dedupeKey === existing.dedupeKey) {
|
||||||
|
removePromptQueueEntry(sessionID, inFlight)
|
||||||
|
promptQueueInFlight.delete(sessionID)
|
||||||
|
promptQueueDraining.delete(sessionID)
|
||||||
|
}
|
||||||
|
schedulePromptQueueDrain(sessionID, 0)
|
||||||
log("[prompt-async-gate] promptAsync reservation released", {
|
log("[prompt-async-gate] promptAsync reservation released", {
|
||||||
sessionID,
|
sessionID,
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const RAW_PROMPT_ALLOWLIST = new Map<string, string>([
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
path.join(SOURCE_ROOT, "plugin", "unstable-agent-babysitter.ts"),
|
path.join(SOURCE_ROOT, "plugin", "unstable-agent-babysitter.ts"),
|
||||||
"binds SDK Session.prompt/.promptAsync into a narrow facade consumed only by gate-routed unstable-agent-babysitter dispatch; performs no direct dispatch itself",
|
"binds SDK Session.promptAsync into a narrow facade consumed only by gate-routed unstable-agent-babysitter dispatch; performs no direct dispatch itself",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
path.join(SOURCE_ROOT, "hooks", "session-recovery", "recover-unavailable-tool.ts"),
|
path.join(SOURCE_ROOT, "hooks", "session-recovery", "recover-unavailable-tool.ts"),
|
||||||
@@ -287,4 +287,21 @@ describe("production prompt injection routes", () => {
|
|||||||
// then
|
// then
|
||||||
expect(offenders).toEqual([])
|
expect(offenders).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given production TypeScript sources #when prompt gate callers are audited #then callers cannot bypass the central prompt queue", async () => {
|
||||||
|
// given
|
||||||
|
const files = await listSourceFiles(SOURCE_ROOT)
|
||||||
|
const offenders: string[] = []
|
||||||
|
|
||||||
|
// when
|
||||||
|
for (const filePath of files) {
|
||||||
|
const contents = await readFile(filePath, "utf8")
|
||||||
|
if (/queue\s*:\s*false\b/.test(contents)) {
|
||||||
|
offenders.push(relativeSourcePath(filePath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(offenders).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { isAmbiguousPromptDispatchFailure } from "./prompt-failure-classifier"
|
||||||
|
|
||||||
|
describe("prompt failure classifier", () => {
|
||||||
|
test("#given prompt dispatch reports a generic JSON parse error #when classifying ambiguity #then it treats the dispatch as possibly accepted", () => {
|
||||||
|
// given
|
||||||
|
const error = new Error("JSON Parse error: Unexpected end of JSON input")
|
||||||
|
|
||||||
|
// when
|
||||||
|
const ambiguous = isAmbiguousPromptDispatchFailure(error)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(ambiguous).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given prompt dispatch timeout casing varies #when classifying ambiguity #then it treats the dispatch as possibly accepted", () => {
|
||||||
|
// given
|
||||||
|
const error = "PromptAsync Timed Out after 30000ms"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const ambiguous = isAmbiguousPromptDispatchFailure(error)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(ambiguous).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export function extractPromptFailureMessage(error: unknown): string {
|
||||||
|
if (typeof error === "string") return error
|
||||||
|
if (error instanceof Error) return error.message
|
||||||
|
if (typeof error === "object" && error !== null) {
|
||||||
|
const record = error as Record<string, unknown>
|
||||||
|
if (typeof record.message === "string") return record.message
|
||||||
|
try {
|
||||||
|
return JSON.stringify(error)
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAmbiguousPromptDispatchFailure(error: unknown): boolean {
|
||||||
|
const message = extractPromptFailureMessage(error).toLowerCase()
|
||||||
|
return (
|
||||||
|
message.includes("unexpected eof")
|
||||||
|
|| message.includes("json parse error")
|
||||||
|
|| message.includes("unexpected end of json input")
|
||||||
|
|| message.includes("timed out")
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export interface PromptTimeoutArgs {
|
|||||||
|
|
||||||
export interface PromptRetryOptions {
|
export interface PromptRetryOptions {
|
||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
|
queueBehavior?: "enqueue" | "defer"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PROMPT_TIMEOUT_MS = 120000
|
export const PROMPT_TIMEOUT_MS = 120000
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe("promptAsyncInDirectory", () => {
|
|||||||
expect(promptAsync).toHaveBeenCalledTimes(0)
|
expect(promptAsync).toHaveBeenCalledTimes(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("#given a routed prompt just dispatched #when the same session is prompted again immediately #then the route keeps the session reserved", async () => {
|
test("#given a routed prompt just dispatched #when the same session is prompted again immediately #then the route coalesces the duplicate", async () => {
|
||||||
// given
|
// given
|
||||||
const promptAsync = mock(async () => ({ data: "sent" }))
|
const promptAsync = mock(async () => ({ data: "sent" }))
|
||||||
const client = {
|
const client = {
|
||||||
@@ -46,7 +46,7 @@ describe("promptAsyncInDirectory", () => {
|
|||||||
unsafeTestValue(args),
|
unsafeTestValue(args),
|
||||||
"/workspace/project",
|
"/workspace/project",
|
||||||
)
|
)
|
||||||
const second = promptAsyncInDirectory(
|
const second = await promptAsyncInDirectory(
|
||||||
unsafeTestValue(client),
|
unsafeTestValue(client),
|
||||||
unsafeTestValue(args),
|
unsafeTestValue(args),
|
||||||
"/workspace/project",
|
"/workspace/project",
|
||||||
@@ -54,7 +54,7 @@ describe("promptAsyncInDirectory", () => {
|
|||||||
|
|
||||||
// then
|
// then
|
||||||
expect(first).toEqual({ data: "sent" })
|
expect(first).toEqual({ data: "sent" })
|
||||||
await expect(second).rejects.toThrow("promptAsync skipped by gate: reserved")
|
expect(second).toBeUndefined()
|
||||||
expect(promptAsync).toHaveBeenCalledTimes(1)
|
expect(promptAsync).toHaveBeenCalledTimes(1)
|
||||||
expect(promptAsync.mock.calls[0]?.[0].query).toEqual({ directory: "/workspace/project" })
|
expect(promptAsync.mock.calls[0]?.[0].query).toEqual({ directory: "/workspace/project" })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
promptSyncWithModelSuggestionRetry,
|
promptSyncWithModelSuggestionRetry,
|
||||||
promptWithModelSuggestionRetry,
|
promptWithModelSuggestionRetry,
|
||||||
} from "./model-suggestion-retry"
|
} from "./model-suggestion-retry"
|
||||||
import { dispatchInternalPrompt } from "./prompt-async-gate"
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "./prompt-async-gate"
|
||||||
|
|
||||||
type OpencodeClient = PluginInput["client"]
|
type OpencodeClient = PluginInput["client"]
|
||||||
|
|
||||||
@@ -70,10 +70,10 @@ export function promptAsyncInDirectory(
|
|||||||
if (result.status === "failed") {
|
if (result.status === "failed") {
|
||||||
throw result.error
|
throw result.error
|
||||||
}
|
}
|
||||||
if (result.status !== "dispatched") {
|
if (!isInternalPromptDispatchAccepted(result)) {
|
||||||
throw new Error(`promptAsync skipped by gate: ${result.status}`)
|
throw new Error(`promptAsync skipped by gate: ${result.status}`)
|
||||||
}
|
}
|
||||||
return result.response
|
return result.status === "dispatched" ? result.response : undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { clearSessionAgent, setSessionAgent, subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
import { clearSessionAgent, setSessionAgent, subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
||||||
import { dispatchInternalPrompt } from "../../hooks/shared/prompt-async-gate"
|
import { dispatchInternalPrompt, isInternalPromptDispatchAccepted } from "../../hooks/shared/prompt-async-gate"
|
||||||
import { getAgentToolRestrictions, log } from "../../shared"
|
import { getAgentToolRestrictions, log } from "../../shared"
|
||||||
import { getAgentDisplayName, stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
import { getAgentDisplayName, stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||||
import {
|
import {
|
||||||
@@ -140,6 +140,7 @@ export async function executeSync(
|
|||||||
sessionID,
|
sessionID,
|
||||||
source: "call-omo-agent:sync",
|
source: "call-omo-agent:sync",
|
||||||
settleMs: 0,
|
settleMs: 0,
|
||||||
|
queueBehavior: "defer",
|
||||||
input: {
|
input: {
|
||||||
path: { id: sessionID },
|
path: { id: sessionID },
|
||||||
body: {
|
body: {
|
||||||
@@ -155,7 +156,7 @@ export async function executeSync(
|
|||||||
if (promptResult.status === "failed") {
|
if (promptResult.status === "failed") {
|
||||||
throw promptResult.error
|
throw promptResult.error
|
||||||
}
|
}
|
||||||
if (promptResult.status !== "dispatched") {
|
if (!isInternalPromptDispatchAccepted(promptResult)) {
|
||||||
throw new Error(`promptAsync skipped by gate: ${promptResult.status}`)
|
throw new Error(`promptAsync skipped by gate: ${promptResult.status}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ export async function executeSyncContinuation(
|
|||||||
tools,
|
tools,
|
||||||
parts: [{ type: "text", text: effectivePrompt }],
|
parts: [{ type: "text", text: effectivePrompt }],
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
queueBehavior: "defer",
|
||||||
})
|
})
|
||||||
} catch (promptError) {
|
} catch (promptError) {
|
||||||
if (toastManager) {
|
if (toastManager) {
|
||||||
|
|||||||
@@ -110,11 +110,13 @@ export async function sendSyncPrompt(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const routedPromptArgs = routePromptRetry(promptArgs, input.directory)
|
const routedPromptArgs = routePromptRetry(promptArgs, input.directory)
|
||||||
await deps.promptWithModelSuggestionRetry(client, routedPromptArgs)
|
await deps.promptWithModelSuggestionRetry(client, routedPromptArgs, { queueBehavior: "defer" })
|
||||||
} catch (promptError) {
|
} catch (promptError) {
|
||||||
if (isOracleAgent(input.agentToUse) && isUnexpectedEofError(promptError)) {
|
if (isOracleAgent(input.agentToUse) && isUnexpectedEofError(promptError)) {
|
||||||
try {
|
try {
|
||||||
await deps.promptSyncWithModelSuggestionRetry(client, routePromptSyncRetry(promptArgs, input.directory))
|
await deps.promptSyncWithModelSuggestionRetry(client, routePromptSyncRetry(promptArgs, input.directory), {
|
||||||
|
queueBehavior: "defer",
|
||||||
|
})
|
||||||
return null
|
return null
|
||||||
} catch (oracleRetryError) {
|
} catch (oracleRetryError) {
|
||||||
if (!isPromptGateReservedError(oracleRetryError)) {
|
if (!isPromptGateReservedError(oracleRetryError)) {
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ Original error: ${createResult.error}`
|
|||||||
...(agentModel ? { model: { providerID: agentModel.providerID, modelID: agentModel.modelID } } : {}),
|
...(agentModel ? { model: { providerID: agentModel.providerID, modelID: agentModel.modelID } } : {}),
|
||||||
...(agentVariant ? { variant: agentVariant } : {}),
|
...(agentVariant ? { variant: agentVariant } : {}),
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
queueBehavior: "defer",
|
||||||
})
|
})
|
||||||
} catch (promptError) {
|
} catch (promptError) {
|
||||||
log("[look_at] Prompt error (ignored, will still fetch messages):", promptError)
|
log("[look_at] Prompt error (ignored, will still fetch messages):", promptError)
|
||||||
|
|||||||
Reference in New Issue
Block a user