Merge pull request #4109 from code-yeongyu/code-yeongyu/unify-prompt-async-routes
refactor(prompt-gate): unify internal prompt dispatch routes
This commit is contained in:
@@ -175,7 +175,7 @@ Schema autocomplete: `"$schema": "https://raw.githubusercontent.com/code-yeongyu
|
||||
- **OpenClaw bidirectional:** Outbound dispatchers fire on session events; inbound daemon polls Discord/Telegram and `send-keys` replies into the tracked tmux pane.
|
||||
- **Internal message injection is dangerous:** OpenCode의 stupid한 설계로 플러그인이 `session.prompt` / `session.promptAsync` 같은 메인 세션 메시지 API를 통해 메인 시스템을 망가뜨릴 수 있다.
|
||||
- Root cause to remember: OpenCode `promptAsync` returns before the prompt is durably accepted, and later failures can arrive as `session.error`. Multiple OMO hooks/tools can observe the same idle/error/completion edge and inject the same internal message into a live parent session.
|
||||
- Treat every `session.prompt` / `session.promptAsync` call as a write to shared session state. Production code may call them only inside `src/shared/prompt-async-gate.ts`; all other routes must use `promptAsyncAfterSessionIdle`, `promptAfterSessionIdle`, or a proven equivalent gate.
|
||||
- Treat every `session.prompt` / `session.promptAsync` call as a write to shared session state. Production code may call them only inside `src/shared/prompt-async-gate.ts`; all other routes must use `dispatchInternalPrompt({ mode: "async" | "sync", ... })` or a proven equivalent gate.
|
||||
- Required gate semantics: reserve per session before dispatch, check active session state, keep a short post-dispatch hold, release only on intentional abort/recovery paths, and restore optimistic task/loop state when dispatch is skipped or fails later.
|
||||
- Forbidden patterns: raw prompt calls outside the shared gate, `postDispatchHoldMs: 0`, no-session fallback to raw prompt, and new internal message routes without duplicate-injection regression tests.
|
||||
- Tests must pin both the shared invariant and the route behavior: update the static raw-prompt audit, then add route-specific tests proving concurrent/live/idle/error triggers collapse to one dispatch. Cover background completion wakes, fallback retries, team mailbox live delivery, recovery continuations, CLI run resumes, Claude Code hook injections, and sync/background subagent prompts.
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- `createPluginModule` test seam moved out of public API surface to `src/testing/create-plugin-module.ts`. New public exports for the prompt-async-gate primitives: `promptAsyncAfterSessionIdle`, `promptAfterSessionIdle`, `releasePromptAsyncReservation`, `DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS`, `DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS`.
|
||||
- `createPluginModule` test seam moved out of public API surface to `src/testing/create-plugin-module.ts`. New public exports for the prompt-async-gate primitives: `dispatchInternalPrompt`, `releasePromptAsyncReservation`, `DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS`, `DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS`.
|
||||
- `ParentWakeNotifier` module (`src/features/background-agent/parent-wake-notifier.ts`) extracted from `BackgroundManager`. Background-agent parent-wake state now lives in its own narrow class with dependency-injected client, directory, and notification enqueue callback.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -63,16 +63,12 @@ The root `AGENTS.md` now records the governing invariant in the section
|
||||
Create `src/shared/prompt-async-gate.ts` as the single production owner of raw
|
||||
OpenCode prompt dispatch.
|
||||
|
||||
The gate exposes the public wrappers that production callers must use:
|
||||
The gate exposes one public dispatcher that production callers must use:
|
||||
|
||||
```ts
|
||||
export function promptAsyncAfterSessionIdle(
|
||||
options: PromptAsyncAfterSessionIdleOptions,
|
||||
): Promise<PromptAsyncGateResult>
|
||||
|
||||
export function promptAfterSessionIdle(
|
||||
options: PromptAfterSessionIdleOptions,
|
||||
): Promise<PromptAsyncGateResult>
|
||||
export function dispatchInternalPrompt(
|
||||
options: InternalPromptDispatchArgs,
|
||||
): Promise<InternalPromptDispatchResult>
|
||||
```
|
||||
|
||||
The gate coordinates callers with a module-global reservation map:
|
||||
@@ -125,16 +121,16 @@ export const DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS = 30_000
|
||||
`session.prompt` call with `Promise.race`. A hung OpenCode API call must fail
|
||||
closed instead of holding a reservation forever.
|
||||
|
||||
Both public gate helpers delegate to one internal runner:
|
||||
The public dispatcher delegates to one internal runner:
|
||||
|
||||
```ts
|
||||
dispatchAfterSessionIdle<TInput>(args)
|
||||
```
|
||||
|
||||
`promptAsyncAfterSessionIdle` passes a `session.promptAsync` dispatcher.
|
||||
`promptAfterSessionIdle` passes a `session.prompt` dispatcher. Sharing the
|
||||
runner keeps reservation, hold, timeout, logging, and active-session behavior
|
||||
identical for async and sync prompt routes.
|
||||
`dispatchInternalPrompt({ mode: "async", ... })` binds `session.promptAsync`.
|
||||
`dispatchInternalPrompt({ mode: "sync", ... })` binds `session.prompt`.
|
||||
Sharing the runner keeps reservation, hold, timeout, logging, and active-session
|
||||
behavior identical for async and sync prompt routes.
|
||||
|
||||
The public gate result is a discriminated union. Callers must treat `active`
|
||||
and `reserved` as successful suppression, not automatic retry signals. A route
|
||||
@@ -198,7 +194,7 @@ optional chaining, and aliased or cast access patterns.
|
||||
### Migration
|
||||
|
||||
Existing `session.prompt` and `session.promptAsync` callers must route through
|
||||
`promptAfterSessionIdle` or `promptAsyncAfterSessionIdle`.
|
||||
`dispatchInternalPrompt` with the matching dispatch mode.
|
||||
|
||||
Existing production callers were wired through the introduction PR #4034.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { loadAgentProfileColors } from "./agent-profile-colors"
|
||||
import { suppressRunInput } from "./stdin-suppression"
|
||||
import { createTimestampedStdoutController } from "./timestamp-output"
|
||||
import { createCliPostHog, getPostHogDistinctId } from "../../shared/posthog"
|
||||
import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../../shared/prompt-async-gate"
|
||||
|
||||
export { resolveRunAgent }
|
||||
|
||||
@@ -110,7 +110,8 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
() => {},
|
||||
)
|
||||
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID,
|
||||
source: "cli-run",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
clearAllDelegatedChildSessionBootstrap,
|
||||
getDelegatedChildSessionBootstrap,
|
||||
} from "../../shared/delegated-child-session-bootstrap"
|
||||
import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../../shared/prompt-async-gate"
|
||||
import { clearSessionPromptParams, getSessionPromptParams } from "../../shared/session-prompt-params-state"
|
||||
import {
|
||||
getSessionAgent,
|
||||
@@ -2516,7 +2516,8 @@ describe("BackgroundManager.resume promptAsync gate state", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
await promptAsyncAfterSessionIdle({
|
||||
await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "session-reserved-resume",
|
||||
source: "test-existing-reservation",
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
|
||||
import { setContinuationMarkerSource } from "../../features/run-continuation-state"
|
||||
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||
import { type PromptAsyncGateResult, promptAsyncAfterSessionIdle } 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 {
|
||||
createInternalAgentTextPart,
|
||||
@@ -1290,7 +1290,8 @@ The fallback retry session is now created and can be inspected directly.
|
||||
applySessionPromptParams(existingTask.sessionId!, existingTask.model)
|
||||
}
|
||||
|
||||
promptAsyncAfterSessionIdle({
|
||||
dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: this.client,
|
||||
sessionID: existingTask.sessionId,
|
||||
source: "background-agent-resume",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { resolveRegisteredAgentName } from "../claude-code-session-state"
|
||||
import { createInternalAgentTextPart, log, messagesInDirectory, normalizeSDKResponse } from "../../shared"
|
||||
import { isSessionActive as isOpenCodeSessionActive, settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle"
|
||||
import { promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../../hooks/shared/prompt-async-gate"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
@@ -137,7 +137,8 @@ export class ParentWakeNotifier {
|
||||
const notificationContent = latestWake.notifications.join("\n\n")
|
||||
|
||||
try {
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: this.deps.client,
|
||||
sessionID,
|
||||
source: "background-agent-parent-wake",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { promptWithModelSuggestionRetry } from "../../shared"
|
||||
import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../../shared/prompt-async-gate"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
@@ -35,7 +35,8 @@ export function promptAsyncInDirectory(
|
||||
return Promise.reject(new Error("session id is required for routed promptAsync"))
|
||||
}
|
||||
|
||||
return promptAsyncAfterSessionIdle({
|
||||
return dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID,
|
||||
input: routedArgs,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type ToolDefinition, tool } from "@opencode-ai/plugin/tool"
|
||||
import { z } from "zod"
|
||||
|
||||
import type { TeamModeConfig } from "../../../config/schema/team-mode"
|
||||
import { promptAsyncAfterSessionIdle } from "../../../hooks/shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../../../hooks/shared/prompt-async-gate"
|
||||
import { log } from "../../../shared/logger"
|
||||
import { applyMemberSessionRouting, buildMemberPromptBody } from "../member-session-routing"
|
||||
import { buildEnvelope } from "../team-mailbox/poll"
|
||||
@@ -199,7 +199,8 @@ async function deliverLive(
|
||||
applyMemberSessionRouting(recipientSessionId, recipientMember)
|
||||
|
||||
try {
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: recipientSessionId,
|
||||
source: "team-live-delivery",
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
findNearestMessageWithFields,
|
||||
findNearestMessageWithFieldsFromSDK,
|
||||
} from "../../features/hook-message-injector"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
||||
|
||||
export async function runAggressiveTruncationStrategy(params: {
|
||||
sessionID: string
|
||||
@@ -88,7 +88,8 @@ export async function runAggressiveTruncationStrategy(params: {
|
||||
const launchVariant = previousMessage?.model?.variant
|
||||
const inheritedTools = resolveInheritedPromptTools(params.sessionID, previousMessage?.tools)
|
||||
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: params.client,
|
||||
sessionID: params.sessionID,
|
||||
source: "auto-compact",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import { createInternalAgentContinuationTextPart, resolveInheritedPromptTools } from "../../shared"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
|
||||
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
|
||||
@@ -92,21 +92,22 @@ export async function injectBoulderContinuation(input: {
|
||||
: undefined
|
||||
const launchVariant = promptContext.model?.variant
|
||||
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: HOOK_NAME,
|
||||
settleMs: idleSettleMs,
|
||||
input: {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: continuationAgent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
parts: [createInternalAgentContinuationTextPart(prompt)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: continuationAgent,
|
||||
...(launchModel ? { model: launchModel } : {}),
|
||||
...(launchVariant ? { variant: launchVariant } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
parts: [createInternalAgentContinuationTextPart(prompt)],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
},
|
||||
})
|
||||
if (promptResult.status === "failed") {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { createInternalAgentContinuationTextPart } from "../../shared"
|
||||
import { getAgentConfigKey } from "../../shared/agent-display-names"
|
||||
import { log } from "../../shared/logger"
|
||||
import { shouldPromptAfterSessionIdle } from "../shared/session-idle-settle"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
||||
import { injectBoulderContinuation } from "./boulder-continuation-injector"
|
||||
import { HOOK_NAME } from "./hook-name"
|
||||
import { resolveActiveBoulderSession } from "./resolve-active-boulder-session"
|
||||
@@ -291,7 +291,8 @@ export async function handleAtlasSessionIdle(input: {
|
||||
return
|
||||
}
|
||||
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: HOOK_NAME,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { clearToolInputCache, stopToolInputCacheCleanup } from "../tool-input-ca
|
||||
import type { PluginConfig } from "../types"
|
||||
import { createInternalAgentTextPart, isHookDisabled, log } from "../../../shared"
|
||||
import { resolveSessionEventID } from "../../../shared/event-session-id"
|
||||
import { promptAfterSessionIdle } from "../../../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../../../shared/prompt-async-gate"
|
||||
import {
|
||||
clearAllSessionHookState,
|
||||
clearSessionHookState,
|
||||
@@ -109,7 +109,8 @@ export function createSessionEventHandler(
|
||||
})
|
||||
} else if (stopResult.block && stopResult.injectPrompt) {
|
||||
log("Stop hook returned block with inject_prompt", { sessionID })
|
||||
const promptResult = await promptAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "sync",
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: "claude-code-stop-hook:inject-prompt",
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { AGENT_RECOVERY_PROMPT, NO_TEXT_TAIL_THRESHOLD, RECOVERY_COOLDOWN_MS, RECENT_COMPACTION_WINDOW_MS } from "./constants"
|
||||
import type { CompactionContextClient } from "./types"
|
||||
import type { TailMonitorState } from "./tail-monitor"
|
||||
import { promptAsyncAfterSessionIdle, releasePromptAsyncReservation } from "../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt, releasePromptAsyncReservation } from "../shared/prompt-async-gate"
|
||||
|
||||
export function createRecoveryLogic(
|
||||
ctx: CompactionContextClient | undefined,
|
||||
@@ -82,7 +82,8 @@ export function createRecoveryLogic(
|
||||
}
|
||||
|
||||
try {
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: "compaction-context-injector",
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
resolveInheritedPromptTools,
|
||||
} from "../../shared"
|
||||
import { normalizeAgentForPrompt, stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
||||
|
||||
type MessageInfo = {
|
||||
agent?: string
|
||||
@@ -139,7 +139,8 @@ export async function injectContinuationPrompt(
|
||||
|
||||
let response: unknown
|
||||
try {
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID: options.sessionID,
|
||||
source: "ralph-loop",
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getLastUserRetryPayload } from "./last-user-retry-parts"
|
||||
import { extractSessionMessages } from "./session-messages"
|
||||
import { resolveRegisteredAgentName } from "../../features/claude-code-session-state"
|
||||
import {
|
||||
promptAsyncAfterSessionIdle,
|
||||
dispatchInternalPrompt,
|
||||
releasePromptAsyncReservation,
|
||||
} from "../shared/prompt-async-gate"
|
||||
|
||||
@@ -157,7 +157,8 @@ export function createAutoRetryHelpers(deps: HookDeps) {
|
||||
sessionAwaitingFallbackResult.add(sessionID)
|
||||
scheduleSessionFallbackTimeout(sessionID, retryAgent)
|
||||
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: `runtime-fallback:${source}`,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MessageData, ResumeConfig } from "./types"
|
||||
import { readParts } from "./storage/parts-reader"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
type ToolResultContent = { type: "text"; text: string }
|
||||
@@ -169,7 +169,8 @@ export async function recoverToolResultMissing(
|
||||
return false
|
||||
}
|
||||
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID,
|
||||
source: options?.source ?? "session-recovery-tool-result-missing",
|
||||
|
||||
@@ -13,6 +13,10 @@ mock.module("./storage", () => ({
|
||||
readParts: () => storedParts,
|
||||
}))
|
||||
|
||||
mock.module("./storage/parts-reader", () => ({
|
||||
readParts: () => storedParts,
|
||||
}))
|
||||
|
||||
const { recoverUnavailableTool } = await import("./recover-unavailable-tool")
|
||||
|
||||
const failedAssistantMsg: MessageData = {
|
||||
@@ -82,7 +86,10 @@ describe("recoverUnavailableTool", () => {
|
||||
tool: "bash",
|
||||
state: { input: {} },
|
||||
}]
|
||||
const { client, promptAsync } = createMockClient()
|
||||
const { client, promptAsync } = createMockClient([{
|
||||
info: { id: "msg_failed", role: "assistant" },
|
||||
parts: [{ type: "tool", id: "prt_stored_valid_call", callID: "toolu_recovered", name: "bash", input: {} }],
|
||||
}])
|
||||
|
||||
//#when
|
||||
const result = await recoverUnavailableTool(client, "ses_2", failedAssistantMsg)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readParts } from "./storage"
|
||||
import type { MessageData } from "./types"
|
||||
import { normalizeSDKResponse } from "../../shared"
|
||||
import { isSqliteBackend } from "../../shared/opencode-storage-detection"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
@@ -83,11 +83,13 @@ export async function recoverUnavailableTool(
|
||||
parts = await readPartsFromSDKFallback(client, sessionID, failedAssistantMsg.info.id)
|
||||
} else {
|
||||
const storedParts = readParts(failedAssistantMsg.info.id)
|
||||
parts = storedParts.map((part) => ({
|
||||
type: part.type === "tool" ? "tool_use" : part.type,
|
||||
id: "callID" in part ? (part as { callID?: string }).callID : part.id,
|
||||
name: "tool" in part && typeof part.tool === "string" ? part.tool : undefined,
|
||||
}))
|
||||
parts = storedParts.length > 0
|
||||
? storedParts.map((part) => ({
|
||||
type: part.type === "tool" ? "tool_use" : part.type,
|
||||
id: "callID" in part ? (part as { callID?: string }).callID : part.id,
|
||||
name: "tool" in part && typeof part.tool === "string" ? part.tool : undefined,
|
||||
}))
|
||||
: await readPartsFromSDKFallback(client, sessionID, failedAssistantMsg.info.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +121,8 @@ export async function recoverUnavailableTool(
|
||||
return false
|
||||
}
|
||||
|
||||
const promptResult = await promptAsyncAfterSessionIdle<PromptWithToolResultInput>({
|
||||
const promptResult = await dispatchInternalPrompt<PromptWithToolResultInput>({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID,
|
||||
source: "session-recovery-unavailable-tool",
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
isRealUserMessage,
|
||||
resolveInheritedPromptTools,
|
||||
} from "../../shared"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
||||
import type { MessageData, ResumeConfig } from "./types"
|
||||
|
||||
const RECOVERY_RESUME_TEXT = "[session recovered - continuing previous task]"
|
||||
@@ -38,7 +38,8 @@ export async function resumeSession(client: Client, config: ResumeConfig): Promi
|
||||
: undefined
|
||||
const launchVariant = config.model?.variant
|
||||
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: config.sessionID,
|
||||
source: "session-recovery",
|
||||
|
||||
@@ -2,13 +2,128 @@ import { afterEach, describe, expect, test } from "bun:test"
|
||||
|
||||
import {
|
||||
_setPromptGateMessagesFetchTimeoutMsForTesting,
|
||||
promptAfterSessionIdle,
|
||||
promptAsyncAfterSessionIdle,
|
||||
dispatchInternalPrompt,
|
||||
releaseAllPromptAsyncReservationsForTesting,
|
||||
releasePromptAsyncReservation,
|
||||
} from "./prompt-async-gate"
|
||||
|
||||
describe("promptAsyncAfterSessionIdle", () => {
|
||||
describe("dispatchInternalPrompt", () => {
|
||||
afterEach(() => {
|
||||
// then
|
||||
releaseAllPromptAsyncReservationsForTesting()
|
||||
})
|
||||
|
||||
test("#given async mode #when the unified prompt dispatcher runs #then promptAsync is used", async () => {
|
||||
// given
|
||||
const calls: string[] = []
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async (input: { path: { id: string } }) => {
|
||||
calls.push(`async:${input.path.id}`)
|
||||
return { route: "async", sessionID: input.path.id }
|
||||
},
|
||||
prompt: async (input: { path: { id: string } }) => {
|
||||
calls.push(`sync:${input.path.id}`)
|
||||
return { route: "sync", sessionID: input.path.id }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_unified_async",
|
||||
input: { path: { id: "ses_unified_async" }, body: { parts: [] } },
|
||||
source: "test:unified-async",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
status: "dispatched",
|
||||
response: { route: "async", sessionID: "ses_unified_async" },
|
||||
})
|
||||
expect(calls).toEqual(["async:ses_unified_async"])
|
||||
})
|
||||
|
||||
test("#given sync mode #when the unified prompt dispatcher runs #then prompt is used", async () => {
|
||||
// given
|
||||
const calls: string[] = []
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async (input: { path: { id: string } }) => {
|
||||
calls.push(`async:${input.path.id}`)
|
||||
return { route: "async", sessionID: input.path.id }
|
||||
},
|
||||
prompt: async (input: { path: { id: string } }) => {
|
||||
calls.push(`sync:${input.path.id}`)
|
||||
return { route: "sync", sessionID: input.path.id }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await dispatchInternalPrompt({
|
||||
mode: "sync",
|
||||
client,
|
||||
sessionID: "ses_unified_sync",
|
||||
input: { path: { id: "ses_unified_sync" }, body: { parts: [] } },
|
||||
source: "test:unified-sync",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
status: "dispatched",
|
||||
response: { route: "sync", sessionID: "ses_unified_sync" },
|
||||
})
|
||||
expect(calls).toEqual(["sync:ses_unified_sync"])
|
||||
})
|
||||
|
||||
test("#given async dispatch holds a session reservation #when sync mode targets the same session #then the unified service suppresses the duplicate", async () => {
|
||||
// given
|
||||
const calls: string[] = []
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
calls.push("async")
|
||||
},
|
||||
prompt: async () => {
|
||||
calls.push("sync")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// when
|
||||
const first = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_unified_shared_reservation",
|
||||
input: { path: { id: "ses_unified_shared_reservation" }, body: { parts: [] } },
|
||||
source: "test:unified-shared:first",
|
||||
settleMs: 0,
|
||||
})
|
||||
const second = await dispatchInternalPrompt({
|
||||
mode: "sync",
|
||||
client,
|
||||
sessionID: "ses_unified_shared_reservation",
|
||||
input: { path: { id: "ses_unified_shared_reservation" }, body: { parts: [] } },
|
||||
source: "test:unified-shared:second",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(first.status).toBe("dispatched")
|
||||
expect(second).toEqual({ status: "reserved", reservedBy: "test:unified-shared:first" })
|
||||
expect(calls).toEqual(["async"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispatchInternalPrompt shared gate behavior", () => {
|
||||
afterEach(() => {
|
||||
// then
|
||||
releaseAllPromptAsyncReservationsForTesting()
|
||||
@@ -32,7 +147,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const first = promptAsyncAfterSessionIdle({
|
||||
const first = dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_race",
|
||||
input: { path: { id: "ses_race" }, body: { parts: [] } },
|
||||
@@ -41,7 +157,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
await Promise.resolve()
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
const second = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_race",
|
||||
input: { path: { id: "ses_race" }, body: { parts: [] } },
|
||||
@@ -70,7 +187,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const first = promptAsyncAfterSessionIdle({
|
||||
const first = dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_hold_after_dispatch",
|
||||
input: { path: { id: "ses_hold_after_dispatch" }, body: { parts: [] } },
|
||||
@@ -78,7 +196,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
settleMs: 0,
|
||||
})
|
||||
const firstResult = await first
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
const second = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_hold_after_dispatch",
|
||||
input: { path: { id: "ses_hold_after_dispatch" }, body: { parts: [] } },
|
||||
@@ -106,7 +225,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
const client = { session }
|
||||
|
||||
// when
|
||||
const result = await promptAsyncAfterSessionIdle({
|
||||
const result = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_bound_prompt_async",
|
||||
input: { path: { id: "ses_bound_prompt_async" }, body: { parts: [] } },
|
||||
@@ -135,7 +255,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await promptAsyncAfterSessionIdle({
|
||||
const result = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_busy",
|
||||
input: { path: { id: "ses_busy" }, body: { parts: [] } },
|
||||
@@ -174,7 +295,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await promptAsyncAfterSessionIdle({
|
||||
const result = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_waiting_tools",
|
||||
input: { path: { id: "ses_waiting_tools" }, body: { parts: [] } },
|
||||
@@ -207,7 +329,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await promptAsyncAfterSessionIdle({
|
||||
const result = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_recovery_tools",
|
||||
input: { path: { id: "ses_recovery_tools" }, body: { parts: [] } },
|
||||
@@ -237,7 +360,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await promptAsyncAfterSessionIdle({
|
||||
const result = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_messages_hang",
|
||||
input: { path: { id: "ses_messages_hang" }, body: { parts: [] } },
|
||||
@@ -268,7 +392,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
|
||||
try {
|
||||
// when
|
||||
const first = await promptAsyncAfterSessionIdle({
|
||||
const first = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_expired_hold",
|
||||
input: { path: { id: "ses_expired_hold" }, body: { parts: [] } },
|
||||
@@ -277,7 +402,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
postDispatchHoldMs: 1,
|
||||
})
|
||||
currentNow += 2
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
const second = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_expired_hold",
|
||||
input: { path: { id: "ses_expired_hold" }, body: { parts: [] } },
|
||||
@@ -307,7 +433,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const first = await promptAsyncAfterSessionIdle({
|
||||
const first = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_release_scope",
|
||||
input: {
|
||||
@@ -320,7 +447,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
settleMs: 0,
|
||||
})
|
||||
releasePromptAsyncReservation("ses_release_scope", "ralph-loop:activity")
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
const second = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_release_scope",
|
||||
input: {
|
||||
@@ -350,7 +478,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const first = await promptAsyncAfterSessionIdle({
|
||||
const first = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_release_family_scope",
|
||||
input: {
|
||||
@@ -365,7 +494,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
"model-fallback-abort:session.error",
|
||||
{ reservedByPrefix: "model-fallback:" },
|
||||
)
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
const second = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_release_family_scope",
|
||||
input: {
|
||||
@@ -397,7 +527,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const first = await promptAsyncAfterSessionIdle({
|
||||
const first = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_dispatch_timeout",
|
||||
input: { path: { id: "ses_dispatch_timeout" }, body: { parts: [] } },
|
||||
@@ -406,7 +537,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
dispatchTimeoutMs: 1,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
const second = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_dispatch_timeout",
|
||||
input: { path: { id: "ses_dispatch_timeout" }, body: { parts: [] } },
|
||||
@@ -435,14 +567,16 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const first = await promptAsyncAfterSessionIdle({
|
||||
const first = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_post_dispatch_reject",
|
||||
input: { path: { id: "ses_post_dispatch_reject" }, body: { parts: [] } },
|
||||
source: "test:reject:first",
|
||||
settleMs: 0,
|
||||
})
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
const second = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_post_dispatch_reject",
|
||||
input: { path: { id: "ses_post_dispatch_reject" }, body: { parts: [] } },
|
||||
@@ -468,7 +602,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const first = await promptAsyncAfterSessionIdle({
|
||||
const first = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_prefix_sibling",
|
||||
input: {
|
||||
@@ -483,7 +618,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
"model-fallback-abort:session.error",
|
||||
{ reservedByPrefix: "model-fallback:" },
|
||||
)
|
||||
const second = await promptAsyncAfterSessionIdle({
|
||||
const second = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_prefix_sibling",
|
||||
input: {
|
||||
@@ -520,23 +656,19 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const first = promptAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_prompt_race",
|
||||
input: { path: { id: "ses_prompt_race" }, body: { parts: [] } },
|
||||
source: "test:prompt:first",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
const first = dispatchInternalPrompt({ mode: "sync", client,
|
||||
sessionID: "ses_prompt_race",
|
||||
input: { path: { id: "ses_prompt_race" }, body: { parts: [] } },
|
||||
source: "test:prompt:first",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0, })
|
||||
await Promise.resolve()
|
||||
const second = await promptAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_prompt_race",
|
||||
input: { path: { id: "ses_prompt_race" }, body: { parts: [] } },
|
||||
source: "test:prompt:second",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
const second = await dispatchInternalPrompt({ mode: "sync", client,
|
||||
sessionID: "ses_prompt_race",
|
||||
input: { path: { id: "ses_prompt_race" }, body: { parts: [] } },
|
||||
source: "test:prompt:second",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0, })
|
||||
releasePrompt?.()
|
||||
const firstResult = await first
|
||||
|
||||
@@ -558,21 +690,17 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const first = promptAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_prompt_hold_after_dispatch",
|
||||
input: { path: { id: "ses_prompt_hold_after_dispatch" }, body: { parts: [] } },
|
||||
source: "test:prompt-hold:first",
|
||||
settleMs: 0,
|
||||
})
|
||||
const first = dispatchInternalPrompt({ mode: "sync", client,
|
||||
sessionID: "ses_prompt_hold_after_dispatch",
|
||||
input: { path: { id: "ses_prompt_hold_after_dispatch" }, body: { parts: [] } },
|
||||
source: "test:prompt-hold:first",
|
||||
settleMs: 0, })
|
||||
const firstResult = await first
|
||||
const second = await promptAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_prompt_hold_after_dispatch",
|
||||
input: { path: { id: "ses_prompt_hold_after_dispatch" }, body: { parts: [] } },
|
||||
source: "test:prompt-hold:second",
|
||||
settleMs: 0,
|
||||
})
|
||||
const second = await dispatchInternalPrompt({ mode: "sync", client,
|
||||
sessionID: "ses_prompt_hold_after_dispatch",
|
||||
input: { path: { id: "ses_prompt_hold_after_dispatch" }, body: { parts: [] } },
|
||||
source: "test:prompt-hold:second",
|
||||
settleMs: 0, })
|
||||
|
||||
// then
|
||||
expect(firstResult.status).toBe("dispatched")
|
||||
@@ -593,7 +721,8 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const result = await promptAsyncAfterSessionIdle({
|
||||
const result = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: "ses_status_hang",
|
||||
input: { path: { id: "ses_status_hang" }, body: { parts: [] } },
|
||||
@@ -622,14 +751,12 @@ describe("promptAsyncAfterSessionIdle", () => {
|
||||
const client = { session }
|
||||
|
||||
// when
|
||||
const result = await promptAfterSessionIdle({
|
||||
client,
|
||||
sessionID: "ses_bound_prompt",
|
||||
input: { path: { id: "ses_bound_prompt" }, body: { parts: [] } },
|
||||
source: "test:bound-prompt",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0,
|
||||
})
|
||||
const result = await dispatchInternalPrompt({ mode: "sync", client,
|
||||
sessionID: "ses_bound_prompt",
|
||||
input: { path: { id: "ses_bound_prompt" }, body: { parts: [] } },
|
||||
source: "test:bound-prompt",
|
||||
settleMs: 0,
|
||||
postDispatchHoldMs: 0, })
|
||||
|
||||
// then
|
||||
expect(result).toEqual({
|
||||
|
||||
@@ -9,7 +9,7 @@ import { listUnreadMessages } from "../../features/team-mode/team-mailbox/inbox"
|
||||
import { loadRuntimeState, transitionRuntimeState } from "../../features/team-mode/team-state-store/store"
|
||||
import { resolveSessionEventID } from "../../shared/event-session-id"
|
||||
import { log } from "../../shared/logger"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
||||
|
||||
type PromptAsyncInput = {
|
||||
path: { id: string }
|
||||
@@ -111,7 +111,8 @@ export function createTeamIdleWakeHint(ctx: TeamIdleWakeHintContext, config: Tea
|
||||
}
|
||||
|
||||
applyMemberSessionRouting(sessionID, memberEntry)
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: "team-idle-wake-hint",
|
||||
|
||||
@@ -4,7 +4,7 @@ const { afterEach, describe, expect, test } = require("bun:test")
|
||||
import { injectContinuation } from "./continuation-injection"
|
||||
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
|
||||
import {
|
||||
promptAsyncAfterSessionIdle,
|
||||
dispatchInternalPrompt,
|
||||
releaseAllPromptAsyncReservationsForTesting,
|
||||
releasePromptAsyncReservation,
|
||||
} from "../shared/prompt-async-gate"
|
||||
@@ -260,7 +260,8 @@ describe("injectContinuation", () => {
|
||||
}
|
||||
|
||||
// when
|
||||
const peerMessageResult = await promptAsyncAfterSessionIdle({
|
||||
const peerMessageResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: "team-live-delivery",
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
normalizeAgentForPromptKey,
|
||||
stripAgentListSortPrefix,
|
||||
} from "../../shared/agent-display-names"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
||||
|
||||
import {
|
||||
CONTINUATION_PROMPT,
|
||||
@@ -187,7 +187,8 @@ ${todoList}`
|
||||
: undefined
|
||||
const launchVariant = model?.variant
|
||||
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: HOOK_NAME,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
isUnstableTask,
|
||||
THINKING_SUMMARY_MAX_CHARS,
|
||||
} from "./task-message-analyzer"
|
||||
import { promptAsyncAfterSessionIdle } from "../shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../shared/prompt-async-gate"
|
||||
|
||||
const HOOK_NAME = "unstable-agent-babysitter"
|
||||
const DEFAULT_TIMEOUT_MS = 120000
|
||||
@@ -216,7 +216,8 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
|
||||
? { providerID: model.providerID, modelID: model.modelID }
|
||||
: undefined
|
||||
const launchVariant = model?.variant
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID: mainSessionID,
|
||||
source: HOOK_NAME,
|
||||
|
||||
+7
-4
@@ -43,7 +43,7 @@ import { buildTeamIdleWakeHintClient } from "./build-team-idle-wake-hint-client"
|
||||
import { createTeamLeadOrphanHandler } from "../hooks/team-session-events/team-lead-orphan-handler";
|
||||
import { createTeamMemberErrorHandler } from "../hooks/team-session-events/team-member-error-handler";
|
||||
import { createTeamMemberStatusHandler } from "../hooks/team-session-events/team-member-status-handler";
|
||||
import { promptAfterSessionIdle, promptAsyncAfterSessionIdle, releasePromptAsyncReservation } from "../hooks/shared/prompt-async-gate";
|
||||
import { dispatchInternalPrompt, releasePromptAsyncReservation } from "../hooks/shared/prompt-async-gate";
|
||||
|
||||
import type { CreatedHooks } from "../create-hooks";
|
||||
import type { Managers } from "../create-managers";
|
||||
@@ -510,7 +510,8 @@ export function createEventHandler(args: {
|
||||
};
|
||||
|
||||
if (typeof pluginContext.client.session.promptAsync === "function") {
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: pluginContext.client,
|
||||
sessionID,
|
||||
source: `model-fallback:${source}`,
|
||||
@@ -527,7 +528,8 @@ export function createEventHandler(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
const promptResult = await promptAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "sync",
|
||||
client: pluginContext.client,
|
||||
sessionID,
|
||||
source: `model-fallback:${source}:sync`,
|
||||
@@ -942,7 +944,8 @@ export function createEventHandler(args: {
|
||||
log("[event] compaction before recovery continue failed:", { sessionID, error: err });
|
||||
});
|
||||
|
||||
const promptResult = await promptAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "sync",
|
||||
client: pluginContext.client,
|
||||
sessionID,
|
||||
source: "session-recovery:post-compaction-continue",
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { PluginContext } from "./types"
|
||||
|
||||
import { createUnstableAgentBabysitterHook } from "../hooks"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { promptAsyncAfterSessionIdle } from "../hooks/shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../hooks/shared/prompt-async-gate"
|
||||
|
||||
export function createUnstableAgentBabysitter(args: {
|
||||
ctx: PluginContext
|
||||
@@ -27,7 +27,8 @@ export function createUnstableAgentBabysitter(args: {
|
||||
},
|
||||
status: async () => ctx.client.session.status(),
|
||||
prompt: async (promptArgs) => {
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID: promptArgs.path.id,
|
||||
source: "unstable-agent-babysitter",
|
||||
@@ -38,7 +39,8 @@ export function createUnstableAgentBabysitter(args: {
|
||||
}
|
||||
},
|
||||
promptAsync: async (promptArgs) => {
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID: promptArgs.path.id,
|
||||
source: "unstable-agent-babysitter",
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
type PromptRetryOptions,
|
||||
} from "./prompt-timeout-context"
|
||||
import {
|
||||
promptAfterSessionIdle,
|
||||
promptAsyncAfterSessionIdle,
|
||||
dispatchInternalPrompt,
|
||||
releasePromptAsyncReservation,
|
||||
} from "./prompt-async-gate"
|
||||
|
||||
@@ -100,7 +99,8 @@ export async function promptWithModelSuggestionRetry(
|
||||
const timeoutContext = createPromptTimeoutContext(args, timeoutMs)
|
||||
|
||||
try {
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID: args.path.id,
|
||||
input: {
|
||||
@@ -140,7 +140,8 @@ export async function promptSyncWithModelSuggestionRetry(
|
||||
try {
|
||||
const timeoutContext = createPromptTimeoutContext(args, timeoutMs)
|
||||
try {
|
||||
const promptResult = await promptAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "sync",
|
||||
client,
|
||||
sessionID: args.path.id,
|
||||
input: {
|
||||
@@ -197,7 +198,8 @@ export async function promptSyncWithModelSuggestionRetry(
|
||||
|
||||
const timeoutContext = createPromptTimeoutContext(retryArgs, timeoutMs)
|
||||
try {
|
||||
const promptResult = await promptAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "sync",
|
||||
client,
|
||||
sessionID: retryArgs.path.id,
|
||||
input: {
|
||||
|
||||
@@ -38,6 +38,24 @@ type PromptClient<TInput> = {
|
||||
}
|
||||
}
|
||||
|
||||
export type InternalPromptDispatchMode = "async" | "sync"
|
||||
|
||||
type InternalPromptDispatchCommonArgs<TInput> = {
|
||||
sessionID: string
|
||||
input: TInput
|
||||
source: string
|
||||
settleMs?: number
|
||||
postDispatchHoldMs?: number
|
||||
dispatchTimeoutMs?: number
|
||||
checkStatus?: boolean
|
||||
checkToolState?: boolean
|
||||
}
|
||||
|
||||
export type InternalPromptDispatchArgs<TInput = PromptAsyncInput> = InternalPromptDispatchCommonArgs<TInput> & (
|
||||
| { mode: "async"; client: PromptAsyncClient<TInput> }
|
||||
| { mode: "sync"; client: PromptClient<TInput> }
|
||||
)
|
||||
|
||||
type PromptAsyncReservation = {
|
||||
source: string
|
||||
reservedAt: number
|
||||
@@ -45,18 +63,20 @@ type PromptAsyncReservation = {
|
||||
expiresAt?: number
|
||||
}
|
||||
|
||||
declare function setTimeout(callback: () => void, delay?: number): ReturnType<typeof globalThis.setTimeout>
|
||||
declare function clearTimeout(timeout: ReturnType<typeof globalThis.setTimeout>): void
|
||||
declare function setTimeout(callback: () => void, delay?: number): unknown
|
||||
declare function clearTimeout(timeout: unknown): void
|
||||
|
||||
let promptGateMessagesFetchTimeoutMsForTesting: number | undefined
|
||||
|
||||
export type PromptAsyncGateResult =
|
||||
export type InternalPromptDispatchResult =
|
||||
| { status: "dispatched"; response: unknown }
|
||||
| { status: "active" }
|
||||
| { status: "reserved"; reservedBy: string }
|
||||
| { status: "unavailable" }
|
||||
| { status: "failed"; error: unknown }
|
||||
|
||||
export type PromptAsyncGateResult = InternalPromptDispatchResult
|
||||
|
||||
type PromptAsyncReservationReleaseOptions = {
|
||||
reservedBy?: string | readonly string[]
|
||||
reservedByPrefix?: string | readonly string[]
|
||||
@@ -121,7 +141,7 @@ async function withDispatchTimeout<T>(
|
||||
return operation
|
||||
}
|
||||
|
||||
let timeoutID: ReturnType<typeof globalThis.setTimeout> | undefined
|
||||
let timeoutID: unknown
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutID = setTimeout(() => {
|
||||
reject(new Error(`${operationName} timed out after ${dispatchTimeoutMs}ms`))
|
||||
@@ -260,7 +280,7 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
||||
checkStatus: boolean
|
||||
checkToolState: boolean
|
||||
dispatch: (input: TInput) => Promise<unknown>
|
||||
}): Promise<PromptAsyncGateResult> {
|
||||
}): Promise<InternalPromptDispatchResult> {
|
||||
const {
|
||||
sessionName,
|
||||
client,
|
||||
@@ -360,17 +380,9 @@ async function dispatchAfterSessionIdle<TInput>(args: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
||||
client: PromptAsyncClient<TInput>
|
||||
sessionID: string
|
||||
input: TInput
|
||||
source: string
|
||||
settleMs?: number
|
||||
postDispatchHoldMs?: number
|
||||
dispatchTimeoutMs?: number
|
||||
checkStatus?: boolean
|
||||
checkToolState?: boolean
|
||||
}): Promise<PromptAsyncGateResult> {
|
||||
export async function dispatchInternalPrompt<TInput = PromptAsyncInput>(
|
||||
args: InternalPromptDispatchArgs<TInput>,
|
||||
): Promise<InternalPromptDispatchResult> {
|
||||
const {
|
||||
client,
|
||||
sessionID,
|
||||
@@ -380,16 +392,32 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
|
||||
} = args
|
||||
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
||||
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
||||
const session = client.session
|
||||
const sessionName = args.mode === "async" ? "promptAsync" : "prompt"
|
||||
const dispatch = (() => {
|
||||
if (args.mode === "async") {
|
||||
const session = args.client.session
|
||||
if (typeof session?.promptAsync !== "function") {
|
||||
return undefined
|
||||
}
|
||||
const dispatchPromptAsync = session.promptAsync.bind(session)
|
||||
return (dispatchInput: TInput) => dispatchPromptAsync(dispatchInput)
|
||||
}
|
||||
|
||||
if (typeof session?.promptAsync !== "function") {
|
||||
log("[prompt-async-gate] promptAsync unavailable", { sessionID, source })
|
||||
const session = args.client.session
|
||||
if (typeof session?.prompt !== "function") {
|
||||
return undefined
|
||||
}
|
||||
const dispatchPrompt = session.prompt.bind(session)
|
||||
return (dispatchInput: TInput) => dispatchPrompt(dispatchInput)
|
||||
})()
|
||||
|
||||
if (!dispatch) {
|
||||
log(`[prompt-async-gate] ${sessionName} unavailable`, { sessionID, source })
|
||||
return { status: "unavailable" }
|
||||
}
|
||||
const dispatchPromptAsync = session.promptAsync.bind(session)
|
||||
|
||||
return dispatchAfterSessionIdle({
|
||||
sessionName: "promptAsync",
|
||||
sessionName,
|
||||
client,
|
||||
sessionID,
|
||||
input,
|
||||
@@ -399,50 +427,7 @@ export async function promptAsyncAfterSessionIdle<TInput = PromptAsyncInput>(arg
|
||||
dispatchTimeoutMs,
|
||||
checkStatus: args.checkStatus !== false,
|
||||
checkToolState: args.checkToolState !== false,
|
||||
dispatch: (dispatchInput) => dispatchPromptAsync(dispatchInput),
|
||||
})
|
||||
}
|
||||
|
||||
export async function promptAfterSessionIdle<TInput = PromptAsyncInput>(args: {
|
||||
client: PromptClient<TInput>
|
||||
sessionID: string
|
||||
input: TInput
|
||||
source: string
|
||||
settleMs?: number
|
||||
postDispatchHoldMs?: number
|
||||
dispatchTimeoutMs?: number
|
||||
checkStatus?: boolean
|
||||
checkToolState?: boolean
|
||||
}): Promise<PromptAsyncGateResult> {
|
||||
const {
|
||||
client,
|
||||
sessionID,
|
||||
input,
|
||||
source,
|
||||
settleMs = DEFAULT_SESSION_IDLE_SETTLE_MS,
|
||||
} = args
|
||||
const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS
|
||||
const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS
|
||||
const session = client.session
|
||||
|
||||
if (typeof session?.prompt !== "function") {
|
||||
log("[prompt-async-gate] prompt unavailable", { sessionID, source })
|
||||
return { status: "unavailable" }
|
||||
}
|
||||
const dispatchPrompt = session.prompt.bind(session)
|
||||
|
||||
return dispatchAfterSessionIdle({
|
||||
sessionName: "prompt",
|
||||
client,
|
||||
sessionID,
|
||||
input,
|
||||
source,
|
||||
settleMs,
|
||||
postDispatchHoldMs,
|
||||
dispatchTimeoutMs,
|
||||
checkStatus: args.checkStatus !== false,
|
||||
checkToolState: args.checkToolState !== false,
|
||||
dispatch: (dispatchInput) => dispatchPrompt(dispatchInput),
|
||||
dispatch,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const RAW_PROMPT_ALLOWLIST = new Map<string, string>([
|
||||
],
|
||||
[
|
||||
path.join(SOURCE_ROOT, "hooks", "session-recovery", "recover-unavailable-tool.ts"),
|
||||
"runtime type guard checks promptAsync presence before gate-routed promptAsyncAfterSessionIdle",
|
||||
"runtime type guard checks promptAsync presence before gate-routed dispatchInternalPrompt",
|
||||
],
|
||||
])
|
||||
|
||||
@@ -148,7 +148,7 @@ function isPromptBindingPattern(node: ts.Node): boolean {
|
||||
return node.name.elements.some((element) => {
|
||||
const keyName = element.propertyName
|
||||
? getPropertyName(element.propertyName)
|
||||
: getPropertyName(element.name)
|
||||
: ts.isIdentifier(element.name) ? getPropertyName(element.name) : null
|
||||
return keyName === "prompt" || keyName === "promptAsync"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
promptSyncWithModelSuggestionRetry,
|
||||
promptWithModelSuggestionRetry,
|
||||
} from "./model-suggestion-retry"
|
||||
import { promptAsyncAfterSessionIdle } from "./prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "./prompt-async-gate"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
@@ -59,7 +59,8 @@ export function promptAsyncInDirectory(
|
||||
return Promise.reject(new Error("session id is required for routed promptAsync"))
|
||||
}
|
||||
|
||||
return promptAsyncAfterSessionIdle({
|
||||
return dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client,
|
||||
sessionID,
|
||||
input: routedArgs,
|
||||
|
||||
@@ -59,7 +59,8 @@ describe("closeTmuxPane runner integration", () => {
|
||||
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
expect(runTmuxCommandMock.mock.calls).toEqual([
|
||||
const paneCloseCalls = runTmuxCommandMock.mock.calls.filter((call: [string, string[]]) => call[1].includes("%42"))
|
||||
expect(paneCloseCalls).toEqual([
|
||||
["sh", ["send-keys", "-t", "%42", "C-c"]],
|
||||
["sh", ["kill-pane", "-t", "%42"]],
|
||||
])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { clearSessionAgent, setSessionAgent, subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
||||
import { promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate"
|
||||
import { dispatchInternalPrompt } from "../../hooks/shared/prompt-async-gate"
|
||||
import { getAgentToolRestrictions, log } from "../../shared"
|
||||
import { getAgentDisplayName, stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||
import {
|
||||
@@ -134,7 +134,8 @@ export async function executeSync(
|
||||
return `Error: Failed to send prompt: promptAsync is not available on this OpenCode client.\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||
}
|
||||
|
||||
const promptResult = await promptAsyncAfterSessionIdle({
|
||||
const promptResult = await dispatchInternalPrompt({
|
||||
mode: "async",
|
||||
client: ctx.client,
|
||||
sessionID,
|
||||
source: "call-omo-agent:sync",
|
||||
|
||||
Reference in New Issue
Block a user