From b5d24619c8d7b243d9fdf848b7ded8d8c3ec8d71 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 16:34:05 +0900 Subject: [PATCH 01/12] test(prompt-async-gate): pin unified internal prompt dispatch contract Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/shared/prompt-async-gate.test.ts | 117 +++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/src/hooks/shared/prompt-async-gate.test.ts b/src/hooks/shared/prompt-async-gate.test.ts index cc64d2dbe..4a7ee1452 100644 --- a/src/hooks/shared/prompt-async-gate.test.ts +++ b/src/hooks/shared/prompt-async-gate.test.ts @@ -2,12 +2,129 @@ import { afterEach, describe, expect, test } from "bun:test" import { _setPromptGateMessagesFetchTimeoutMsForTesting, + dispatchInternalPrompt, promptAfterSessionIdle, promptAsyncAfterSessionIdle, releaseAllPromptAsyncReservationsForTesting, releasePromptAsyncReservation, } from "./prompt-async-gate" +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("promptAsyncAfterSessionIdle", () => { afterEach(() => { // then From a42f894f88c4d1dcc686aba060c0fb94f6c5c324 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 16:37:59 +0900 Subject: [PATCH 02/12] refactor(prompt-async-gate): collapse dispatch into mode-based entrypoint Use one dispatchInternalPrompt surface with mode: async | sync so source, settle, hold, timeout, status checks, reservations, and release semantics stay in one runner. Keep the old helper names temporarily so caller migration can land atomically in follow-up commits. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/shared/prompt-async-gate.ts | 162 +++++++++++++++++++------------- 1 file changed, 99 insertions(+), 63 deletions(-) diff --git a/src/shared/prompt-async-gate.ts b/src/shared/prompt-async-gate.ts index 30850a54b..df49a5ed1 100644 --- a/src/shared/prompt-async-gate.ts +++ b/src/shared/prompt-async-gate.ts @@ -38,6 +38,30 @@ type PromptClient = { } } +type InternalPromptDispatchClient = { + session?: { + status?: () => Promise + messages?: (input: { path: { id: string }; query: PromptMessagesQuery }) => Promise + promptAsync?: (input: TInput) => Promise + prompt?: (input: TInput) => Promise + } +} + +export type InternalPromptDispatchMode = "async" | "sync" + +export type InternalPromptDispatchArgs = { + mode: InternalPromptDispatchMode + client: InternalPromptDispatchClient + sessionID: string + input: TInput + source: string + settleMs?: number + postDispatchHoldMs?: number + dispatchTimeoutMs?: number + checkStatus?: boolean + checkToolState?: boolean +} + type PromptAsyncReservation = { source: string reservedAt: number @@ -45,18 +69,20 @@ type PromptAsyncReservation = { expiresAt?: number } -declare function setTimeout(callback: () => void, delay?: number): ReturnType -declare function clearTimeout(timeout: ReturnType): 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 +147,7 @@ async function withDispatchTimeout( return operation } - let timeoutID: ReturnType | undefined + let timeoutID: unknown const timeoutPromise = new Promise((_, reject) => { timeoutID = setTimeout(() => { reject(new Error(`${operationName} timed out after ${dispatchTimeoutMs}ms`)) @@ -260,7 +286,7 @@ async function dispatchAfterSessionIdle(args: { checkStatus: boolean checkToolState: boolean dispatch: (input: TInput) => Promise -}): Promise { +}): Promise { const { sessionName, client, @@ -360,6 +386,68 @@ async function dispatchAfterSessionIdle(args: { } } +function getInternalPromptDispatcher( + mode: InternalPromptDispatchMode, + session: InternalPromptDispatchClient["session"], +): { + sessionName: "promptAsync" | "prompt" + dispatch?: (input: TInput) => Promise +} { + if (mode === "async") { + if (typeof session?.promptAsync !== "function") { + return { sessionName: "promptAsync" } + } + const dispatchPromptAsync = session.promptAsync.bind(session) + return { + sessionName: "promptAsync", + dispatch: (input) => dispatchPromptAsync(input), + } + } + + if (typeof session?.prompt !== "function") { + return { sessionName: "prompt" } + } + const dispatchPrompt = session.prompt.bind(session) + return { + sessionName: "prompt", + dispatch: (input) => dispatchPrompt(input), + } +} + +export async function dispatchInternalPrompt( + args: InternalPromptDispatchArgs, +): Promise { + 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 { sessionName, dispatch } = getInternalPromptDispatcher(args.mode, client.session) + + if (!dispatch) { + log(`[prompt-async-gate] ${sessionName} unavailable`, { sessionID, source }) + return { status: "unavailable" } + } + + return dispatchAfterSessionIdle({ + sessionName, + client, + sessionID, + input, + source, + settleMs, + postDispatchHoldMs, + dispatchTimeoutMs, + checkStatus: args.checkStatus !== false, + checkToolState: args.checkToolState !== false, + dispatch, + }) +} + export async function promptAsyncAfterSessionIdle(args: { client: PromptAsyncClient sessionID: string @@ -371,35 +459,9 @@ export async function promptAsyncAfterSessionIdle(arg checkStatus?: boolean checkToolState?: boolean }): Promise { - 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?.promptAsync !== "function") { - log("[prompt-async-gate] promptAsync unavailable", { sessionID, source }) - return { status: "unavailable" } - } - const dispatchPromptAsync = session.promptAsync.bind(session) - - return dispatchAfterSessionIdle({ - sessionName: "promptAsync", - client, - sessionID, - input, - source, - settleMs, - postDispatchHoldMs, - dispatchTimeoutMs, - checkStatus: args.checkStatus !== false, - checkToolState: args.checkToolState !== false, - dispatch: (dispatchInput) => dispatchPromptAsync(dispatchInput), + return dispatchInternalPrompt({ + ...args, + mode: "async", }) } @@ -414,35 +476,9 @@ export async function promptAfterSessionIdle(args: { checkStatus?: boolean checkToolState?: boolean }): Promise { - 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), + return dispatchInternalPrompt({ + ...args, + mode: "sync", }) } From df198d8b2dd991a7d31009faba6fad560581a532 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 16:44:56 +0900 Subject: [PATCH 03/12] refactor(background-agent): use unified internal prompt dispatch Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/background-agent/manager.test.ts | 5 +++-- src/features/background-agent/manager.ts | 5 +++-- src/features/background-agent/parent-wake-notifier.ts | 5 +++-- src/features/background-agent/session-route.ts | 5 +++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 3a219b45e..34ebe2420 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -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", diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 384246396..9189dcdfc 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -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", diff --git a/src/features/background-agent/parent-wake-notifier.ts b/src/features/background-agent/parent-wake-notifier.ts index 62ce9469c..725dcfacb 100644 --- a/src/features/background-agent/parent-wake-notifier.ts +++ b/src/features/background-agent/parent-wake-notifier.ts @@ -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", diff --git a/src/features/background-agent/session-route.ts b/src/features/background-agent/session-route.ts index 0bd7759bb..a8cc7fdbf 100644 --- a/src/features/background-agent/session-route.ts +++ b/src/features/background-agent/session-route.ts @@ -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, From fee515c5ac82cd95b22bf3e1383aa9fad00fed2f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 16:48:06 +0900 Subject: [PATCH 04/12] refactor(prompt-callers): migrate team and call_omo_agent dispatch Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/team-mode/tools/messaging.ts | 5 +++-- src/tools/call-omo-agent/sync-executor.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/features/team-mode/tools/messaging.ts b/src/features/team-mode/tools/messaging.ts index d18791a14..3c95a8b5a 100644 --- a/src/features/team-mode/tools/messaging.ts +++ b/src/features/team-mode/tools/messaging.ts @@ -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", diff --git a/src/tools/call-omo-agent/sync-executor.ts b/src/tools/call-omo-agent/sync-executor.ts index 98d698fde..5459e0ee7 100644 --- a/src/tools/call-omo-agent/sync-executor.ts +++ b/src/tools/call-omo-agent/sync-executor.ts @@ -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\nsession_id: ${sessionID}\n` } - const promptResult = await promptAsyncAfterSessionIdle({ + const promptResult = await dispatchInternalPrompt({ + mode: "async", client: ctx.client, sessionID, source: "call-omo-agent:sync", From dd3fecaf40bf4d7b144dde7d9da0d4117b7d873b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 17:01:50 +0900 Subject: [PATCH 05/12] refactor(plugin): use unified internal prompt dispatch Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/plugin/event.ts | 11 +++++++---- src/plugin/unstable-agent-babysitter.ts | 8 +++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 6c5cd1254..d14936726 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -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", diff --git a/src/plugin/unstable-agent-babysitter.ts b/src/plugin/unstable-agent-babysitter.ts index 040c26d21..5cfe2d555 100644 --- a/src/plugin/unstable-agent-babysitter.ts +++ b/src/plugin/unstable-agent-babysitter.ts @@ -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", From 989ab7171dd6e53af65f4f24fde976a3317b1dad Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 17:07:35 +0900 Subject: [PATCH 06/12] refactor(hooks): use unified internal prompt dispatch Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../aggressive-truncation-strategy.ts | 5 +- .../atlas/boulder-continuation-injector.ts | 23 +++---- src/hooks/atlas/idle-event.ts | 5 +- .../handlers/session-event-handler.ts | 5 +- .../compaction-context-injector/recovery.ts | 5 +- .../continuation-prompt-injector.ts | 5 +- src/hooks/runtime-fallback/auto-retry.ts | 5 +- .../recover-tool-result-missing.ts | 5 +- .../recover-unavailable-tool.ts | 5 +- src/hooks/session-recovery/resume.ts | 5 +- .../team-idle-wake-hint.ts | 5 +- .../continuation-injection.ts | 5 +- .../unstable-agent-babysitter-hook.ts | 5 +- src/shared/prompt-async-gate.ts | 65 +++++++------------ 14 files changed, 72 insertions(+), 76 deletions(-) diff --git a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts index 5fdcbe87c..be7580137 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts @@ -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", diff --git a/src/hooks/atlas/boulder-continuation-injector.ts b/src/hooks/atlas/boulder-continuation-injector.ts index 4535b2470..4451903ae 100644 --- a/src/hooks/atlas/boulder-continuation-injector.ts +++ b/src/hooks/atlas/boulder-continuation-injector.ts @@ -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") { diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 4dce6ab4b..973b079ec 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -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, diff --git a/src/hooks/claude-code-hooks/handlers/session-event-handler.ts b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts index 8326fdb5f..a9ed6deb8 100644 --- a/src/hooks/claude-code-hooks/handlers/session-event-handler.ts +++ b/src/hooks/claude-code-hooks/handlers/session-event-handler.ts @@ -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", diff --git a/src/hooks/compaction-context-injector/recovery.ts b/src/hooks/compaction-context-injector/recovery.ts index 91713d7e2..e0cb37c12 100644 --- a/src/hooks/compaction-context-injector/recovery.ts +++ b/src/hooks/compaction-context-injector/recovery.ts @@ -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", diff --git a/src/hooks/ralph-loop/continuation-prompt-injector.ts b/src/hooks/ralph-loop/continuation-prompt-injector.ts index fd9c133e2..30b12f8ca 100644 --- a/src/hooks/ralph-loop/continuation-prompt-injector.ts +++ b/src/hooks/ralph-loop/continuation-prompt-injector.ts @@ -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", diff --git a/src/hooks/runtime-fallback/auto-retry.ts b/src/hooks/runtime-fallback/auto-retry.ts index 74d7932b5..5e907b270 100644 --- a/src/hooks/runtime-fallback/auto-retry.ts +++ b/src/hooks/runtime-fallback/auto-retry.ts @@ -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}`, diff --git a/src/hooks/session-recovery/recover-tool-result-missing.ts b/src/hooks/session-recovery/recover-tool-result-missing.ts index ec504067e..2f7e30b0f 100644 --- a/src/hooks/session-recovery/recover-tool-result-missing.ts +++ b/src/hooks/session-recovery/recover-tool-result-missing.ts @@ -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 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", diff --git a/src/hooks/session-recovery/recover-unavailable-tool.ts b/src/hooks/session-recovery/recover-unavailable-tool.ts index 2b8cf0702..4d703eeeb 100644 --- a/src/hooks/session-recovery/recover-unavailable-tool.ts +++ b/src/hooks/session-recovery/recover-unavailable-tool.ts @@ -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 @@ -119,7 +119,8 @@ export async function recoverUnavailableTool( return false } - const promptResult = await promptAsyncAfterSessionIdle({ + const promptResult = await dispatchInternalPrompt({ + mode: "async", client, sessionID, source: "session-recovery-unavailable-tool", diff --git a/src/hooks/session-recovery/resume.ts b/src/hooks/session-recovery/resume.ts index 24fbd05b0..2dd641644 100644 --- a/src/hooks/session-recovery/resume.ts +++ b/src/hooks/session-recovery/resume.ts @@ -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", diff --git a/src/hooks/team-session-events/team-idle-wake-hint.ts b/src/hooks/team-session-events/team-idle-wake-hint.ts index c9f8fbd95..6c654dd18 100644 --- a/src/hooks/team-session-events/team-idle-wake-hint.ts +++ b/src/hooks/team-session-events/team-idle-wake-hint.ts @@ -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", diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.ts index d5a988bf8..8b121d6ca 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.ts @@ -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, diff --git a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts index 30cb01cca..3d39dee93 100644 --- a/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts +++ b/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts @@ -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, diff --git a/src/shared/prompt-async-gate.ts b/src/shared/prompt-async-gate.ts index df49a5ed1..033fbaff2 100644 --- a/src/shared/prompt-async-gate.ts +++ b/src/shared/prompt-async-gate.ts @@ -38,20 +38,9 @@ type PromptClient = { } } -type InternalPromptDispatchClient = { - session?: { - status?: () => Promise - messages?: (input: { path: { id: string }; query: PromptMessagesQuery }) => Promise - promptAsync?: (input: TInput) => Promise - prompt?: (input: TInput) => Promise - } -} - export type InternalPromptDispatchMode = "async" | "sync" -export type InternalPromptDispatchArgs = { - mode: InternalPromptDispatchMode - client: InternalPromptDispatchClient +type InternalPromptDispatchCommonArgs = { sessionID: string input: TInput source: string @@ -62,6 +51,11 @@ export type InternalPromptDispatchArgs = { checkToolState?: boolean } +export type InternalPromptDispatchArgs = InternalPromptDispatchCommonArgs & ( + | { mode: "async"; client: PromptAsyncClient } + | { mode: "sync"; client: PromptClient } +) + type PromptAsyncReservation = { source: string reservedAt: number @@ -386,34 +380,6 @@ async function dispatchAfterSessionIdle(args: { } } -function getInternalPromptDispatcher( - mode: InternalPromptDispatchMode, - session: InternalPromptDispatchClient["session"], -): { - sessionName: "promptAsync" | "prompt" - dispatch?: (input: TInput) => Promise -} { - if (mode === "async") { - if (typeof session?.promptAsync !== "function") { - return { sessionName: "promptAsync" } - } - const dispatchPromptAsync = session.promptAsync.bind(session) - return { - sessionName: "promptAsync", - dispatch: (input) => dispatchPromptAsync(input), - } - } - - if (typeof session?.prompt !== "function") { - return { sessionName: "prompt" } - } - const dispatchPrompt = session.prompt.bind(session) - return { - sessionName: "prompt", - dispatch: (input) => dispatchPrompt(input), - } -} - export async function dispatchInternalPrompt( args: InternalPromptDispatchArgs, ): Promise { @@ -426,7 +392,24 @@ export async function dispatchInternalPrompt( } = args const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS - const { sessionName, dispatch } = getInternalPromptDispatcher(args.mode, 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) + } + + 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 }) From 1bbe065c60e7640020e2b94075fe4c11755e6d87 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 17:09:04 +0900 Subject: [PATCH 07/12] refactor(prompt-callers): migrate shared and cli dispatch Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/cli/run/runner.ts | 5 +++-- .../continuation-injection.test.ts | 5 +++-- src/shared/model-suggestion-retry.ts | 12 +++++++----- src/shared/session-route.ts | 5 +++-- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/cli/run/runner.ts b/src/cli/run/runner.ts index 81763668f..d02ac4dac 100644 --- a/src/cli/run/runner.ts +++ b/src/cli/run/runner.ts @@ -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 { () => {}, ) - const promptResult = await promptAsyncAfterSessionIdle({ + const promptResult = await dispatchInternalPrompt({ + mode: "async", client, sessionID, source: "cli-run", diff --git a/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts b/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts index c757d4a81..caf6ba91d 100644 --- a/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts +++ b/src/hooks/todo-continuation-enforcer/continuation-injection.test.ts @@ -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", diff --git a/src/shared/model-suggestion-retry.ts b/src/shared/model-suggestion-retry.ts index 3113e962f..3f0dff3b7 100644 --- a/src/shared/model-suggestion-retry.ts +++ b/src/shared/model-suggestion-retry.ts @@ -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: { diff --git a/src/shared/session-route.ts b/src/shared/session-route.ts index 3a39277d6..d94ce4961 100644 --- a/src/shared/session-route.ts +++ b/src/shared/session-route.ts @@ -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, From 12bd658079adc7845243290c2218ea5a61dcc546 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 17:15:54 +0900 Subject: [PATCH 08/12] refactor(prompt-async-gate): remove deprecated dispatch wrappers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/shared/prompt-async-gate.test.ts | 136 +++++++++++---------- src/shared/prompt-async-gate.ts | 34 ------ 2 files changed, 73 insertions(+), 97 deletions(-) diff --git a/src/hooks/shared/prompt-async-gate.test.ts b/src/hooks/shared/prompt-async-gate.test.ts index 4a7ee1452..c5055edd9 100644 --- a/src/hooks/shared/prompt-async-gate.test.ts +++ b/src/hooks/shared/prompt-async-gate.test.ts @@ -3,8 +3,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { _setPromptGateMessagesFetchTimeoutMsForTesting, dispatchInternalPrompt, - promptAfterSessionIdle, - promptAsyncAfterSessionIdle, releaseAllPromptAsyncReservationsForTesting, releasePromptAsyncReservation, } from "./prompt-async-gate" @@ -125,7 +123,7 @@ describe("dispatchInternalPrompt", () => { }) }) -describe("promptAsyncAfterSessionIdle", () => { +describe("dispatchInternalPrompt shared gate behavior", () => { afterEach(() => { // then releaseAllPromptAsyncReservationsForTesting() @@ -149,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: [] } }, @@ -158,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: [] } }, @@ -187,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: [] } }, @@ -195,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: [] } }, @@ -223,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: [] } }, @@ -252,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: [] } }, @@ -291,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: [] } }, @@ -324,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: [] } }, @@ -354,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: [] } }, @@ -385,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: [] } }, @@ -394,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: [] } }, @@ -424,7 +433,8 @@ describe("promptAsyncAfterSessionIdle", () => { } // when - const first = await promptAsyncAfterSessionIdle({ + const first = await dispatchInternalPrompt({ + mode: "async", client, sessionID: "ses_release_scope", input: { @@ -437,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: { @@ -467,7 +478,8 @@ describe("promptAsyncAfterSessionIdle", () => { } // when - const first = await promptAsyncAfterSessionIdle({ + const first = await dispatchInternalPrompt({ + mode: "async", client, sessionID: "ses_release_family_scope", input: { @@ -482,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: { @@ -514,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: [] } }, @@ -523,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: [] } }, @@ -552,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: [] } }, @@ -585,7 +602,8 @@ describe("promptAsyncAfterSessionIdle", () => { } // when - const first = await promptAsyncAfterSessionIdle({ + const first = await dispatchInternalPrompt({ + mode: "async", client, sessionID: "ses_prefix_sibling", input: { @@ -600,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: { @@ -637,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 @@ -675,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") @@ -710,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: [] } }, @@ -739,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({ diff --git a/src/shared/prompt-async-gate.ts b/src/shared/prompt-async-gate.ts index 033fbaff2..25b9c088c 100644 --- a/src/shared/prompt-async-gate.ts +++ b/src/shared/prompt-async-gate.ts @@ -431,40 +431,6 @@ export async function dispatchInternalPrompt( }) } -export async function promptAsyncAfterSessionIdle(args: { - client: PromptAsyncClient - sessionID: string - input: TInput - source: string - settleMs?: number - postDispatchHoldMs?: number - dispatchTimeoutMs?: number - checkStatus?: boolean - checkToolState?: boolean -}): Promise { - return dispatchInternalPrompt({ - ...args, - mode: "async", - }) -} - -export async function promptAfterSessionIdle(args: { - client: PromptClient - sessionID: string - input: TInput - source: string - settleMs?: number - postDispatchHoldMs?: number - dispatchTimeoutMs?: number - checkStatus?: boolean - checkToolState?: boolean -}): Promise { - return dispatchInternalPrompt({ - ...args, - mode: "sync", - }) -} - export function releaseAllPromptAsyncReservationsForTesting(): void { promptAsyncReservations.clear() promptGateMessagesFetchTimeoutMsForTesting = undefined From 6768decddb7b1207eb75310103a37e0d766ebcc6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 17:16:10 +0900 Subject: [PATCH 09/12] fix(session-recovery): fallback when stored unavailable-tool parts are absent Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../recover-unavailable-tool.test.ts | 9 ++++++++- .../session-recovery/recover-unavailable-tool.ts | 12 +++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/hooks/session-recovery/recover-unavailable-tool.test.ts b/src/hooks/session-recovery/recover-unavailable-tool.test.ts index 4076283f5..bed98498e 100644 --- a/src/hooks/session-recovery/recover-unavailable-tool.test.ts +++ b/src/hooks/session-recovery/recover-unavailable-tool.test.ts @@ -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) diff --git a/src/hooks/session-recovery/recover-unavailable-tool.ts b/src/hooks/session-recovery/recover-unavailable-tool.ts index 4d703eeeb..dd8631f48 100644 --- a/src/hooks/session-recovery/recover-unavailable-tool.ts +++ b/src/hooks/session-recovery/recover-unavailable-tool.ts @@ -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) } } From 98df0a43e389fc91c673325a531d37a322bd2664 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 17:16:18 +0900 Subject: [PATCH 10/12] docs(prompt-gate): document unified dispatch invariant Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- AGENTS.md | 2 +- CHANGELOG.md | 2 +- docs/reference/prompt-async-gate-rfc.md | 24 +++++++++------------ src/shared/prompt-async-route-audit.test.ts | 2 +- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c4bcc7518..51836dcf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e957d713..c5e83d6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/reference/prompt-async-gate-rfc.md b/docs/reference/prompt-async-gate-rfc.md index 799a2cb80..f72caa292 100644 --- a/docs/reference/prompt-async-gate-rfc.md +++ b/docs/reference/prompt-async-gate-rfc.md @@ -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 - -export function promptAfterSessionIdle( - options: PromptAfterSessionIdleOptions, -): Promise +export function dispatchInternalPrompt( + options: InternalPromptDispatchArgs, +): Promise ``` 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(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. diff --git a/src/shared/prompt-async-route-audit.test.ts b/src/shared/prompt-async-route-audit.test.ts index 19649efe6..a90e9cb73 100644 --- a/src/shared/prompt-async-route-audit.test.ts +++ b/src/shared/prompt-async-route-audit.test.ts @@ -16,7 +16,7 @@ const RAW_PROMPT_ALLOWLIST = new Map([ ], [ 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", ], ]) From 0f92d2c98d753bd34da25db63810aa41ec91a9c2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 17:17:20 +0900 Subject: [PATCH 11/12] test(prompt-gate): narrow audit binding detection Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/shared/prompt-async-route-audit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/prompt-async-route-audit.test.ts b/src/shared/prompt-async-route-audit.test.ts index a90e9cb73..933f29197 100644 --- a/src/shared/prompt-async-route-audit.test.ts +++ b/src/shared/prompt-async-route-audit.test.ts @@ -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" }) } From 7a3a0a031ccf7280375d1a502ac0418da4b6b4d1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 17:21:35 +0900 Subject: [PATCH 12/12] test(tmux): ignore unrelated pane runner mock calls Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/shared/tmux/tmux-utils/pane-close-runner.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/shared/tmux/tmux-utils/pane-close-runner.test.ts b/src/shared/tmux/tmux-utils/pane-close-runner.test.ts index 63a7f53cf..164daf9e8 100644 --- a/src/shared/tmux/tmux-utils/pane-close-runner.test.ts +++ b/src/shared/tmux/tmux-utils/pane-close-runner.test.ts @@ -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"]], ])