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:
YeonGyu-Kim
2026-05-17 17:30:02 +09:00
committed by GitHub
33 changed files with 365 additions and 218 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+10 -14
View File
@@ -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.
+3 -2
View File
@@ -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 -2
View File
@@ -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,
+3 -2
View File
@@ -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") {
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+3 -2
View File
@@ -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",
+190 -63
View File
@@ -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
View File
@@ -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",
+5 -3
View File
@@ -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",
+7 -5
View File
@@ -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: {
+50 -65
View File
@@ -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,
})
}
+2 -2
View File
@@ -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 -2
View File
@@ -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"]],
])
+3 -2
View File
@@ -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",